diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d84f4ade --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +/.actrc export-ignore +/.distignore export-ignore +/.editorconfig export-ignore +/.github export-ignore +/.gitignore export-ignore +/.typos.toml export-ignore +/AGENTS.md export-ignore +/behat.yml export-ignore +/features export-ignore +/phpcs.xml.dist export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore +/tests export-ignore +/wp-cli.yml export-ignore diff --git a/.github/workflows/check-branch-alias.yml b/.github/workflows/check-branch-alias.yml new file mode 100644 index 00000000..78da6371 --- /dev/null +++ b/.github/workflows/check-branch-alias.yml @@ -0,0 +1,14 @@ +name: Check Branch Alias + +on: + release: + types: [released] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + check-branch-alias: + uses: wp-cli/.github/.github/workflows/reusable-check-branch-alias.yml@main diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 07e4fd1f..e9fe5776 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -6,6 +6,8 @@ on: branches: - main - master + schedule: + - cron: '17 2 * * *' # Run every day on a seemly random time. jobs: code-quality: diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 00000000..844ffe25 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,47 @@ +name: "Copilot Setup Steps" + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +permissions: + contents: read + +jobs: + copilot-setup-steps: + name: Setup environment + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Check existence of composer.json file + id: check_composer_file + run: echo "files_exists=$(test -f composer.json && echo true || echo false)" >> "$GITHUB_OUTPUT" + + - name: Set up PHP environment + if: steps.check_composer_file.outputs.files_exists == 'true' + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2 + with: + php-version: 'latest' + ini-values: zend.assertions=1, error_reporting=-1, display_errors=On + coverage: 'none' + tools: composer,cs2pr + env: + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Composer dependencies & cache dependencies + if: steps.check_composer_file.outputs.files_exists == 'true' + uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 + env: + COMPOSER_ROOT_VERSION: dev-${{ github.event.repository.default_branch }} diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 00000000..68334703 --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,33 @@ +--- +name: Issue and PR Triage + +'on': + issues: + types: [opened] + pull_request_target: + types: [opened] + workflow_dispatch: + inputs: + issue_number: + description: 'Issue/PR number to triage (leave empty to process all)' + required: false + type: string + +permissions: + issues: write + pull-requests: write + actions: write + contents: read + models: read + +jobs: + issue-triage: + uses: wp-cli/.github/.github/workflows/reusable-issue-triage.yml@main + with: + issue_number: >- + ${{ + (github.event_name == 'workflow_dispatch' && inputs.issue_number) || + (github.event_name == 'pull_request_target' && github.event.pull_request.number) || + (github.event_name == 'issues' && github.event.issue.number) || + '' + }} diff --git a/.github/workflows/manage-labels.yml b/.github/workflows/manage-labels.yml new file mode 100644 index 00000000..45711bde --- /dev/null +++ b/.github/workflows/manage-labels.yml @@ -0,0 +1,19 @@ +--- +name: Manage Labels + +'on': + workflow_dispatch: + push: + branches: + - main + - master + paths: + - 'composer.json' + +permissions: + issues: write + contents: read + +jobs: + manage-labels: + uses: wp-cli/.github/.github/workflows/reusable-manage-labels.yml@main diff --git a/.github/workflows/regenerate-readme.yml b/.github/workflows/regenerate-readme.yml index c633d9d4..6198d630 100644 --- a/.github/workflows/regenerate-readme.yml +++ b/.github/workflows/regenerate-readme.yml @@ -10,6 +10,10 @@ on: - "features/**" - "README.md" +permissions: + contents: write + pull-requests: write + jobs: regenerate-readme: uses: wp-cli/.github/.github/workflows/reusable-regenerate-readme.yml@main diff --git a/.github/workflows/welcome-new-contributors.yml b/.github/workflows/welcome-new-contributors.yml new file mode 100644 index 00000000..bc01490b --- /dev/null +++ b/.github/workflows/welcome-new-contributors.yml @@ -0,0 +1,15 @@ +name: Welcome New Contributors + +on: + pull_request_target: + types: [opened] + branches: + - main + - master + +permissions: + pull-requests: write + +jobs: + welcome: + uses: wp-cli/.github/.github/workflows/reusable-welcome-new-contributors.yml@main diff --git a/.typos.toml b/.typos.toml new file mode 100644 index 00000000..965f891f --- /dev/null +++ b/.typos.toml @@ -0,0 +1,6 @@ +[default] +extend-ignore-re = [ + "(?Rm)^.*(#|//)\\s*spellchecker:disable-line$", + "(?s)(#|//)\\s*spellchecker:off.*?\\n\\s*(#|//)\\s*spellchecker:on", + "(#|//)\\s*spellchecker:ignore-next-line\\n.*" +] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..1ff84f6d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,121 @@ +# Instructions + +This package is part of WP-CLI, the official command line interface for WordPress. For a detailed explanation of the project structure and development workflow, please refer to the main @README.md file. + +## Best Practices for Code Contributions + +When contributing to this package, please adhere to the following guidelines: + +* **Follow Existing Conventions:** Before writing any code, analyze the existing codebase in this package to understand the coding style, naming conventions, and architectural patterns. +* **Focus on the Package's Scope:** All changes should be relevant to the functionality of the package. +* **Write Tests:** All new features and bug fixes must be accompanied by acceptance tests using Behat. You can find the existing tests in the `features/` directory. There may be PHPUnit unit tests as well in the `tests/` directory. +* **Update Documentation:** If your changes affect the user-facing functionality, please update the relevant inline code documentation. + +### Building and running + +Before submitting any changes, it is crucial to validate them by running the full suite of static code analysis and tests. To run the full suite of checks, execute the following command: `composer test`. + +This single command ensures that your changes meet all the quality gates of the project. While you can run the individual steps separately, it is highly recommended to use this single command to ensure a comprehensive validation. + +### Useful Composer Commands + +The project uses Composer to manage dependencies and run scripts. The following commands are available: + +* `composer install`: Install dependencies. +* `composer test`: Run the full test suite, including linting, code style checks, static analysis, and unit/behavior tests. +* `composer lint`: Check for syntax errors. +* `composer phpcs`: Check for code style violations. +* `composer phpcbf`: Automatically fix code style violations. +* `composer phpstan`: Run static analysis. +* `composer phpunit`: Run unit tests. +* `composer behat`: Run behavior-driven tests. + +### Coding Style + +The project follows the `WP_CLI_CS` coding standard, which is enforced by PHP_CodeSniffer. The configuration can be found in `phpcs.xml.dist`. Before submitting any code, please run `composer phpcs` to check for violations and `composer phpcbf` to automatically fix them. + +## Documentation + +The `README.md` file might be generated dynamically from the project's codebase using `wp scaffold package-readme` ([doc](https://github.com/wp-cli/scaffold-package-command#wp-scaffold-package-readme)). In that case, changes need to be made against the corresponding part of the codebase. + +### Inline Documentation + +Only write high-value comments if at all. Avoid talking to the user through comments. + +## Testing + +The project has a comprehensive test suite that includes unit tests, behavior-driven tests, and static analysis. + +* **Unit tests** are written with PHPUnit and can be found in the `tests/` directory. The configuration is in `phpunit.xml.dist`. +* **Behavior-driven tests** are written with Behat and can be found in the `features/` directory. The configuration is in `behat.yml`. +* **Static analysis** is performed with PHPStan. + +All tests are run on GitHub Actions for every pull request. + +When writing tests, aim to follow existing patterns. Key conventions include: + +* When adding tests, first examine existing tests to understand and conform to established conventions. +* For unit tests, extend the base `WP_CLI\Tests\TestCase` test class. +* For Behat tests, only WP-CLI commands installed in `composer.json` can be run. + +### Behat Steps + +WP-CLI makes use of a Behat-based testing framework and provides a set of custom step definitions to write feature tests. + +> **Note:** If you are expecting an error output in a test, you need to use `When I try ...` instead of `When I run ...` . + +#### Given + +* `Given an empty directory` - Creates an empty directory. +* `Given /^an? (empty|non-existent) ([^\s]+) directory$/` - Creates or deletes a specific directory. +* `Given an empty cache` - Clears the WP-CLI cache directory. +* `Given /^an? ([^\s]+) (file|cache file):$/` - Creates a file with the given contents. +* `Given /^"([^"]+)" replaced with "([^"]+)" in the ([^\s]+) file$/` - Search and replace a string in a file using regex. +* `Given /^that HTTP requests to (.*?) will respond with:$/` - Mock HTTP requests to a given URL. +* `Given WP files` - Download WordPress files without installing. +* `Given wp-config.php` - Create a wp-config.php file using `wp config create`. +* `Given a database` - Creates an empty database. +* `Given a WP install(ation)` - Installs WordPress. +* `Given a WP install(ation) in :subdir` - Installs WordPress in a given directory. +* `Given a WP install(ation) with Composer` - Installs WordPress with Composer. +* `Given a WP install(ation) with Composer and a custom vendor directory :vendor_directory` - Installs WordPress with Composer and a custom vendor directory. +* `Given /^a WP multisite (subdirectory|subdomain)?\s?(install|installation)$/` - Installs WordPress Multisite. +* `Given these installed and active plugins:` - Installs and activates one or more plugins. +* `Given a custom wp-content directory` - Configure a custom `wp-content` directory. +* `Given download:` - Download multiple files into the given destinations. +* `Given /^save (STDOUT|STDERR) ([\'].+[^\'])?\s?as \{(\w+)\}$/` - Store STDOUT or STDERR contents in a variable. +* `Given /^a new Phar with (?:the same version|version "([^"]+)")$/` - Build a new WP-CLI Phar file with a given version. +* `Given /^a downloaded Phar with (?:the same version|version "([^"]+)")$/` - Download a specific WP-CLI Phar version from GitHub. +* `Given /^save the (.+) file ([\'].+[^\'])? as \{(\w+)\}$/` - Stores the contents of the given file in a variable. +* `Given a misconfigured WP_CONTENT_DIR constant directory` - Modify wp-config.php to set `WP_CONTENT_DIR` to an empty string. +* `Given a dependency on current wp-cli` - Add `wp-cli/wp-cli` as a Composer dependency. +* `Given a PHP built-in web server` - Start a PHP built-in web server in the current directory. +* `Given a PHP built-in web server to serve :subdir` - Start a PHP built-in web server in the given subdirectory. + +#### When + +* ``When /^I launch in the background `([^`]+)`$/`` - Launch a given command in the background. +* ``When /^I (run|try) `([^`]+)`$/`` - Run or try a given command. +* ``When /^I (run|try) `([^`]+)` from '([^\s]+)'$/`` - Run or try a given command in a subdirectory. +* `When /^I (run|try) the previous command again$/` - Run or try the previous command again. + +#### Then + +* `Then /^the return code should( not)? be (\d+)$/` - Expect a specific exit code of the previous command. +* `Then /^(STDOUT|STDERR) should( strictly)? (be|contain|not contain):$/` - Check the contents of STDOUT or STDERR. +* `Then /^(STDOUT|STDERR) should be a number$/` - Expect STDOUT or STDERR to be a numeric value. +* `Then /^(STDOUT|STDERR) should not be a number$/` - Expect STDOUT or STDERR to not be a numeric value. +* `Then /^STDOUT should be a table containing rows:$/` - Expect STDOUT to be a table containing the given rows. +* `Then /^STDOUT should end with a table containing rows:$/` - Expect STDOUT to end with a table containing the given rows. +* `Then /^STDOUT should be JSON containing:$/` - Expect valid JSON output in STDOUT. +* `Then /^STDOUT should be a JSON array containing:$/` - Expect valid JSON array output in STDOUT. +* `Then /^STDOUT should be CSV containing:$/` - Expect STDOUT to be CSV containing certain values. +* `Then /^STDOUT should be YAML containing:$/` - Expect STDOUT to be YAML containing certain content. +* `Then /^(STDOUT|STDERR) should be empty$/` - Expect STDOUT or STDERR to be empty. +* `Then /^(STDOUT|STDERR) should not be empty$/` - Expect STDOUT or STDERR not to be empty. +* `Then /^(STDOUT|STDERR) should be a version string (<|<=|>|>=|==|=|<>) ([+\w.{}-]+)$/` - Expect STDOUT or STDERR to be a version string comparing to the given version. +* `Then /^the (.+) (file|directory) should( strictly)? (exist|not exist|be:|contain:|not contain):$/` - Expect a certain file or directory to (not) exist or (not) contain certain contents. +* `Then /^the contents of the (.+) file should( not)? match (((\/.*\/)|(#.#))([a-z]+)?)$/` - Match file contents against a regex. +* `Then /^(STDOUT|STDERR) should( not)? match (((\/.*\/)|(#.#))([a-z]+)?)$/` - Match STDOUT or STDERR against a regex. +* `Then /^an email should (be sent|not be sent)$/` - Expect an email to be sent (or not). +* `Then the HTTP status code should be :code` - Expect the HTTP status code for visiting `http://localhost:8080`. diff --git a/README.md b/README.md index 547fa52a..8ba809b8 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ wp-cli/db-command Performs basic database operations using credentials stored in wp-config.php. -[![Testing](https://github.com/wp-cli/db-command/actions/workflows/testing.yml/badge.svg)](https://github.com/wp-cli/db-command/actions/workflows/testing.yml) +[![Testing](https://github.com/wp-cli/db-command/actions/workflows/testing.yml/badge.svg)](https://github.com/wp-cli/db-command/actions/workflows/testing.yml) [![Code Coverage](https://codecov.io/gh/wp-cli/db-command/branch/main/graph/badge.svg)](https://codecov.io/gh/wp-cli/db-command/tree/main) Quick links: [Using](#using) | [Installing](#installing) | [Contributing](#contributing) | [Support](#support) @@ -303,6 +303,8 @@ Opens a MySQL console using credentials from wp-config.php wp db cli [--database=] [--default-character-set=] [--dbuser=] [--dbpass=] [--=] [--defaults] ~~~ +**Alias:** `connect` + **OPTIONS** [--database=] @@ -429,11 +431,13 @@ To confirm the ID for the site you want to query, you can use the `wp site list` Exports the database to a file or to STDOUT. ~~~ -wp db export [] [--dbuser=] [--dbpass=] [--=] [--tables=] [--exclude_tables=] [--include-tablespaces] [--porcelain] [--add-drop-table] [--defaults] +wp db export [] [--dbuser=] [--dbpass=] [--=] [--tables=] [--exclude_tables=] [--include-tablespaces] [--porcelain] [--defaults] ~~~ +**Alias:** `dump` + Runs `mysqldump` utility using `DB_HOST`, `DB_NAME`, `DB_USER` and -`DB_PASSWORD` database credentials specified in wp-config.php. Accepts any valid `mysqldump` flags. +`DB_PASSWORD` database credentials specified in wp-config.php. Accepts any valid [`mysqldump` flags](https://dev.mysql.com/doc/en/mysqldump.html#mysqldump-option-summary). **OPTIONS** @@ -462,16 +466,13 @@ Runs `mysqldump` utility using `DB_HOST`, `DB_NAME`, `DB_USER` and [--porcelain] Output filename for the exported database. - [--add-drop-table] - Include a `DROP TABLE IF EXISTS` statement before each `CREATE TABLE` statement. - [--defaults] Loads the environment's MySQL option files. Default behavior is to skip loading them to avoid failures due to misconfiguration. **EXAMPLES** - # Export database with drop query included - $ wp db export --add-drop-table + # Export database with `--skip-opt` and `--add-drop-table` mysqldump flags + $ wp db export --skip-opt --add-drop-table Success: Exported to 'wordpress_dbase-db72bb5.sql'. # Export certain tables @@ -644,39 +645,39 @@ Defaults to searching through all tables registered to $wpdb. On multisite, this - count --- -The percent color codes available are: - -| Code | Color -| ---- | ----- -| %y | Yellow (dark) (mustard) -| %g | Green (dark) -| %b | Blue (dark) -| %r | Red (dark) -| %m | Magenta (dark) -| %c | Cyan (dark) -| %w | White (dark) (light gray) -| %k | Black -| %Y | Yellow (bright) -| %G | Green (bright) -| %B | Blue (bright) -| %R | Red (bright) -| %M | Magenta (bright) -| %C | Cyan (bright) -| %W | White -| %K | Black (bright) (dark gray) -| %3 | Yellow background (dark) (mustard) -| %2 | Green background (dark) -| %4 | Blue background (dark) -| %1 | Red background (dark) -| %5 | Magenta background (dark) -| %6 | Cyan background (dark) -| %7 | White background (dark) (light gray) -| %0 | Black background -| %8 | Reverse -| %U | Underline -| %F | Blink (unlikely to work) - -They can be concatenated. For instance, the default match color of black on a mustard (dark yellow) background `%3%k` can be made black on a bright yellow background with `%Y%0%8`. + The percent color codes available are: + + | Code | Color + | ---- | ----- + | %y | Yellow (dark) (mustard) + | %g | Green (dark) + | %b | Blue (dark) + | %r | Red (dark) + | %m | Magenta (dark) + | %c | Cyan (dark) + | %w | White (dark) (light gray) + | %k | Black + | %Y | Yellow (bright) + | %G | Green (bright) + | %B | Blue (bright) + | %R | Red (bright) + | %M | Magenta (bright) + | %C | Cyan (bright) + | %W | White + | %K | Black (bright) (dark gray) + | %3 | Yellow background (dark) (mustard) + | %2 | Green background (dark) + | %4 | Blue background (dark) + | %1 | Red background (dark) + | %5 | Magenta background (dark) + | %6 | Cyan background (dark) + | %7 | White background (dark) (light gray) + | %0 | Black background + | %8 | Reverse + | %U | Underline + | %F | Blink (unlikely to work) + + They can be concatenated. For instance, the default match color of black on a mustard (dark yellow) background `%3%k` can be made black on a bright yellow background with `%Y%0%8`. **AVAILABLE FIELDS** @@ -755,7 +756,22 @@ Defaults to all tables registered to the $wpdb database handler. List tables based on wildcard search, e.g. 'wp_*_options' or 'wp_post?'. [--scope=] - Can be all, global, ms_global, blog, or old tables. Defaults to all. + List tables based on the scope. + + - all: returns 'all' and 'global' tables. No old tables are returned. + - blog: returns the blog-level tables for the queried blog. + - global: returns the global tables for the installation, returning multisite tables only on multisite. + - ms_global: returns the multisite global tables, regardless if current installation is multisite. + - old: returns tables which are deprecated. + --- + default: all + options: + - all + - blog + - global + - ms_global + - old + --- [--network] List all the tables in a multisite install. @@ -1016,9 +1032,13 @@ Want to contribute a new feature? Please first [open a new issue](https://github Once you've decided to commit the time to seeing your pull request through, [please follow our guidelines for creating a pull request](https://make.wordpress.org/cli/handbook/pull-requests/) to make sure it's a pleasant experience. See "[Setting up](https://make.wordpress.org/cli/handbook/pull-requests/#setting-up)" for details specific to working on this package locally. +### License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + ## Support -GitHub issues aren't for general support questions, but there are other venues you can try: https://wp-cli.org/#support +GitHub issues aren't for general support questions. For support resources and next steps, see the WP-CLI Support page: https://make.wordpress.org/cli/handbook/support/ *This README.md is generated dynamically from the project's codebase using `wp scaffold package-readme` ([doc](https://github.com/wp-cli/scaffold-package-command#wp-scaffold-package-readme)). To suggest changes, please submit a pull request against the corresponding part of the codebase.* diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..e69de29b diff --git a/features/db-check.feature b/features/db-check.feature index aa5ce47b..53c73d1a 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,28 @@ Feature: Check the database Success: Database checked. """ + @require-mysql-or-mariadb + Scenario: db check with --quiet flag should only show errors + Given a WP install + + When I run `wp db check --quiet` + Then STDOUT should be empty + + @require-mysql-or-mariadb + Scenario: db check can explicitly pass --silent to mysqlcheck + Given a WP install + + When I run `wp db check --silent` + Then STDOUT should not contain: + """ + wp_cli_test.wp_users + """ + And STDOUT should contain: + """ + Success: Database checked. + """ + + @require-mysql-or-mariadb Scenario: Run db check with MySQL defaults to check the database Given a WP install @@ -26,6 +49,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 +63,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 +149,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 +162,44 @@ 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-mysql-or-mariadb + Scenario: Empty DB credentials should not cause empty parameter errors + Given an empty directory + And WP files + + When I run `wp config create {CORE_CONFIG_SETTINGS} --dbcharset="" --skip-check` + Then STDOUT should not be empty + + When I run `cat wp-config.php` + Then STDOUT should contain: + """ + define( 'DB_CHARSET', '' ); + """ + + When I run `wp db create` + Then STDOUT should not be empty + + When I try `wp db check --debug` + Then the return code should be 0 + And STDOUT should contain: + """ + Success: Database checked. + """ + And STDERR should not contain: + """ + --default-character-set='' + """ + And STDERR should not contain: + """ + --default-character-set= + """ + + @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..ba55cf6b 100644 --- a/features/db-columns.feature +++ b/features/db-columns.feature @@ -1,7 +1,7 @@ Feature: Display information about a given table. # This requires conditional tags to target different DB versions, as bigint(20) is only bigint on MySQL 8. - @require-wp-4.2 @broken + @broken Scenario: Display information about the wp_posts table Given a WP install @@ -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..de212a13 100644 --- a/features/db-export.feature +++ b/features/db-export.feature @@ -20,20 +20,57 @@ Feature: Export a WordPress database """ And the wp_cli_test.sql file should exist + @skip-sqlite 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: + 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["`]?/ + + @skip-sqlite + Scenario: Include only specific tables when exporting the database + Given a WP install + + When I try `wp db export wp_cli_test.sql --tables=wp_users --porcelain` + Then the wp_cli_test.sql file should exist + And the contents of the wp_cli_test.sql file should match /CREATE TABLE ["`]?wp_users["`]?/ + And the contents of the wp_cli_test.sql file should not match /CREATE TABLE ["`]?wp_posts["`]?/ + And the contents of the wp_cli_test.sql file should not match /CREATE TABLE ["`]?wp_options["`]?/ + + @require-sqlite + Scenario: Exclude tables when exporting the database + Given a WP install + + 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 contents of the wp_cli_test.sql file should not match /_mysql_data_types_cache/ + 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["`]?/ + + @require-sqlite + Scenario: Include only specific tables when exporting the database + Given a WP install + + When I try `wp db export wp_cli_test.sql --tables=wp_users --porcelain` + Then the wp_cli_test.sql file should exist + And the contents of the wp_cli_test.sql file should not match /_mysql_data_types_cache/ + And the contents of the wp_cli_test.sql file should match /CREATE TABLE ["`]?wp_users["`]?/ + And the contents of the wp_cli_test.sql file should not match /CREATE TABLE ["`]?wp_posts["`]?/ + And the contents of the wp_cli_test.sql file should not match /CREATE TABLE ["`]?wp_options["`]?/ + + @require-sqlite + Scenario: Export database to STDOUT + Given a WP install + + When I run `wp db export -` + Then STDOUT should contain: """ - wp_options + PRAGMA foreign_keys=OFF """ + @skip-sqlite Scenario: Export database to STDOUT Given a WP install @@ -42,7 +79,7 @@ Feature: Export a WordPress database """ -- Dump completed on """ - + @skip-sqlite Scenario: Export database with mysql defaults to STDOUT Given a WP install @@ -52,6 +89,7 @@ Feature: Export a WordPress database -- Dump completed on """ + @skip-sqlite Scenario: Export database with mysql --no-defaults to STDOUT Given a WP install @@ -61,6 +99,7 @@ Feature: Export a WordPress database -- Dump completed on """ + @skip-sqlite Scenario: Export database with passed-in options Given a WP install @@ -78,6 +117,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 + """ + + @skip-sqlite Scenario: MySQL defaults are available as appropriate with --defaults flag Given a WP install @@ -89,3 +147,16 @@ Feature: Export a WordPress database When I try `wp db export --no-defaults --debug` Then STDERR should match #Debug \(db\): Running initial shell command: /usr/bin/env (mysqldump|mariadb-dump) --no-defaults# + + @skip-sqlite + @skip-windows + Scenario: Export database when PHP exec() is disabled + Given a WP install + + When I try `{INVOKE_WP_CLI_WITH_PHP_ARGS--ddisable_functions=exec} db export wp_cli_test.sql --porcelain` + Then the return code should be 0 + And STDOUT should contain: + """ + wp_cli_test.sql + """ + And the wp_cli_test.sql file should exist diff --git a/features/db-import.feature b/features/db-import.feature index 5761fb24..8438d06b 100644 --- a/features/db-import.feature +++ b/features/db-import.feature @@ -36,6 +36,7 @@ Feature: Import a WordPress database Success: Imported from 'wp_cli_test.sql'. """ + @require-mysql-or-mariadb Scenario: Import from STDIN Given a WP install @@ -45,6 +46,17 @@ Feature: Import a WordPress database Success: Imported from 'STDIN'. """ + # TODO: Debug the difference here. + @require-sqlite + Scenario: Import from STDIN + Given a WP install + + When I run `echo "" | wp db import -` + Then STDOUT should be: + """ + Success: Imported from 'STDIN'. + """ + Scenario: Import from database name path by default and skip speed optimization Given a WP install @@ -57,6 +69,8 @@ Feature: Import a WordPress database Success: Imported from 'wp_cli_test.sql'. """ + # SQLite doesn't support the --dbuser flag. + @require-mysql-or-mariadb Scenario: Import from database name path by default with passed-in dbuser/dbpass Given a WP install @@ -77,6 +91,8 @@ Feature: Import a WordPress database """ And STDOUT should be empty + # SQLite doesn't support the --force flag. + @require-mysql-or-mariadb Scenario: Import database with passed-in options Given a WP install And a debug.sql file: @@ -90,6 +106,9 @@ Feature: Import a WordPress database Success: Imported from 'debug.sql'. """ + # For SQLite this would fail at the `wp db create` step + # because of the missing plugin/drop-in. + @require-mysql-or-mariadb Scenario: Help runs properly at various points of a functional WP install Given an empty directory @@ -127,6 +146,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 +163,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-mysql-or-mariadb Scenario: Import db that has emoji in post Given a WP install @@ -178,3 +199,32 @@ Feature: Import a WordPress database """ 🍣 """ + + @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..48a50af5 100644 --- a/features/db-query.feature +++ b/features/db-query.feature @@ -21,6 +21,8 @@ Feature: Query the database with WordPress' MySQL config 1 """ + # SQLite doesn't support the --html option nor different dbuser. + @require-mysql-or-mariadb Scenario: Database querying with passed-in options Given a WP install @@ -38,6 +40,8 @@ Feature: Query the database with WordPress' MySQL config """ And STDOUT should be empty + # SQLite doesn't support the --html option nor different dbuser. + @require-mysql-or-mariadb Scenario: Database querying with MySQL defaults and passed-in options Given a WP install @@ -55,6 +59,8 @@ Feature: Query the database with WordPress' MySQL config """ And STDOUT should be empty + # SQLite doesn't support the --html option nor different dbuser. + @require-mysql-or-mariadb Scenario: Database querying with --nodefaults and passed-in options Given a WP install @@ -72,6 +78,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-search.feature b/features/db-search.feature index e37826dd..f5e3225e 100644 --- a/features/db-search.feature +++ b/features/db-search.feature @@ -264,7 +264,6 @@ Feature: Search through the database And the return code should be 1 When I run `wp db query "CREATE TABLE no_key ( awesome_stuff TEXT );"` - And I run `wp db query "CREATE TABLE no_text ( id int(11) unsigned NOT NULL AUTO_INCREMENT, PRIMARY KEY (id) );"` And I try `wp db search example.com no_key --all-tables` Then STDOUT should be empty And STDERR should be: @@ -273,6 +272,10 @@ Feature: Search through the database """ And the return code should be 0 + @require-mysql-or-mariadb + Scenario: Search on a single site install - No text columns for table + Given a WP install + And I run `wp db query "CREATE TABLE no_text ( id int(11) unsigned NOT NULL AUTO_INCREMENT, PRIMARY KEY (id) );"` When I try `wp db search example.com no_text --all-tables` Then STDOUT should be empty And STDERR should be: @@ -676,19 +679,19 @@ Feature: Search through the database :あいうえおかきくけこさしすせとたちつてと """ - When I run `wp db search 'ppppp' --before_context=3 --after_context=4` + When I run `wp db search "ppppp" --before_context=3 --after_context=4` Then STDOUT should contain: """ :ムnöppppp """ - When I run `wp db search 'ppppp' --before_context=1 --after_context=1` + When I run `wp db search "ppppp" --before_context=1 --after_context=1` Then STDOUT should contain: """ :öppppp """ - When I run `wp db search 'ムn' --before_context=2 --after_context=1` + When I run `wp db search "ムn" --before_context=2 --after_context=1` Then STDOUT should contain: """ :llムnö @@ -698,7 +701,7 @@ Feature: Search through the database :llムnöp """ - When I run `wp db search 'ムn' --before_context=2 --after_context=2` + When I run `wp db search "ムn" --before_context=2 --after_context=2` Then STDOUT should contain: """ :llムnöp @@ -717,7 +720,7 @@ Feature: Search through the database # Note ö is o with combining umlaut. And I run `wp option update regextst_combining 'lllllムnöppppp'` - When I run `wp db search 'https?:\/\/example.c.m' --regex` + When I run `wp db search "https?:\/\/example.c.m" --regex` Then STDOUT should contain: """ wp_options:option_value @@ -728,10 +731,10 @@ Feature: Search through the database [...] """ - When I run `wp db search 'unfindable' --regex` + When I run `wp db search "unfindable" --regex` Then STDOUT should be empty - When I try `wp db search 'unfindable' --regex --regex-flags='abcd'` + When I try `wp db search "unfindable" --regex --regex-flags="abcd"` Then STDERR should contain: """ unfindable @@ -742,42 +745,42 @@ Feature: Search through the database """ And the return code should be 1 - When I try `wp db search 'unfindable' --regex --regex-delimiter='1'` + When I try `wp db search "unfindable" --regex --regex-delimiter="1"` Then STDERR should be: """ Error: The regex '1unfindable1' fails. """ And the return code should be 1 - When I try `wp db search 'regex error)' --regex` + When I try `wp db search "regex error)" --regex` Then STDERR should be: """ Error: The regex pattern 'regex error)' with default delimiter 'chr(1)' and no flags fails. """ And the return code should be 1 - When I try `wp db search 'regex error)' --regex --regex-flags=u` + When I try `wp db search "regex error)" --regex --regex-flags=u` Then STDERR should be: """ Error: The regex pattern 'regex error)' with default delimiter 'chr(1)' and flags 'u' fails. """ And the return code should be 1 - When I try `wp db search 'regex error)' --regex --regex-delimiter=/` + When I try `wp db search "regex error)" --regex --regex-delimiter=#` Then STDERR should be: """ - Error: The regex '/regex error)/' fails. + Error: The regex '#regex error)#' fails. """ And the return code should be 1 - When I try `wp db search 'regex error)' --regex --regex-delimiter=/ --regex-flags=u` + When I try `wp db search "regex error)" --regex --regex-delimiter=# --regex-flags=u` Then STDERR should be: """ - Error: The regex '/regex error)/u' fails. + Error: The regex '#regex error)#u' fails. """ And the return code should be 1 - When I run `wp db search '[0-9é]+?https:' --regex --regex-flags=u --before_context=0 --after_context=0` + When I run `wp db search "[0-9é]+?https:" --regex --regex-flags=u --before_context=0 --after_context=0` Then STDOUT should contain: """ :1234567890123456789éhttps: @@ -791,7 +794,7 @@ Feature: Search through the database [...] """ - When I run `wp db search 'htt(p(s):)\/\/' --regex --before_context=1 --after_context=3` + When I run `wp db search "htt(p(s):)\/\/" --regex --before_context=1 --after_context=3` Then STDOUT should contain: """ :あhttps://reg [...] éhttps://reg @@ -801,7 +804,7 @@ Feature: Search through the database rege """ - When I run `wp db search 'https://' --regex --regex-delimiter=# --before_context=9 --after_context=11` + When I run `wp db search "https://" --regex --regex-delimiter=# --before_context=9 --after_context=11` Then STDOUT should contain: """ :2345é789あhttps://regextst.co [...] 23456789éhttps://regextst.co @@ -811,10 +814,10 @@ Feature: Search through the database regextst.com """ - When I run `wp db search 'httPs://' --regex --regex-delimiter=# --before_context=3 --after_context=0` + When I run `wp db search "httPs://" --regex --regex-delimiter=# --before_context=3 --after_context=0` Then STDOUT should be empty - When I run `wp db search 'httPs://' --regex --regex-flags=i --regex-delimiter=# --before_context=3 --after_context=0` + When I run `wp db search "httPs://" --regex --regex-flags=i --regex-delimiter=# --before_context=3 --after_context=0` Then STDOUT should contain: """ :89あhttps:// [...] 89éhttps:// @@ -824,19 +827,19 @@ Feature: Search through the database https://r """ - When I run `wp db search 'ppppp' --regex --before_context=3 --after_context=4` + When I run `wp db search "ppppp" --regex --before_context=3 --after_context=4` Then STDOUT should contain: """ :ムnöppppp """ - When I run `wp db search 'ppppp' --regex --before_context=1 --after_context=1` + When I run `wp db search "ppppp" --regex --before_context=1 --after_context=1` Then STDOUT should contain: """ :öppppp """ - When I run `wp db search 'ムn' --before_context=2 --after_context=1` + When I run `wp db search "ムn" --before_context=2 --after_context=1` Then STDOUT should contain: """ :llムnö @@ -846,7 +849,7 @@ Feature: Search through the database :llムnöp """ - When I run `wp db search 'ムn' --regex --before_context=2 --after_context=2` + When I run `wp db search "ムn" --regex --before_context=2 --after_context=2` Then STDOUT should contain: """ :llムnöp @@ -856,7 +859,7 @@ Feature: Search through the database :llムnöpp """ - When I run `wp db search 't\.c' --regex --before_context=1 --after_context=1` + When I run `wp db search "t\.c" --regex --before_context=1 --after_context=1` Then STDOUT should contain: """ :st.co [...] st.co [...] st.co [...] 0t.co @@ -866,10 +869,9 @@ Feature: Search through the database st.com """ - When I run `wp db search 'https://' --regex` + When I run `wp db search "https://" --regex` Then the return code should be 0 - @require-wp-4.7 Scenario: Search with output options Given a WP install @@ -922,12 +924,65 @@ Feature: Search through the database """ Success: Found """ - And STDOUT should contain: + And STDOUT should match /\d tables? skipped:.*wp_term_relationships/ + And STDERR should be empty + + Scenario: Search with output options + Given a WP install + + When I run `wp db query "SELECT option_id FROM wp_options WHERE option_name = 'siteurl';" --skip-column-names | cat` + Then save STDOUT as {SITEURL_ID} + + When I run `wp db query "SELECT option_id FROM wp_options WHERE option_name = 'home';" --skip-column-names | cat` + Then save STDOUT as {HOMEURL_ID} + + When I run `wp db search example.com` + Then STDOUT should contain: + """ + wp_options:option_value + {SITEURL_ID}:https://example.com + wp_options:option_value + {HOMEURL_ID}:https://example.com + """ + + When I run `wp db search example.com --table_column_once` + Then STDOUT should contain: + """ + wp_options:option_value + {SITEURL_ID}:https://example.com + {HOMEURL_ID}:https://example.com + """ + + When I run `wp db search example.com --one_line` + Then STDOUT should contain: + """ + wp_options:option_value:{SITEURL_ID}:https://example.com + wp_options:option_value:{HOMEURL_ID}:https://example.com + """ + + When I run `wp db search example.com --table_column_once --one_line` + Then STDOUT should contain: + """ + wp_options:option_value:{SITEURL_ID}:https://example.com + wp_options:option_value:{HOMEURL_ID}:https://example.com + """ + + When I run `wp db search example.com --all-tables --before_context=0 --after_context=0 --matches_only` + Then STDOUT should not contain: + """ + : """ - 1 table skipped: wp_term_relationships. + And STDERR should be empty + + When I run `wp db search example.com --all-tables --before_context=0 --after_context=0 --stats` + Then STDOUT should contain: + """ + Success: Found """ + And STDOUT should match /\d tables? skipped:.*wp_term_relationships/ And STDERR should be empty + @skip-windows Scenario: Search with custom colors Given a WP install @@ -991,6 +1046,9 @@ Feature: Search through the database """ And the return code should be 0 + # SQLite doesn't support SOURCE statement. + # TODO: Add similar test for SQLite. + @require-mysql-or-mariadb Scenario: Search should cater for field/table names that use reserved words or unusual characters Given a WP install And a esc_sql_ident.sql file: @@ -1003,7 +1061,7 @@ Feature: Search through the database When I run `wp db query "SOURCE esc_sql_ident.sql;"` Then STDERR should be empty - When I run `wp db search 'v_v' TABLE --all-tables` + When I run `wp db search "v_v" TABLE --all-tables` Then STDOUT should be: """ TABLE:VALUES diff --git a/features/db-size.feature b/features/db-size.feature index 0958bbf1..17f0d687 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,23 @@ 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 + """ + + # On CI, SQLite on Windows is missing the dbstat extension. + @skip-windows Scenario: Display only table sizes for a WordPress install Given a WP install @@ -27,6 +45,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 +68,23 @@ 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 + """ + + # On CI, SQLite on Windows is missing the dbstat extension. + @skip-windows Scenario: Display only table sizes in a human readable format for a WordPress install Given a WP install @@ -155,6 +191,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 +212,22 @@ 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 match /Name,Size\n\.ht\.sqlite(\.php)?,"/ + + But STDOUT should not be a number + + When I run `wp db size --size_format=b --format=json` + Then STDOUT should match /^\[\{"Name":"\.ht\.sqlite(\.php)?","Size":"/ + + But STDOUT should not be a number + + # On CI, SQLite on Windows is missing the dbstat extension. + @skip-windows Scenario: Display all table sizes for a WordPress install Given a WP install @@ -235,6 +288,8 @@ Feature: Display database size [{"Name":"wp_posts", """ + # On CI, SQLite on Windows is missing the dbstat extension. + @skip-windows Scenario: Display ordered table sizes for a WordPress install Given a WP install diff --git a/features/db-tables.feature b/features/db-tables.feature index af8cc6ac..cb3f6810 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,40 @@ 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: + """ + wp_users + 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 +153,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: + """ + wp_users + 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 + """ + + # AUTO_INCREMENT doesn't work with SQLite. + @require-mysql-or-mariadb Scenario: Listing a site's tables should only list that site's tables Given a WP multisite install @@ -211,11 +325,14 @@ Feature: List database tables """ When I run `wp db tables '*_posts' --network` - Then STDOUT should be: + Then STDOUT should contain: """ - as_wp_2_posts as_wp_posts """ + And STDOUT should contain: + """ + as_wp_2_posts + """ When I run `wp db tables '*_posts' --scope=blog` Then STDOUT should be: @@ -224,11 +341,14 @@ Feature: List database tables """ When I run `wp db tables '*_posts' --scope=blog --network` - Then STDOUT should be: + Then STDOUT should contain: """ - as_wp_2_posts as_wp_posts """ + And STDOUT should contain: + """ + as_wp_2_posts + """ When I try `wp db tables '*_posts' --scope=global` Then STDERR should not be empty diff --git a/features/db.feature b/features/db.feature index dbfdb4b0..7a58c3a2 100644 --- a/features/db.feature +++ b/features/db.feature @@ -1,5 +1,6 @@ Feature: Perform database operations + @skip-sqlite 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. """ + @skip-sqlite 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 + @skip-sqlite 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 + @skip-sqlite 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 + @skip-sqlite Scenario: DB Operations with passed-in options Given a WP install @@ -185,6 +190,30 @@ Feature: Perform database operations """ And STDOUT should not be empty + @skip-sqlite + Scenario: db repair with --quiet flag should only show errors + Given a WP install + + When I run `wp db repair --quiet` + Then STDOUT should not contain: + """ + error + """ + + @skip-sqlite + Scenario: db repair can explicitly pass --silent to mysqlcheck + Given a WP install + + When I run `wp db repair --silent` + Then STDOUT should not contain: + """ + error + """ + And STDOUT should contain: + """ + Success: Database repaired. + """ + Scenario: DB Query Given a WP install @@ -204,6 +233,9 @@ Feature: Perform database operations total """ + Scenario: DB Query with --skip-column-names + Given a WP install + When I run `wp db query 'SELECT * FROM wp_options WHERE option_name="home"' --skip-column-names` Then STDOUT should not contain: """ @@ -214,6 +246,7 @@ Feature: Perform database operations home """ + @skip-sqlite Scenario: DB export/import Given a WP install @@ -223,7 +256,7 @@ Feature: Perform database operations 1 """ - When I run `wp db export /tmp/wp-cli-behat.sql` + When I run `wp db export wp-cli-behat.sql` Then STDOUT should contain: """ Success: Exported @@ -250,7 +283,7 @@ Feature: Perform database operations When I try `wp post list --format=count` Then STDERR should not be empty - When I run `wp db import /tmp/wp-cli-behat.sql` + When I run `wp db import wp-cli-behat.sql` Then STDOUT should contain: """ Success: Imported @@ -262,11 +295,12 @@ Feature: Perform database operations 1 """ + @skip-sqlite Scenario: DB export no charset Given an empty directory And WP files - When I run `wp core config {CORE_CONFIG_SETTINGS} --dbcharset=""` + When I run `wp config create {CORE_CONFIG_SETTINGS} --dbcharset="" --skip-check` Then STDOUT should not be empty When I run `cat wp-config.php` @@ -278,17 +312,18 @@ Feature: Perform database operations When I run `wp db create` Then STDOUT should not be empty - When I run `wp db export /tmp/wp-cli-behat.sql` + When I run `wp db export wp-cli-behat.sql` Then STDOUT should contain: """ Success: Exported """ + @skip-sqlite Scenario: Persist DB charset and collation Given an empty directory And WP files - When I run `wp core config {CORE_CONFIG_SETTINGS} --dbcharset=latin1 --dbcollate=latin1_spanish_ci` + When I run `wp config create {CORE_CONFIG_SETTINGS} --dbcharset=latin1 --dbcollate=latin1_spanish_ci --skip-check` Then STDOUT should not be empty When I run `wp db create` @@ -327,6 +362,7 @@ Feature: Perform database operations latin1_spanish_ci """ + @skip-sqlite 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 +370,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 +382,80 @@ Feature: Perform database operations """ Query succeeded. Rows affected: 1 """ + + @require-sqlite @skip-windows + # Skipped on Windows due to persistent file locking issues when run via Behat. + 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 @skip-windows + # Skipped on Windows due to persistent file locking issues when run via Behat. + Scenario: SQLite DB export/import + Given a WP install + And a session_yes file: + """ + y + """ + + When I run `wp post list --format=count` + Then STDOUT should contain: + """ + 1 + """ + + When I run `wp db export 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 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..b439f14f 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -5,8 +5,8 @@ @@ -55,6 +55,9 @@ */src/DB_Command\.php$ + + */src/DB_Command_SQLite\.php$ + /tests/phpstan/scan-files diff --git a/src/DB_Command.php b/src/DB_Command.php index 06781cee..13de3c55 100644 --- a/src/DB_Command.php +++ b/src/DB_Command.php @@ -1,7 +1,11 @@ maybe_load_sqlite_dropin(); + + if ( $this->is_sqlite() ) { + $this->sqlite_create(); + return; + } $this->run_query( self::get_create_query(), $assoc_args ); @@ -115,6 +127,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 +170,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 +279,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', @@ -259,6 +295,12 @@ public function check( $_, $assoc_args ) { WP_CLI::debug( "Running shell command: {$command}", 'db' ); $assoc_args['check'] = true; + + // Pass --silent to mysqlcheck when in quiet mode. + if ( WP_CLI::get_config( 'quiet' ) ) { + $assoc_args['silent'] = true; + } + self::run( Utils\esc_cmd( $command, DB_NAME ), $assoc_args @@ -298,6 +340,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(), @@ -307,6 +356,12 @@ public function optimize( $_, $assoc_args ) { WP_CLI::debug( "Running shell command: {$command}", 'db' ); $assoc_args['optimize'] = true; + + // Pass --silent to mysqlcheck when in quiet mode. + if ( WP_CLI::get_config( 'quiet' ) ) { + $assoc_args['silent'] = true; + } + self::run( Utils\esc_cmd( $command, DB_NAME ), $assoc_args @@ -346,6 +401,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(), @@ -355,6 +417,12 @@ public function repair( $_, $assoc_args ) { WP_CLI::debug( "Running shell command: {$command}", 'db' ); $assoc_args['repair'] = true; + + // Pass --silent to mysqlcheck when in quiet mode. + if ( WP_CLI::get_config( 'quiet' ) ) { + $assoc_args['silent'] = true; + } + self::run( Utils\esc_cmd( $command, DB_NAME ), $assoc_args @@ -396,6 +464,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 +573,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, $assoc_args ); + return; + } $command = sprintf( '/usr/bin/env %s%s --no-auto-rehash', @@ -519,7 +611,7 @@ public function query( $args, $assoc_args ) { $assoc_args['execute'] = $this->get_sql_mode_query( $assoc_args ) . $assoc_args['execute']; } - $is_row_modifying_query = isset( $assoc_args['execute'] ) && preg_match( '/\b(UPDATE|DELETE|INSERT|REPLACE|LOAD DATA)\b/i', $assoc_args['execute'] ); + $is_row_modifying_query = isset( $assoc_args['execute'] ) && preg_match( '/\b(UPDATE|DELETE|INSERT|REPLACE(?!\s*\()|LOAD DATA)\b/i', $assoc_args['execute'] ); if ( $is_row_modifying_query ) { $assoc_args['execute'] .= '; SELECT ROW_COUNT();'; @@ -544,7 +636,7 @@ public function query( $args, $assoc_args ) { * Exports the database to a file or to STDOUT. * * Runs `mysqldump` utility using `DB_HOST`, `DB_NAME`, `DB_USER` and - * `DB_PASSWORD` database credentials specified in wp-config.php. Accepts any valid `mysqldump` flags. + * `DB_PASSWORD` database credentials specified in wp-config.php. Accepts any valid [`mysqldump` flags](https://dev.mysql.com/doc/en/mysqldump.html#mysqldump-option-summary). * * ## OPTIONS * @@ -573,16 +665,13 @@ public function query( $args, $assoc_args ) { * [--porcelain] * : Output filename for the exported database. * - * [--add-drop-table] - * : Include a `DROP TABLE IF EXISTS` statement before each `CREATE TABLE` statement. - * * [--defaults] * : Loads the environment's MySQL option files. Default behavior is to skip loading them to avoid failures due to misconfiguration. * * ## EXAMPLES * - * # Export database with drop query included - * $ wp db export --add-drop-table + * # Export database with `--skip-opt` and `--add-drop-table` mysqldump flags + * $ wp db export --skip-opt --add-drop-table * Success: Exported to 'wordpress_dbase-db72bb5.sql'. * * # Export certain tables @@ -629,6 +718,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 +728,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' ); @@ -651,7 +748,7 @@ public function export( $args, $assoc_args ) { $mysqldump_binary = Utils\force_env_on_nix_systems( Utils\get_sql_dump_command() ); - $support_column_statistics = exec( $mysqldump_binary . ' --help | grep "column-statistics"' ); + $support_column_statistics = $this->command_supports_option( $mysqldump_binary, 'column-statistics' ); /* * In case that `--default-character-set` is not given and `DB_CHARSET` is `utf8`, @@ -763,6 +860,19 @@ private function get_posts_table_charset( $assoc_args ) { return $stdout; } + /** + * Check whether a shell command advertises support for a specific option in `--help`. + * + * @param string $command Base shell command to inspect. + * @param string $option Option name to look for. + * @return bool Whether the option is listed in help output. + */ + private function command_supports_option( $command, $option ) { + $result = Process::create( "{$command} --help" )->run(); + + return false !== strpos( $result->stdout . $result->stderr, $option ); + } + /** * Imports a database from a file or from STDIN. * @@ -798,12 +908,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, $assoc_args ); + return; + } + // Process options to MySQL. $mysql_args = array_merge( [ 'database' => DB_NAME ], @@ -851,7 +968,22 @@ public function import( $args, $assoc_args ) { * : List tables based on wildcard search, e.g. 'wp_*_options' or 'wp_post?'. * * [--scope=] - * : Can be all, global, ms_global, blog, or old tables. Defaults to all. + * : List tables based on the scope. + * + * - all: returns 'all' and 'global' tables. No old tables are returned. + * - blog: returns the blog-level tables for the queried blog. + * - global: returns the global tables for the installation, returning multisite tables only on multisite. + * - ms_global: returns the multisite global tables, regardless if current installation is multisite. + * - old: returns tables which are deprecated. + * --- + * default: all + * options: + * - all + * - blog + * - global + * - ms_global + * - old + * --- * * [--network] * : List all the tables in a multisite install. @@ -1080,19 +1212,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 +1247,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 +1292,12 @@ 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_key = (int) $size_key; + $size_format = isset( $sizes[ $size_key ] ) ? $sizes[ $size_key ] : $sizes[0]; } @@ -1199,7 +1355,6 @@ public function size( $args, $assoc_args ) { if ( ! empty( $size_format ) && ! $tables && ! $format && ! $human_readable && true !== $all_tables && true !== $all_tables_with_prefix ) { WP_CLI::line( str_replace( " {$size_format_display}", '', $rows[0]['Size'] ) ); } else { - // Sort the rows by user input if ( $orderby ) { usort( @@ -1220,7 +1375,8 @@ function ( $a, $b ) use ( $order, $orderby ) { // Display the rows. $args = [ - 'format' => $format, + 'format' => $format, + 'alignments' => [ 'Size' => Column::ALIGN_RIGHT ], ]; $formatter = new Formatter( $args, $fields ); @@ -1500,6 +1656,7 @@ public function search( $args, $assoc_args ) { $tables = Utils\wp_get_table_names( $args, $assoc_args ); $search_results = []; + $is_sqlite = $this->is_sqlite(); $start_search_time = microtime( true ); @@ -1511,8 +1668,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 ) && ! $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 +1891,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, ]; @@ -1816,7 +1978,7 @@ private static function run( $cmd, $assoc_args = [], $send_to_shell = true, $int $required['default-character-set'] = constant( 'DB_CHARSET' ); } - // Using 'dbuser' as option name to workaround clash with WP-CLI's global WP 'user' parameter, with 'dbpass' also available for tidyness. + // Using 'dbuser' as option name to workaround clash with WP-CLI's global WP 'user' parameter, with 'dbpass' also available for tidiness. if ( isset( $assoc_args['dbuser'] ) ) { $required['user'] = $assoc_args['dbuser']; unset( $assoc_args['dbuser'] ); @@ -1828,6 +1990,29 @@ private static function run( $cmd, $assoc_args = [], $send_to_shell = true, $int $final_args = array_merge( $required, $assoc_args ); + // Filter out empty string values to avoid passing empty parameters to MySQL commands + // which can cause errors like "Character set '' is not a compiled character set". + // However, keep empty strings for credential options like 'user' and 'pass' so that + // an explicitly empty value is not silently converted into an omitted parameter. + $final_args = array_filter( + $final_args, + static function ( $value, $key ) { + // Always drop null values. + if ( null === $value ) { + return false; + } + + // Preserve explicitly empty credential arguments. + if ( '' === $value && in_array( $key, [ 'user', 'pass' ], true ) ) { + return true; + } + + // For all other options, filter out empty strings. + return '' !== $value; + }, + ARRAY_FILTER_USE_BOTH + ); + // Adapt ordering of arguments. uksort( $final_args, diff --git a/src/DB_Command_SQLite.php b/src/DB_Command_SQLite.php new file mode 100644 index 00000000..77893a00 --- /dev/null +++ b/src/DB_Command_SQLite.php @@ -0,0 +1,590 @@ +run(); + $available = 0 === $result->return_code; + } + + return $available; + } + + /** + * Check if SQLite is being used. + * + * @return bool True if SQLite is detected, false otherwise. + */ + protected function is_sqlite() { + // Check if DB_ENGINE constant is defined and set to 'sqlite'. + if ( defined( 'DB_ENGINE' ) && 'sqlite' === DB_ENGINE ) { + return true; + } + + // Check if the SQLite drop-in is loaded by looking for SQLITE_DB_DROPIN_VERSION constant. + if ( defined( 'SQLITE_DB_DROPIN_VERSION' ) ) { + return true; + } + + // Check if db.php drop-in exists and contains SQLite markers. + $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 ) ) { + $db_dropin_contents = file_get_contents( $db_dropin_path ); + if ( false !== $db_dropin_contents && false !== strpos( $db_dropin_contents, 'SQLITE_DB_DROPIN_VERSION' ) ) { + return true; + } + } + + return false; + } + + /** + * Get the path to the SQLite database file. + * + * @return string|false Path to SQLite database file, or false if not found. + */ + protected function get_sqlite_db_path() { + // Check for FQDB constant (fully qualified database path). + if ( defined( 'FQDB' ) ) { + return FQDB; + } + + $wp_content_dir = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : ABSPATH . 'wp-content'; + + // Check for FQDBDIR and construct path. + $db_dir = defined( 'FQDBDIR' ) ? FQDBDIR : $wp_content_dir . '/database'; + $db_file = defined( 'DB_FILE' ) ? DB_FILE : '.ht.sqlite'; + + $db_path = rtrim( $db_dir, '/' ) . '/' . ltrim( $db_file, '/' ); + + // If the file exists, return it. + if ( file_exists( $db_path ) ) { + return $db_path; + } + + // Try alternative common locations. + $alt_paths = [ + $wp_content_dir . '/database/.ht.sqlite', + $wp_content_dir . '/.ht.sqlite', + ABSPATH . '.ht.sqlite', + ]; + + foreach ( $alt_paths as $alt_path ) { + if ( file_exists( $alt_path ) ) { + return $alt_path; + } + } + + // Return the default expected path even if it doesn't exist yet. + return $db_path; + } + + /** + * 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 ); + + if ( ! is_dir( $db_dir ) ) { + if ( ! mkdir( $db_dir, 0755, true ) ) { + WP_CLI::error( "Could not create directory: {$db_dir}" ); + } + } + + if ( file_exists( $db_path ) ) { + WP_CLI::error( 'Database already exists.' ); + } + + if ( ! $this->is_sqlite3_available() ) { + WP_CLI::error( 'The sqlite3 CLI binary is required but not found. Please install SQLite3.' ); + } + + $command = Utils\esc_cmd( 'sqlite3 %s %s', $db_path, '' ); + + WP_CLI::debug( "Running shell command: {$command}", 'db' ); + + $result = \WP_CLI\Process::create( $command, null, null )->run(); + + if ( 0 !== $result->return_code ) { + WP_CLI::error( 'Could not create database' ); + } + + 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.' ); + } + + global $wpdb; + if ( $wpdb instanceof \wpdb ) { + $wpdb->close(); + } + + 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 ) ) { + global $wpdb; + if ( $wpdb instanceof \wpdb ) { + $wpdb->close(); + } + + 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}" ); + } + } + + if ( ! $this->is_sqlite3_available() ) { + WP_CLI::error( 'The sqlite3 CLI binary is required but not found. Please install SQLite3.' ); + } + + $command = Utils\esc_cmd( 'sqlite3 %s %s', $db_path, '' ); + + WP_CLI::debug( "Running shell command: {$command}", 'db' ); + + $result = \WP_CLI\Process::create( $command, null, null )->run(); + + if ( 0 !== $result->return_code ) { + WP_CLI::error( 'Could not create database' ); + } + + WP_CLI::success( 'Database reset.' ); + } + + /** + * Execute a query against the SQLite database. + * + * @param string $query SQL query to execute. + * @param array $assoc_args Associative arguments. + */ + protected function sqlite_query( $query, $assoc_args = [] ) { + global $wpdb; + + if ( ! isset( $wpdb ) || ! $wpdb instanceof \WP_SQLite_DB ) { + WP_CLI::error( 'SQLite database not available.' ); + } + + $skip_column_names = Utils\get_flag_value( $assoc_args, 'skip-column-names', false ); + + 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 results using the Formatter class. + $headers = array_keys( $results[0] ); + $this->display_query_results( $headers, $results, $skip_column_names ); + } + } catch ( Exception $e ) { + WP_CLI::error( 'Query failed: ' . $e->getMessage() ); + } + } + + /** + * Display query results using the Formatter class. + * + * @param array $headers Column headers. + * @param array $rows Data rows. + * @param bool $skip_column_names Whether to skip displaying column names. + */ + protected function display_query_results( $headers, $rows, $skip_column_names = false ) { + if ( $skip_column_names ) { + // Display rows without headers - just the values. + foreach ( $rows as $row ) { + WP_CLI::line( implode( "\t", array_values( $row ) ) ); + } + } else { + // Use the Formatter class to display results as a table. + $assoc_args = []; + $formatter = new Formatter( $assoc_args, $headers ); + $formatter->display_items( $rows ); + } + } + + /** + * 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.' ); + } + + if ( ! $this->is_sqlite3_available() ) { + WP_CLI::error( 'The sqlite3 binary could not be found. Please install sqlite3 to use the export command.' ); + } + $temp_db = tempnam( sys_get_temp_dir(), 'temp.db' ); + if ( false === $temp_db ) { + WP_CLI::error( 'Could not create temporary database file for export.' ); + } + $temp_db = str_replace( '\\', '/', $temp_db ); + + if ( ! copy( $db_path, $temp_db ) ) { + // Clean up temporary file if the copy operation fails. + unlink( $temp_db ); + WP_CLI::error( 'Could not copy database to temporary file for export.' ); + } + + $exclude_tables = []; + + // When passing --tables, exclude everything *except* the tables requested. + if ( isset( $assoc_args['tables'] ) ) { + $include_tables = explode( ',', trim( $assoc_args['tables'], ',' ) ); + unset( $assoc_args['tables'] ); + + // Use the sqlite3 binary to fetch all table names + $query = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';"; + + // Build the command safely + $command = 'sqlite3 ' . escapeshellarg( $temp_db ) . ' ' . escapeshellarg( $query ); + + WP_CLI::debug( "Running shell command: {$command}", 'db' ); + + $result = \WP_CLI\Process::create( $command, null, null )->run(); + + if ( 0 !== $result->return_code ) { + WP_CLI::error( 'Could not export database' ); + } + + $all_tables = array_map( 'trim', explode( "\n", $result->stdout ) ); + $all_tables = array_filter( $all_tables ); + + $exclude_tables = array_diff( $all_tables, $include_tables ); + } + + if ( isset( $assoc_args['exclude_tables'] ) ) { + $exclude_tables = explode( ',', trim( $assoc_args['exclude_tables'], ',' ) ); + unset( $assoc_args['exclude_tables'] ); + } + + // Always exclude this one created by the drop-in. + $exclude_tables[] = '_mysql_data_types_cache'; + + $exclude_tables = array_unique( array_filter( $exclude_tables ) ); + + // Build DROP TABLE statements with safely-escaped identifiers. + $drop_statements = array(); + foreach ( $exclude_tables as $table ) { + $escaped_identifier = '"' . str_replace( '"', '""', $table ) . '"'; + $drop_statements[] = sprintf( 'DROP TABLE %s;', $escaped_identifier ); + } + + $init_file = tempnam( sys_get_temp_dir(), 'export_init' ); + if ( false === $init_file ) { + if ( file_exists( $temp_db ) ) { + unlink( $temp_db ); + } + + WP_CLI::error( 'Failed to create temporary SQLite init file for export.' ); + } + $init_file = str_replace( '\\', '/', $init_file ); + + $init_contents = ''; + if ( ! empty( $drop_statements ) ) { + $init_contents .= implode( "\n", $drop_statements ) . "\n"; + } + $init_contents .= ".dump\n"; + + $bytes_written = file_put_contents( $init_file, $init_contents ); + if ( false === $bytes_written ) { + if ( file_exists( $init_file ) ) { + unlink( $init_file ); + } + if ( file_exists( $temp_db ) ) { + unlink( $temp_db ); + } + WP_CLI::error( 'Could not export database' ); + } + + $command = Utils\esc_cmd( 'sqlite3 -init %s %s .exit', $init_file, $temp_db ); + + WP_CLI::debug( "Running shell command: {$command}", 'db' ); + + $result = \WP_CLI\Process::create( $command, null, null )->run(); + if ( file_exists( $init_file ) ) { + unlink( $init_file ); + } + + if ( 0 !== $result->return_code ) { + if ( file_exists( $temp_db ) ) { + unlink( $temp_db ); + } + WP_CLI::error( 'Could not export database' ); + } + + $stdout = ( '-' === $file ); + + if ( $stdout ) { + WP_CLI::line( $result->stdout ); + } elseif ( false === file_put_contents( $file, $result->stdout ) ) { + // Clean up temporary file and surface a clear error if the export cannot be written. + if ( file_exists( $temp_db ) ) { + unlink( $temp_db ); + } + WP_CLI::error( "Could not write exported database to '{$file}'. Please check that the path is writable." ); + } + + unlink( $temp_db ); + + 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. + * @param array $assoc_args Associative arguments. + */ + protected function sqlite_import( $file, $assoc_args ) { + $db_path = $this->get_sqlite_db_path(); + + if ( ! $db_path ) { + WP_CLI::error( 'Could not determine the database path.' ); + } + $db_path = str_replace( '\\', '/', $db_path ); + + if ( ! file_exists( $db_path ) ) { + WP_CLI::error( 'Database does not exist.' ); + } + + if ( ! $this->is_sqlite3_available() ) { + WP_CLI::error( 'The sqlite3 CLI binary could not be found. Please ensure it is installed and available on your PATH.' ); + } + if ( '-' === $file ) { + $contents = file_get_contents( 'php://stdin' ); + if ( false === $contents ) { + WP_CLI::error( 'Failed to read from stdin.' ); + } + + $file = 'STDIN'; + } elseif ( ! is_readable( $file ) ) { + WP_CLI::error( sprintf( 'Import file missing or not readable: %s', $file ) ); + } else { + $contents = (string) file_get_contents( $file ); + } + + // Ignore errors about unique constraints and existing indexes. + $contents = str_replace( 'INSERT INTO', 'INSERT OR IGNORE INTO', $contents ); + $contents = preg_replace( '/\bCREATE TABLE (?!IF NOT EXISTS\b)/i', 'CREATE TABLE IF NOT EXISTS ', $contents ); + $contents = preg_replace( '/\bCREATE TRIGGER (?!IF NOT EXISTS\b)/i', 'CREATE TRIGGER IF NOT EXISTS ', (string) $contents ); + $contents = preg_replace( '/\bCREATE VIEW (?!IF NOT EXISTS\b)/i', 'CREATE VIEW IF NOT EXISTS ', (string) $contents ); + $contents = preg_replace( '/\bCREATE INDEX (?!IF NOT EXISTS\b)/i', 'CREATE INDEX IF NOT EXISTS ', (string) $contents ); + $contents = preg_replace( '/\bCREATE UNIQUE INDEX (?!IF NOT EXISTS\b)/i', 'CREATE UNIQUE INDEX IF NOT EXISTS ', (string) $contents ); + + $import_file = tempnam( sys_get_temp_dir(), 'temp.db' ); + if ( false === $import_file ) { + WP_CLI::error( 'Failed to create a temporary file for SQLite import.' ); + } + + $import_file = str_replace( '\\', '/', $import_file ); + $bytes_written = file_put_contents( $import_file, $contents ); + if ( false === $bytes_written ) { + if ( file_exists( $import_file ) ) { + unlink( $import_file ); + } + WP_CLI::error( sprintf( 'Failed to write SQLite import data to temporary file: %s', $import_file ) ); + } + + // Build sqlite3 command. + $command_parts = [ 'sqlite3' ]; + + if ( ! Utils\get_flag_value( $assoc_args, 'skip-optimization' ) ) { + $command_parts[] = '-cmd'; + $command_parts[] = 'PRAGMA foreign_keys=OFF;'; + $command_parts[] = '-cmd'; + $command_parts[] = 'PRAGMA ignore_check_constraints=ON;'; + $command_parts[] = '-cmd'; + $command_parts[] = 'PRAGMA synchronous=OFF;'; + $command_parts[] = '-cmd'; + $command_parts[] = 'PRAGMA journal_mode=MEMORY;'; + } + + $command_parts[] = '-init'; + $command_parts[] = $import_file; + $command_parts[] = $db_path; + + // Build a properly escaped string command. Process::create() requires a string, not an array. + $command = Utils\esc_cmd( + implode( ' ', array_fill( 0, count( $command_parts ), '%s' ) ), + ...$command_parts + ); + + // Pass .exit to quit interactive mode after running -init. + $command .= ' ' . escapeshellarg( '.exit' ); + + WP_CLI::debug( "Running shell command: {$command}", 'db' ); + + $result = \WP_CLI\Process::create( $command, null, null )->run(); + unlink( $import_file ); + + if ( 0 !== $result->return_code ) { + WP_CLI::error( 'Could not import database.' ); + } + + WP_CLI::success( sprintf( "Imported from '%s'.", $file ) ); + } + + /** + * 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 { + } }