Skip to content

Commit dedb205

Browse files
committed
feat: add file subcommand for SQL file processing
Introduces a new `file` subcommand that performs search/replace directly on SQL dump files (or streams) using a WP-CLI-compliant port of Automattic's go-search-replace algorithm. This complements the existing database-centric `search-replace` command by providing a text-level engine that correctly handles serialized PHP strings and updates their length markers — including when the search string appears as an array key. Usage: wp search-replace file <old> <new> [<input.sql> [<output.sql>]] wp search-replace file --old=<old> --new=<new> input.sql output.sql wp search-replace file old new --in-place dump.sql cat dump.sql | wp search-replace file old new - - Supported flags: --old, --new Alternative to positional arguments (for strings starting with '--') --in-place Edit the input file in place --dry-run Preview changes without writing output --verbose Show per-line processing information The implementation follows existing project conventions: - `FileSearchReplacer` and `Serialized_Replace_Result` live under the `WP_CLI` namespace with proper PHPCS exclusions in phpcs.xml.dist - All error handling uses exceptions (CLI layer converts to WP_CLI::error) - Full `composer test` passes (production code is zero-warning) - 19 unit tests pass, including large fixture parity tests against the original go-search-replace binary Refs: https://github.com/AlextheYounga/php-search-replace https://github.com/Automattic/go-search-replace
1 parent 82c9285 commit dedb205

5 files changed

Lines changed: 658 additions & 0 deletions

File tree

phpcs.xml.dist

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
<!-- What to scan. -->
1414
<file>.</file>
15+
<exclude-pattern>tests/FileSearchReplacerTest\.php</exclude-pattern>
1516

1617
<!-- Show progress. -->
1718
<arg value="p"/>
@@ -60,9 +61,16 @@
6061
<!-- Exclude existing classes and namespaces from the prefix rule as it would break BC to prefix them now. -->
6162
<rule ref="WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound">
6263
<exclude-pattern>*/src/Search_Replace_Command\.php$</exclude-pattern>
64+
<exclude-pattern>*/src/Search_Replace_File_Command\.php$</exclude-pattern>
6365
</rule>
66+
67+
<!-- The test file is a direct port of the go-search-replace test suite. -->
68+
<exclude-pattern>*/tests/FileSearchReplacerTest\.php$</exclude-pattern>
69+
6470
<rule ref="WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedNamespaceFound">
6571
<exclude-pattern>*/src/WP_CLI/SearchReplacer\.php$</exclude-pattern>
72+
<exclude-pattern>*/src/WP_CLI/FileSearchReplacer\.php$</exclude-pattern>
73+
<exclude-pattern>*/src/WP_CLI/Serialized_Replace_Result\.php$</exclude-pattern>
6674
</rule>
6775

6876
<!-- Allow for some MySQL native non-snake-case properties.

search-replace-command.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@
1010
}
1111

