diff --git a/features/db-check.feature b/features/db-check.feature index aa5ce47b..d1f7ba80 100644 --- a/features/db-check.feature +++ b/features/db-check.feature @@ -1,5 +1,6 @@ Feature: Check the database + @require-mysql-or-mariadb Scenario: Run db check to check the database Given a WP install @@ -13,6 +14,7 @@ Feature: Check the database Success: Database checked. """ + @require-mysql-or-mariadb Scenario: Run db check with MySQL defaults to check the database Given a WP install @@ -26,6 +28,7 @@ Feature: Check the database Success: Database checked. """ + @require-mysql-or-mariadb Scenario: Run db check with --no-defaults to check the database Given a WP install @@ -39,6 +42,7 @@ Feature: Check the database Success: Database checked. """ + @require-mysql-or-mariadb Scenario: Run db check with passed-in options Given a WP install @@ -124,6 +128,7 @@ Feature: Check the database """ And STDOUT should be empty + @require-mysql-or-mariadb Scenario: MySQL defaults are available as appropriate with --defaults flag Given a WP install @@ -136,3 +141,12 @@ Feature: Check the database When I try `wp db check --no-defaults --debug` Then STDERR should match #Debug \(db\): Running shell command: /usr/bin/env (mysqlcheck|mariadb-check) --no-defaults %s# + @require-sqlite + Scenario: SQLite commands that show warnings + Given a WP install + + When I try `wp db check` + Then STDERR should contain: + """ + Warning: Database check is not supported for SQLite databases + """ diff --git a/features/db-cli.feature b/features/db-cli.feature new file mode 100644 index 00000000..4ef8b885 --- /dev/null +++ b/features/db-cli.feature @@ -0,0 +1,11 @@ +Feature: Open a MySQL console + + @require-sqlite + Scenario: SQLite commands that show warnings for cli + Given a WP install + + When I try `wp db cli` + Then STDERR should contain: + """ + Warning: Interactive console (cli) is not supported for SQLite databases + """ diff --git a/features/db-columns.feature b/features/db-columns.feature index b2e1ee76..8372451f 100644 --- a/features/db-columns.feature +++ b/features/db-columns.feature @@ -41,6 +41,7 @@ Feature: Display information about a given table. Couldn't find any tables matching: wp_foobar """ + @require-mysql-or-mariadb Scenario: Display information about a non default WordPress table Given a WP install And I run `wp db query "CREATE TABLE not_wp ( date DATE NOT NULL, awesome_stuff TEXT, PRIMARY KEY (date) );;"` @@ -50,3 +51,14 @@ Feature: Display information about a given table. | Field | Type | Null | Key | Default | Extra | | date | date | NO | PRI | | | | awesome_stuff | text | YES | | | | + + @require-sqlite + Scenario: Display information about a non default WordPress table + Given a WP install + And I run `wp db query "CREATE TABLE not_wp ( date DATE NOT NULL, awesome_stuff TEXT, PRIMARY KEY (date) );;"` + + When I try `wp db columns not_wp` + Then STDOUT should be a table containing rows: + | Field | Type | Null | Key | Default | + | date | TEXT | NO | PRI | '' | + | awesome_stuff | TEXT | YES | | | diff --git a/features/db-create.feature b/features/db-create.feature new file mode 100644 index 00000000..8b90873a --- /dev/null +++ b/features/db-create.feature @@ -0,0 +1,24 @@ +Feature: Create a new database + + @require-mysql-or-mariadb + Scenario: Create a new database + Given an empty directory + And WP files + And wp-config.php + + When I run `wp db create` + Then STDOUT should contain: + """ + Success: Database created. + """ + + @require-sqlite + Scenario: SQLite DB create operation should fail if already existing + Given a WP install + + When I try `wp db create` + Then the return code should be 1 + And STDERR should contain: + """ + Database already exists + """ diff --git a/features/db-export.feature b/features/db-export.feature index 370ae1df..45dfd9ad 100644 --- a/features/db-export.feature +++ b/features/db-export.feature @@ -23,16 +23,10 @@ Feature: Export a WordPress database Scenario: Exclude tables when exporting the database Given a WP install - When I run `wp db export wp_cli_test.sql --exclude_tables=wp_users --porcelain` + When I try `wp db export wp_cli_test.sql --exclude_tables=wp_users --porcelain` Then the wp_cli_test.sql file should exist - And the wp_cli_test.sql file should not contain: - """ - wp_users - """ - And the wp_cli_test.sql file should contain: - """ - wp_options - """ + And the contents of the wp_cli_test.sql file should not match /CREATE TABLE ["`]?wp_users["`]?/ + And the contents of the wp_cli_test.sql file should match /CREATE TABLE ["`]?wp_options["`]?/ Scenario: Export database to STDOUT Given a WP install @@ -61,6 +55,7 @@ Feature: Export a WordPress database -- Dump completed on """ + @require-mysql-or-mariadb Scenario: Export database with passed-in options Given a WP install @@ -78,6 +73,25 @@ Feature: Export a WordPress database """ And STDOUT should be empty + @require-sqlite + Scenario: Export database with passed-in options + Given a WP install + + When I run `wp db export - --skip-comments` + Then STDOUT should not contain: + """ + -- Table structure + """ + + # dbpass has no effect on SQLite + When I try `wp db export - --dbpass=no_such_pass` + Then the return code should be 0 + And STDERR should not contain: + """ + Access denied + """ + + @require-mysql-or-mariadb Scenario: MySQL defaults are available as appropriate with --defaults flag Given a WP install diff --git a/features/db-import.feature b/features/db-import.feature index 5761fb24..75ce74f3 100644 --- a/features/db-import.feature +++ b/features/db-import.feature @@ -127,6 +127,8 @@ Feature: Import a WordPress database """ wp db import """ + + @require-mysql-or-mariadb Scenario: MySQL defaults are available as appropriate with --defaults flag Given a WP install @@ -142,7 +144,7 @@ Feature: Import a WordPress database When I try `wp db import --no-defaults --debug` Then STDERR should match #Debug \(db\): Running shell command: /usr/bin/env (mysql|mariadb) --no-defaults --no-auto-rehash# - @require-wp-4.2 + @require-wp-4.2 @require-mysql-or-mariadb Scenario: Import db that has emoji in post Given a WP install @@ -178,3 +180,32 @@ Feature: Import a WordPress database """ 🍣 """ + + @require-wp-4.2 @require-sqlite + Scenario: Import db that has emoji in post + Given a WP install + + When I run `wp post create --post_title="🍣"` + And I run `wp post list` + Then the return code should be 0 + And STDOUT should contain: + """ + 🍣 + """ + + When I try `wp db export wp_cli_test.sql --debug` + Then the return code should be 0 + And the wp_cli_test.sql file should exist + + When I run `wp db import --dbuser=wp_cli_test --dbpass=password1` + Then STDOUT should be: + """ + Success: Imported from 'wp_cli_test.sql'. + """ + + When I run `wp post list` + Then the return code should be 0 + And STDOUT should contain: + """ + 🍣 + """ diff --git a/features/db-optimize.feature b/features/db-optimize.feature new file mode 100644 index 00000000..4fec46d3 --- /dev/null +++ b/features/db-optimize.feature @@ -0,0 +1,21 @@ +Feature: Optimize the database + + @require-mysql-or-mariadb + Scenario: Run db optimize to optimize the database + Given a WP install + + When I run `wp db optimize` + Then STDOUT should contain: + """ + Success: Database optimized. + """ + + @require-sqlite + Scenario: SQLite commands that show warnings for optimize + Given a WP install + + When I try `wp db optimize` + Then STDERR should contain: + """ + Warning: Database optimization is not supported for SQLite databases + """ diff --git a/features/db-query.feature b/features/db-query.feature index 5831a536..2f518995 100644 --- a/features/db-query.feature +++ b/features/db-query.feature @@ -72,6 +72,7 @@ Feature: Query the database with WordPress' MySQL config """ And STDOUT should be empty + @require-mysql-or-mariadb Scenario: MySQL defaults are available as appropriate with --defaults flag Given a WP install diff --git a/features/db-repair.feature b/features/db-repair.feature new file mode 100644 index 00000000..46104b21 --- /dev/null +++ b/features/db-repair.feature @@ -0,0 +1,21 @@ +Feature: Repair the database + + @require-mysql-or-mariadb + Scenario: Run db repair to repair the database + Given a WP install + + When I run `wp db repair` + Then STDOUT should contain: + """ + Success: Database repaired. + """ + + @require-sqlite + Scenario: SQLite commands that show warnings for repair + Given a WP install + + When I try `wp db repair` + Then STDERR should contain: + """ + Warning: Database repair is not supported for SQLite databases + """ diff --git a/features/db-size.feature b/features/db-size.feature index 0958bbf1..2728fc17 100644 --- a/features/db-size.feature +++ b/features/db-size.feature @@ -2,6 +2,7 @@ Feature: Display database size + @require-mysql-or-mariadb Scenario: Display only database size for a WordPress install Given a WP install @@ -16,6 +17,21 @@ Feature: Display database size B """ + @require-sqlite + Scenario: Display only database size for a WordPress install + Given a WP install + + When I run `wp db size` + Then STDOUT should contain: + """ + .ht.sqlite + """ + + And STDOUT should contain: + """ + B + """ + Scenario: Display only table sizes for a WordPress install Given a WP install @@ -27,6 +43,7 @@ Feature: Display database size wp_cli_test """ + @require-mysql-or-mariadb Scenario: Display only database size in a human readable format for a WordPress install Given a WP install @@ -49,6 +66,21 @@ Feature: Display database size """ And STDOUT should be empty + @require-sqlite + Scenario: Display only database size in a human readable format for a WordPress install + Given a WP install + + When I run `wp db size --human-readable` + Then STDOUT should contain: + """ + .ht.sqlite + """ + + And STDOUT should contain: + """ + KB + """ + Scenario: Display only table sizes in a human readable format for a WordPress install Given a WP install @@ -155,6 +187,7 @@ Feature: Display database size MB """ + @require-mysql-or-mariadb Scenario: Display database size in bytes with specific format for a WordPress install Given a WP install @@ -175,6 +208,27 @@ Feature: Display database size But STDOUT should not be a number + @require-sqlite + Scenario: Display database size in bytes with specific format for a WordPress install + Given a WP install + + When I run `wp db size --size_format=b --format=csv` + Then STDOUT should contain: + """ + Name,Size + .ht.sqlite," + """ + + But STDOUT should not be a number + + When I run `wp db size --size_format=b --format=json` + Then STDOUT should contain: + """ + [{"Name":".ht.sqlite","Size":" + """ + + But STDOUT should not be a number + Scenario: Display all table sizes for a WordPress install Given a WP install diff --git a/features/db-tables.feature b/features/db-tables.feature index af8cc6ac..8c93bc0d 100644 --- a/features/db-tables.feature +++ b/features/db-tables.feature @@ -1,5 +1,6 @@ Feature: List database tables + @require-mysql-or-mariadb Scenario: List database tables on a single WordPress install Given a WP install @@ -35,7 +36,42 @@ Feature: List database tables wp_postmeta,wp_posts """ - @require-wp-3.9 + @require-sqlite + Scenario: List database tables on a single WordPress install + Given a WP install + + When I run `wp db tables` + Then STDOUT should contain: + """ + _mysql_data_types_cache + wp_users + sqlite_sequence + wp_usermeta + wp_termmeta + wp_terms + wp_term_taxonomy + wp_term_relationships + wp_commentmeta + wp_comments + wp_links + wp_options + wp_postmeta + wp_posts + """ + + When I run `wp db tables --format=csv` + Then STDOUT should contain: + """ + ,wp_commentmeta,wp_comments, + """ + + When I run `wp db tables 'wp_post*' --format=csv` + Then STDOUT should be: + """ + wp_postmeta,wp_posts + """ + + @require-wp-3.9 @require-mysql-or-mariadb Scenario: List database tables on a multisite WordPress install Given a WP multisite install @@ -119,6 +155,86 @@ Feature: List database tables wp_posts """ + @require-sqlite + Scenario: List database tables on a multisite WordPress install + Given a WP multisite install + + When I run `wp db tables` + Then STDOUT should contain: + """ + _mysql_data_types_cache + wp_users + sqlite_sequence + wp_usermeta + wp_termmeta + wp_terms + wp_term_taxonomy + wp_term_relationships + wp_commentmeta + wp_comments + wp_links + wp_options + wp_postmeta + wp_posts + wp_blogs + wp_blogmeta + wp_registration_log + wp_site + wp_sitemeta + wp_signups + """ + + When I run `wp site create --slug=foo` + And I run `wp db tables --url=example.com/foo` + Then STDOUT should contain: + """ + wp_users + """ + And STDOUT should contain: + """ + wp_usermeta + """ + And STDOUT should contain: + """ + wp_2_posts + """ + + When I run `wp db tables --url=example.com/foo --scope=global` + Then STDOUT should not contain: + """ + wp_2_posts + """ + + When I run `wp db tables --all-tables-with-prefix` + Then STDOUT should contain: + """ + wp_2_posts + """ + And STDOUT should contain: + """ + wp_posts + """ + + When I run `wp db tables --url=example.com/foo --all-tables-with-prefix` + Then STDOUT should contain: + """ + wp_2_posts + """ + And STDOUT should not contain: + """ + wp_posts + """ + + When I run `wp db tables --url=example.com/foo --network` + Then STDOUT should contain: + """ + wp_2_posts + """ + And STDOUT should contain: + """ + wp_posts + """ + Scenario: Listing a site's tables should only list that site's tables Given a WP multisite install diff --git a/features/db.feature b/features/db.feature index dbfdb4b0..2d47ba1e 100644 --- a/features/db.feature +++ b/features/db.feature @@ -1,5 +1,6 @@ Feature: Perform database operations + @require-mysql-or-mariadb Scenario: DB CRUD Given an empty directory And WP files @@ -52,6 +53,7 @@ Feature: Perform database operations Are you sure you want to reset the 'wp_cli_test' database? [y/n] Success: Database reset. """ + @require-mysql-or-mariadb Scenario: DB CRUD with passed-in dbuser/dbpass Given an empty directory And WP files @@ -107,6 +109,7 @@ Feature: Perform database operations """ And STDOUT should be empty + @require-mysql-or-mariadb Scenario: Clean up a WordPress install without dropping its database entirely but tables with prefix. Given a WP install @@ -138,6 +141,7 @@ Feature: Perform database operations """ And the return code should be 0 + @require-mysql-or-mariadb Scenario: DB Operations Given a WP install @@ -147,6 +151,7 @@ Feature: Perform database operations When I run `wp db repair` Then STDOUT should not be empty + @require-mysql-or-mariadb Scenario: DB Operations with passed-in options Given a WP install @@ -185,6 +190,7 @@ Feature: Perform database operations """ And STDOUT should not be empty + @require-mysql-or-mariadb Scenario: DB Query Given a WP install @@ -214,6 +220,7 @@ Feature: Perform database operations home """ + @require-mysql-or-mariadb Scenario: DB export/import Given a WP install @@ -262,6 +269,7 @@ Feature: Perform database operations 1 """ + @require-mysql-or-mariadb Scenario: DB export no charset Given an empty directory And WP files @@ -327,6 +335,7 @@ Feature: Perform database operations latin1_spanish_ci """ + @require-mysql-or-mariadb Scenario: Row modifying queries should return the number of affected rows Given a WP install When I run `wp db query "UPDATE wp_users SET user_status = 1 WHERE ID = 1"` @@ -334,7 +343,7 @@ Feature: Perform database operations """ Query succeeded. Rows affected: 1 """ - + When I run `wp db query "SELECT * FROM wp_users WHERE ID = 1"` Then STDOUT should not contain: """ @@ -346,3 +355,74 @@ Feature: Perform database operations """ Query succeeded. Rows affected: 1 """ + + @require-sqlite + Scenario: SQLite DB CRUD operations + Given a WP install + And a session_yes file: + """ + y + """ + + When I try `wp db create` + Then the return code should be 1 + And STDERR should contain: + """ + Database already exists + """ + + When I run `wp db drop < session_yes` + Then STDOUT should contain: + """ + Success: Database dropped. + """ + + When I run `wp db reset < session_yes` + Then STDOUT should contain: + """ + Success: Database reset + """ + + @require-sqlite + Scenario: SQLite DB query + Given a WP install + + When I run `wp db query 'SELECT COUNT(*) as total FROM wp_posts'` + Then STDOUT should contain: + """ + total + """ + + @require-sqlite + Scenario: SQLite DB export/import + Given a WP install + + When I run `wp post list --format=count` + Then STDOUT should contain: + """ + 1 + """ + + When I run `wp db export /tmp/wp-cli-sqlite-behat.sql` + Then STDOUT should contain: + """ + Success: Exported + """ + + When I run `wp db reset < session_yes` + Then STDOUT should contain: + """ + Success: Database reset + """ + + When I run `wp db import /tmp/wp-cli-sqlite-behat.sql` + Then STDOUT should contain: + """ + Success: Imported + """ + + When I run `wp post list --format=count` + Then STDOUT should contain: + """ + 1 + """ diff --git a/phpcs.xml.dist b/phpcs.xml.dist index c39cec40..a398187a 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -55,6 +55,13 @@ */src/DB_Command\.php$ + + */src/DB_Command_SQLite\.php$ + + + + */src/DB_Command_SQLite\.php$ + /tests/phpstan/scan-files diff --git a/src/DB_Command.php b/src/DB_Command.php index 06781cee..08b2a15a 100644 --- a/src/DB_Command.php +++ b/src/DB_Command.php @@ -3,6 +3,8 @@ use WP_CLI\Formatter; use WP_CLI\Utils; +require_once __DIR__ . '/DB_Command_SQLite.php'; + /** * Performs basic database operations using credentials stored in wp-config.php. * @@ -27,6 +29,8 @@ */ class DB_Command extends WP_CLI_Command { + use DB_Command_SQLite; + /** * Legacy UTF-8 encoding for MySQL. * @@ -82,6 +86,12 @@ class DB_Command extends WP_CLI_Command { * Success: Database created. */ public function create( $_, $assoc_args ) { + $this->maybe_load_sqlite_dropin(); + + if ( $this->is_sqlite() ) { + $this->sqlite_create(); + return; + } $this->run_query( self::get_create_query(), $assoc_args ); @@ -115,6 +125,15 @@ public function create( $_, $assoc_args ) { * Success: Database dropped. */ public function drop( $_, $assoc_args ) { + $this->maybe_load_sqlite_dropin(); + + if ( $this->is_sqlite() ) { + $db_path = $this->get_sqlite_db_path(); + WP_CLI::confirm( "Are you sure you want to drop the SQLite database at '{$db_path}'?", $assoc_args ); + $this->sqlite_drop(); + return; + } + WP_CLI::confirm( "Are you sure you want to drop the '" . DB_NAME . "' database?", $assoc_args ); $this->run_query( sprintf( 'DROP DATABASE `%s`', DB_NAME ), $assoc_args ); @@ -149,6 +168,15 @@ public function drop( $_, $assoc_args ) { * Success: Database reset. */ public function reset( $_, $assoc_args ) { + $this->maybe_load_sqlite_dropin(); + + if ( $this->is_sqlite() ) { + $db_path = $this->get_sqlite_db_path(); + WP_CLI::confirm( "Are you sure you want to reset the SQLite database at '{$db_path}'?", $assoc_args ); + $this->sqlite_reset(); + return; + } + WP_CLI::confirm( "Are you sure you want to reset the '" . DB_NAME . "' database?", $assoc_args ); $this->run_query( sprintf( 'DROP DATABASE IF EXISTS `%s`', DB_NAME ), $assoc_args ); @@ -249,6 +277,12 @@ public function clean( $_, $assoc_args ) { * Success: Database checked. */ public function check( $_, $assoc_args ) { + $this->maybe_load_sqlite_dropin(); + + if ( $this->is_sqlite() ) { + WP_CLI::warning( 'Database check is not supported for SQLite databases.' ); + return; + } $command = sprintf( '/usr/bin/env %s%s %s', @@ -298,6 +332,13 @@ public function check( $_, $assoc_args ) { * Success: Database optimized. */ public function optimize( $_, $assoc_args ) { + $this->maybe_load_sqlite_dropin(); + + if ( $this->is_sqlite() ) { + WP_CLI::warning( 'Database optimization is not supported for SQLite databases. SQLite automatically optimizes on VACUUM.' ); + return; + } + $command = sprintf( '/usr/bin/env %s%s %s', Utils\get_sql_check_command(), @@ -346,6 +387,13 @@ public function optimize( $_, $assoc_args ) { * Success: Database repaired. */ public function repair( $_, $assoc_args ) { + $this->maybe_load_sqlite_dropin(); + + if ( $this->is_sqlite() ) { + WP_CLI::warning( 'Database repair is not supported for SQLite databases.' ); + return; + } + $command = sprintf( '/usr/bin/env %s%s %s', Utils\get_sql_check_command(), @@ -396,6 +444,12 @@ public function repair( $_, $assoc_args ) { * @alias connect */ public function cli( $_, $assoc_args ) { + $this->maybe_load_sqlite_dropin(); + + if ( $this->is_sqlite() ) { + WP_CLI::warning( 'Interactive console (cli) is not supported for SQLite databases. Use `wp db query` instead.' ); + return; + } $command = sprintf( '/usr/bin/env %s%s --no-auto-rehash', @@ -499,6 +553,24 @@ public function cli( $_, $assoc_args ) { * +---------+-----------------------+ */ public function query( $args, $assoc_args ) { + $this->maybe_load_sqlite_dropin(); + + if ( $this->is_sqlite() ) { + // Get the query from args or STDIN. + $query = ''; + if ( ! empty( $args ) ) { + $query = $args[0]; + } else { + $query = stream_get_contents( STDIN ); + } + + if ( empty( $query ) ) { + WP_CLI::error( 'No query specified.' ); + } + + $this->sqlite_query( $query ); + return; + } $command = sprintf( '/usr/bin/env %s%s --no-auto-rehash', @@ -629,6 +701,8 @@ public function query( $args, $assoc_args ) { * @alias dump */ public function export( $args, $assoc_args ) { + $this->maybe_load_sqlite_dropin(); + if ( ! empty( $args[0] ) ) { $result_file = $args[0]; } else { @@ -637,6 +711,12 @@ public function export( $args, $assoc_args ) { $result_file = sprintf( '%s-%s-%s.sql', DB_NAME, date( 'Y-m-d' ), $hash ); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date } + + if ( $this->is_sqlite() ) { + $this->sqlite_export( $result_file, $assoc_args ); + return; + } + $stdout = ( '-' === $result_file ); $porcelain = Utils\get_flag_value( $assoc_args, 'porcelain' ); @@ -798,12 +878,19 @@ private function get_posts_table_charset( $assoc_args ) { * Success: Imported from 'wordpress_dbase.sql'. */ public function import( $args, $assoc_args ) { + $this->maybe_load_sqlite_dropin(); + if ( ! empty( $args[0] ) ) { $result_file = $args[0]; } else { $result_file = sprintf( '%s.sql', DB_NAME ); } + if ( $this->is_sqlite() ) { + $this->sqlite_import( $result_file ); + return; + } + // Process options to MySQL. $mysql_args = array_merge( [ 'database' => DB_NAME ], @@ -1080,19 +1167,30 @@ public function size( $args, $assoc_args ) { $default_unit = ( empty( $size_format ) && ! $human_readable ) ? ' B' : ''; + $is_sqlite = $this->is_sqlite(); + if ( $tables || $all_tables || $all_tables_with_prefix ) { // Add all of the table sizes. foreach ( Utils\wp_get_table_names( $args, $assoc_args ) as $table_name ) { // Get the table size. - $table_bytes = $wpdb->get_var( - $wpdb->prepare( - 'SELECT SUM(data_length + index_length) FROM information_schema.TABLES where table_schema = %s and Table_Name = %s GROUP BY Table_Name LIMIT 1', - DB_NAME, - $table_name - ) - ); + if ( $is_sqlite ) { + $table_bytes = $wpdb->get_var( + $wpdb->prepare( + 'SELECT SUM(pgsize) as size_in_bytes FROM dbstat where name = %s LIMIT 1', + $table_name + ) + ); + } else { + $table_bytes = $wpdb->get_var( + $wpdb->prepare( + 'SELECT SUM(data_length + index_length) FROM information_schema.TABLES where table_schema = %s and Table_Name = %s GROUP BY Table_Name LIMIT 1', + DB_NAME, + $table_name + ) + ); + } // Add the table size to the list. $rows[] = [ @@ -1104,16 +1202,23 @@ public function size( $args, $assoc_args ) { } else { // Get the database size. - $db_bytes = $wpdb->get_var( - $wpdb->prepare( - 'SELECT SUM(data_length + index_length) FROM information_schema.TABLES where table_schema = %s GROUP BY table_schema;', - DB_NAME - ) - ); + if ( $is_sqlite ) { + $db_bytes = $this->sqlite_size(); + $db_path = $this->get_sqlite_db_path(); + $db_name = $db_path ? basename( $db_path ) : ''; + } else { + $db_bytes = $wpdb->get_var( + $wpdb->prepare( + 'SELECT SUM(data_length + index_length) FROM information_schema.TABLES where table_schema = %s GROUP BY table_schema;', + DB_NAME + ) + ); + $db_name = DB_NAME; + } // Add the database size to the list. $rows[] = [ - 'Name' => DB_NAME, + 'Name' => $db_name, 'Size' => strtoupper( $db_bytes ) . $default_unit, 'Bytes' => strtoupper( $db_bytes ), ]; @@ -1142,6 +1247,10 @@ public function size( $args, $assoc_args ) { $size_key = floor( log( (float) $row['Size'] ) / log( 1000 ) ); $sizes = [ 'B', 'KB', 'MB', 'GB', 'TB' ]; + if ( is_infinite( $size_key ) ) { + $size_key = 0; + } + $size_format = isset( $sizes[ $size_key ] ) ? $sizes[ $size_key ] : $sizes[0]; } @@ -1511,8 +1620,8 @@ public function search( $args, $assoc_args ) { if ( ! $text_columns ) { if ( $stats ) { $skipped[] = $table; - // Don't bother warning for term relationships (which is just 3 int columns). - } elseif ( ! preg_match( '/_term_relationships$/', $table ) ) { + // Don't bother warning for term relationships (which is just 3 int columns) or SQLite. + } elseif ( ! preg_match( '/_term_relationships$/', $table ) && ! $this->is_sqlite() ) { WP_CLI::warning( $primary_keys ? "No text columns for table '$table' - skipped." : "No primary key or text columns for table '$table' - skipped." ); } continue; @@ -1734,7 +1843,12 @@ public function columns( $args, $assoc_args ) { ); $formatter_fields = [ 'Field', 'Type', 'Null', 'Key', 'Default', 'Extra' ]; - $formatter_args = [ + + if ( $this->is_sqlite() ) { + $formatter_fields = [ 'Field', 'Type', 'Null', 'Key', 'Default' ]; + } + + $formatter_args = [ 'format' => $format, ]; diff --git a/src/DB_Command_SQLite.php b/src/DB_Command_SQLite.php new file mode 100644 index 00000000..e4fe0878 --- /dev/null +++ b/src/DB_Command_SQLite.php @@ -0,0 +1,572 @@ +get_sqlite_db_path(); + + if ( ! $db_path ) { + return false; + } + + try { + $pdo = new PDO( 'sqlite:' . $db_path ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + return $pdo; + } catch ( PDOException $e ) { + WP_CLI::debug( 'SQLite PDO connection failed: ' . $e->getMessage(), 'db' ); + return false; + } + } + + /** + * Create SQLite database. + */ + protected function sqlite_create() { + $db_path = $this->get_sqlite_db_path(); + if ( ! $db_path ) { + WP_CLI::error( 'Could not determine the database path.' ); + } + $db_dir = dirname( $db_path ); + + // Create directory if it doesn't exist. + if ( ! is_dir( $db_dir ) ) { + if ( ! mkdir( $db_dir, 0755, true ) ) { + WP_CLI::error( "Could not create directory: {$db_dir}" ); + } + } + + // Check if database already exists. + if ( file_exists( $db_path ) ) { + WP_CLI::error( 'Database already exists.' ); + } + + // Create the SQLite database file. + try { + $pdo = new PDO( 'sqlite:' . $db_path ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + // Execute a simple query to initialize the database. + $pdo->exec( 'CREATE TABLE IF NOT EXISTS _wpcli_test (id INTEGER)' ); + $pdo->exec( 'DROP TABLE _wpcli_test' ); + } catch ( PDOException $e ) { + WP_CLI::error( 'Could not create SQLite database: ' . $e->getMessage() ); + } + + WP_CLI::success( 'Database created.' ); + } + + /** + * Drop SQLite database. + */ + protected function sqlite_drop() { + $db_path = $this->get_sqlite_db_path(); + + if ( ! $db_path ) { + WP_CLI::error( 'Could not determine the database path.' ); + } + + if ( ! file_exists( $db_path ) ) { + WP_CLI::error( 'Database does not exist.' ); + } + + if ( ! unlink( $db_path ) ) { + WP_CLI::error( "Could not delete database file: {$db_path}" ); + } + + WP_CLI::success( 'Database dropped.' ); + } + + /** + * Reset SQLite database. + */ + protected function sqlite_reset() { + $db_path = $this->get_sqlite_db_path(); + + if ( ! $db_path ) { + WP_CLI::error( 'Could not determine the database path.' ); + } + + // Delete if exists. + if ( file_exists( $db_path ) ) { + if ( ! unlink( $db_path ) ) { + WP_CLI::error( "Could not delete database file: {$db_path}" ); + } + } + + // Create directory if needed. + $db_dir = dirname( $db_path ); + if ( ! is_dir( $db_dir ) ) { + if ( ! mkdir( $db_dir, 0755, true ) ) { + WP_CLI::error( "Could not create directory: {$db_dir}" ); + } + } + + // Recreate the SQLite database file. + try { + $pdo = new PDO( 'sqlite:' . $db_path ); + $pdo->setAttribute( PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION ); + // Execute a simple query to initialize the database. + $pdo->exec( 'CREATE TABLE IF NOT EXISTS _wpcli_test (id INTEGER)' ); + $pdo->exec( 'DROP TABLE _wpcli_test' ); + } catch ( PDOException $e ) { + WP_CLI::error( 'Could not create SQLite database: ' . $e->getMessage() ); + } + + WP_CLI::success( 'Database reset.' ); + } + + /** + * Execute a query against the SQLite database. + * + * @param string $query SQL query to execute. + */ + protected function sqlite_query( $query ) { + global $wpdb; + + // Use $wpdb if the SQLite drop-in is loaded. + if ( isset( $wpdb ) && $wpdb instanceof \WP_SQLite_DB ) { + try { + $is_row_modifying_query = preg_match( '/\b(UPDATE|DELETE|INSERT|REPLACE)\b/i', $query ); + + if ( $is_row_modifying_query ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $affected_rows = $wpdb->query( $query ); + if ( false === $affected_rows ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags + WP_CLI::error( 'Query failed: ' . strip_tags( $wpdb->last_error ) ); + } + WP_CLI::success( "Query succeeded. Rows affected: {$affected_rows}" ); + } else { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $results = $wpdb->get_results( $query, ARRAY_A ); + + if ( $wpdb->last_error ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags + WP_CLI::error( 'Query failed: ' . strip_tags( $wpdb->last_error ) ); + } + + if ( empty( $results ) ) { + // No results to display. + return; + } + + // Display as a table similar to MySQL output. + $headers = array_keys( $results[0] ); + $this->display_table( $headers, $results ); + } + } catch ( Exception $e ) { + WP_CLI::error( 'Query failed: ' . $e->getMessage() ); + } + return; + } + + // Fallback to PDO if the drop-in is not loaded. + $pdo = $this->get_sqlite_pdo(); + + if ( ! $pdo ) { + WP_CLI::error( 'Could not connect to SQLite database.' ); + } + + try { + $is_row_modifying_query = preg_match( '/\b(UPDATE|DELETE|INSERT|REPLACE)\b/i', $query ); + + if ( $is_row_modifying_query ) { + $stmt = $pdo->prepare( $query ); + $stmt->execute(); + $affected_rows = $stmt->rowCount(); + WP_CLI::success( "Query succeeded. Rows affected: {$affected_rows}" ); + } else { + $stmt = $pdo->query( $query ); + + if ( ! $stmt ) { + // There was an error. + $error_info = $pdo->errorInfo(); + WP_CLI::error( 'Query failed: ' . $error_info[2] ); + } + + // Fetch and display results. + $results = $stmt->fetchAll( PDO::FETCH_ASSOC ); + + if ( empty( $results ) ) { + // No results to display. + return; + } + + // Display as a table similar to MySQL output. + $headers = array_keys( $results[0] ); + $this->display_table( $headers, $results ); + } + } catch ( PDOException $e ) { + WP_CLI::error( 'Query failed: ' . $e->getMessage() ); + } + } + + /** + * Display results as a table similar to MySQL output. + * + * @param array $headers Column headers. + * @param array $rows Data rows. + */ + protected function display_table( $headers, $rows ) { + // Calculate column widths. + $widths = []; + foreach ( $headers as $header ) { + $widths[ $header ] = strlen( $header ); + } + + foreach ( $rows as $row ) { + foreach ( $row as $key => $value ) { + $widths[ $key ] = max( $widths[ $key ], strlen( (string) $value ) ); + } + } + + // Display header. + $separator = '+'; + $header_line = '|'; + foreach ( $headers as $header ) { + $separator .= str_repeat( '-', $widths[ $header ] + 2 ) . '+'; + $header_line .= ' ' . str_pad( $header, $widths[ $header ] ) . ' |'; + } + + WP_CLI::line( $separator ); + WP_CLI::line( $header_line ); + WP_CLI::line( $separator ); + + // Display rows. + foreach ( $rows as $row ) { + $row_line = '|'; + foreach ( $headers as $header ) { + $value = isset( $row[ $header ] ) ? $row[ $header ] : ''; + $row_line .= ' ' . str_pad( (string) $value, $widths[ $header ] ) . ' |'; + } + WP_CLI::line( $row_line ); + } + + WP_CLI::line( $separator ); + } + + /** + * Export SQLite database. + * + * @param string $file Output file path. + * @param array $assoc_args Associative arguments. + */ + protected function sqlite_export( $file, $assoc_args ) { + $db_path = $this->get_sqlite_db_path(); + + if ( ! $db_path ) { + WP_CLI::error( 'Could not determine the database path.' ); + } + + if ( ! file_exists( $db_path ) ) { + WP_CLI::error( 'Database does not exist.' ); + } + + $pdo = $this->get_sqlite_pdo(); + if ( ! $pdo ) { + WP_CLI::error( 'Could not connect to SQLite database.' ); + } + + $stdout = ( '-' === $file ); + + if ( $stdout ) { + $output = fopen( 'php://stdout', 'w' ); + } else { + $output = fopen( $file, 'w' ); + } + + if ( ! $output ) { + WP_CLI::error( "Could not open file for writing: {$file}" ); + } + + $exclude_tables = Utils\get_flag_value( $assoc_args, 'exclude_tables', '' ); + $exclude_tables = explode( ',', trim( $exclude_tables, ',' ) ); + $exclude_tables = array_map( 'strtolower', $exclude_tables ); + + try { + // Export schema and data as SQL. + fwrite( $output, "-- SQLite database dump\n" ); + fwrite( $output, '-- Database: ' . basename( $db_path ) . "\n\n" ); + + // Get all tables. + $stmt = $pdo->query( "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" ); + if ( ! $stmt ) { + // There was an error. + $error_info = $pdo->errorInfo(); + WP_CLI::error( 'Could not retrieve table list: ' . $error_info[2] ); + } + $tables = $stmt->fetchAll( PDO::FETCH_COLUMN ); + + foreach ( $tables as $table ) { + if ( in_array( $table, $exclude_tables, true ) ) { + continue; + } + + // Escape table name for identifiers. + $escaped_table = '"' . str_replace( '"', '""', $table ) . '"'; + + // Get CREATE TABLE statement. + $stmt = $pdo->query( "SELECT sql FROM sqlite_master WHERE type='table' AND name=" . $pdo->quote( $table ) ); + if ( ! $stmt ) { + // There was an error. + $error_info = $pdo->errorInfo(); + WP_CLI::error( "Could not retrieve CREATE TABLE statement for table {$escaped_table}: " . $error_info[2] ); + } + $create_stmt = $stmt->fetchColumn(); + + if ( isset( $assoc_args['add-drop-table'] ) ) { + fwrite( $output, "DROP TABLE IF EXISTS {$escaped_table};\n" ); + } + + fwrite( $output, $create_stmt . ";\n\n" ); + + // Export data. + $stmt = $pdo->query( "SELECT * FROM {$escaped_table}" ); + if ( ! $stmt ) { + // There was an error. + $error_info = $pdo->errorInfo(); + WP_CLI::error( "Could not retrieve data for table {$escaped_table}: " . $error_info[2] ); + } + $rows = $stmt->fetchAll( PDO::FETCH_ASSOC ); + + foreach ( $rows as $row ) { + $columns = array_keys( $row ); + $values = array_map( [ $pdo, 'quote' ], array_values( $row ) ); + + fwrite( $output, "INSERT INTO {$escaped_table} (" . implode( ', ', $columns ) . ') VALUES (' . implode( ', ', $values ) . ");\n" ); + } + + fwrite( $output, "\n" ); + } + + fwrite( $output, '-- Dump completed on ' . gmdate( 'Y-m-d H:i:s' ) . "\n\n" ); + } catch ( PDOException $e ) { + fclose( $output ); + WP_CLI::error( 'Export failed: ' . $e->getMessage() ); + } + + fclose( $output ); + + if ( ! $stdout ) { + if ( isset( $assoc_args['porcelain'] ) ) { + WP_CLI::line( $file ); + } else { + WP_CLI::success( "Exported to '{$file}'." ); + } + } + } + + /** + * Import SQL into SQLite database. + * + * @param string $file Input file path. + */ + protected function sqlite_import( $file ) { + $pdo = $this->get_sqlite_pdo(); + + if ( ! $pdo ) { + WP_CLI::error( 'Could not connect to SQLite database.' ); + } + + if ( '-' === $file ) { + $sql = stream_get_contents( STDIN ); + $file = 'STDIN'; + } else { + if ( ! is_readable( $file ) ) { + WP_CLI::error( sprintf( 'Import file missing or not readable: %s', $file ) ); + } + $sql = file_get_contents( $file ); + } + + if ( false === $sql ) { + WP_CLI::error( 'Could not read import file.' ); + } + + try { + // Split SQL into individual statements. + $lines = preg_split( '/;[\r\n]+/', $sql ); + if ( ! is_array( $lines ) ) { + $lines = []; + } + $statements = array_filter( + array_map( + 'trim', + $lines + ) + ); + + $pdo->beginTransaction(); + + foreach ( $statements as $statement ) { + if ( empty( $statement ) || 0 === strpos( $statement, '--' ) ) { + continue; + } + + $pdo->exec( $statement ); + } + + $pdo->commit(); + + WP_CLI::success( sprintf( "Imported from '%s'.", $file ) ); + } catch ( PDOException $e ) { + $pdo->rollBack(); + WP_CLI::error( 'Import failed: ' . $e->getMessage() ); + } + } + + /** + * Get SQLite database size. + * + * @return int Database file size in bytes, or 0 if not found. + */ + protected function sqlite_size() { + $db_path = $this->get_sqlite_db_path(); + + if ( ! $db_path || ! file_exists( $db_path ) ) { + return 0; + } + + $size = filesize( $db_path ); + if ( false === $size ) { + return 0; + } + + return $size; + } + + /** + * Load WordPress db.php drop-in if SQLite is detected. + * + * This should be called early in commands that run at after_wp_config_load. + */ + protected function maybe_load_sqlite_dropin() { + if ( ! $this->is_sqlite() ) { + return; + } + + // Check if already loaded. + if ( defined( 'SQLITE_DB_DROPIN_VERSION' ) ) { + return; + } + + $wp_content_dir = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : ABSPATH . 'wp-content'; + $db_dropin_path = $wp_content_dir . '/db.php'; + + if ( ! file_exists( $db_dropin_path ) ) { + return; + } + + // Constants used in wp-includes/functions.php + if ( ! defined( 'WPINC' ) ) { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound + define( 'WPINC', 'wp-includes' ); + } + + if ( ! defined( 'WP_CONTENT_DIR' ) ) { + define( 'WP_CONTENT_DIR', ABSPATH . 'wp-content' ); + } + + // Load required WordPress files if not already loaded. + if ( ! function_exists( 'add_action' ) ) { + $required_files = [ + ABSPATH . WPINC . '/compat.php', + ABSPATH . WPINC . '/plugin.php', + // Defines `wp_debug_backtrace_summary()` as used by wpdb. + ABSPATH . WPINC . '/functions.php', + ABSPATH . WPINC . '/class-wpdb.php', + ]; + + foreach ( $required_files as $required_file ) { + if ( file_exists( $required_file ) ) { + require_once $required_file; + } + } + } + + // Load the db.php drop-in. + require_once $db_dropin_path; + } +} diff --git a/tests/phpstan/scan-files.php b/tests/phpstan/scan-files.php index 80950306..92b1f46c 100644 --- a/tests/phpstan/scan-files.php +++ b/tests/phpstan/scan-files.php @@ -7,4 +7,7 @@ define( 'DB_PASSWORD', '' ); define( 'DB_CHARSET', '' ); define( 'DB_COLLATE', '' ); + + class WP_SQLite_DB extends \wpdb { + } }