├── .actrc ├── .distignore ├── .editorconfig ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE ├── PULL_REQUEST_TEMPLATE ├── dependabot.yml └── workflows │ ├── code-quality.yml │ ├── regenerate-readme.yml │ └── testing.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── behat.yml ├── composer.json ├── features ├── search-replace-export.feature └── search-replace.feature ├── phpcs.xml.dist ├── search-replace-command.php ├── src ├── Search_Replace_Command.php └── WP_CLI │ └── SearchReplacer.php └── wp-cli.yml /.actrc: -------------------------------------------------------------------------------- 1 | # Configuration file for nektos/act. 2 | # See https://github.com/nektos/act#configuration 3 | -P ubuntu-latest=shivammathur/node:latest 4 | -------------------------------------------------------------------------------- /.distignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .git 3 | .gitignore 4 | .gitlab-ci.yml 5 | .editorconfig 6 | .travis.yml 7 | behat.yml 8 | circle.yml 9 | phpcs.xml.dist 10 | phpunit.xml.dist 11 | bin/ 12 | features/ 13 | utils/ 14 | *.zip 15 | *.tar.gz 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # https://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | # From https://github.com/WordPress/wordpress-develop/blob/trunk/.editorconfig with a couple of additions. 8 | 9 | root = true 10 | 11 | [*] 12 | charset = utf-8 13 | end_of_line = lf 14 | insert_final_newline = true 15 | trim_trailing_whitespace = true 16 | indent_style = tab 17 | 18 | [{*.yml,*.feature,.jshintrc,*.json}] 19 | indent_style = space 20 | indent_size = 2 21 | 22 | [*.md] 23 | trim_trailing_whitespace = false 24 | 25 | [{*.txt,wp-config-sample.php}] 26 | end_of_line = crlf 27 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @wp-cli/committers 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | labels: 9 | - scope:distribution 10 | - package-ecosystem: github-actions 11 | directory: "/" 12 | schedule: 13 | interval: daily 14 | open-pull-requests-limit: 10 15 | labels: 16 | - scope:distribution 17 | 18 | -------------------------------------------------------------------------------- /.github/workflows/code-quality.yml: -------------------------------------------------------------------------------- 1 | name: Code Quality Checks 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | - master 9 | 10 | jobs: 11 | code-quality: 12 | uses: wp-cli/.github/.github/workflows/reusable-code-quality.yml@main 13 | -------------------------------------------------------------------------------- /.github/workflows/regenerate-readme.yml: -------------------------------------------------------------------------------- 1 | name: Regenerate README file 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | - master 9 | paths-ignore: 10 | - "features/**" 11 | - "README.md" 12 | 13 | jobs: 14 | regenerate-readme: 15 | uses: wp-cli/.github/.github/workflows/reusable-regenerate-readme.yml@main 16 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | name: Testing 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | push: 7 | branches: 8 | - main 9 | - master 10 | schedule: 11 | - cron: '17 1 * * *' # Run every day on a seemly random time. 12 | 13 | jobs: 14 | test: 15 | uses: wp-cli/.github/.github/workflows/reusable-testing.yml@main 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | wp-cli.local.yml 3 | node_modules/ 4 | vendor/ 5 | *.zip 6 | *.tar.gz 7 | *.swp 8 | composer.lock 9 | *.log 10 | phpunit.xml 11 | phpcs.xml 12 | .phpcs.xml 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | We appreciate you taking the initiative to contribute to this project. 5 | 6 | Contributing isn’t limited to just code. We encourage you to contribute in the way that best fits your abilities, by writing tutorials, giving a demo at your local meetup, helping other users with their support questions, or revising our documentation. 7 | 8 | For a more thorough introduction, [check out WP-CLI's guide to contributing](https://make.wordpress.org/cli/handbook/contributing/). This package follows those policy and guidelines. 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (C) 2011-2018 WP-CLI Development Group (https://github.com/wp-cli/search-replace-command/contributors) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | wp-cli/search-replace-command 2 | ============================= 3 | 4 | Searches/replaces strings in the database. 5 | 6 | [![Testing](https://github.com/wp-cli/search-replace-command/actions/workflows/testing.yml/badge.svg)](https://github.com/wp-cli/search-replace-command/actions/workflows/testing.yml) 7 | 8 | Quick links: [Using](#using) | [Installing](#installing) | [Contributing](#contributing) | [Support](#support) 9 | 10 | ## Using 11 | 12 | ~~~ 13 | wp search-replace [...] [--dry-run] [--network] [--all-tables-with-prefix] [--all-tables] [--export[=]] [--export_insert_size=] [--skip-tables=] [--skip-columns=] [--include-columns=] [--precise] [--recurse-objects] [--verbose] [--regex] [--regex-flags=] [--regex-delimiter=] [--regex-limit=] [--format=] [--report] [--report-changed-only] [--log[=]] [--before_context=] [--after_context=] 14 | ~~~ 15 | 16 | Searches through all rows in a selection of tables and replaces 17 | appearances of the first string with the second string. 18 | 19 | By default, the command uses tables registered to the `$wpdb` object. On 20 | multisite, this will just be the tables for the current site unless 21 | `--network` is specified. 22 | 23 | Search/replace intelligently handles PHP serialized data, and does not 24 | change primary key values. 25 | 26 | **OPTIONS** 27 | 28 | 29 | A string to search for within the database. 30 | 31 | 32 | Replace instances of the first string with this new string. 33 | 34 | [
...] 35 | List of database tables to restrict the replacement to. Wildcards are 36 | supported, e.g. `'wp_*options'` or `'wp_post*'`. 37 | 38 | [--dry-run] 39 | Run the entire search/replace operation and show report, but don't save 40 | changes to the database. 41 | 42 | [--network] 43 | Search/replace through all the tables registered to $wpdb in a 44 | multisite install. 45 | 46 | [--all-tables-with-prefix] 47 | Enable replacement on any tables that match the table prefix even if 48 | not registered on $wpdb. 49 | 50 | [--all-tables] 51 | Enable replacement on ALL tables in the database, regardless of the 52 | prefix, and even if not registered on $wpdb. Overrides --network 53 | and --all-tables-with-prefix. 54 | 55 | [--export[=]] 56 | Write transformed data as SQL file instead of saving replacements to 57 | the database. If is not supplied, will output to STDOUT. 58 | 59 | [--export_insert_size=] 60 | Define number of rows in single INSERT statement when doing SQL export. 61 | You might want to change this depending on your database configuration 62 | (e.g. if you need to do fewer queries). Default: 50 63 | 64 | [--skip-tables=] 65 | Do not perform the replacement on specific tables. Use commas to 66 | specify multiple tables. Wildcards are supported, e.g. `'wp_*options'` or `'wp_post*'`. 67 | 68 | [--skip-columns=] 69 | Do not perform the replacement on specific columns. Use commas to 70 | specify multiple columns. 71 | 72 | [--include-columns=] 73 | Perform the replacement on specific columns. Use commas to 74 | specify multiple columns. 75 | 76 | [--precise] 77 | Force the use of PHP (instead of SQL) which is more thorough, 78 | but slower. 79 | 80 | [--recurse-objects] 81 | Enable recursing into objects to replace strings. Defaults to true; 82 | pass --no-recurse-objects to disable. 83 | 84 | [--verbose] 85 | Prints rows to the console as they're updated. 86 | 87 | [--regex] 88 | Runs the search using a regular expression (without delimiters). 89 | Warning: search-replace will take about 15-20x longer when using --regex. 90 | 91 | [--regex-flags=] 92 | Pass PCRE modifiers to regex search-replace (e.g. 'i' for case-insensitivity). 93 | 94 | [--regex-delimiter=] 95 | The delimiter to use for the regex. It must be escaped if it appears in the search string. The default value is the result of `chr(1)`. 96 | 97 | [--regex-limit=] 98 | The maximum possible replacements for the regex per row (or per unserialized data bit per row). Defaults to -1 (no limit). 99 | 100 | [--format=] 101 | Render output in a particular format. 102 | --- 103 | default: table 104 | options: 105 | - table 106 | - count 107 | --- 108 | 109 | [--report] 110 | Produce report. Defaults to true. 111 | 112 | [--report-changed-only] 113 | Report changed fields only. Defaults to false, unless logging, when it defaults to true. 114 | 115 | [--log[=]] 116 | Log the items changed. If is not supplied or is "-", will output to STDOUT. 117 | Warning: causes a significant slow down, similar or worse to enabling --precise or --regex. 118 | 119 | [--before_context=] 120 | For logging, number of characters to display before the old match and the new replacement. Default 40. Ignored if not logging. 121 | 122 | [--after_context=] 123 | For logging, number of characters to display after the old match and the new replacement. Default 40. Ignored if not logging. 124 | 125 | **EXAMPLES** 126 | 127 | # Search and replace but skip one column 128 | $ wp search-replace 'http://example.test' 'http://example.com' --skip-columns=guid 129 | 130 | # Run search/replace operation but dont save in database 131 | $ wp search-replace 'foo' 'bar' wp_posts wp_postmeta wp_terms --dry-run 132 | 133 | # Run case-insensitive regex search/replace operation (slow) 134 | $ wp search-replace '\[foo id="([0-9]+)"' '[bar id="\1"' --regex --regex-flags='i' 135 | 136 | # Turn your production multisite database into a local dev database 137 | $ wp search-replace --url=example.com example.com example.test 'wp_*options' wp_blogs wp_site --network 138 | 139 | # Search/replace to a SQL file without transforming the database 140 | $ wp search-replace foo bar --export=database.sql 141 | 142 | # Bash script: Search/replace production to development url (multisite compatible) 143 | #!/bin/bash 144 | if $(wp --url=http://example.com core is-installed --network); then 145 | wp search-replace --url=http://example.com 'http://example.com' 'http://example.test' --recurse-objects --network --skip-columns=guid --skip-tables=wp_users 146 | else 147 | wp search-replace 'http://example.com' 'http://example.test' --recurse-objects --skip-columns=guid --skip-tables=wp_users 148 | fi 149 | 150 | ## Installing 151 | 152 | This package is included with WP-CLI itself, no additional installation necessary. 153 | 154 | To install the latest version of this package over what's included in WP-CLI, run: 155 | 156 | wp package install git@github.com:wp-cli/search-replace-command.git 157 | 158 | ## Contributing 159 | 160 | We appreciate you taking the initiative to contribute to this project. 161 | 162 | Contributing isn’t limited to just code. We encourage you to contribute in the way that best fits your abilities, by writing tutorials, giving a demo at your local meetup, helping other users with their support questions, or revising our documentation. 163 | 164 | For a more thorough introduction, [check out WP-CLI's guide to contributing](https://make.wordpress.org/cli/handbook/contributing/). This package follows those policy and guidelines. 165 | 166 | ### Reporting a bug 167 | 168 | Think you’ve found a bug? We’d love for you to help us get it fixed. 169 | 170 | Before you create a new issue, you should [search existing issues](https://github.com/wp-cli/search-replace-command/issues?q=label%3Abug%20) to see if there’s an existing resolution to it, or if it’s already been fixed in a newer version. 171 | 172 | Once you’ve done a bit of searching and discovered there isn’t an open or fixed issue for your bug, please [create a new issue](https://github.com/wp-cli/search-replace-command/issues/new). Include as much detail as you can, and clear steps to reproduce if possible. For more guidance, [review our bug report documentation](https://make.wordpress.org/cli/handbook/bug-reports/). 173 | 174 | ### Creating a pull request 175 | 176 | Want to contribute a new feature? Please first [open a new issue](https://github.com/wp-cli/search-replace-command/issues/new) to discuss whether the feature is a good fit for the project. 177 | 178 | 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. 179 | 180 | ## Support 181 | 182 | GitHub issues aren't for general support questions, but there are other venues you can try: https://wp-cli.org/#support 183 | 184 | 185 | *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.* 186 | -------------------------------------------------------------------------------- /behat.yml: -------------------------------------------------------------------------------- 1 | default: 2 | suites: 3 | default: 4 | contexts: 5 | - WP_CLI\Tests\Context\FeatureContext 6 | paths: 7 | - features 8 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wp-cli/search-replace-command", 3 | "type": "wp-cli-package", 4 | "description": "Searches/replaces strings in the database.", 5 | "homepage": "https://github.com/wp-cli/search-replace-command", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Daniel Bachhuber", 10 | "email": "daniel@runcommand.io", 11 | "homepage": "https://runcommand.io" 12 | } 13 | ], 14 | "require": { 15 | "wp-cli/wp-cli": "^2.12" 16 | }, 17 | "require-dev": { 18 | "wp-cli/db-command": "^1.3 || ^2", 19 | "wp-cli/entity-command": "^1.3 || ^2", 20 | "wp-cli/extension-command": "^1.2 || ^2", 21 | "wp-cli/wp-cli-tests": "^4" 22 | }, 23 | "config": { 24 | "process-timeout": 7200, 25 | "sort-packages": true, 26 | "allow-plugins": { 27 | "dealerdirect/phpcodesniffer-composer-installer": true, 28 | "johnpbloch/wordpress-core-installer": true 29 | } 30 | }, 31 | "extra": { 32 | "branch-alias": { 33 | "dev-main": "2.x-dev" 34 | }, 35 | "bundled": true, 36 | "commands": [ 37 | "search-replace" 38 | ] 39 | }, 40 | "autoload": { 41 | "classmap": [ 42 | "src/" 43 | ], 44 | "files": [ 45 | "search-replace-command.php" 46 | ] 47 | }, 48 | "minimum-stability": "dev", 49 | "prefer-stable": true, 50 | "scripts": { 51 | "behat": "run-behat-tests", 52 | "behat-rerun": "rerun-behat-tests", 53 | "lint": "run-linter-tests", 54 | "phpcs": "run-phpcs-tests", 55 | "phpcbf": "run-phpcbf-cleanup", 56 | "phpunit": "run-php-unit-tests", 57 | "prepare-tests": "install-package-tests", 58 | "test": [ 59 | "@lint", 60 | "@phpcs", 61 | "@phpunit", 62 | "@behat" 63 | ] 64 | }, 65 | "support": { 66 | "issues": "https://github.com/wp-cli/search-replace-command/issues" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /features/search-replace-export.feature: -------------------------------------------------------------------------------- 1 | Feature: Search / replace with file export 2 | 3 | @require-mysql 4 | Scenario: Search / replace export to STDOUT 5 | Given a WP install 6 | And I run `echo ' '` 7 | And save STDOUT as {SPACE} 8 | 9 | When I run `wp search-replace example.com example.net --export` 10 | Then STDOUT should contain: 11 | """ 12 | DROP TABLE IF EXISTS `wp_commentmeta`; 13 | CREATE TABLE `wp_commentmeta` 14 | """ 15 | And STDOUT should contain: 16 | """ 17 | 'siteurl', 'https://example.net', 18 | """ 19 | 20 | When I run `wp option get home` 21 | Then STDOUT should be: 22 | """ 23 | https://example.com 24 | """ 25 | 26 | When I run `wp search-replace example.com example.net --skip-tables=wp_options --export` 27 | Then STDOUT should not contain: 28 | """ 29 | INSERT INTO `wp_options` 30 | """ 31 | 32 | When I run `wp search-replace example.com example.net --skip-tables=wp_opt\?ons,wp_post\* --export` 33 | Then STDOUT should not contain: 34 | """ 35 | wp_posts 36 | """ 37 | And STDOUT should not contain: 38 | """ 39 | wp_postmeta 40 | """ 41 | And STDOUT should not contain: 42 | """ 43 | wp_options 44 | """ 45 | And STDOUT should contain: 46 | """ 47 | wp_users 48 | """ 49 | 50 | When I run `wp search-replace example.com example.net --skip-columns=option_value --export` 51 | Then STDOUT should contain: 52 | """ 53 | INSERT INTO `wp_options` (`option_id`, `option_name`, `option_value`, `autoload`) VALUES{SPACE} 54 | """ 55 | And STDOUT should contain: 56 | """ 57 | 'siteurl', 'https://example.com' 58 | """ 59 | 60 | When I run `wp search-replace example.com example.net --skip-columns=option_value --export --export_insert_size=1` 61 | Then STDOUT should contain: 62 | """ 63 | 'siteurl', 'https://example.com' 64 | """ 65 | And STDOUT should contain: 66 | """ 67 | INSERT INTO `wp_options` (`option_id`, `option_name`, `option_value`, `autoload`) VALUES{SPACE} 68 | """ 69 | 70 | When I run `wp search-replace foo bar --export | tail -n 1` 71 | Then STDOUT should not contain: 72 | """ 73 | Success: Made 74 | """ 75 | 76 | When I run `wp search-replace example.com example.net --export > wordpress.sql` 77 | And I run `wp db import wordpress.sql` 78 | Then STDOUT should not be empty 79 | 80 | When I run `wp option get home` 81 | Then STDOUT should be: 82 | """ 83 | https://example.net 84 | """ 85 | 86 | @require-mysql 87 | Scenario: Search / replace export to file 88 | Given a WP install 89 | And I run `wp post generate --count=100` 90 | And I run `wp option add example_url https://example.com` 91 | 92 | When I run `wp search-replace example.com example.net --export=wordpress.sql` 93 | Then STDOUT should contain: 94 | """ 95 | Success: Made 96 | """ 97 | # Skip exact number as it changes in trunk due to https://core.trac.wordpress.org/changeset/42981 98 | And STDOUT should contain: 99 | """ 100 | replacements and exported to wordpress.sql 101 | """ 102 | And STDOUT should be a table containing rows: 103 | | Table | Column | Replacements | Type | 104 | | wp_options | option_value | 6 | PHP | 105 | 106 | When I run `wp option get home` 107 | Then STDOUT should be: 108 | """ 109 | https://example.com 110 | """ 111 | 112 | When I run `wp site empty --yes` 113 | And I run `wp post list --format=count` 114 | Then STDOUT should be: 115 | """ 116 | 0 117 | """ 118 | 119 | When I run `wp db import wordpress.sql` 120 | Then STDOUT should not be empty 121 | 122 | When I run `wp option get home` 123 | Then STDOUT should be: 124 | """ 125 | https://example.net 126 | """ 127 | 128 | When I run `wp option get example_url` 129 | Then STDOUT should be: 130 | """ 131 | https://example.net 132 | """ 133 | 134 | When I run `wp post list --format=count` 135 | Then STDOUT should be: 136 | """ 137 | 101 138 | """ 139 | 140 | @require-mysql 141 | Scenario: Search / replace export to file with verbosity 142 | Given a WP install 143 | 144 | When I run `wp search-replace example.com example.net --export=wordpress.sql --verbose` 145 | Then STDOUT should contain: 146 | """ 147 | Checking: wp_posts 148 | """ 149 | And STDOUT should contain: 150 | """ 151 | Checking: wp_options 152 | """ 153 | 154 | Scenario: Search / replace export with dry-run 155 | Given a WP install 156 | 157 | When I try `wp search-replace example.com example.net --export --dry-run` 158 | Then STDERR should be: 159 | """ 160 | Error: You cannot supply --dry-run and --export at the same time. 161 | """ 162 | 163 | @require-mysql 164 | Scenario: Search / replace shouldn't affect primary key 165 | Given a WP install 166 | And I run `wp post create --post_title=foo --porcelain` 167 | Then save STDOUT as {POST_ID} 168 | 169 | When I run `wp option update {POST_ID} foo` 170 | And I run `wp option get {POST_ID}` 171 | Then STDOUT should be: 172 | """ 173 | foo 174 | """ 175 | 176 | When I run `wp search-replace {POST_ID} 99999999 --export=wordpress.sql` 177 | And I run `wp db import wordpress.sql` 178 | Then STDOUT should not be empty 179 | 180 | When I run `wp post get {POST_ID} --field=title` 181 | Then STDOUT should be: 182 | """ 183 | foo 184 | """ 185 | 186 | When I try `wp option get {POST_ID}` 187 | Then STDOUT should be empty 188 | 189 | When I run `wp option get 99999999` 190 | Then STDOUT should be: 191 | """ 192 | foo 193 | """ 194 | 195 | Scenario: Search / replace export invalid file 196 | Given a WP install 197 | 198 | When I try `wp search-replace example.com example.net --export=foo/bar.sql` 199 | Then STDERR should contain: 200 | """ 201 | Error: Unable to open export file "foo/bar.sql" for writing: 202 | """ 203 | 204 | @require-mysql 205 | Scenario: Search / replace specific table 206 | Given a WP install 207 | 208 | When I run `wp post create --post_title=foo --porcelain` 209 | Then save STDOUT as {POST_ID} 210 | 211 | When I run `wp option update bar foo` 212 | Then STDOUT should not be empty 213 | 214 | When I run `wp search-replace foo burrito wp_posts --export=wordpress.sql --verbose` 215 | Then STDOUT should contain: 216 | """ 217 | Checking: wp_posts 218 | """ 219 | And STDOUT should contain: 220 | """ 221 | Success: Made 1 replacement and exported to wordpress.sql. 222 | """ 223 | 224 | When I run `wp db import wordpress.sql` 225 | Then STDOUT should not be empty 226 | 227 | When I run `wp post get {POST_ID} --field=title` 228 | Then STDOUT should be: 229 | """ 230 | burrito 231 | """ 232 | 233 | When I run `wp option get bar` 234 | Then STDOUT should be: 235 | """ 236 | foo 237 | """ 238 | 239 | @require-mysql 240 | Scenario: Search / replace export should cater for field/table names that use reserved words or unusual characters 241 | Given a WP install 242 | # Unlike search-replace.features version, don't use `back``tick` column name as WP_CLI\Iterators\Table::build_fields() can't handle it. 243 | And a esc_sql_ident.sql file: 244 | """ 245 | CREATE TABLE `TABLE` (`KEY` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, `VALUES` TEXT, `single'double"quote` TEXT, PRIMARY KEY (`KEY`) ); 246 | INSERT INTO `TABLE` (`VALUES`, `single'double"quote`) VALUES ('v"vvvv_v1', 'v"vvvv_v1' ); 247 | INSERT INTO `TABLE` (`VALUES`, `single'double"quote`) VALUES ('v"vvvv_v2', 'v"vvvv_v2' ); 248 | """ 249 | 250 | When I run `wp db query "SOURCE esc_sql_ident.sql;"` 251 | Then STDERR should be empty 252 | 253 | When I run `wp search-replace 'v"vvvv_v' 'w"wwww_w' TABLE --export --all-tables` 254 | Then STDOUT should contain: 255 | """ 256 | INSERT INTO `TABLE` (`KEY`, `VALUES`, `single'double"quote`) VALUES 257 | """ 258 | And STDOUT should contain: 259 | """ 260 | ('1', 'w\"wwww_w1', 'w\"wwww_w1') 261 | """ 262 | And STDOUT should contain: 263 | """ 264 | ('2', 'w\"wwww_w2', 'w\"wwww_w2') 265 | """ 266 | And STDERR should be empty 267 | 268 | When I run `wp search-replace 'v"vvvv_v2' 'w"wwww_w2' TABLE --export --regex --all-tables` 269 | Then STDOUT should contain: 270 | """ 271 | INSERT INTO `TABLE` (`KEY`, `VALUES`, `single'double"quote`) VALUES 272 | """ 273 | And STDOUT should contain: 274 | """ 275 | ('1', 'v\"vvvv_v1', 'v\"vvvv_v1') 276 | """ 277 | And STDOUT should contain: 278 | """ 279 | ('2', 'w\"wwww_w2', 'w\"wwww_w2') 280 | """ 281 | And STDERR should be empty 282 | 283 | @require-mysql 284 | Scenario: Suppress report or only report changes on export to file 285 | Given a WP install 286 | 287 | When I run `wp option set foo baz` 288 | And I run `wp option get foo` 289 | Then STDOUT should be: 290 | """ 291 | baz 292 | """ 293 | 294 | When I run `wp post create --post_title=baz --porcelain` 295 | Then save STDOUT as {POST_ID} 296 | 297 | When I run `wp post meta add {POST_ID} foo baz` 298 | Then STDOUT should not be empty 299 | 300 | When I run `wp search-replace baz bar --export=wordpress.sql` 301 | Then STDOUT should contain: 302 | """ 303 | Success: Made 3 replacements and exported to wordpress.sql. 304 | """ 305 | And STDOUT should be a table containing rows: 306 | | Table | Column | Replacements | Type | 307 | | wp_commentmeta | meta_id | 0 | PHP | 308 | | wp_options | option_value | 1 | PHP | 309 | | wp_postmeta | meta_value | 1 | PHP | 310 | | wp_posts | post_title | 1 | PHP | 311 | | wp_users | display_name | 0 | PHP | 312 | And STDERR should be empty 313 | 314 | When I run `wp search-replace baz bar --report --export=wordpress.sql` 315 | Then STDOUT should contain: 316 | """ 317 | Success: Made 3 replacements and exported to wordpress.sql. 318 | """ 319 | And STDOUT should be a table containing rows: 320 | | Table | Column | Replacements | Type | 321 | | wp_commentmeta | meta_id | 0 | PHP | 322 | | wp_options | option_value | 1 | PHP | 323 | | wp_postmeta | meta_value | 1 | PHP | 324 | | wp_posts | post_title | 1 | PHP | 325 | | wp_users | display_name | 0 | PHP | 326 | And STDERR should be empty 327 | 328 | When I run `wp search-replace baz bar --no-report --export=wordpress.sql` 329 | Then STDOUT should contain: 330 | """ 331 | Success: Made 3 replacements and exported to wordpress.sql. 332 | """ 333 | And STDOUT should not contain: 334 | """ 335 | Table Column Replacements Type 336 | """ 337 | And STDOUT should not contain: 338 | """ 339 | wp_commentmeta meta_id 0 PHP 340 | """ 341 | And STDOUT should not contain: 342 | """ 343 | wp_options option_value 1 PHP 344 | """ 345 | And STDERR should be empty 346 | 347 | When I run `wp search-replace baz bar --no-report-changed-only --export=wordpress.sql` 348 | Then STDOUT should contain: 349 | """ 350 | Success: Made 3 replacements and exported to wordpress.sql. 351 | """ 352 | And STDOUT should be a table containing rows: 353 | | Table | Column | Replacements | Type | 354 | | wp_commentmeta | meta_id | 0 | PHP | 355 | | wp_options | option_value | 1 | PHP | 356 | | wp_postmeta | meta_value | 1 | PHP | 357 | | wp_posts | post_title | 1 | PHP | 358 | | wp_users | display_name | 0 | PHP | 359 | And STDERR should be empty 360 | 361 | When I run `wp search-replace baz bar --report-changed-only --export=wordpress.sql` 362 | Then STDOUT should contain: 363 | """ 364 | Success: Made 3 replacements and exported to wordpress.sql. 365 | """ 366 | And STDOUT should end with a table containing rows: 367 | | Table | Column | Replacements | Type | 368 | | wp_options | option_value | 1 | PHP | 369 | | wp_postmeta | meta_value | 1 | PHP | 370 | | wp_posts | post_title | 1 | PHP | 371 | And STDOUT should not contain: 372 | """ 373 | wp_commentmeta meta_id 0 PHP 374 | """ 375 | And STDOUT should not contain: 376 | """ 377 | wp_users display_name 0 PHP 378 | """ 379 | And STDERR should be empty 380 | 381 | @require-mysql 382 | Scenario: Search / replace should remove placeholder escape on export 383 | Given a WP install 384 | And I run `wp post create --post_title=test-remove-placeholder-escape% --porcelain` 385 | Then save STDOUT as {POST_ID} 386 | 387 | When I run `wp search-replace baz bar --export | grep test-remove-placeholder-escape` 388 | Then STDOUT should contain: 389 | """ 390 | 'test-remove-placeholder-escape%' 391 | """ 392 | And STDOUT should not contain: 393 | """ 394 | 'test-remove-placeholder-escape{' 395 | """ 396 | 397 | @require-mysql 398 | Scenario: NULLs exported as NULL and not null string 399 | Given a WP install 400 | And I run `wp db query "INSERT INTO wp_postmeta VALUES (9999, 9999, NULL, 'foo')"` 401 | 402 | When I run `wp search-replace bar replaced wp_postmeta --export` 403 | Then STDOUT should contain: 404 | """ 405 | ('9999', '9999', NULL, 'foo') 406 | """ 407 | And STDOUT should not contain: 408 | """ 409 | ('9999', '9999', '', 'foo') 410 | """ 411 | -------------------------------------------------------------------------------- /features/search-replace.feature: -------------------------------------------------------------------------------- 1 | Feature: Do global search/replace 2 | 3 | @require-mysql 4 | Scenario: Basic search/replace 5 | Given a WP install 6 | 7 | When I run `wp search-replace foo bar` 8 | Then STDOUT should contain: 9 | """ 10 | guid 11 | """ 12 | 13 | When I run `wp search-replace foo bar --skip-tables=wp_posts` 14 | Then STDOUT should not contain: 15 | """ 16 | wp_posts 17 | """ 18 | 19 | When I run `wp search-replace foo bar --skip-tables=wp_post\*` 20 | Then STDOUT should not contain: 21 | """ 22 | wp_posts 23 | """ 24 | And STDOUT should not contain: 25 | """ 26 | wp_postmeta 27 | """ 28 | And STDOUT should contain: 29 | """ 30 | wp_users 31 | """ 32 | 33 | When I run `wp search-replace foo bar --skip-columns=guid` 34 | Then STDOUT should not contain: 35 | """ 36 | guid 37 | """ 38 | 39 | When I run `wp search-replace foo bar --include-columns=post_content` 40 | Then STDOUT should be a table containing rows: 41 | | Table | Column | Replacements | Type | 42 | | wp_posts | post_content | 0 | SQL | 43 | 44 | @require-mysql 45 | Scenario: Multisite search/replace 46 | Given a WP multisite install 47 | And I run `wp site create --slug="foo" --title="foo" --email="foo@example.com"` 48 | And I run `wp search-replace foo bar --network` 49 | Then STDOUT should be a table containing rows: 50 | | Table | Column | Replacements | Type | 51 | | wp_2_options | option_value | 4 | PHP | 52 | | wp_blogs | path | 1 | SQL | 53 | 54 | @require-mysql 55 | Scenario: Don't run on unregistered tables by default 56 | Given a WP install 57 | And I run `wp db query "CREATE TABLE wp_awesome ( id int(11) unsigned NOT NULL AUTO_INCREMENT, awesome_stuff TEXT, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=latin1;"` 58 | 59 | When I run `wp search-replace foo bar` 60 | Then STDOUT should not contain: 61 | """ 62 | wp_awesome 63 | """ 64 | 65 | When I run `wp search-replace foo bar --all-tables-with-prefix` 66 | Then STDOUT should contain: 67 | """ 68 | wp_awesome 69 | """ 70 | 71 | @require-mysql 72 | Scenario: Run on unregistered, unprefixed tables with --all-tables flag 73 | Given a WP install 74 | And I run `wp db query "CREATE TABLE awesome_table ( id int(11) unsigned NOT NULL AUTO_INCREMENT, awesome_stuff TEXT, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=latin1;"` 75 | 76 | When I run `wp search-replace foo bar` 77 | Then STDOUT should not contain: 78 | """ 79 | awesome_table 80 | """ 81 | 82 | When I run `wp search-replace foo bar --all-tables` 83 | Then STDOUT should contain: 84 | """ 85 | awesome_table 86 | """ 87 | 88 | @require-mysql 89 | Scenario: Run on all tables matching string with wildcard 90 | Given a WP install 91 | 92 | When I run `wp option set bar fooz` 93 | And I run `wp option get bar` 94 | Then STDOUT should be: 95 | """ 96 | fooz 97 | """ 98 | 99 | When I run `wp post create --post_title=bar --porcelain` 100 | Then save STDOUT as {POST_ID} 101 | 102 | When I run `wp post meta add {POST_ID} fooz bar` 103 | Then STDOUT should not be empty 104 | 105 | When I run `wp search-replace bar burrito wp_post\?` 106 | Then STDOUT should be a table containing rows: 107 | | Table | Column | Replacements | Type | 108 | | wp_posts | post_title | 1 | SQL | 109 | And STDOUT should not contain: 110 | """ 111 | wp_options 112 | """ 113 | 114 | When I run `wp post get {POST_ID} --field=title` 115 | Then STDOUT should be: 116 | """ 117 | burrito 118 | """ 119 | 120 | When I run `wp post meta get {POST_ID} fooz` 121 | Then STDOUT should be: 122 | """ 123 | bar 124 | """ 125 | 126 | When I run `wp option get bar` 127 | Then STDOUT should be: 128 | """ 129 | fooz 130 | """ 131 | 132 | When I try `wp search-replace fooz burrito wp_opt\*on` 133 | Then STDERR should be: 134 | """ 135 | Error: Couldn't find any tables matching: wp_opt*on 136 | """ 137 | And the return code should be 1 138 | 139 | When I run `wp search-replace fooz burrito wp_opt\* wp_postme\*` 140 | Then STDOUT should be a table containing rows: 141 | | Table | Column | Replacements | Type | 142 | | wp_options | option_value | 1 | PHP | 143 | | wp_postmeta | meta_key | 1 | SQL | 144 | And STDOUT should not contain: 145 | """ 146 | wp_posts 147 | """ 148 | 149 | When I run `wp option get bar` 150 | Then STDOUT should be: 151 | """ 152 | burrito 153 | """ 154 | 155 | When I run `wp post meta get {POST_ID} burrito` 156 | Then STDOUT should be: 157 | """ 158 | bar 159 | """ 160 | 161 | @require-mysql 162 | Scenario: Quiet search/replace 163 | Given a WP install 164 | 165 | When I run `wp search-replace foo bar --quiet` 166 | Then STDOUT should be empty 167 | 168 | @require-mysql 169 | Scenario: Verbose search/replace 170 | Given a WP install 171 | And I run `wp post create --post_title='Replace this text' --porcelain` 172 | And save STDOUT as {POSTID} 173 | 174 | When I run `wp search-replace 'Replace' 'Replaced' --verbose` 175 | Then STDOUT should contain: 176 | """ 177 | Checking: wp_posts.post_title 178 | 1 rows affected 179 | """ 180 | 181 | When I run `wp search-replace 'Replace' 'Replaced' --verbose --precise` 182 | Then STDOUT should contain: 183 | """ 184 | Checking: wp_posts.post_title 185 | 1 rows affected 186 | """ 187 | 188 | Scenario: Regex search/replace 189 | Given a WP install 190 | When I run `wp search-replace '(Hello)\s(world)' '$2, $1' --regex` 191 | Then STDOUT should contain: 192 | """ 193 | wp_posts 194 | """ 195 | When I run `wp post list --fields=post_title` 196 | Then STDOUT should contain: 197 | """ 198 | world, Hello 199 | """ 200 | 201 | Scenario: Regex search/replace with a incorrect `--regex-flags` 202 | Given a WP install 203 | When I try `wp search-replace '(Hello)\s(world)' '$2, $1' --regex --regex-flags='kppr'` 204 | Then STDERR should be: 205 | """ 206 | Error: The regex pattern '(Hello)\s(world)' with default delimiter 'chr(1)' and flags 'kppr' fails. 207 | preg_match(): Unknown modifier 'k'. 208 | """ 209 | And the return code should be 1 210 | 211 | @require-mysql 212 | Scenario: Search and replace within theme mods 213 | Given a WP install 214 | And a setup-theme-mod.php file: 215 | """ 216 | 'https://subdomain.example.com/foo.jpg' ) ); 218 | """ 219 | And I run `wp eval-file setup-theme-mod.php` 220 | 221 | When I run `wp theme mod get header_image_data` 222 | Then STDOUT should be a table containing rows: 223 | | key | value | 224 | | header_image_data | {"url":"https:\/\/subdomain.example.com\/foo.jpg"} | 225 | 226 | When I run `wp search-replace subdomain.example.com example.com --no-recurse-objects` 227 | Then STDOUT should be a table containing rows: 228 | | Table | Column | Replacements | Type | 229 | | wp_options | option_value | 0 | PHP | 230 | 231 | When I run `wp search-replace subdomain.example.com example.com` 232 | Then STDOUT should be a table containing rows: 233 | | Table | Column | Replacements | Type | 234 | | wp_options | option_value | 1 | PHP | 235 | 236 | When I run `wp theme mod get header_image_data` 237 | Then STDOUT should be a table containing rows: 238 | | key | value | 239 | | header_image_data | {"url":"https:\/\/example.com\/foo.jpg"} | 240 | 241 | @require-mysql 242 | Scenario: Search and replace with quoted strings 243 | Given a WP install 244 | 245 | When I run `wp post create --post_content='Apple' --porcelain` 246 | Then save STDOUT as {POST_ID} 247 | 248 | When I run `wp post get {POST_ID} --field=content` 249 | Then STDOUT should be: 250 | """ 251 | Apple 252 | """ 253 | 254 | When I run `wp search-replace 'Apple' 'Google' --dry-run` 255 | Then STDOUT should be a table containing rows: 256 | | Table | Column | Replacements | Type | 257 | | wp_posts | post_content | 1 | SQL | 258 | 259 | When I run `wp search-replace 'Apple' 'Google'` 260 | Then STDOUT should be a table containing rows: 261 | | Table | Column | Replacements | Type | 262 | | wp_posts | post_content | 1 | SQL | 263 | 264 | When I run `wp search-replace 'Google' 'Apple' --dry-run` 265 | Then STDOUT should contain: 266 | """ 267 | 1 replacement to be made. 268 | """ 269 | 270 | When I run `wp post get {POST_ID} --field=content` 271 | Then STDOUT should be: 272 | """ 273 | Google 274 | """ 275 | 276 | Scenario: Search and replace with the same terms 277 | Given a WP install 278 | 279 | When I try `wp search-replace foo foo` 280 | Then STDERR should be: 281 | """ 282 | Warning: Replacement value 'foo' is identical to search value 'foo'. Skipping operation. 283 | """ 284 | And STDOUT should be empty 285 | And the return code should be 0 286 | 287 | @require-mysql 288 | Scenario: Search and replace a table that has a multi-column primary key 289 | Given a WP install 290 | And I run `wp db query "CREATE TABLE wp_multicol ( "id" bigint(20) NOT NULL AUTO_INCREMENT,"name" varchar(60) NOT NULL,"value" text NOT NULL,PRIMARY KEY ("id","name"),UNIQUE KEY "name" ("name") ) ENGINE=InnoDB DEFAULT CHARSET=utf8 "` 291 | And I run `wp db query "INSERT INTO wp_multicol VALUES (1, 'foo', 'bar')"` 292 | And I run `wp db query "INSERT INTO wp_multicol VALUES (2, 'bar', 'foo')"` 293 | 294 | When I run `wp search-replace bar replaced wp_multicol --all-tables` 295 | Then STDOUT should be a table containing rows: 296 | | Table | Column | Replacements | Type | 297 | | wp_multicol | name | 1 | SQL | 298 | | wp_multicol | value | 1 | SQL | 299 | 300 | # Skip on 5.0 for now due to difficulties introduced by https://core.trac.wordpress.org/changeset/42981 301 | @less-than-wp-5.0 302 | Scenario Outline: Large guid search/replace where replacement contains search (or not) 303 | Given a WP install 304 | And I run `wp option get siteurl` 305 | And save STDOUT as {SITEURL} 306 | And I run `wp site empty --yes` 307 | And I run `wp post generate --count=20` 308 | 309 | When I run `wp search-replace {SITEURL} ` 310 | Then STDOUT should be a table containing rows: 311 | | Table | Column | Replacements | Type | 312 | | wp_posts | guid | 20 | SQL | 313 | 314 | Examples: 315 | | replacement | flags | 316 | | {SITEURL}/subdir | | 317 | | https://newdomain.com | | 318 | | https://newdomain.com | --dry-run | 319 | 320 | @require-mysql 321 | Scenario Outline: Choose replacement method (PHP or MySQL/MariaDB) given proper flags or data. 322 | Given a WP install 323 | And I run `wp option get siteurl` 324 | And save STDOUT as {SITEURL} 325 | When I run `wp search-replace {SITEURL} https://wordpress.org` 326 | 327 | Then STDOUT should be a table containing rows: 328 | | Table | Column | Replacements | Type | 329 | | wp_options | option_value | 2 | | 330 | | wp_posts | post_title | 0 | | 331 | 332 | Examples: 333 | | flags | serial | noserial | 334 | | | PHP | SQL | 335 | | --precise | PHP | PHP | 336 | 337 | @require-mysql 338 | Scenario Outline: Ensure search and replace uses PHP (precise) mode when serialized data is found 339 | Given a WP install 340 | And I run `wp post create --post_content='' --porcelain` 341 | And save STDOUT as {CONTROLPOST} 342 | And I run `wp search-replace --precise foo bar` 343 | And I run `wp post get {CONTROLPOST} --field=content` 344 | And save STDOUT as {CONTROL} 345 | And I run `wp post create --post_content='' --porcelain` 346 | And save STDOUT as {TESTPOST} 347 | And I run `wp search-replace foo bar` 348 | 349 | When I run `wp post get {TESTPOST} --field=content` 350 | Then STDOUT should be: 351 | """ 352 | {CONTROL} 353 | """ 354 | 355 | Examples: 356 | | input | 357 | | a:1:{s:3:"bar";s:3:"foo";} | 358 | | O:8:"stdClass":1:{s:1:"a";s:3:"foo";} | 359 | 360 | @require-mysql 361 | Scenario: Search replace with a regex flag 362 | Given a WP install 363 | 364 | When I run `wp search-replace 'EXAMPLE.com' 'BAXAMPLE.com' wp_options --regex` 365 | Then STDOUT should be a table containing rows: 366 | | Table | Column | Replacements | Type | 367 | | wp_options | option_value | 0 | PHP | 368 | 369 | When I run `wp option get home` 370 | Then STDOUT should be: 371 | """ 372 | https://example.com 373 | """ 374 | 375 | When I run `wp search-replace 'EXAMPLE.com' 'BAXAMPLE.com' wp_options --regex --regex-flags=i` 376 | Then STDOUT should be a table containing rows: 377 | | Table | Column | Replacements | Type | 378 | | wp_options | option_value | 5 | PHP | 379 | 380 | When I run `wp option get home` 381 | Then STDOUT should be: 382 | """ 383 | https://BAXAMPLE.com 384 | """ 385 | 386 | @require-mysql 387 | Scenario: Search replace with a regex delimiter 388 | Given a WP install 389 | 390 | When I run `wp search-replace 'HTTPS://EXAMPLE.COM' 'https://example.jp/' wp_options --regex --regex-flags=i --regex-delimiter='#'` 391 | Then STDOUT should be a table containing rows: 392 | | Table | Column | Replacements | Type | 393 | | wp_options | option_value | 2 | PHP | 394 | 395 | When I run `wp option get home` 396 | Then STDOUT should be: 397 | """ 398 | https://example.jp 399 | """ 400 | 401 | When I run `wp search-replace 'https://example.jp/' 'https://example.com/' wp_options --regex-delimiter='/'` 402 | Then STDOUT should be a table containing rows: 403 | | Table | Column | Replacements | Type | 404 | | wp_options | option_value | 2 | PHP | 405 | 406 | When I run `wp option get home` 407 | Then STDOUT should be: 408 | """ 409 | https://example.com 410 | """ 411 | 412 | # NOTE: The preg_match() error message is a substring of the actual message that matches across supported PHP versions. 413 | # In PHP 8.2, the error message changed from 414 | # "preg_match(): Delimiter must not be alphanumeric or backslash." 415 | # to 416 | # "preg_match(): Delimiter must not be alphanumeric, backslash, or NUL" 417 | When I try `wp search-replace 'HTTPS://EXAMPLE.COM' 'https://example.jp/' wp_options --regex --regex-flags=i --regex-delimiter='1'` 418 | Then STDERR should contain: 419 | """ 420 | Error: The regex '1HTTPS://EXAMPLE.COM1i' fails. 421 | preg_match(): Delimiter must not be alphanumeric 422 | """ 423 | And the return code should be 1 424 | 425 | When I try `wp search-replace 'regex error)' '' --regex` 426 | Then STDERR should contain: 427 | """ 428 | Error: The regex pattern 'regex error)' with default delimiter 'chr(1)' and no flags fails. 429 | """ 430 | And STDERR should contain: 431 | """ 432 | preg_match(): Compilation failed: 433 | """ 434 | And STDERR should contain: 435 | """ 436 | at offset 11 437 | """ 438 | And the return code should be 1 439 | 440 | When I try `wp search-replace 'regex error)' '' --regex --regex-flags=u` 441 | Then STDERR should contain: 442 | """ 443 | Error: The regex pattern 'regex error)' with default delimiter 'chr(1)' and flags 'u' fails. 444 | """ 445 | And STDERR should contain: 446 | """ 447 | preg_match(): Compilation failed: 448 | """ 449 | And STDERR should contain: 450 | """ 451 | at offset 11 452 | """ 453 | And the return code should be 1 454 | 455 | When I try `wp search-replace 'regex error)' '' --regex --regex-delimiter=/` 456 | Then STDERR should contain: 457 | """ 458 | Error: The regex '/regex error)/' fails. 459 | """ 460 | And STDERR should contain: 461 | """ 462 | preg_match(): Compilation failed: 463 | """ 464 | And STDERR should contain: 465 | """ 466 | at offset 11 467 | """ 468 | And the return code should be 1 469 | 470 | When I try `wp search-replace 'regex error)' '' --regex --regex-delimiter=/ --regex-flags=u` 471 | Then STDERR should contain: 472 | """ 473 | Error: The regex '/regex error)/u' fails. 474 | """ 475 | And STDERR should contain: 476 | """ 477 | preg_match(): Compilation failed: 478 | """ 479 | And STDERR should contain: 480 | """ 481 | at offset 11 482 | """ 483 | And the return code should be 1 484 | 485 | @require-mysql 486 | Scenario: Formatting as count-only 487 | Given a WP install 488 | And I run `wp option set foo 'ALPHA.example.com'` 489 | 490 | # --quite should suppress --format=count 491 | When I run `wp search-replace 'ALPHA.example.com' 'BETA.example.com' --quiet --format=count` 492 | Then STDOUT should be empty 493 | 494 | # --format=count should suppress --verbose 495 | When I run `wp search-replace 'BETA.example.com' 'ALPHA.example.com' --format=count --verbose` 496 | Then STDOUT should be: 497 | """ 498 | 1 499 | """ 500 | 501 | # The normal command 502 | When I run `wp search-replace 'ALPHA.example.com' 'BETA.example.com' --format=count` 503 | Then STDOUT should be: 504 | """ 505 | 1 506 | """ 507 | 508 | # Lets just make sure that zero works, too. 509 | When I run `wp search-replace 'DELTA.example.com' 'ALPHA.example.com' --format=count` 510 | Then STDOUT should be: 511 | """ 512 | 0 513 | """ 514 | 515 | @require-mysql 516 | Scenario: Search / replace should cater for field/table names that use reserved words or unusual characters 517 | Given a WP install 518 | And a esc_sql_ident.sql file: 519 | """ 520 | CREATE TABLE `TABLE` (`KEY` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT, `VALUES` TEXT, `back``tick` TEXT, `single'double"quote` TEXT, PRIMARY KEY (`KEY`) ); 521 | INSERT INTO `TABLE` (`VALUES`, `back``tick`, `single'double"quote`) VALUES ('v"vvvv_v1', 'v"vvvv_v1', 'v"vvvv_v1' ); 522 | INSERT INTO `TABLE` (`VALUES`, `back``tick`, `single'double"quote`) VALUES ('v"vvvv_v2', 'v"vvvv_v2', 'v"vvvv_v2' ); 523 | """ 524 | 525 | When I run `wp db query "SOURCE esc_sql_ident.sql;"` 526 | Then STDERR should be empty 527 | 528 | When I run `wp search-replace 'v"vvvv_v' 'w"wwww_w' TABLE --format=count --all-tables` 529 | Then STDOUT should be: 530 | """ 531 | 6 532 | """ 533 | And STDERR should be empty 534 | 535 | # Regex uses wpdb::update() which can't handle backticks in field names so avoid `back``tick` column. 536 | When I run `wp search-replace 'w"wwww_w' 'v"vvvv_v' TABLE --regex --include-columns='VALUES,single'\''double"quote' --format=count --all-tables` 537 | Then STDOUT should be: 538 | """ 539 | 4 540 | """ 541 | And STDERR should be empty 542 | 543 | @require-mysql @suppress_report__only_changes 544 | Scenario: Suppress report or only report changes 545 | Given a WP install 546 | 547 | When I run `wp option set foo baz` 548 | And I run `wp option get foo` 549 | Then STDOUT should be: 550 | """ 551 | baz 552 | """ 553 | 554 | When I run `wp post create --post_title=baz --porcelain` 555 | Then save STDOUT as {POST_ID} 556 | 557 | When I run `wp post meta add {POST_ID} foo baz` 558 | Then STDOUT should not be empty 559 | 560 | When I run `wp search-replace baz baz1` 561 | Then STDOUT should contain: 562 | """ 563 | Success: Made 3 replacements. 564 | """ 565 | And STDOUT should be a table containing rows: 566 | | Table | Column | Replacements | Type | 567 | | wp_commentmeta | meta_key | 0 | SQL | 568 | | wp_options | option_value | 1 | PHP | 569 | | wp_postmeta | meta_value | 1 | SQL | 570 | | wp_posts | post_title | 1 | SQL | 571 | | wp_users | display_name | 0 | SQL | 572 | And STDERR should be empty 573 | 574 | When I run `wp search-replace baz1 baz2 --report` 575 | Then STDOUT should contain: 576 | """ 577 | Success: Made 3 replacements. 578 | """ 579 | And STDOUT should be a table containing rows: 580 | | Table | Column | Replacements | Type | 581 | | wp_commentmeta | meta_key | 0 | SQL | 582 | | wp_options | option_value | 1 | PHP | 583 | | wp_postmeta | meta_value | 1 | SQL | 584 | | wp_posts | post_title | 1 | SQL | 585 | | wp_users | display_name | 0 | SQL | 586 | And STDERR should be empty 587 | 588 | When I run `wp search-replace baz2 baz3 --no-report` 589 | Then STDOUT should contain: 590 | """ 591 | Success: Made 3 replacements. 592 | """ 593 | And STDOUT should not contain: 594 | """ 595 | Table Column Replacements Type 596 | """ 597 | And STDOUT should not contain: 598 | """ 599 | wp_commentmeta meta_key 0 SQL 600 | """ 601 | And STDOUT should not contain: 602 | """ 603 | wp_options option_value 1 PHP 604 | """ 605 | And STDERR should be empty 606 | 607 | When I run `wp search-replace baz3 baz4 --no-report-changed-only` 608 | Then STDOUT should contain: 609 | """ 610 | Success: Made 3 replacements. 611 | """ 612 | And STDOUT should be a table containing rows: 613 | | Table | Column | Replacements | Type | 614 | | wp_commentmeta | meta_key | 0 | SQL | 615 | | wp_options | option_value | 1 | PHP | 616 | | wp_postmeta | meta_value | 1 | SQL | 617 | | wp_posts | post_title | 1 | SQL | 618 | | wp_users | display_name | 0 | SQL | 619 | And STDERR should be empty 620 | 621 | When I run `wp search-replace baz4 baz5 --report-changed-only` 622 | Then STDOUT should contain: 623 | """ 624 | Success: Made 3 replacements. 625 | """ 626 | And STDOUT should end with a table containing rows: 627 | | Table | Column | Replacements | Type | 628 | | wp_options | option_value | 1 | PHP | 629 | | wp_postmeta | meta_value | 1 | SQL | 630 | | wp_posts | post_title | 1 | SQL | 631 | And STDOUT should not contain: 632 | """ 633 | wp_commentmeta meta_key 0 SQL 634 | """ 635 | And STDOUT should not contain: 636 | """ 637 | wp_users display_name 0 SQL 638 | """ 639 | And STDERR should be empty 640 | 641 | When I run `wp search-replace nobaz1 baz6 --report-changed-only` 642 | Then STDOUT should contain: 643 | """ 644 | Success: Made 0 replacements. 645 | """ 646 | And STDOUT should not contain: 647 | """ 648 | Table Column Replacements Type 649 | """ 650 | And STDERR should be empty 651 | 652 | @require-mysql @no_table__no_primary_key 653 | Scenario: Deal with non-existent table and table with no primary keys 654 | Given a WP install 655 | 656 | When I try `wp search-replace foo bar no_such_table --all-tables` 657 | Then STDOUT should be empty 658 | And STDERR should be: 659 | """ 660 | Error: Couldn't find any tables matching: no_such_table 661 | """ 662 | And the return code should be 1 663 | 664 | When I run `wp db query "CREATE TABLE no_key ( awesome_stuff TEXT );"` 665 | And I run `wp search-replace foo bar no_key --all-tables` 666 | Then STDOUT should contain: 667 | """ 668 | Success: Made 0 replacements. 669 | """ 670 | And STDOUT should end with a table containing rows: 671 | | Table | Column | Replacements | Type | 672 | | no_key | | skipped | | 673 | And STDERR should be empty 674 | 675 | When I run `wp search-replace foo bar no_key --report-changed-only --all-tables` 676 | Then STDOUT should contain: 677 | """ 678 | Success: Made 0 replacements. 679 | """ 680 | And STDOUT should not contain: 681 | """ 682 | | Table | Column | Replacements | Type | 683 | | no_key | | skipped | | 684 | """ 685 | And STDERR should be empty 686 | 687 | When I try `wp search-replace foo bar no_key --no-report --all-tables` 688 | Then STDOUT should contain: 689 | """ 690 | Success: Made 0 replacements. 691 | """ 692 | And STDOUT should not contain: 693 | """ 694 | Table Column Replacements Type 695 | """ 696 | And STDERR should be: 697 | """ 698 | Warning: No primary keys for table 'no_key'. 699 | """ 700 | And the return code should be 0 701 | 702 | @require-mysql 703 | Scenario: Search / replace is case sensitive 704 | Given a WP install 705 | When I run `wp post create --post_title='Case Sensitive' --porcelain` 706 | Then save STDOUT as {POST_ID} 707 | 708 | When I run `wp search-replace sensitive insensitive` 709 | Then STDOUT should contain: 710 | """ 711 | Success: Made 0 replacements. 712 | """ 713 | And STDERR should be empty 714 | 715 | When I run `wp search-replace sensitive insensitive --dry-run` 716 | Then STDOUT should contain: 717 | """ 718 | Success: 0 replacements to be made. 719 | """ 720 | And STDERR should be empty 721 | 722 | When I run `wp search-replace Sensitive insensitive --dry-run` 723 | Then STDOUT should contain: 724 | """ 725 | Success: 1 replacement to be made. 726 | """ 727 | And STDERR should be empty 728 | 729 | When I run `wp search-replace Sensitive insensitive` 730 | Then STDOUT should contain: 731 | """ 732 | Success: Made 1 replacement. 733 | """ 734 | And STDERR should be empty 735 | 736 | @require-mysql 737 | Scenario: Logging with simple replace 738 | Given a WP install 739 | 740 | When I run `wp post create --post_title='Title_baz__baz_' --post_content='Content_baz_12345678901234567890_baz_12345678901234567890' --porcelain` 741 | Then save STDOUT as {POST_ID} 742 | 743 | When I run `wp search-replace '_baz_' '_' wp_posts --dry-run --log --before_context=10 --after_context=10` 744 | Then STDOUT should contain: 745 | """ 746 | Success: 2 replacements to be made. 747 | """ 748 | And STDOUT should end with a table containing rows: 749 | | Table | Column | Replacements | Type | 750 | | wp_posts | post_content | 1 | SQL | 751 | | wp_posts | post_title | 1 | SQL | 752 | 753 | And STDOUT should contain: 754 | """ 755 | wp_posts.post_content:{POST_ID} 756 | < Content_baz_1234567890 [...] 1234567890_baz_1234567890 757 | > Content_1234567890 [...] 1234567890_1234567890 758 | """ 759 | And STDOUT should contain: 760 | """ 761 | wp_posts.post_title:{POST_ID} 762 | < Title_baz__baz_ 763 | > Title__ 764 | """ 765 | And STDERR should be empty 766 | 767 | When I run `wp search-replace '_baz_' '' wp_posts --dry-run --log=replace.log` 768 | Then STDOUT should contain: 769 | """ 770 | Success: 2 replacements to be made. 771 | """ 772 | And STDOUT should not contain: 773 | """ 774 | < Content 775 | """ 776 | And the replace.log file should contain: 777 | """ 778 | wp_posts.post_content:{POST_ID} 779 | < Content_baz_12345678901234567890_baz_12345678901234567890 780 | > Content1234567890123456789012345678901234567890 781 | """ 782 | And the replace.log file should contain: 783 | """ 784 | wp_posts.post_title:{POST_ID} 785 | < Title_baz__baz_ 786 | > Title 787 | """ 788 | And STDERR should be empty 789 | 790 | # kana with diacritic and decomposed "a" + umlaut. 791 | When I run `wp search-replace '_baz_' '_バäz_' wp_posts --log=- --before_context=10 --after_context=20` 792 | Then STDOUT should contain: 793 | """ 794 | Success: Made 2 replacements. 795 | """ 796 | And STDOUT should contain: 797 | """ 798 | wp_posts.post_content:{POST_ID} 799 | < Content_baz_12345678901234567890 [...] 1234567890_baz_12345678901234567890 800 | > Content_バäz_12345678901234567890 [...] 1234567890_バäz_12345678901234567890 801 | """ 802 | And STDERR should be empty 803 | 804 | # Testing UTF-8 context 805 | When I run `wp search-replace 'z_' 'zzzz_' wp_posts --log --before_context=2 --after_context=1` 806 | Then STDOUT should contain: 807 | """ 808 | Success: Made 2 replacements. 809 | """ 810 | And STDOUT should contain: 811 | """ 812 | wp_posts.post_content:{POST_ID} 813 | < バäz_1 [...] バäz_1 814 | > バäzzzz_1 [...] バäzzzz_1 815 | """ 816 | And STDERR should be empty 817 | 818 | When I run `wp option set foobar '_bar1_ _bar1_12345678901234567890123456789012345678901234567890_bar1_ _bar1_1234567890123456789012345678901234567890'` 819 | And I run `wp search-replace '_bar1_' '_baz1_' wp_options --log` 820 | Then STDOUT should contain: 821 | """ 822 | < _bar1_ _bar1_1234567890123456789012345678901234567890 [...] 1234567890123456789012345678901234567890_bar1_ _bar1_1234567890123456789012345678901234567890 823 | > _baz1_ _baz1_1234567890123456789012345678901234567890 [...] 1234567890123456789012345678901234567890_baz1_ _baz1_1234567890123456789012345678901234567890 824 | """ 825 | And STDERR should be empty 826 | 827 | When I run `wp option get foobar` 828 | Then STDOUT should be: 829 | """ 830 | _baz1_ _baz1_12345678901234567890123456789012345678901234567890_baz1_ _baz1_1234567890123456789012345678901234567890 831 | """ 832 | 833 | When I run `wp search-replace '_baz1_' '_bar1_' wp_options --log --before_context=10 --after_context=10` 834 | Then STDOUT should contain: 835 | """ 836 | < _baz1_ _baz1_1234567890 [...] 1234567890_baz1_ _baz1_1234567890 837 | > _bar1_ _bar1_1234567890 [...] 1234567890_bar1_ _bar1_1234567890 838 | """ 839 | And STDERR should be empty 840 | 841 | When I run `wp option set foobar2 '12345678901234567890_bar2_1234567890_bar2_ _bar2_ _bar2_'` 842 | And I run `wp search-replace '_bar2_' '_baz2baz2_' wp_options --log --before_context=10 --after_context=10` 843 | Then STDOUT should contain: 844 | """ 845 | < 1234567890_bar2_1234567890 [...] 1234567890_bar2_ _bar2_ _bar2_ 846 | > 1234567890_baz2baz2_1234567890 [...] 1234567890_baz2baz2_ _baz2baz2_ _baz2baz2_ 847 | """ 848 | And STDERR should be empty 849 | 850 | When I run `wp option get foobar2` 851 | Then STDOUT should be: 852 | """ 853 | 12345678901234567890_baz2baz2_1234567890_baz2baz2_ _baz2baz2_ _baz2baz2_ 854 | """ 855 | 856 | When I run `wp search-replace '_baz2baz2_' '_barz2_' wp_options --log --before_context=10 --after_context=4` 857 | Then STDOUT should contain: 858 | """ 859 | < 1234567890_baz2baz2_1234 [...] 1234567890_baz2baz2_ _baz2baz2_ _baz2baz2_ 860 | > 1234567890_barz2_1234 [...] 1234567890_barz2_ _barz2_ _barz2_ 861 | """ 862 | And STDERR should be empty 863 | 864 | When I run `wp option set foobar3 '_bar3 _bar3 _bar3 _bar3'` 865 | And I run `wp search-replace '_bar3' 'baz3' wp_options --log` 866 | Then STDOUT should contain: 867 | """ 868 | < _bar3 _bar3 _bar3 _bar3 869 | > baz3 baz3 baz3 baz3 870 | """ 871 | And STDERR should be empty 872 | 873 | When I run `wp option get foobar3` 874 | Then STDOUT should be: 875 | """ 876 | baz3 baz3 baz3 baz3 877 | """ 878 | 879 | When I run `wp search-replace 'baz3' 'baz\3' wp_options --dry-run --log` 880 | Then STDOUT should contain: 881 | """ 882 | < baz3 baz3 baz3 baz3 883 | > baz\3 baz\3 baz\3 baz\3 884 | """ 885 | And STDERR should be empty 886 | 887 | Scenario: Logging with regex replace 888 | Given a WP install 889 | 890 | When I run `wp post create --post_title='Title_baz__boz_' --post_content='Content_baz_1234567890_bez_1234567890_biz_1234567890_boz_1234567890_buz_' --porcelain` 891 | Then save STDOUT as {POST_ID} 892 | 893 | When I run `wp search-replace '_b[aeiou]z_' '_bz_' wp_posts --regex --dry-run --log --before_context=11 --after_context=11` 894 | Then STDOUT should contain: 895 | """ 896 | Success: 2 replacements to be made. 897 | """ 898 | And STDOUT should end with a table containing rows: 899 | | Table | Column | Replacements | Type | 900 | | wp_posts | post_content | 1 | PHP | 901 | | wp_posts | post_title | 1 | PHP | 902 | 903 | And STDOUT should contain: 904 | """ 905 | wp_posts.post_content:{POST_ID} 906 | < Content_baz_1234567890_bez_1234567890_biz_1234567890_boz_1234567890_buz_ 907 | > Content_bz_1234567890_bz_1234567890_bz_1234567890_bz_1234567890_bz_ 908 | """ 909 | And STDOUT should contain: 910 | """ 911 | wp_posts.post_title:{POST_ID} 912 | < Title_baz__boz_ 913 | > Title_bz__bz_ 914 | """ 915 | And STDERR should be empty 916 | 917 | When I run `wp search-replace '_b([aeiou])z_' '_$1b\\1z_\0' wp_posts --regex --log --before_context=11 --after_context=11` 918 | Then STDOUT should contain: 919 | """ 920 | Success: Made 2 replacements. 921 | """ 922 | 923 | And STDOUT should contain: 924 | """ 925 | wp_posts.post_content:{POST_ID} 926 | < Content_baz_1234567890_bez_1234567890_biz_1234567890_boz_1234567890_buz_ 927 | > Content_ab\1z__baz_1234567890_eb\1z__bez_1234567890_ib\1z__biz_1234567890_ob\1z__boz_1234567890_ub\1z__buz_ 928 | """ 929 | And STDOUT should contain: 930 | """ 931 | wp_posts.post_title:{POST_ID} 932 | < Title_baz__boz_ 933 | > Title_ab\1z__baz__ob\1z__boz_ 934 | """ 935 | And STDERR should be empty 936 | 937 | When I run `wp post get {POST_ID} --field=title` 938 | Then STDOUT should be: 939 | """ 940 | Title_ab\1z__baz__ob\1z__boz_ 941 | """ 942 | 943 | When I run `wp post get {POST_ID} --field=content` 944 | Then STDOUT should be: 945 | """ 946 | Content_ab\1z__baz_1234567890_eb\1z__bez_1234567890_ib\1z__biz_1234567890_ob\1z__boz_1234567890_ub\1z__buz_ 947 | """ 948 | 949 | @require-mysql 950 | Scenario: Logging with prefixes and custom colors 951 | Given a WP install 952 | And I run `wp option set blogdescription 'Just another WordPress site'` 953 | 954 | When I run `WP_CLI_SEARCH_REPLACE_LOG_PREFIXES='- ,+ ' wp search-replace Just Yet --dry-run --log` 955 | Then STDOUT should contain: 956 | """ 957 | - Just another WordPress site 958 | + Yet another WordPress site 959 | """ 960 | And STDERR should be empty 961 | 962 | When I run `WP_CLI_SEARCH_REPLACE_LOG_PREFIXES=',' wp search-replace Just Yet --dry-run --log` 963 | Then STDOUT should not contain: 964 | """ 965 | < Just 966 | """ 967 | And STDOUT should contain: 968 | """ 969 | Just 970 | """ 971 | And STDOUT should not contain: 972 | """ 973 | > Yet 974 | """ 975 | And STDOUT should contain: 976 | """ 977 | Yet 978 | """ 979 | And STDERR should be empty 980 | 981 | When I run `SHELL_PIPE=0 wp search-replace WordPress WP --dry-run --log` 982 | Then STDOUT should strictly contain: 983 | """ 984 | wp_options.option_value: 985 | """ 986 | And STDOUT should strictly contain: 987 | """ 988 | < Just another WordPress site 989 | > Just another WP site 990 | """ 991 | And STDERR should be empty 992 | 993 | When I run `SHELL_PIPE=0 WP_CLI_SEARCH_REPLACE_LOG_COLORS='%b,%r,%g' wp search-replace WordPress WP --dry-run --log` 994 | Then STDOUT should strictly contain: 995 | """ 996 | wp_options.option_value: 997 | """ 998 | And STDOUT should strictly contain: 999 | """ 1000 | < Just another WordPress site 1001 | > Just another WP site 1002 | """ 1003 | And STDERR should be empty 1004 | 1005 | When I run `SHELL_PIPE=0 WP_CLI_SEARCH_REPLACE_LOG_COLORS='%b,%r,%g' wp search-replace WordPress WP --dry-run --log=replace.log` 1006 | Then STDOUT should not contain: 1007 | """ 1008 | wp_options.option_value 1009 | """ 1010 | And the replace.log file should strictly contain: 1011 | """ 1012 | wp_options.option_value: 1013 | """ 1014 | And the replace.log file should strictly contain: 1015 | """ 1016 | < Just another WordPress site 1017 | > Just another WP site 1018 | """ 1019 | And STDERR should be empty 1020 | 1021 | When I run `SHELL_PIPE=0 wp search-replace WordPress WP --dry-run --log=replace.log` 1022 | Then STDOUT should not contain: 1023 | """ 1024 | wp_options.option_value 1025 | """ 1026 | And the replace.log file should contain: 1027 | """ 1028 | wp_options.option_value: 1029 | """ 1030 | And the replace.log file should contain: 1031 | """ 1032 | < Just another WordPress site 1033 | > Just another WP site 1034 | """ 1035 | And STDERR should be empty 1036 | 1037 | When I run `SHELL_PIPE=0 WP_CLI_SEARCH_REPLACE_LOG_COLORS=',,' wp search-replace WordPress WP --dry-run --log` 1038 | Then STDOUT should contain: 1039 | """ 1040 | wp_options.option_value: 1041 | """ 1042 | And STDOUT should contain: 1043 | """ 1044 | < Just another WordPress site 1045 | > Just another WP site 1046 | """ 1047 | And STDERR should be empty 1048 | 1049 | # Regression test for https://github.com/wp-cli/search-replace-command/issues/58 1050 | @require-mysql 1051 | Scenario: The parameters --regex and --all-tables-with-prefix produce valid SQL 1052 | Given a WP install 1053 | And a test_db.sql file: 1054 | """ 1055 | CREATE TABLE `wp_123_test` ( 1056 | `name` varchar(50), 1057 | `value` varchar(5000), 1058 | `created_at` datetime NOT NULL, 1059 | `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, 1060 | PRIMARY KEY (`name`) 1061 | ) ENGINE=InnoDB; 1062 | INSERT INTO `wp_123_test` VALUES ('test_val','wp_123_test_value_X','2016-11-15 14:41:33','2016-11-15 21:41:33'); 1063 | INSERT INTO `wp_123_test` VALUES ('123.','wp_123_test_value_X','2016-11-15 14:41:33','2016-11-15 21:41:33'); 1064 | INSERT INTO `wp_123_test` VALUES ('quote\'quote','wp_123_test_value_X','2016-11-15 14:41:33','2016-11-15 21:41:33'); 1065 | INSERT INTO `wp_123_test` VALUES ('0','wp_123_test_value_X','2016-11-15 14:41:33','2016-11-15 21:41:33'); 1066 | INSERT INTO `wp_123_test` VALUES ('','wp_123_test_value_X','2016-11-15 14:41:33','2016-11-15 21:41:33'); 1067 | INSERT INTO `wp_123_test` VALUES ('18446744073709551616','wp_123_test_value_X','2016-11-15 14:41:33','2016-11-15 21:41:33'); 1068 | INSERT INTO `wp_123_test` VALUES ('-18446744073709551615','wp_123_test_value_X','2016-11-15 14:41:33','2016-11-15 21:41:33'); 1069 | INSERT INTO `wp_123_test` VALUES ('123456789012345678801234567890','wp_123_test_value_X','2016-11-15 14:41:33','2016-11-15 21:41:33'); 1070 | 1071 | CREATE TABLE `wp_123_test2` (`bigint_unsigned_key` BIGINT UNSIGNED NOT NULL, `value` VARCHAR(255), PRIMARY KEY (`bigint_unsigned_key`) ); 1072 | INSERT INTO `wp_123_test2` VALUES ('18446744073709551615','wp_123_test2_value_X'); 1073 | 1074 | CREATE TABLE `wp_123_test3` (`bigint_signed_key` BIGINT SIGNED NOT NULL, `value` VARCHAR(255), PRIMARY KEY (`bigint_signed_key`) ); 1075 | INSERT INTO `wp_123_test3` VALUES ('-9223372036854775808','wp_123_test3_value_X'); 1076 | """ 1077 | And I run `wp db query "SOURCE test_db.sql;"` 1078 | 1079 | When I run `wp search-replace --dry-run --regex 'mytestdomain.com\/' 'mytestdomain2.com/' --all-tables-with-prefix --skip-columns=guid,domain` 1080 | Then STDOUT should contain: 1081 | """ 1082 | Success: 0 replacements to be made. 1083 | """ 1084 | 1085 | When I run `wp search-replace --dry-run --regex 'wp_123_test_value_X' 'wp_123_test_value_Y' --all-tables-with-prefix` 1086 | Then STDOUT should contain: 1087 | """ 1088 | Success: 8 replacements to be made. 1089 | """ 1090 | 1091 | When I run `wp search-replace --dry-run --regex 'wp_123_test2_value_X' 'wp_123_test2_value_Y' --all-tables-with-prefix` 1092 | Then STDOUT should contain: 1093 | """ 1094 | Success: 1 replacement to be made. 1095 | """ 1096 | 1097 | When I run `wp search-replace --dry-run --regex 'wp_123_test3_value_X' 'wp_123_test3_value_Y' --all-tables-with-prefix` 1098 | Then STDOUT should contain: 1099 | """ 1100 | Success: 1 replacement to be made. 1101 | """ 1102 | 1103 | # Regression test for https://github.com/wp-cli/search-replace-command/issues/68 1104 | @require-mysql 1105 | Scenario: Incomplete classes are handled gracefully during (un)serialization 1106 | 1107 | Given a WP install 1108 | And I run `wp option add cereal_isation 'a:1:{i:0;O:10:"CornFlakes":0:{}}'` 1109 | 1110 | When I try `wp search-replace CornFlakes Smacks` 1111 | Then STDERR should contain: 1112 | """ 1113 | Warning: Skipping an uninitialized class "CornFlakes", replacements might not be complete. 1114 | """ 1115 | And STDOUT should contain: 1116 | """ 1117 | Success: Made 0 replacements. 1118 | """ 1119 | 1120 | When I run `wp option get cereal_isation` 1121 | Then STDOUT should contain: 1122 | """ 1123 | a:1:{i:0;O:10:"CornFlakes":0:{}} 1124 | """ 1125 | 1126 | @require-mysql @less-than-php-8.0 1127 | Scenario: Warn and ignore type-hinted objects that have some error in deserialization (PHP < 8.0) 1128 | Given a WP install 1129 | And I run `wp db query "INSERT INTO wp_options (option_name,option_value) VALUES ('cereal_isation','O:13:\"mysqli_result\":5:{s:13:\"current_field\";N;s:11:\"field_count\";N;s:7:\"lengths\";N;s:8:\"num_rows\";N;s:4:\"type\";N;}')"` 1130 | And I run `wp db query "INSERT INTO wp_options (option_name,option_value) VALUES ('cereal_isation_2','O:8:\"mysqli_result\":5:{s:13:\"current_field\";i:1;s:11:\"field_count\";i:2;s:7:\"lengths\";a:1:{i:0;s:4:\"blah\";}s:8:\"num_rows\";i:1;s:4:\"type\";i:2;}')"` 1131 | 1132 | When I try `wp search-replace mysqli_result stdClass` 1133 | Then STDERR should contain: 1134 | """ 1135 | Warning: WP_CLI\SearchReplacer::run_recursively(): Couldn't fetch mysqli_result 1136 | """ 1137 | And STDOUT should contain: 1138 | """ 1139 | Success: Made 1 replacement. 1140 | """ 1141 | 1142 | When I run `wp db query "SELECT option_value from wp_options where option_name='cereal_isation_2'" --skip-column-names` 1143 | Then STDOUT should contain: 1144 | """ 1145 | O:8:"stdClass":5:{s:13:"current_field";i:1;s:11:"field_count";i:2;s:7:"lengths";a:1:{i:0;s:4:"blah";}s:8:"num_rows";i:1;s:4:"type";i:2;} 1146 | """ 1147 | And save STDOUT as {SERIALIZED_RESULT} 1148 | And a test_php.php file: 1149 | """ 1150 | 1 1161 | """ 1162 | And STDOUT should contain: 1163 | """ 1164 | [field_count] => 2 1165 | """ 1166 | 1167 | @require-mysql @require-php-8.0 @less-than-php-8.1 1168 | Scenario: Warn and ignore type-hinted objects that have some error in deserialization (PHP 8.0) 1169 | Given a WP install 1170 | And I run `wp db query "INSERT INTO wp_options (option_name,option_value) VALUES ('cereal_isation','O:13:\"mysqli_result\":5:{s:13:\"current_field\";N;s:11:\"field_count\";N;s:7:\"lengths\";N;s:8:\"num_rows\";N;s:4:\"type\";N;}')"` 1171 | And I run `wp db query "INSERT INTO wp_options (option_name,option_value) VALUES ('cereal_isation_2','O:8:\"mysqli_result\":5:{s:13:\"current_field\";i:1;s:11:\"field_count\";i:2;s:7:\"lengths\";a:1:{i:0;s:4:\"blah\";}s:8:\"num_rows\";i:1;s:4:\"type\";i:2;}')"` 1172 | 1173 | When I try `wp search-replace mysqli_result stdClass` 1174 | Then STDERR should contain: 1175 | """ 1176 | Warning: Skipping an inconvertible serialized object of type "mysqli_result", replacements might not be complete. Reason: mysqli_result object is already closed. 1177 | """ 1178 | And STDOUT should contain: 1179 | """ 1180 | Success: Made 1 replacement. 1181 | """ 1182 | 1183 | When I run `wp db query "SELECT option_value from wp_options where option_name='cereal_isation_2'" --skip-column-names` 1184 | Then STDOUT should contain: 1185 | """ 1186 | O:8:"stdClass":5:{s:13:"current_field";i:1;s:11:"field_count";i:2;s:7:"lengths";a:1:{i:0;s:4:"blah";}s:8:"num_rows";i:1;s:4:"type";i:2;} 1187 | """ 1188 | And save STDOUT as {SERIALIZED_RESULT} 1189 | And a test_php.php file: 1190 | """ 1191 | 1 1202 | """ 1203 | And STDOUT should contain: 1204 | """ 1205 | [field_count] => 2 1206 | """ 1207 | 1208 | @require-mysql @require-php-8.1 1209 | Scenario: Warn and ignore type-hinted objects that have some error in deserialization (PHP 8.1+) 1210 | Given a WP install 1211 | And I run `wp db query "INSERT INTO wp_options (option_name,option_value) VALUES ('cereal_isation','O:13:\"mysqli_result\":5:{s:13:\"current_field\";N;s:11:\"field_count\";N;s:7:\"lengths\";N;s:8:\"num_rows\";N;s:4:\"type\";N;}')"` 1212 | And I run `wp db query "INSERT INTO wp_options (option_name,option_value) VALUES ('cereal_isation_2','O:8:\"mysqli_result\":5:{s:13:\"current_field\";i:1;s:11:\"field_count\";i:2;s:7:\"lengths\";a:1:{i:0;s:4:\"blah\";}s:8:\"num_rows\";i:1;s:4:\"type\";i:2;}')"` 1213 | 1214 | When I try `wp search-replace mysqli_result stdClass` 1215 | Then STDERR should contain: 1216 | """ 1217 | Warning: Skipping an inconvertible serialized object: "O:13:"mysqli_result":5:{s:13:"current_field";N;s:11:"field_count";N;s:7:"lengths";N;s:8:"num_rows";N;s:4:"type";N;}", replacements might not be complete. Reason: Cannot assign null to property mysqli_result::$current_field of type int. 1218 | """ 1219 | And STDOUT should contain: 1220 | """ 1221 | Success: Made 1 replacement. 1222 | """ 1223 | 1224 | When I run `wp db query "SELECT option_value from wp_options where option_name='cereal_isation_2'" --skip-column-names` 1225 | Then STDOUT should contain: 1226 | """ 1227 | O:8:"stdClass":5:{s:13:"current_field";i:1;s:11:"field_count";i:2;s:7:"lengths";a:1:{i:0;s:4:"blah";}s:8:"num_rows";i:1;s:4:"type";i:2;} 1228 | """ 1229 | And save STDOUT as {SERIALIZED_RESULT} 1230 | And a test_php.php file: 1231 | """ 1232 | 1 1243 | """ 1244 | And STDOUT should contain: 1245 | """ 1246 | [field_count] => 2 1247 | """ 1248 | 1249 | Scenario: Regex search/replace with `--regex-limit=1` option 1250 | Given a WP install 1251 | And I run `wp post create --post_content="I have a pen, I have an apple. Pen, pine-apple, apple-pen."` 1252 | 1253 | When I run `wp search-replace --regex "ap{2}le" "orange" --regex-limit=1 --log` 1254 | Then STDOUT should contain: 1255 | """ 1256 | I have a pen, I have an orange. Pen, pine-apple, apple-pen. 1257 | """ 1258 | 1259 | Scenario: Regex search/replace with `--regex-limit=2` option 1260 | Given a WP install 1261 | And I run `wp post create --post_content="I have a pen, I have an apple. Pen, pine-apple, apple-pen."` 1262 | 1263 | When I run `wp search-replace --regex "ap{2}le" "orange" --regex-limit=2 --log` 1264 | Then STDOUT should contain: 1265 | """ 1266 | I have a pen, I have an orange. Pen, pine-orange, apple-pen. 1267 | """ 1268 | 1269 | Scenario: Regex search/replace with incorrect or default `--regex-limit` 1270 | Given a WP install 1271 | When I try `wp search-replace '(Hello)\s(world)' '$2, $1' --regex --regex-limit=asdf` 1272 | Then STDERR should be: 1273 | """ 1274 | Error: `--regex-limit` expects a non-zero positive integer or -1. 1275 | """ 1276 | When I try `wp search-replace '(Hello)\s(world)' '$2, $1' --regex --regex-limit=0` 1277 | Then STDERR should be: 1278 | """ 1279 | Error: `--regex-limit` expects a non-zero positive integer or -1. 1280 | """ 1281 | When I try `wp search-replace '(Hello)\s(world)' '$2, $1' --regex --regex-limit=-2` 1282 | Then STDERR should be: 1283 | """ 1284 | Error: `--regex-limit` expects a non-zero positive integer or -1. 1285 | """ 1286 | When I run `wp search-replace '(Hello)\s(world)' '$2, $1' --regex --regex-limit=-1` 1287 | Then STDOUT should contain: 1288 | """ 1289 | Success: 1290 | """ 1291 | 1292 | @require-mysql 1293 | Scenario: Chunking a precise search and replace works without skipping lines 1294 | Given a WP install 1295 | And a create_sql_file.sh file: 1296 | """ 1297 | #!/bin/bash 1298 | echo "CREATE TABLE \`wp_123_test\` (\`key\` INT(5) UNSIGNED NOT NULL AUTO_INCREMENT, \`text\` TEXT, PRIMARY KEY (\`key\`) );" > test_db.sql 1299 | echo "INSERT INTO \`wp_123_test\` (\`text\`) VALUES" >> test_db.sql 1300 | index=1 1301 | while [[ $index -le 199 ]]; 1302 | do 1303 | echo "('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc')," >> test_db.sql 1304 | index=`expr $index + 1` 1305 | done 1306 | echo "('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc');" >> test_db.sql 1307 | echo "CREATE TABLE \`wp_123_test_multikey\` (\`key1\` INT(5) UNSIGNED NOT NULL AUTO_INCREMENT, \`key2\` INT(5) UNSIGNED NOT NULL, \`key3\` INT(5) UNSIGNED NOT NULL, \`text\` TEXT, PRIMARY KEY (\`key1\`,\`key2\`,\`key3\`) );" >> test_db.sql 1308 | echo "INSERT INTO \`wp_123_test_multikey\` (\`key2\`,\`key3\`,\`text\`) VALUES" >> test_db.sql 1309 | index=1 1310 | while [[ $index -le 204 ]]; 1311 | do 1312 | echo "(0,0,'abc'),(1,1,'abc'),(2,2,'abc'),(3,3,'abc'),(4,4,'abc'),(5,0,'abc'),(6,1,'abc'),(7,2,'abc'),(8,3,'abc'),(9,4,'abc')," >> test_db.sql 1313 | index=`expr $index + 1` 1314 | done 1315 | echo "(0,0,'abc'),(1,1,'abc'),(2,2,'abc'),(3,3,'abc'),(4,4,'abc'),(5,0,'abc'),(6,1,'abc'),(7,2,'abc'),(8,3,'abc'),(9,4,'abc');" >> test_db.sql 1316 | """ 1317 | And I run `bash create_sql_file.sh` 1318 | And I run `wp db query "SOURCE test_db.sql;"` 1319 | 1320 | When I run `wp search-replace --dry-run 'abc' 'def' --all-tables-with-prefix --skip-columns=guid,domain --precise` 1321 | Then STDOUT should contain: 1322 | """ 1323 | Success: 4050 replacements to be made. 1324 | """ 1325 | 1326 | When I run `wp search-replace 'abc' 'def' --all-tables-with-prefix --skip-columns=guid,domain --precise` 1327 | Then STDOUT should contain: 1328 | """ 1329 | Success: Made 4050 replacements. 1330 | """ 1331 | 1332 | When I run `wp search-replace --dry-run 'abc' 'def' --all-tables-with-prefix --skip-columns=guid,domain --precise` 1333 | Then STDOUT should contain: 1334 | """ 1335 | Success: 0 replacements to be made. 1336 | """ 1337 | 1338 | When I run `wp search-replace 'abc' 'def' --all-tables-with-prefix --skip-columns=guid,domain --precise` 1339 | Then STDOUT should contain: 1340 | """ 1341 | Success: Made 0 replacements. 1342 | """ 1343 | 1344 | @require-mysql 1345 | Scenario: Chunking a regex search and replace works without skipping lines 1346 | Given a WP install 1347 | And a create_sql_file.sh file: 1348 | """ 1349 | #!/bin/bash 1350 | echo "CREATE TABLE \`wp_123_test\` (\`key\` INT(5) UNSIGNED NOT NULL AUTO_INCREMENT, \`text\` TEXT, PRIMARY KEY (\`key\`) );" > test_db.sql 1351 | echo "INSERT INTO \`wp_123_test\` (\`text\`) VALUES" >> test_db.sql 1352 | index=1 1353 | while [[ $index -le 199 ]]; 1354 | do 1355 | echo "('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc')," >> test_db.sql 1356 | index=`expr $index + 1` 1357 | done 1358 | echo "('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc'),('abc');" >> test_db.sql 1359 | echo "CREATE TABLE \`wp_123_test_multikey\` (\`key1\` INT(5) UNSIGNED NOT NULL AUTO_INCREMENT, \`key2\` INT(5) UNSIGNED NOT NULL, \`key3\` INT(5) UNSIGNED NOT NULL, \`text\` TEXT, PRIMARY KEY (\`key1\`,\`key2\`,\`key3\`) );" >> test_db.sql 1360 | echo "INSERT INTO \`wp_123_test_multikey\` (\`key2\`,\`key3\`,\`text\`) VALUES" >> test_db.sql 1361 | index=1 1362 | while [[ $index -le 204 ]]; 1363 | do 1364 | echo "(0,0,'abc'),(1,1,'abc'),(2,2,'abc'),(3,3,'abc'),(4,4,'abc'),(5,0,'abc'),(6,1,'abc'),(7,2,'abc'),(8,3,'abc'),(9,4,'abc')," >> test_db.sql 1365 | index=`expr $index + 1` 1366 | done 1367 | echo "(0,0,'abc'),(1,1,'abc'),(2,2,'abc'),(3,3,'abc'),(4,4,'abc'),(5,0,'abc'),(6,1,'abc'),(7,2,'abc'),(8,3,'abc'),(9,4,'abc');" >> test_db.sql 1368 | """ 1369 | And I run `bash create_sql_file.sh` 1370 | And I run `wp db query "SOURCE test_db.sql;"` 1371 | 1372 | When I run `wp search-replace --dry-run 'abc' 'def' --all-tables-with-prefix --skip-columns=guid,domain --regex` 1373 | Then STDOUT should contain: 1374 | """ 1375 | Success: 4050 replacements to be made. 1376 | """ 1377 | 1378 | When I run `wp search-replace 'abc' 'def' --all-tables-with-prefix --skip-columns=guid,domain --regex` 1379 | Then STDOUT should contain: 1380 | """ 1381 | Success: Made 4050 replacements. 1382 | """ 1383 | 1384 | When I run `wp search-replace --dry-run 'abc' 'def' --all-tables-with-prefix --skip-columns=guid,domain --regex` 1385 | Then STDOUT should contain: 1386 | """ 1387 | Success: 0 replacements to be made. 1388 | """ 1389 | 1390 | When I run `wp search-replace 'abc' 'def' --all-tables-with-prefix --skip-columns=guid,domain --regex` 1391 | Then STDOUT should contain: 1392 | """ 1393 | Success: Made 0 replacements. 1394 | """ 1395 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | Custom ruleset for WP-CLI search-replace-command 4 | 5 | 12 | 13 | 14 | . 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 44 | 45 | 47 | 48 | 49 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | */src/Search_Replace_Command\.php$ 63 | 64 | 65 | */src/WP_CLI/SearchReplacer\.php$ 66 | 67 | 68 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /search-replace-command.php: -------------------------------------------------------------------------------- 1 | ' ); 32 | private $log_colors; 33 | private $log_encoding; 34 | private $start_time; 35 | 36 | /** 37 | * Searches/replaces strings in the database. 38 | * 39 | * Searches through all rows in a selection of tables and replaces 40 | * appearances of the first string with the second string. 41 | * 42 | * By default, the command uses tables registered to the `$wpdb` object. On 43 | * multisite, this will just be the tables for the current site unless 44 | * `--network` is specified. 45 | * 46 | * Search/replace intelligently handles PHP serialized data, and does not 47 | * change primary key values. 48 | * 49 | * ## OPTIONS 50 | * 51 | * 52 | * : A string to search for within the database. 53 | * 54 | * 55 | * : Replace instances of the first string with this new string. 56 | * 57 | * [
...] 58 | * : List of database tables to restrict the replacement to. Wildcards are 59 | * supported, e.g. `'wp_*options'` or `'wp_post*'`. 60 | * 61 | * [--dry-run] 62 | * : Run the entire search/replace operation and show report, but don't save 63 | * changes to the database. 64 | * 65 | * [--network] 66 | * : Search/replace through all the tables registered to $wpdb in a 67 | * multisite install. 68 | * 69 | * [--all-tables-with-prefix] 70 | * : Enable replacement on any tables that match the table prefix even if 71 | * not registered on $wpdb. 72 | * 73 | * [--all-tables] 74 | * : Enable replacement on ALL tables in the database, regardless of the 75 | * prefix, and even if not registered on $wpdb. Overrides --network 76 | * and --all-tables-with-prefix. 77 | * 78 | * [--export[=]] 79 | * : Write transformed data as SQL file instead of saving replacements to 80 | * the database. If is not supplied, will output to STDOUT. 81 | * 82 | * [--export_insert_size=] 83 | * : Define number of rows in single INSERT statement when doing SQL export. 84 | * You might want to change this depending on your database configuration 85 | * (e.g. if you need to do fewer queries). Default: 50 86 | * 87 | * [--skip-tables=] 88 | * : Do not perform the replacement on specific tables. Use commas to 89 | * specify multiple tables. Wildcards are supported, e.g. `'wp_*options'` or `'wp_post*'`. 90 | * 91 | * [--skip-columns=] 92 | * : Do not perform the replacement on specific columns. Use commas to 93 | * specify multiple columns. 94 | * 95 | * [--include-columns=] 96 | * : Perform the replacement on specific columns. Use commas to 97 | * specify multiple columns. 98 | * 99 | * [--precise] 100 | * : Force the use of PHP (instead of SQL) which is more thorough, 101 | * but slower. 102 | * 103 | * [--recurse-objects] 104 | * : Enable recursing into objects to replace strings. Defaults to true; 105 | * pass --no-recurse-objects to disable. 106 | * 107 | * [--verbose] 108 | * : Prints rows to the console as they're updated. 109 | * 110 | * [--regex] 111 | * : Runs the search using a regular expression (without delimiters). 112 | * Warning: search-replace will take about 15-20x longer when using --regex. 113 | * 114 | * [--regex-flags=] 115 | * : Pass PCRE modifiers to regex search-replace (e.g. 'i' for case-insensitivity). 116 | * 117 | * [--regex-delimiter=] 118 | * : The delimiter to use for the regex. It must be escaped if it appears in the search string. The default value is the result of `chr(1)`. 119 | * 120 | * [--regex-limit=] 121 | * : The maximum possible replacements for the regex per row (or per unserialized data bit per row). Defaults to -1 (no limit). 122 | * 123 | * [--format=] 124 | * : Render output in a particular format. 125 | * --- 126 | * default: table 127 | * options: 128 | * - table 129 | * - count 130 | * --- 131 | * 132 | * [--report] 133 | * : Produce report. Defaults to true. 134 | * 135 | * [--report-changed-only] 136 | * : Report changed fields only. Defaults to false, unless logging, when it defaults to true. 137 | * 138 | * [--log[=]] 139 | * : Log the items changed. If is not supplied or is "-", will output to STDOUT. 140 | * Warning: causes a significant slow down, similar or worse to enabling --precise or --regex. 141 | * 142 | * [--before_context=] 143 | * : For logging, number of characters to display before the old match and the new replacement. Default 40. Ignored if not logging. 144 | * 145 | * [--after_context=] 146 | * : For logging, number of characters to display after the old match and the new replacement. Default 40. Ignored if not logging. 147 | * 148 | * ## EXAMPLES 149 | * 150 | * # Search and replace but skip one column 151 | * $ wp search-replace 'http://example.test' 'http://example.com' --skip-columns=guid 152 | * 153 | * # Run search/replace operation but dont save in database 154 | * $ wp search-replace 'foo' 'bar' wp_posts wp_postmeta wp_terms --dry-run 155 | * 156 | * # Run case-insensitive regex search/replace operation (slow) 157 | * $ wp search-replace '\[foo id="([0-9]+)"' '[bar id="\1"' --regex --regex-flags='i' 158 | * 159 | * # Turn your production multisite database into a local dev database 160 | * $ wp search-replace --url=example.com example.com example.test 'wp_*options' wp_blogs wp_site --network 161 | * 162 | * # Search/replace to a SQL file without transforming the database 163 | * $ wp search-replace foo bar --export=database.sql 164 | * 165 | * # Bash script: Search/replace production to development url (multisite compatible) 166 | * #!/bin/bash 167 | * if $(wp --url=http://example.com core is-installed --network); then 168 | * wp search-replace --url=http://example.com 'http://example.com' 'http://example.test' --recurse-objects --network --skip-columns=guid --skip-tables=wp_users 169 | * else 170 | * wp search-replace 'http://example.com' 'http://example.test' --recurse-objects --skip-columns=guid --skip-tables=wp_users 171 | * fi 172 | */ 173 | public function __invoke( $args, $assoc_args ) { 174 | global $wpdb; 175 | $old = array_shift( $args ); 176 | $new = array_shift( $args ); 177 | $total = 0; 178 | $report = array(); 179 | $this->dry_run = Utils\get_flag_value( $assoc_args, 'dry-run' ); 180 | $php_only = Utils\get_flag_value( $assoc_args, 'precise' ); 181 | $this->recurse_objects = Utils\get_flag_value( $assoc_args, 'recurse-objects', true ); 182 | $this->verbose = Utils\get_flag_value( $assoc_args, 'verbose' ); 183 | $this->format = Utils\get_flag_value( $assoc_args, 'format' ); 184 | $this->regex = Utils\get_flag_value( $assoc_args, 'regex', false ); 185 | 186 | if ( null !== $this->regex ) { 187 | $default_regex_delimiter = false; 188 | $this->regex_flags = Utils\get_flag_value( $assoc_args, 'regex-flags', false ); 189 | $this->regex_delimiter = Utils\get_flag_value( $assoc_args, 'regex-delimiter', '' ); 190 | if ( '' === $this->regex_delimiter ) { 191 | $this->regex_delimiter = chr( 1 ); 192 | $default_regex_delimiter = true; 193 | } 194 | } 195 | 196 | $regex_limit = Utils\get_flag_value( $assoc_args, 'regex-limit' ); 197 | if ( null !== $regex_limit ) { 198 | if ( ! preg_match( '/^(?:[0-9]+|-1)$/', $regex_limit ) || 0 === (int) $regex_limit ) { 199 | WP_CLI::error( '`--regex-limit` expects a non-zero positive integer or -1.' ); 200 | } 201 | $this->regex_limit = (int) $regex_limit; 202 | } 203 | 204 | if ( ! empty( $this->regex ) ) { 205 | if ( '' === $this->regex_delimiter ) { 206 | $this->regex_delimiter = chr( 1 ); 207 | } 208 | $search_regex = $this->regex_delimiter; 209 | $search_regex .= $old; 210 | $search_regex .= $this->regex_delimiter; 211 | $search_regex .= $this->regex_flags; 212 | 213 | // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Preventing a warning when testing the regex. 214 | if ( false === @preg_match( $search_regex, '' ) ) { 215 | $error = error_get_last(); 216 | $preg_error_message = ( ! empty( $error ) && array_key_exists( 'message', $error ) ) ? "\n{$error['message']}." : ''; 217 | if ( $default_regex_delimiter ) { 218 | $flags_msg = $this->regex_flags ? "flags '$this->regex_flags'" : 'no flags'; 219 | $msg = "The regex pattern '$old' with default delimiter 'chr(1)' and {$flags_msg} fails."; 220 | } else { 221 | $msg = "The regex '$search_regex' fails."; 222 | } 223 | WP_CLI::error( $msg . $preg_error_message ); 224 | } 225 | } 226 | 227 | $this->skip_columns = explode( ',', Utils\get_flag_value( $assoc_args, 'skip-columns', '' ) ); 228 | $this->skip_tables = explode( ',', Utils\get_flag_value( $assoc_args, 'skip-tables', '' ) ); 229 | $this->include_columns = array_filter( explode( ',', Utils\get_flag_value( $assoc_args, 'include-columns', '' ) ) ); 230 | 231 | if ( $old === $new && ! $this->regex ) { 232 | WP_CLI::warning( "Replacement value '{$old}' is identical to search value '{$new}'. Skipping operation." ); 233 | exit; 234 | } 235 | 236 | $export = Utils\get_flag_value( $assoc_args, 'export' ); 237 | if ( null !== $export ) { 238 | if ( $this->dry_run ) { 239 | WP_CLI::error( 'You cannot supply --dry-run and --export at the same time.' ); 240 | } 241 | if ( true === $export ) { 242 | $this->export_handle = STDOUT; 243 | $this->verbose = false; 244 | } else { 245 | $this->export_handle = @fopen( $assoc_args['export'], 'w' ); 246 | if ( false === $this->export_handle ) { 247 | $error = error_get_last(); 248 | WP_CLI::error( sprintf( 'Unable to open export file "%s" for writing: %s.', $assoc_args['export'], $error['message'] ) ); 249 | } 250 | } 251 | $export_insert_size = Utils\get_flag_value( $assoc_args, 'export_insert_size', 50 ); 252 | // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- See the code, this is deliberate. 253 | if ( (int) $export_insert_size == $export_insert_size && $export_insert_size > 0 ) { 254 | $this->export_insert_size = $export_insert_size; 255 | } 256 | $php_only = true; 257 | } 258 | 259 | $log = Utils\get_flag_value( $assoc_args, 'log' ); 260 | if ( null !== $log ) { 261 | if ( true === $log || '-' === $log ) { 262 | $this->log_handle = STDOUT; 263 | } else { 264 | $this->log_handle = @fopen( $assoc_args['log'], 'w' ); 265 | if ( false === $this->log_handle ) { 266 | $error = error_get_last(); 267 | WP_CLI::error( sprintf( 'Unable to open log file "%s" for writing: %s.', $assoc_args['log'], $error['message'] ) ); 268 | } 269 | } 270 | if ( $this->log_handle ) { 271 | $before_context = Utils\get_flag_value( $assoc_args, 'before_context' ); 272 | if ( null !== $before_context && preg_match( '/^[0-9]+$/', $before_context ) ) { 273 | $this->log_before_context = (int) $before_context; 274 | } 275 | 276 | $after_context = Utils\get_flag_value( $assoc_args, 'after_context' ); 277 | if ( null !== $after_context && preg_match( '/^[0-9]+$/', $after_context ) ) { 278 | $this->log_after_context = (int) $after_context; 279 | } 280 | 281 | $log_prefixes = getenv( 'WP_CLI_SEARCH_REPLACE_LOG_PREFIXES' ); 282 | if ( false !== $log_prefixes && preg_match( '/^([^,]*),([^,]*)$/', $log_prefixes, $matches ) ) { 283 | $this->log_prefixes = array( $matches[1], $matches[2] ); 284 | } 285 | 286 | if ( STDOUT === $this->log_handle ) { 287 | $default_log_colors = array( 288 | 'log_table_column_id' => '%B', 289 | 'log_old' => '%R', 290 | 'log_new' => '%G', 291 | ); 292 | } else { 293 | $default_log_colors = array( 294 | 'log_table_column_id' => '', 295 | 'log_old' => '', 296 | 'log_new' => '', 297 | ); 298 | } 299 | 300 | $log_colors = getenv( 'WP_CLI_SEARCH_REPLACE_LOG_COLORS' ); 301 | if ( false !== $log_colors && preg_match( '/^([^,]*),([^,]*),([^,]*)$/', $log_colors, $matches ) ) { 302 | $default_log_colors = array( 303 | 'log_table_column_id' => $matches[1], 304 | 'log_old' => $matches[2], 305 | 'log_new' => $matches[3], 306 | ); 307 | } 308 | 309 | $this->log_colors = $this->get_colors( $assoc_args, $default_log_colors ); 310 | $this->log_encoding = 0 === strpos( $wpdb->charset, 'utf8' ) ? 'UTF-8' : false; 311 | } 312 | } 313 | 314 | $this->report = Utils\get_flag_value( $assoc_args, 'report', true ); 315 | // Defaults to true if logging, else defaults to false. 316 | $this->report_changed_only = Utils\get_flag_value( $assoc_args, 'report-changed-only', null !== $this->log_handle ); 317 | 318 | if ( $this->regex_flags ) { 319 | $php_only = true; 320 | } 321 | 322 | // never mess with hashed passwords 323 | $this->skip_columns[] = 'user_pass'; 324 | 325 | // Get table names based on leftover $args or supplied $assoc_args 326 | $tables = Utils\wp_get_table_names( $args, $assoc_args ); 327 | 328 | foreach ( $tables as $table ) { 329 | 330 | foreach ( $this->skip_tables as $skip_table ) { 331 | if ( fnmatch( $skip_table, $table ) ) { 332 | continue 2; 333 | } 334 | } 335 | 336 | $table_sql = self::esc_sql_ident( $table ); 337 | 338 | if ( $this->export_handle ) { 339 | fwrite( $this->export_handle, "\nDROP TABLE IF EXISTS $table_sql;\n" ); 340 | 341 | // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident 342 | $row = $wpdb->get_row( "SHOW CREATE TABLE $table_sql", ARRAY_N ); 343 | 344 | fwrite( $this->export_handle, $row[1] . ";\n" ); 345 | list( $table_report, $total_rows ) = $this->php_export_table( $table, $old, $new ); 346 | if ( $this->report ) { 347 | $report = array_merge( $report, $table_report ); 348 | } 349 | $total += $total_rows; 350 | // Don't perform replacements on the actual database 351 | continue; 352 | } 353 | 354 | list( $primary_keys, $columns, $all_columns ) = self::get_columns( $table ); 355 | 356 | // since we'll be updating one row at a time, 357 | // we need a primary key to identify the row 358 | if ( empty( $primary_keys ) ) { 359 | 360 | // wasn't updated, so skip to the next table 361 | if ( $this->report_changed_only ) { 362 | continue; 363 | } 364 | if ( $this->report ) { 365 | $report[] = array( $table, '', 'skipped', '' ); 366 | } else { 367 | WP_CLI::warning( $all_columns ? "No primary keys for table '$table'." : "No such table '$table'." ); 368 | } 369 | continue; 370 | } 371 | 372 | foreach ( $columns as $col ) { 373 | if ( ! empty( $this->include_columns ) && ! in_array( $col, $this->include_columns, true ) ) { 374 | continue; 375 | } 376 | 377 | if ( in_array( $col, $this->skip_columns, true ) ) { 378 | continue; 379 | } 380 | 381 | if ( $this->verbose && 'count' !== $this->format ) { 382 | $this->start_time = microtime( true ); 383 | WP_CLI::log( sprintf( 'Checking: %s.%s', $table, $col ) ); 384 | } 385 | 386 | if ( ! $php_only && ! $this->regex ) { 387 | $col_sql = self::esc_sql_ident( $col ); 388 | $wpdb->last_error = ''; 389 | 390 | // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident 391 | $serial_row = $wpdb->get_row( "SELECT * FROM $table_sql WHERE $col_sql REGEXP '^[aiO]:[1-9]' LIMIT 1" ); 392 | 393 | // When the regex triggers an error, we should fall back to PHP 394 | if ( false !== strpos( $wpdb->last_error, 'ERROR 1139' ) ) { 395 | $serial_row = true; 396 | } 397 | } 398 | 399 | if ( $php_only || $this->regex || null !== $serial_row ) { 400 | $type = 'PHP'; 401 | $count = $this->php_handle_col( $col, $primary_keys, $table, $old, $new ); 402 | } else { 403 | $type = 'SQL'; 404 | $count = $this->sql_handle_col( $col, $primary_keys, $table, $old, $new ); 405 | } 406 | 407 | if ( $this->report && ( $count || ! $this->report_changed_only ) ) { 408 | $report[] = array( $table, $col, $count, $type ); 409 | } 410 | 411 | $total += $count; 412 | } 413 | } 414 | 415 | if ( $this->export_handle && STDOUT !== $this->export_handle ) { 416 | fclose( $this->export_handle ); 417 | } 418 | 419 | // Only informational output after this point 420 | if ( WP_CLI::get_config( 'quiet' ) || STDOUT === $this->export_handle ) { 421 | return; 422 | } 423 | 424 | if ( 'count' === $this->format ) { 425 | WP_CLI::line( $total ); 426 | return; 427 | } 428 | 429 | if ( $this->report && ! empty( $report ) ) { 430 | $table = new Table(); 431 | $table->setHeaders( array( 'Table', 'Column', 'Replacements', 'Type' ) ); 432 | $table->setRows( $report ); 433 | $table->display(); 434 | } 435 | 436 | if ( ! $this->dry_run ) { 437 | if ( ! empty( $assoc_args['export'] ) ) { 438 | $success_message = 1 === $total ? "Made 1 replacement and exported to {$assoc_args['export']}." : "Made {$total} replacements and exported to {$assoc_args['export']}."; 439 | } else { 440 | $success_message = 1 === $total ? 'Made 1 replacement.' : "Made $total replacements."; 441 | if ( $total && 'Default' !== Utils\wp_get_cache_type() ) { 442 | $success_message .= ' Please remember to flush your persistent object cache with `wp cache flush`.'; 443 | } 444 | } 445 | WP_CLI::success( $success_message ); 446 | } else { 447 | $success_message = ( 1 === $total ) ? '%d replacement to be made.' : '%d replacements to be made.'; 448 | WP_CLI::success( sprintf( $success_message, $total ) ); 449 | } 450 | } 451 | 452 | private function php_export_table( $table, $old, $new ) { 453 | list( $primary_keys, $columns, $all_columns ) = self::get_columns( $table ); 454 | 455 | $chunk_size = getenv( 'BEHAT_RUN' ) ? 10 : 1000; 456 | $args = array( 457 | 'table' => $table, 458 | 'fields' => $all_columns, 459 | 'chunk_size' => $chunk_size, 460 | ); 461 | 462 | $replacer = new SearchReplacer( $old, $new, $this->recurse_objects, $this->regex, $this->regex_flags, $this->regex_delimiter, false, $this->regex_limit ); 463 | $col_counts = array_fill_keys( $all_columns, 0 ); 464 | if ( $this->verbose && 'table' === $this->format ) { 465 | $this->start_time = microtime( true ); 466 | WP_CLI::log( sprintf( 'Checking: %s', $table ) ); 467 | } 468 | 469 | $rows = array(); 470 | foreach ( new Iterators\Table( $args ) as $i => $row ) { 471 | $row_fields = array(); 472 | foreach ( $all_columns as $col ) { 473 | $value = $row->$col; 474 | if ( $value && ! in_array( $col, $primary_keys, true ) && ! in_array( $col, $this->skip_columns, true ) ) { 475 | $new_value = $replacer->run( $value ); 476 | if ( $new_value !== $value ) { 477 | ++$col_counts[ $col ]; 478 | $value = $new_value; 479 | } 480 | } 481 | $row_fields[ $col ] = $value; 482 | } 483 | $rows[] = $row_fields; 484 | } 485 | $this->write_sql_row_fields( $table, $rows ); 486 | 487 | $table_report = array(); 488 | $total_rows = 0; 489 | $total_cols = 0; 490 | foreach ( $col_counts as $col => $col_count ) { 491 | if ( $this->report && ( $col_count || ! $this->report_changed_only ) ) { 492 | $table_report[] = array( $table, $col, $col_count, 'PHP' ); 493 | } 494 | if ( $col_count ) { 495 | ++$total_cols; 496 | $total_rows += $col_count; 497 | } 498 | } 499 | 500 | if ( $this->verbose && 'table' === $this->format ) { 501 | $time = round( microtime( true ) - $this->start_time, 3 ); 502 | WP_CLI::log( sprintf( '%d columns and %d total rows affected using PHP (in %ss).', $total_cols, $total_rows, $time ) ); 503 | } 504 | 505 | return array( $table_report, $total_rows ); 506 | } 507 | 508 | private function sql_handle_col( $col, $primary_keys, $table, $old, $new ) { 509 | global $wpdb; 510 | 511 | $table_sql = self::esc_sql_ident( $table ); 512 | $col_sql = self::esc_sql_ident( $col ); 513 | if ( $this->dry_run ) { 514 | if ( $this->log_handle ) { 515 | $count = $this->log_sql_diff( $col, $primary_keys, $table, $old, $new ); 516 | } else { 517 | // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident 518 | $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT($col_sql) FROM $table_sql WHERE $col_sql LIKE BINARY %s;", '%' . self::esc_like( $old ) . '%' ) ); 519 | } 520 | } else { 521 | if ( $this->log_handle ) { 522 | $this->log_sql_diff( $col, $primary_keys, $table, $old, $new ); 523 | } 524 | // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident 525 | $count = $wpdb->query( $wpdb->prepare( "UPDATE $table_sql SET $col_sql = REPLACE($col_sql, %s, %s);", $old, $new ) ); 526 | } 527 | 528 | if ( $this->verbose && 'table' === $this->format ) { 529 | $time = round( microtime( true ) - $this->start_time, 3 ); 530 | WP_CLI::log( sprintf( '%d rows affected using SQL (in %ss).', $count, $time ) ); 531 | } 532 | return $count; 533 | } 534 | 535 | private function php_handle_col( $col, $primary_keys, $table, $old, $new ) { 536 | global $wpdb; 537 | 538 | $count = 0; 539 | $replacer = new SearchReplacer( $old, $new, $this->recurse_objects, $this->regex, $this->regex_flags, $this->regex_delimiter, null !== $this->log_handle, $this->regex_limit ); 540 | 541 | $table_sql = self::esc_sql_ident( $table ); 542 | $col_sql = self::esc_sql_ident( $col ); 543 | 544 | $base_key_condition = ''; 545 | $where_key = ''; 546 | if ( ! $this->regex ) { 547 | $base_key_condition = "$col_sql" . $wpdb->prepare( ' LIKE BINARY %s', '%' . self::esc_like( $old ) . '%' ); 548 | $where_key = "WHERE $base_key_condition"; 549 | } 550 | 551 | $escaped_primary_keys = self::esc_sql_ident( $primary_keys ); 552 | $primary_keys_sql = implode( ',', $escaped_primary_keys ); 553 | $order_by_keys = array_map( 554 | static function ( $key ) { 555 | return "{$key} ASC"; 556 | }, 557 | $escaped_primary_keys 558 | ); 559 | $order_by_sql = 'ORDER BY ' . implode( ',', $order_by_keys ); 560 | $limit = 1000; 561 | 562 | // 2 errors: 563 | // - WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident 564 | // - WordPress.CodeAnalysis.AssignmentInCondition -- no reason to do copy-paste for a single valid assignment in while 565 | // phpcs:ignore 566 | while ( $rows = $wpdb->get_results( "SELECT {$primary_keys_sql} FROM {$table_sql} {$where_key} {$order_by_sql} LIMIT {$limit}" ) ) { 567 | foreach ( $rows as $keys ) { 568 | $where_sql = ''; 569 | foreach ( (array) $keys as $k => $v ) { 570 | if ( '' !== $where_sql ) { 571 | $where_sql .= ' AND '; 572 | } 573 | $where_sql .= self::esc_sql_ident( $k ) . ' = ' . self::esc_sql_value( $v ); 574 | } 575 | 576 | // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident 577 | $col_value = $wpdb->get_var( "SELECT {$col_sql} FROM {$table_sql} WHERE {$where_sql}" ); 578 | 579 | if ( '' === $col_value ) { 580 | continue; 581 | } 582 | 583 | $value = $replacer->run( $col_value ); 584 | 585 | if ( $value === $col_value ) { 586 | continue; 587 | } 588 | 589 | // In case a needed re-serialization was unsuccessful, we should not update the value, 590 | // as this implies we hit an exception while processing. 591 | if ( gettype( $value ) !== gettype( $col_value ) ) { 592 | continue; 593 | } 594 | 595 | if ( $this->log_handle ) { 596 | $this->log_php_diff( $col, $keys, $table, $old, $new, $replacer->get_log_data() ); 597 | $replacer->clear_log_data(); 598 | } 599 | 600 | ++$count; 601 | if ( ! $this->dry_run ) { 602 | $update_where = array(); 603 | foreach ( (array) $keys as $k => $v ) { 604 | $update_where[ $k ] = $v; 605 | } 606 | 607 | $wpdb->update( $table, [ $col => $value ], $update_where ); 608 | } 609 | } 610 | 611 | // Because we are ordering by primary keys from least to greatest, 612 | // we can exclude previous chunks from consideration by adding greater-than conditions 613 | // to insist the next chunk's keys must be greater than the last of this chunk's keys. 614 | $last_row = end( $rows ); 615 | $next_key_conditions = array(); 616 | 617 | // NOTE: For a composite key (X, Y, Z), selecting the next rows requires the following conditions: 618 | // ( X = lastX AND Y = lastY AND Z > lastZ ) OR 619 | // ( X = lastX AND Y > lastY ) OR 620 | // ( X > lastX ) 621 | for ( $last_key_index = count( $primary_keys ) - 1; $last_key_index >= 0; $last_key_index-- ) { 622 | $next_key_subconditions = array(); 623 | 624 | for ( $i = 0; $i <= $last_key_index; $i++ ) { 625 | $k = $primary_keys[ $i ]; 626 | $v = $last_row->{ $k }; 627 | 628 | if ( $i < $last_key_index ) { 629 | $next_key_subconditions[] = self::esc_sql_ident( $k ) . ' = ' . self::esc_sql_value( $v ); 630 | } else { 631 | $next_key_subconditions[] = self::esc_sql_ident( $k ) . ' > ' . self::esc_sql_value( $v ); 632 | } 633 | } 634 | 635 | $next_key_conditions[] = '( ' . implode( ' AND ', $next_key_subconditions ) . ' )'; 636 | } 637 | 638 | $where_key_conditions = array(); 639 | if ( $base_key_condition ) { 640 | $where_key_conditions[] = $base_key_condition; 641 | } 642 | $where_key_conditions[] = '( ' . implode( ' OR ', $next_key_conditions ) . ' )'; 643 | 644 | $where_key = 'WHERE ' . implode( ' AND ', $where_key_conditions ); 645 | } 646 | 647 | if ( $this->verbose && 'table' === $this->format ) { 648 | $time = round( microtime( true ) - $this->start_time, 3 ); 649 | WP_CLI::log( sprintf( '%d rows affected using PHP (in %ss).', $count, $time ) ); 650 | } 651 | 652 | return $count; 653 | } 654 | 655 | private function write_sql_row_fields( $table, $rows ) { 656 | global $wpdb; 657 | 658 | if ( empty( $rows ) ) { 659 | return; 660 | } 661 | 662 | $table_sql = self::esc_sql_ident( $table ); 663 | 664 | $insert = "INSERT INTO $table_sql ("; 665 | $insert .= join( ', ', self::esc_sql_ident( array_keys( $rows[0] ) ) ); 666 | $insert .= ') VALUES '; 667 | $insert .= "\n"; 668 | 669 | $sql = $insert; 670 | $values = array(); 671 | 672 | $index = 1; 673 | $count = count( $rows ); 674 | $export_insert_size = $this->export_insert_size; 675 | 676 | foreach ( $rows as $row_fields ) { 677 | $subs = array(); 678 | 679 | foreach ( $row_fields as $field_value ) { 680 | if ( null === $field_value ) { 681 | $subs[] = 'NULL'; 682 | } else { 683 | $subs[] = '%s'; 684 | $values[] = $field_value; 685 | } 686 | } 687 | 688 | $sql .= '(' . join( ', ', $subs ) . ')'; 689 | 690 | // Add new insert statement if needed. Before this we close the previous with semicolon and write statement to sql-file. 691 | // "Statement break" is needed: 692 | // 1. When the loop is running every nth time (where n is insert statement size, $export_index_size). Remainder is zero also on first round, so it have to be excluded. 693 | // $index % $export_insert_size == 0 && $index > 0 694 | // 2. Or when the loop is running last time 695 | // $index == $count 696 | if ( ( 0 === $index % $export_insert_size && $index > 0 ) || $index === $count ) { 697 | $sql .= ";\n"; 698 | 699 | if ( method_exists( $wpdb, 'remove_placeholder_escape' ) ) { 700 | // since 4.8.3 701 | // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- verified inputs above 702 | $sql = $wpdb->remove_placeholder_escape( $wpdb->prepare( $sql, array_values( $values ) ) ); 703 | } else { 704 | // 4.8.2 or less 705 | // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- verified inputs above 706 | $sql = $wpdb->prepare( $sql, array_values( $values ) ); 707 | } 708 | 709 | fwrite( $this->export_handle, $sql ); 710 | 711 | // If there is still rows to loop, reset $sql and $values variables. 712 | if ( $count > $index ) { 713 | $sql = $insert; 714 | $values = array(); 715 | } 716 | } else { // Otherwise just add comma and new line 717 | $sql .= ",\n"; 718 | } 719 | 720 | ++$index; 721 | } 722 | } 723 | 724 | private static function get_columns( $table ) { 725 | global $wpdb; 726 | 727 | $table_sql = self::esc_sql_ident( $table ); 728 | $primary_keys = array(); 729 | $text_columns = array(); 730 | $all_columns = array(); 731 | $suppress_errors = $wpdb->suppress_errors(); 732 | 733 | // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident 734 | $results = $wpdb->get_results( "DESCRIBE $table_sql" ); 735 | 736 | if ( ! empty( $results ) ) { 737 | foreach ( $results as $col ) { 738 | if ( 'PRI' === $col->Key ) { 739 | $primary_keys[] = $col->Field; 740 | } 741 | if ( self::is_text_col( $col->Type ) ) { 742 | $text_columns[] = $col->Field; 743 | } 744 | $all_columns[] = $col->Field; 745 | } 746 | } 747 | $wpdb->suppress_errors( $suppress_errors ); 748 | return array( $primary_keys, $text_columns, $all_columns ); 749 | } 750 | 751 | private static function is_text_col( $type ) { 752 | foreach ( array( 'text', 'varchar' ) as $token ) { 753 | if ( false !== stripos( $type, $token ) ) { 754 | return true; 755 | } 756 | } 757 | 758 | return false; 759 | } 760 | 761 | private static function esc_like( $old ) { 762 | global $wpdb; 763 | 764 | // Remove notices in 4.0 and support backwards compatibility 765 | if ( method_exists( $wpdb, 'esc_like' ) ) { 766 | // 4.0 767 | $old = $wpdb->esc_like( $old ); 768 | } else { 769 | // phpcs:ignore WordPress.WP.DeprecatedFunctions.like_escapeFound -- BC-layer for WP 3.9 or less. 770 | $old = like_escape( esc_sql( $old ) ); // Note: this double escaping is actually necessary, even though `esc_like()` will be used in a `prepare()`. 771 | } 772 | 773 | return $old; 774 | } 775 | 776 | /** 777 | * Escapes (backticks) MySQL identifiers (aka schema object names) - i.e. column names, table names, and database/index/alias/view etc names. 778 | * See https://dev.mysql.com/doc/refman/5.5/en/identifiers.html 779 | * 780 | * @param string|array $idents A single identifier or an array of identifiers. 781 | * @return string|array An escaped string if given a string, or an array of escaped strings if given an array of strings. 782 | */ 783 | private static function esc_sql_ident( $idents ) { 784 | $backtick = static function ( $v ) { 785 | // Escape any backticks in the identifier by doubling. 786 | return '`' . str_replace( '`', '``', $v ) . '`'; 787 | }; 788 | if ( is_string( $idents ) ) { 789 | return $backtick( $idents ); 790 | } 791 | return array_map( $backtick, $idents ); 792 | } 793 | 794 | /** 795 | * Puts MySQL string values in single quotes, to avoid them being interpreted as column names. 796 | * 797 | * @param string|array $values A single value or an array of values. 798 | * @return string|array A quoted string if given a string, or an array of quoted strings if given an array of strings. 799 | */ 800 | private static function esc_sql_value( $values ) { 801 | $quote = static function ( $v ) { 802 | // Don't quote integer values to avoid MySQL's implicit type conversion. 803 | if ( preg_match( '/^[+-]?[0-9]{1,20}$/', $v ) ) { // MySQL BIGINT UNSIGNED max 18446744073709551615 (20 digits). 804 | return esc_sql( $v ); 805 | } 806 | 807 | // Put any string values between single quotes. 808 | return "'" . esc_sql( $v ) . "'"; 809 | }; 810 | 811 | if ( is_array( $values ) ) { 812 | return array_map( $quote, $values ); 813 | } 814 | 815 | return $quote( $values ); 816 | } 817 | 818 | /** 819 | * Gets the color codes from the options if any, and returns the passed in array colorized with 2 elements per entry, a color code (or '') and a reset (or ''). 820 | * 821 | * @param array $assoc_args The associative argument array passed to the command. 822 | * @param array $colors Array of default percent color code strings keyed by the color contexts. 823 | * @return array Array containing 2-element arrays keyed to the input $colors array. 824 | */ 825 | private function get_colors( $assoc_args, $colors ) { 826 | $color_reset = WP_CLI::colorize( '%n' ); 827 | 828 | $color_code_callback = static function ( $v ) { 829 | return substr( $v, 1 ); 830 | }; 831 | 832 | $color_codes = array_keys( Colors::getColors() ); 833 | $color_codes = array_map( $color_code_callback, $color_codes ); 834 | $color_codes = implode( '', $color_codes ); 835 | 836 | $color_codes_regex = '/^(?:%[' . $color_codes . '])*$/'; 837 | 838 | foreach ( array_keys( $colors ) as $color_col ) { 839 | $col_color_flag = Utils\get_flag_value( $assoc_args, $color_col . '_color' ); 840 | if ( null !== $col_color_flag ) { 841 | if ( ! preg_match( $color_codes_regex, $col_color_flag, $matches ) ) { 842 | WP_CLI::warning( "Unrecognized percent color code '$col_color_flag' for '{$color_col}_color'." ); 843 | } else { 844 | $colors[ $color_col ] = $matches[0]; 845 | } 846 | } 847 | $colors[ $color_col ] = $colors[ $color_col ] ? array( WP_CLI::colorize( $colors[ $color_col ] ), $color_reset ) : array( '', '' ); 848 | } 849 | 850 | return $colors; 851 | } 852 | 853 | /* 854 | * Logs the difference between old match and new replacement for SQL replacement. 855 | * 856 | * @param string $col Column being processed. 857 | * @param array $primary_keys Primary keys for table. 858 | * @param string $table Table being processed. 859 | * @param string $old Old value to match. 860 | * @param string $new New value to replace the old value with. 861 | * @return int Count of changed rows. 862 | */ 863 | private function log_sql_diff( $col, $primary_keys, $table, $old, $new ) { 864 | global $wpdb; 865 | if ( $primary_keys ) { 866 | $esc_primary_keys = implode( ', ', self::esc_sql_ident( $primary_keys ) ); 867 | $primary_keys_sql = count( $primary_keys ) > 1 ? "CONCAT_WS(',', {$esc_primary_keys}), " : "{$esc_primary_keys}, "; 868 | } else { 869 | $primary_keys_sql = ''; 870 | } 871 | 872 | $table_sql = self::esc_sql_ident( $table ); 873 | $col_sql = self::esc_sql_ident( $col ); 874 | 875 | // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- escaped through self::esc_sql_ident 876 | $results = $wpdb->get_results( $wpdb->prepare( "SELECT {$primary_keys_sql}{$col_sql} FROM {$table_sql} WHERE {$col_sql} LIKE BINARY %s", '%' . self::esc_like( $old ) . '%' ), ARRAY_N ); 877 | 878 | if ( empty( $results ) ) { 879 | return 0; 880 | } 881 | 882 | $search_regex = '/' . preg_quote( $old, '/' ) . '/'; 883 | 884 | foreach ( $results as $result ) { 885 | list( $keys, $data ) = $primary_keys ? array( $result[0], $result[1] ) : array( null, $result[0] ); 886 | if ( preg_match_all( $search_regex, $data, $matches, PREG_OFFSET_CAPTURE ) ) { 887 | list( $old_bits, $new_bits ) = $this->log_bits( $search_regex, $data, $matches, $new ); 888 | $this->log_write( $col, $keys, $table, $old_bits, $new_bits ); 889 | } 890 | } 891 | return count( $results ); 892 | } 893 | 894 | /* 895 | * Logs the difference between old matches and new replacements at the end of a PHP (regex) replacement of a database row. 896 | * 897 | * @param string $col Column being processed. 898 | * @param array $keys Associative array (or object) of primary key names and their values for the row being processed. 899 | * @param string $table Table being processed. 900 | * @param string $old Old value to match. 901 | * @param string $new New value to replace the old value with. 902 | * @param array $log_data Array of data strings before replacements. 903 | */ 904 | private function log_php_diff( $col, $keys, $table, $old, $new, $log_data ) { 905 | if ( $this->regex ) { 906 | $search_regex = $this->regex_delimiter . $old . $this->regex_delimiter . $this->regex_flags; 907 | } else { 908 | $search_regex = '/' . preg_quote( $old, '/' ) . '/'; 909 | } 910 | 911 | $old_bits = array(); 912 | $new_bits = array(); 913 | foreach ( $log_data as $data ) { 914 | if ( preg_match_all( $search_regex, $data, $matches, PREG_OFFSET_CAPTURE ) ) { 915 | $bits = $this->log_bits( $search_regex, $data, $matches, $new ); 916 | $old_bits = array_merge( $old_bits, $bits[0] ); 917 | $new_bits = array_merge( $new_bits, $bits[1] ); 918 | } 919 | } 920 | if ( $old_bits ) { 921 | $this->log_write( $col, $keys, $table, $old_bits, $new_bits ); 922 | } 923 | } 924 | 925 | /** 926 | * Returns the arrays of old matches and new replacements based on the passed-in matches, with context. 927 | * 928 | * @param string $search_regex The search regular expression. 929 | * @param string $old_data Existing data being processed. 930 | * @param array $old_matches Old matches array returned by `preg_match_all()`. 931 | * @param string $new New value to replace the old value with. 932 | * @return array Two element array containing the array of old match log strings and the array of new replacement log strings with before/after contexts. 933 | */ 934 | private function log_bits( $search_regex, $old_data, $old_matches, $new ) { 935 | $encoding = $this->log_encoding; 936 | if ( ! $encoding && ( $this->log_before_context || $this->log_after_context ) && function_exists( 'mb_detect_encoding' ) ) { 937 | $encoding = mb_detect_encoding( $old_data, null, true /*strict*/ ); 938 | } 939 | 940 | // Generate a new data matches analog of the old data matches by simulating a `preg_replace()`. 941 | $is_regex = $this->regex; 942 | $i = 0; 943 | $diff = 0; 944 | $new_matches = array(); 945 | $new_data = preg_replace_callback( 946 | $search_regex, 947 | static function ( $matches ) use ( $old_matches, $new, $is_regex, &$new_matches, &$i, &$diff ) { 948 | if ( $is_regex ) { 949 | // Sub in any back references, "$1", "\2" etc, in the replacement string. 950 | $new = preg_replace_callback( 951 | '/(?regex_limit, 970 | $match_cnt 971 | ); 972 | 973 | $old_bits = array(); 974 | $new_bits = array(); 975 | $append_next = false; 976 | $last_old_offset = 0; 977 | $last_new_offset = 0; 978 | for ( $i = 0; $i < $match_cnt; $i++ ) { 979 | $old_match = $old_matches[0][ $i ][0]; 980 | $old_offset = $old_matches[0][ $i ][1]; 981 | $new_match = $new_matches[0][ $i ][0]; 982 | $new_offset = $new_matches[0][ $i ][1]; 983 | 984 | $old_log = $this->log_colors['log_old'][0] . $old_match . $this->log_colors['log_old'][1]; 985 | $new_log = $this->log_colors['log_new'][0] . $new_match . $this->log_colors['log_new'][1]; 986 | 987 | $old_before = ''; 988 | $old_after = ''; 989 | $new_before = ''; 990 | $new_after = ''; 991 | $after_shortened = false; 992 | 993 | // Offsets are in bytes, so need to use `strlen()` and `substr()` before using `safe_substr()`. 994 | if ( $this->log_before_context && $old_offset && ! $append_next ) { 995 | $old_before = safe_substr( substr( $old_data, $last_old_offset, $old_offset - $last_old_offset ), -$this->log_before_context, null /*length*/, false /*is_width*/, $encoding ); 996 | $new_before = safe_substr( substr( $new_data, $last_new_offset, $new_offset - $last_new_offset ), -$this->log_before_context, null /*length*/, false /*is_width*/, $encoding ); 997 | } 998 | if ( $this->log_after_context ) { 999 | $old_end_offset = $old_offset + strlen( $old_match ); 1000 | $new_end_offset = $new_offset + strlen( $new_match ); 1001 | $old_after = safe_substr( substr( $old_data, $old_end_offset ), 0, $this->log_after_context, false /*is_width*/, $encoding ); 1002 | $new_after = safe_substr( substr( $new_data, $new_end_offset ), 0, $this->log_after_context, false /*is_width*/, $encoding ); 1003 | // To lessen context duplication in output, shorten the after context if it overlaps with the next match. 1004 | if ( $i + 1 < $match_cnt && $old_end_offset + strlen( $old_after ) > $old_matches[0][ $i + 1 ][1] ) { 1005 | $old_after = substr( $old_after, 0, $old_matches[0][ $i + 1 ][1] - $old_end_offset ); 1006 | $new_after = substr( $new_after, 0, $new_matches[0][ $i + 1 ][1] - $new_end_offset ); 1007 | $after_shortened = true; 1008 | // On the next iteration, will append with no before context. 1009 | } 1010 | } 1011 | 1012 | if ( $append_next ) { 1013 | $cnt = count( $old_bits ); 1014 | $old_bits[ $cnt - 1 ] .= $old_log . $old_after; 1015 | $new_bits[ $cnt - 1 ] .= $new_log . $new_after; 1016 | } else { 1017 | $old_bits[] = $old_before . $old_log . $old_after; 1018 | $new_bits[] = $new_before . $new_log . $new_after; 1019 | } 1020 | $append_next = $after_shortened; 1021 | $last_old_offset = $old_offset; 1022 | $last_new_offset = $new_offset; 1023 | } 1024 | 1025 | return array( $old_bits, $new_bits ); 1026 | } 1027 | 1028 | /* 1029 | * Outputs the log strings. 1030 | * 1031 | * @param string $col Column being processed. 1032 | * @param array $keys Associative array (or object) of primary key names and their values for the row being processed. 1033 | * @param string $table Table being processed. 1034 | * @param array $old_bits Array of old match log strings. 1035 | * @param array $new_bits Array of new replacement log strings. 1036 | */ 1037 | private function log_write( $col, $keys, $table, $old_bits, $new_bits ) { 1038 | $id_log = $keys ? ( ':' . implode( ',', (array) $keys ) ) : ''; 1039 | $table_column_id_log = $this->log_colors['log_table_column_id'][0] . $table . '.' . $col . $id_log . $this->log_colors['log_table_column_id'][1]; 1040 | 1041 | $old_log = str_replace( array( "\r\n", "\n" ), ' ', implode( ' [...] ', $old_bits ) ); 1042 | $new_log = str_replace( array( "\r\n", "\n" ), ' ', implode( ' [...] ', $new_bits ) ); 1043 | 1044 | if ( $this->log_prefixes[0] ) { 1045 | $old_log = $this->log_colors['log_old'][0] . $this->log_prefixes[0] . $this->log_colors['log_old'][1] . $old_log; 1046 | } 1047 | if ( $this->log_prefixes[1] ) { 1048 | $new_log = $this->log_colors['log_new'][0] . $this->log_prefixes[1] . $this->log_colors['log_new'][1] . $new_log; 1049 | } 1050 | 1051 | fwrite( $this->log_handle, "{$table_column_id_log}\n{$old_log}\n{$new_log}\n" ); 1052 | } 1053 | } 1054 | -------------------------------------------------------------------------------- /src/WP_CLI/SearchReplacer.php: -------------------------------------------------------------------------------- 1 | from = $from; 33 | $this->to = $to; 34 | $this->recurse_objects = $recurse_objects; 35 | $this->regex = $regex; 36 | $this->regex_flags = $regex_flags; 37 | $this->regex_delimiter = $regex_delimiter; 38 | $this->regex_limit = $regex_limit; 39 | $this->logging = $logging; 40 | $this->clear_log_data(); 41 | 42 | // Get the XDebug nesting level. Will be zero (no limit) if no value is set 43 | $this->max_recursion = intval( ini_get( 'xdebug.max_nesting_level' ) ); 44 | } 45 | 46 | /** 47 | * Take a serialised array and unserialise it replacing elements as needed and 48 | * unserialising any subordinate arrays and performing the replace on those too. 49 | * Ignores any serialized objects unless $recurse_objects is set to true. 50 | * 51 | * @param array|string $data The data to operate on. 52 | * @param bool $serialised Does the value of $data need to be unserialized? 53 | * 54 | * @return array The original array with all elements replaced as needed. 55 | */ 56 | public function run( $data, $serialised = false ) { 57 | return $this->run_recursively( $data, $serialised ); 58 | } 59 | 60 | /** 61 | * @param int $recursion_level Current recursion depth within the original data. 62 | * @param array $visited_data Data that has been seen in previous recursion iterations. 63 | */ 64 | private function run_recursively( $data, $serialised, $recursion_level = 0, $visited_data = array() ) { 65 | 66 | // some unseriliased data cannot be re-serialised eg. SimpleXMLElements 67 | try { 68 | 69 | if ( $this->recurse_objects ) { 70 | 71 | // If we've reached the maximum recursion level, short circuit 72 | if ( 0 !== $this->max_recursion && $recursion_level >= $this->max_recursion ) { 73 | return $data; 74 | } 75 | 76 | if ( is_array( $data ) || is_object( $data ) ) { 77 | // If we've seen this exact object or array before, short circuit 78 | if ( in_array( $data, $visited_data, true ) ) { 79 | return $data; // Avoid infinite loops when there's a cycle 80 | } 81 | // Add this data to the list of 82 | $visited_data[] = $data; 83 | } 84 | } 85 | 86 | try { 87 | // The error suppression operator is not enough in some cases, so we disable 88 | // reporting of notices and warnings as well. 89 | $error_reporting = error_reporting(); 90 | error_reporting( $error_reporting & ~E_NOTICE & ~E_WARNING ); 91 | $unserialized = is_string( $data ) ? @unserialize( $data ) : false; 92 | error_reporting( $error_reporting ); 93 | 94 | } catch ( \TypeError $exception ) { // phpcs:ignore PHPCompatibility.Classes.NewClasses.typeerrorFound 95 | // This type error is thrown when trying to unserialize a string that does not fit the 96 | // type declarations of the properties it is supposed to fill. 97 | // This type checking was introduced with PHP 8.1. 98 | // See https://github.com/wp-cli/search-replace-command/issues/191 99 | \WP_CLI::warning( 100 | sprintf( 101 | 'Skipping an inconvertible serialized object: "%s", replacements might not be complete. Reason: %s.', 102 | $data, 103 | $exception->getMessage() 104 | ) 105 | ); 106 | 107 | throw new Exception( $exception->getMessage(), $exception->getCode(), $exception ); 108 | } 109 | 110 | if ( false !== $unserialized ) { 111 | $data = $this->run_recursively( $unserialized, true, $recursion_level + 1 ); 112 | } elseif ( is_array( $data ) ) { 113 | $keys = array_keys( $data ); 114 | foreach ( $keys as $key ) { 115 | $data[ $key ] = $this->run_recursively( $data[ $key ], false, $recursion_level + 1, $visited_data ); 116 | } 117 | } elseif ( $this->recurse_objects && ( is_object( $data ) || $data instanceof \__PHP_Incomplete_Class ) ) { 118 | if ( $data instanceof \__PHP_Incomplete_Class ) { 119 | $array = new ArrayObject( $data ); 120 | \WP_CLI::warning( 121 | sprintf( 122 | 'Skipping an uninitialized class "%s", replacements might not be complete.', 123 | $array['__PHP_Incomplete_Class_Name'] 124 | ) 125 | ); 126 | } else { 127 | try { 128 | foreach ( $data as $key => $value ) { 129 | $data->$key = $this->run_recursively( $value, false, $recursion_level + 1, $visited_data ); 130 | } 131 | } catch ( \Error $exception ) { // phpcs:ignore PHPCompatibility.Classes.NewClasses.errorFound 132 | // This error is thrown when the object that was unserialized cannot be iterated upon. 133 | // The most notable reason is an empty `mysqli_result` object which is then considered to be "already closed". 134 | // See https://github.com/wp-cli/search-replace-command/pull/192#discussion_r1412310179 135 | \WP_CLI::warning( 136 | sprintf( 137 | 'Skipping an inconvertible serialized object of type "%s", replacements might not be complete. Reason: %s.', 138 | is_object( $data ) ? get_class( $data ) : gettype( $data ), 139 | $exception->getMessage() 140 | ) 141 | ); 142 | 143 | throw new Exception( $exception->getMessage(), $exception->getCode(), $exception ); 144 | } 145 | } 146 | } elseif ( is_string( $data ) ) { 147 | if ( $this->logging ) { 148 | $old_data = $data; 149 | } 150 | if ( $this->regex ) { 151 | $search_regex = $this->regex_delimiter; 152 | $search_regex .= $this->from; 153 | $search_regex .= $this->regex_delimiter; 154 | $search_regex .= $this->regex_flags; 155 | 156 | $result = preg_replace( $search_regex, $this->to, $data, $this->regex_limit ); 157 | if ( null === $result || PREG_NO_ERROR !== preg_last_error() ) { 158 | \WP_CLI::warning( 159 | sprintf( 160 | 'The provided regular expression threw a PCRE error - %s', 161 | $this->preg_error_message( $result ) 162 | ) 163 | ); 164 | } 165 | $data = $result; 166 | } else { 167 | $data = str_replace( $this->from, $this->to, $data ); 168 | } 169 | if ( $this->logging && $old_data !== $data ) { 170 | $this->log_data[] = $old_data; 171 | } 172 | } 173 | 174 | if ( $serialised ) { 175 | return serialize( $data ); 176 | } 177 | } catch ( Exception $exception ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Intentionally empty. 178 | 179 | } 180 | 181 | return $data; 182 | } 183 | 184 | /** 185 | * Gets existing data saved for this run when logging. 186 | * @return array Array of data strings, prior to replacements. 187 | */ 188 | public function get_log_data() { 189 | return $this->log_data; 190 | } 191 | 192 | /** 193 | * Clears data stored for logging. 194 | */ 195 | public function clear_log_data() { 196 | $this->log_data = array(); 197 | } 198 | 199 | /** 200 | * Get the PCRE error constant name from an error value. 201 | * 202 | * @param integer $error Error code. 203 | * @return string Error constant name. 204 | */ 205 | private function preg_error_message( $error ) { 206 | static $error_names = null; 207 | 208 | if ( null === $error_names ) { 209 | $definitions = get_defined_constants( true ); 210 | $pcre_constants = array_key_exists( 'pcre', $definitions ) 211 | ? $definitions['pcre'] 212 | : array(); 213 | $error_names = array_flip( $pcre_constants ); 214 | } 215 | 216 | return isset( $error_names[ $error ] ) 217 | ? $error_names[ $error ] 218 | : ''; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /wp-cli.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - search-replace-command.php 3 | --------------------------------------------------------------------------------