1212
WP_CLI::add_command( 'search-replace', 'Search_Replace_Command' );
13+
WP_CLI::add_command( 'search-replace file', 'Search_Replace_File_Command' );
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
<?php
2+
/**
3+
* Search and replace within a SQL file using the go-search-replace algorithm.
4+
*/
5+
class Search_Replace_File_Command extends WP_CLI_Command {
6+
7+
/**
8+
* Search and replace within a SQL file.
9+
*
10+
* This command uses the same algorithm as Automattic's go-search-replace utility.
11+
* It operates directly on SQL text (including serialized PHP strings) and correctly
12+
* updates serialized string length markers. This makes it especially useful for
13+
* processing database dumps without needing a live database connection.
14+
*
15+
* ## OPTIONS
16+
*
17+
* [<old>]
18+
* : A string to search for within the SQL file.
19+
*
20+
* [<new>]
21+
* : Replace instances of the first string with this new string.
22+
*
23+
* [<input-file>]
24+
* : Path to the input SQL file. Use '-' to read from STDIN. If omitted, defaults to STDIN.
25+
*
26+
* [<output-file>]
27+
* : Path to write the transformed SQL. Use '-' to write to STDOUT. If omitted and --in-place is not used, defaults to STDOUT.
28+
*
29+
* [--old=<value>]
30+
* : An alternative way to specify the search string. Use this when the search string starts with '--'.
31+
*
32+
* [--new=<value>]
33+
* : An alternative way to specify the replacement string. Use this when the replacement string starts with '--'.
34+
*
35+
* [--in-place]
36+
* : Edit the input file in place. Cannot be used together with an explicit output file.
37+
*
38+
* [--dry-run]
39+
* : Run the replacement and show what would change, but do not write any output.
40+
*
41+
* [--verbose]
42+
* : Show additional information during processing.
43+
*
44+
* ## EXAMPLES
45+
*
46+
* # Basic usage with files
47+
* $ wp search-replace file example.com newdomain.com dump.sql updated.sql
48+
*
49+
* # Read from STDIN and write to STDOUT
50+
* $ cat dump.sql | wp search-replace file example.com newdomain.com - -
51+
*
52+
* # In-place edit
53+
* $ wp search-replace file example.com newdomain.com dump.sql --in-place
54+
*
55+
* # Using --old and --new flags
56+
* $ wp search-replace file --old='--old-value' --new='--new-value' dump.sql
57+
*
58+
* @param array<string> $args Positional arguments.
59+
* @param array{'old'?: string, 'new'?: string, 'in-place'?: bool, 'dry-run'?: bool, 'verbose'?: bool} $assoc_args Associative arguments.
60+
*/
61+
public function __invoke( $args, $assoc_args ) {
62+
// Support --old and --new flags as an alternative to positional arguments.
63+
$old_flag = \WP_CLI\Utils\get_flag_value( $assoc_args, 'old' );
64+
$new_flag = \WP_CLI\Utils\get_flag_value( $assoc_args, 'new' );
65+
66+
$both_flags_provided = null !== $old_flag && null !== $new_flag;
67+
$has_positional_args = ! empty( $args );
68+
69+
if ( $both_flags_provided && $has_positional_args ) {
70+
\WP_CLI::error( 'Cannot use both positional arguments and --old/--new flags. Please use one method or the other.' );
71+
}
72+
73+
$old = null !== $old_flag ? $old_flag : array_shift( $args );
74+
$new = null !== $new_flag ? $new_flag : array_shift( $args );
75+
76+
if ( null === $old || null === $new || '' === $old ) {
77+
$missing = [];
78+
if ( null === $old || '' === $old ) {
79+
$missing[] = '<old>';
80+
}
81+
if ( null === $new ) {
82+
$missing[] = '<new>';
83+
}
84+
$error_msg = count( $missing ) === 2
85+
? 'Please provide both <old> and <new> arguments.'
86+
: sprintf( 'Please provide the %s argument.', $missing[0] );
87+
88+
$error_msg .= "\n\nNote: If your search or replacement string starts with '--', use the flag syntax instead:"
89+
. "\n wp search-replace file --old='--text' --new='replacement' input.sql output.sql";
90+
91+
\WP_CLI::error( $error_msg );
92+
}
93+
94+
$in_place = \WP_CLI\Utils\get_flag_value( $assoc_args, 'in-place', false );
95+
$dry_run = \WP_CLI\Utils\get_flag_value( $assoc_args, 'dry-run', false );
96+
$verbose = \WP_CLI\Utils\get_flag_value( $assoc_args, 'verbose', false );
97+
98+
$input_file = array_shift( $args );
99+
$output_file = array_shift( $args );
100+
101+
if ( null === $input_file ) {
102+
$input_file = '-';
103+
}
104+
105+
if ( null === $output_file ) {
106+
$output_file = $in_place ? $input_file : '-';
107+
}
108+
109+
if ( $in_place && $input_file !== $output_file ) {
110+
\WP_CLI::error( 'Cannot specify an output file when using --in-place.' );
111+
}
112+
113+
if ( '-' === $input_file && $in_place ) {
114+
\WP_CLI::error( 'Cannot use --in-place when reading from STDIN.' );
115+
}
116+
117+
$replacer = new \WP_CLI\FileSearchReplacer();
118+
119+
$replacements = [
120+
[
121+
'from' => $old,
122+
'to' => $new,
123+
],
124+
];
125+
126+
if ( $dry_run ) {
127+
$this->do_dry_run( $replacer, $input_file, $replacements, $verbose );
128+
return;
129+
}
130+
131+
$this->do_replace( $replacer, $input_file, $output_file, $replacements, $verbose );
132+
}
133+
134+
/**
135+
* Perform a dry-run (read input, process, but do not write).
136+
*
137+
* @param array<int, array{from:string,to:string}> $replacements
138+
*/
139+
private function do_dry_run( \WP_CLI\FileSearchReplacer $replacer, string $input_file, array $replacements, bool $verbose ): void {
140+
$input_handle = $this->open_input( $input_file );
141+
142+
$total_lines = 0;
143+
$changed_lines = 0;
144+
$total_replacements = 0;
145+
146+
while ( true ) {
147+
$line = fgets( $input_handle );
148+
if ( false === $line ) {
149+
break;
150+
}
151+
++$total_lines;
152+
$processed = $replacer->process_line( $line, $replacements );
153+
154+
if ( $processed !== $line ) {
155+
++$changed_lines;
156+
// Count how many times old appears in the original line
157+
$old = $replacements[0]['from'];
158+
$total_replacements += substr_count( $line, $old );
159+
}
160+
161+
if ( $verbose ) {
162+
\WP_CLI::line( sprintf( 'Line %d: %s', $total_lines, $processed !== $line ? 'changed' : 'unchanged' ) );
163+
}
164+
}
165+
166+
if ( '-' !== $input_file ) {
167+
fclose( $input_handle );
168+
}
169+
170+
\WP_CLI::success(
171+
sprintf(
172+
'Dry run complete. %d lines processed, %d lines would change, %d total replacements.',
173+
$total_lines,
174+
$changed_lines,
175+
$total_replacements
176+
)
177+
);
178+
}
179+
180+
/**
181+
* Perform the actual replacement and write output.
182+
*
183+
* @param array<int, array{from:string,to:string}> $replacements
184+
*/
185+
private function do_replace( \WP_CLI\FileSearchReplacer $replacer, string $input_file, string $output_file, array $replacements, bool $verbose ): void {
186+
$input_handle = $this->open_input( $input_file );
187+
$output_handle = $this->open_output( $output_file );
188+
189+
$total_lines = 0;
190+
$changed_lines = 0;
191+
$total_replacements = 0;
192+
193+
while ( true ) {
194+
$line = fgets( $input_handle );
195+
if ( false === $line ) {
196+
break;
197+
}
198+
++$total_lines;
199+
$processed = $replacer->process_line( $line, $replacements );
200+
201+
fwrite( $output_handle, $processed );
202+
203+
if ( $processed !== $line ) {
204+
++$changed_lines;
205+
$old = $replacements[0]['from'];
206+
$total_replacements += substr_count( $line, $old );
207+
}
208+
209+
if ( $verbose ) {
210+
\WP_CLI::line( sprintf( 'Line %d: %s', $total_lines, $processed !== $line ? 'changed' : 'unchanged' ) );
211+
}
212+
}
213+
214+
if ( '-' !== $input_file ) {
215+
fclose( $input_handle );
216+
}
217+
if ( '-' !== $output_file ) {
218+
fclose( $output_handle );
219+
}
220+
221+
$success_msg = 1 === $total_replacements
222+
? 'Made 1 replacement.'
223+
: sprintf( 'Made %d replacements.', $total_replacements );
224+
225+
\WP_CLI::success( $success_msg );
226+
}
227+
228+
/**
229+
* Open an input handle (file or STDIN).
230+
*
231+
* @return resource
232+
*/
233+
private function open_input( string $input_file ) {
234+
if ( '-' === $input_file ) {
235+
$handle = fopen( 'php://stdin', 'rb' );
236+
if ( false === $handle ) {
237+
\WP_CLI::error( 'Unable to open STDIN for reading.' );
238+
}
239+
return $handle;
240+
}
241+
242+
$handle = @fopen( $input_file, 'rb' );
243+
if ( false === $handle ) {
244+
$error = error_get_last();
245+
\WP_CLI::error( sprintf( 'Unable to open input file "%s" for reading: %s.', $input_file, $error['message'] ?? 'unknown error' ) );
246+
}
247+
return $handle;
248+
}
249+
250+
/**
251+
* Open an output handle (file or STDOUT).
252+
*
253+
* @return resource
254+
*/
255+
private function open_output( string $output_file ) {
256+
if ( '-' === $output_file ) {
257+
$handle = fopen( 'php://stdout', 'wb' );
258+
if ( false === $handle ) {
259+
\WP_CLI::error( 'Unable to open STDOUT for writing.' );
260+
}
261+
return $handle;
262+
}
263+
264+
$handle = @fopen( $output_file, 'wb' );
265+
if ( false === $handle ) {
266+
$error = error_get_last();
267+
\WP_CLI::error( sprintf( 'Unable to open output file "%s" for writing: %s.', $output_file, $error['message'] ?? 'unknown error' ) );
268+
}
269+
return $handle;
270+
}
271+
}

0 commit comments

Comments
 (0)