Add ID range notation to commands that accept one or more IDs#593
Add ID range notation to commands that accept one or more IDs#593
Conversation
… commands Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
… in reset_password Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
This comment was marked as resolved.
This comment was marked as resolved.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
… to protected Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as resolved.
… and term feature files Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
This comment was marked as resolved.
This comment was marked as resolved.
There was a problem hiding this comment.
Pull request overview
This PR adds support for ID range notation (e.g. 15-35, 34-, -35) to multiple WP-CLI entity commands so users can target large sets of objects without shell expansion limits or running multiple CLI processes.
Changes:
- Introduces a reusable
WP_CLI\ExpandsIdRangestrait to parse range arguments and delegate ID lookups to command-specific callbacks. - Wires range expansion into Post, Comment, User, Site (via
CommandWithDBObject) and Term (directly) commands, with per-command SQL helpers to fetch IDs in a range. - Adds Behat feature coverage for range usage in
post,comment,term, anduserworkflows (partial coverage).
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/WP_CLI/ExpandsIdRanges.php | Adds generic range parsing + deduplication for positional args. |
| src/WP_CLI/CommandWithDBObject.php | Mixes the trait into the base DB-object command class. |
| src/Post_Command.php | Expands ranges for post update / post delete and adds a DB helper. |
| src/Comment_Command.php | Expands ranges across several comment subcommands and adds a DB helper. |
| src/User_Command.php | Expands ranges for multiple user subcommands and adds a DB helper. |
| src/Term_Command.php | Uses trait directly for term delete (skips when --by=slug). |
| src/Site_Command.php | Expands ranges for site status subcommands via get_sites_ids() and adds a DB helper. |
| phpcs.xml.dist | Excludes the new namespaced trait file from the global-prefix namespace sniff. |
| features/*.feature | Adds new scenarios validating range expansion for a subset of updated commands. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This comment was marked as resolved.
This comment was marked as resolved.
…and feature tests for site, comment, user range operations Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com>
This comment was marked as resolved.
This comment was marked as resolved.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a valuable feature for performing bulk operations using ID ranges, which will significantly improve user experience for many commands. The implementation using a reusable trait is well-designed. My review focuses on improving code clarity and consistency by reducing duplication in the new database helper methods. I've also identified a critical issue in User_Command where several commands could misinterpret user logins as ID ranges, potentially leading to unintended actions. Addressing this will make the new feature more robust and reliable.
| foreach ( $args as $arg ) { | ||
| if ( is_string( $arg ) && preg_match( '/^\d+-\d+$/', $arg ) ) { | ||
| $user_by_login = get_user_by( 'login', $arg ); | ||
| $user_by_email = get_user_by( 'email', $arg ); | ||
|
|
||
| if ( $user_by_login || $user_by_email ) { | ||
| // Treat as login/email, do not expand as an ID range. | ||
| $expanded_args[] = $arg; | ||
| } else { | ||
| $range_expanded = self::expand_id_ranges( [ $arg ], [ $this, 'get_user_ids_in_range' ] ); | ||
| foreach ( $range_expanded as $expanded_arg ) { | ||
| $expanded_args[] = $expanded_arg; | ||
| } | ||
| } | ||
| } else { | ||
| $expanded_args[] = $arg; | ||
| } | ||
| } |
There was a problem hiding this comment.
The special handling for user logins that look like ID ranges is a great idea. However, the current implementation only checks for full ranges (e.g., 12-24) and misses partial ranges (e.g., 12- or -24). This could lead to a user login like 12- being incorrectly treated as an ID range. The check should be expanded to cover all range formats supported by ExpandsIdRanges. Additionally, the loop for merging expanded IDs can be simplified using array_merge.
foreach ( $args as $arg ) {
if ( is_string( $arg ) && preg_match( '/^(\d+-\d*|-\d+)$/', $arg ) && '-' !== $arg ) {
$user_by_login = get_user_by( 'login', $arg );
$user_by_email = get_user_by( 'email', $arg );
if ( $user_by_login || $user_by_email ) {
// Treat as login/email, do not expand as an ID range.
$expanded_args[] = $arg;
} else {
$expanded_args = array_merge( $expanded_args, self::expand_id_ranges( [ $arg ], [ $this, 'get_user_ids_in_range' ] ) );
}
} else {
$expanded_args[] = $arg;
}
}| * @param array $assoc_args Associative arguments. | ||
| */ | ||
| public function update( $args, $assoc_args ) { | ||
| $args = self::expand_id_ranges( $args, [ $this, 'get_user_ids_in_range' ] ); |
There was a problem hiding this comment.
The update command can accept user logins, emails, or IDs. Similar to the delete command, there's a risk of misinterpreting a user login that looks like an ID range (e.g., '12-24'). You should add the same protective logic here as in the delete method to check if a user exists with that login/email before attempting to expand it as a range. This also applies to reset-password, spam, and unspam commands.
$expanded_args = [];
foreach ( $args as $arg ) {
if ( is_string( $arg ) && preg_match( '/^(\d+-\d*|-\d+)$/', $arg ) && '-' !== $arg ) {
$user_by_login = get_user_by( 'login', $arg );
$user_by_email = get_user_by( 'email', $arg );
if ( $user_by_login || $user_by_email ) {
// Treat as login/email, do not expand as an ID range.
$expanded_args[] = $arg;
} else {
$expanded_args = array_merge( $expanded_args, self::expand_id_ranges( [ $arg ], [ $this, 'get_user_ids_in_range' ] ) );
}
} else {
$expanded_args[] = $arg;
}
}
$args = $expanded_args;| * @subcommand reset-password | ||
| */ | ||
| public function reset_password( $args, $assoc_args ) { | ||
| $args = self::expand_id_ranges( $args, [ $this, 'get_user_ids_in_range' ] ); |
There was a problem hiding this comment.
The reset-password command can accept user logins. Similar to the delete command, there's a risk of misinterpreting a user login that looks like an ID range (e.g., '12-24'). You should add protective logic here to check if a user exists with that login/email before attempting to expand it as a range.
$expanded_args = [];
foreach ( $args as $arg ) {
if ( is_string( $arg ) && preg_match( '/^(\d+-\d*|-\d+)$/', $arg ) && '-' !== $arg ) {
$user_by_login = get_user_by( 'login', $arg );
$user_by_email = get_user_by( 'email', $arg );
if ( $user_by_login || $user_by_email ) {
// Treat as login/email, do not expand as an ID range.
$expanded_args[] = $arg;
} else {
$expanded_args = array_merge( $expanded_args, self::expand_id_ranges( [ $arg ], [ $this, 'get_user_ids_in_range' ] ) );
}
} else {
$expanded_args[] = $arg;
}
}
$args = $expanded_args;| * Success: Spammed 1 of 1 users. | ||
| */ | ||
| public function spam( $args ) { | ||
| $args = self::expand_id_ranges( $args, [ $this, 'get_user_ids_in_range' ] ); |
There was a problem hiding this comment.
The spam command can accept user logins. Similar to the delete command, there's a risk of misinterpreting a user login that looks like an ID range (e.g., '12-24'). You should add protective logic here to check if a user exists with that login/email before attempting to expand it as a range.
$expanded_args = [];
foreach ( $args as $arg ) {
if ( is_string( $arg ) && preg_match( '/^(\d+-\d*|-\d+)$/', $arg ) && '-' !== $arg ) {
$user_by_login = get_user_by( 'login', $arg );
$user_by_email = get_user_by( 'email', $arg );
if ( $user_by_login || $user_by_email ) {
// Treat as login/email, do not expand as an ID range.
$expanded_args[] = $arg;
} else {
$expanded_args = array_merge( $expanded_args, self::expand_id_ranges( [ $arg ], [ $this, 'get_user_ids_in_range' ] ) );
}
} else {
$expanded_args[] = $arg;
}
}
$args = $expanded_args;| * Success: Unspamed 1 of 1 users. | ||
| */ | ||
| public function unspam( $args ) { | ||
| $args = self::expand_id_ranges( $args, [ $this, 'get_user_ids_in_range' ] ); |
There was a problem hiding this comment.
The unspam command can accept user logins. Similar to the delete command, there's a risk of misinterpreting a user login that looks like an ID range (e.g., '12-24'). You should add protective logic here to check if a user exists with that login/email before attempting to expand it as a range.
$expanded_args = [];
foreach ( $args as $arg ) {
if ( is_string( $arg ) && preg_match( '/^(\d+-\d*|-\d+)$/', $arg ) && '-' !== $arg ) {
$user_by_login = get_user_by( 'login', $arg );
$user_by_email = get_user_by( 'email', $arg );
if ( $user_by_login || $user_by_email ) {
// Treat as login/email, do not expand as an ID range.
$expanded_args[] = $arg;
} else {
$expanded_args = array_merge( $expanded_args, self::expand_id_ranges( [ $arg ], [ $this, 'get_user_ids_in_range' ] ) );
}
} else {
$expanded_args[] = $arg;
}
}
$args = $expanded_args;| $start = (int) $matches[1]; | ||
| $end = (int) $matches[2]; | ||
| if ( $start > $end ) { | ||
| // Normalize reversed ranges like "35-15" to "15-35". |
| protected function get_comment_ids_in_range( int $start, ?int $end ): array { | ||
| global $wpdb; | ||
|
|
||
| if ( null === $end ) { | ||
| return array_map( | ||
| 'intval', | ||
| $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_ID >= %d ORDER BY comment_ID ASC", $start ) ) | ||
| ); | ||
| } | ||
|
|
||
| return array_map( | ||
| 'intval', | ||
| $wpdb->get_col( $wpdb->prepare( "SELECT comment_ID FROM {$wpdb->comments} WHERE comment_ID BETWEEN %d AND %d ORDER BY comment_ID ASC", $start, $end ) ) | ||
| ); | ||
| } |
There was a problem hiding this comment.
This method can be made more concise and DRY by building the query dynamically instead of having two separate return paths.
protected function get_comment_ids_in_range( int $start, ?int $end ): array {
global $wpdb;
$query = "SELECT comment_ID FROM {$wpdb->comments} WHERE ";
$params = [ $start ];
if ( null === $end ) {
$query .= 'comment_ID >= %d';
} else {
$query .= 'comment_ID BETWEEN %d AND %d';
$params[] = $end;
}
$query .= ' ORDER BY comment_ID ASC';
return array_map(
'intval',
$wpdb->get_col( $wpdb->prepare( $query, ...$params ) )
);
}| protected function get_post_ids_in_range( int $start, ?int $end ): array { | ||
| global $wpdb; | ||
|
|
||
| if ( null === $end ) { | ||
| return array_map( | ||
| 'intval', | ||
| $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID >= %d ORDER BY ID ASC", $start ) ) | ||
| ); | ||
| } | ||
|
|
||
| return array_map( | ||
| 'intval', | ||
| $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE ID BETWEEN %d AND %d ORDER BY ID ASC", $start, $end ) ) | ||
| ); | ||
| } |
There was a problem hiding this comment.
This method can be made more concise and DRY by building the query dynamically instead of having two separate return paths.
protected function get_post_ids_in_range( int $start, ?int $end ): array {
global $wpdb;
$query = "SELECT ID FROM {$wpdb->posts} WHERE ";
$params = [ $start ];
if ( null === $end ) {
$query .= 'ID >= %d';
} else {
$query .= 'ID BETWEEN %d AND %d';
$params[] = $end;
}
$query .= ' ORDER BY ID ASC';
return array_map(
'intval',
$wpdb->get_col( $wpdb->prepare( $query, ...$params ) )
);
}| protected function get_site_ids_in_range( int $start, ?int $end ): array { | ||
| global $wpdb; | ||
|
|
||
| if ( null === $end ) { | ||
| return array_map( | ||
| 'intval', | ||
| $wpdb->get_col( $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} WHERE blog_id >= %d ORDER BY blog_id ASC", $start ) ) | ||
| ); | ||
| } | ||
|
|
||
| return array_map( | ||
| 'intval', | ||
| $wpdb->get_col( $wpdb->prepare( "SELECT blog_id FROM {$wpdb->blogs} WHERE blog_id BETWEEN %d AND %d ORDER BY blog_id ASC", $start, $end ) ) | ||
| ); | ||
| } |
There was a problem hiding this comment.
This method can be made more concise and DRY by building the query dynamically instead of having two separate return paths.
protected function get_site_ids_in_range( int $start, ?int $end ): array {
global $wpdb;
$query = "SELECT blog_id FROM {$wpdb->blogs} WHERE ";
$params = [ $start ];
if ( null === $end ) {
$query .= 'blog_id >= %d';
} else {
$query .= 'blog_id BETWEEN %d AND %d';
$params[] = $end;
}
$query .= ' ORDER BY blog_id ASC';
return array_map(
'intval',
$wpdb->get_col( $wpdb->prepare( $query, ...$params ) )
);
}| protected function get_term_ids_in_range( string $taxonomy, int $start, ?int $end ): array { | ||
| global $wpdb; | ||
|
|
||
| if ( null === $end ) { | ||
| return array_map( | ||
| 'intval', | ||
| $wpdb->get_col( | ||
| $wpdb->prepare( | ||
| "SELECT t.term_id FROM {$wpdb->terms} t JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id WHERE t.term_id >= %d AND tt.taxonomy = %s ORDER BY t.term_id ASC", | ||
| $start, | ||
| $taxonomy | ||
| ) | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| return array_map( | ||
| 'intval', | ||
| $wpdb->get_col( | ||
| $wpdb->prepare( | ||
| "SELECT t.term_id FROM {$wpdb->terms} t JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id WHERE t.term_id BETWEEN %d AND %d AND tt.taxonomy = %s ORDER BY t.term_id ASC", | ||
| $start, | ||
| $end, | ||
| $taxonomy | ||
| ) | ||
| ) | ||
| ); | ||
| } |
There was a problem hiding this comment.
This method can be made more concise and DRY by building the query dynamically instead of having two separate return paths.
protected function get_term_ids_in_range( string $taxonomy, int $start, ?int $end ): array {
global $wpdb;
$query = "SELECT t.term_id FROM {$wpdb->terms} t JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id WHERE ";
$params = [ $start ];
if ( null === $end ) {
$query .= 't.term_id >= %d';
} else {
$query .= 't.term_id BETWEEN %d AND %d';
$params[] = $end;
}
$query .= ' AND tt.taxonomy = %s ORDER BY t.term_id ASC';
$params[] = $taxonomy;
return array_map(
'intval',
$wpdb->get_col( $wpdb->prepare( $query, ...$params ) )
);
}
Commands accepting multiple IDs now support range notation, allowing bulk operations without shell expansion limits or spawning multiple WP-CLI processes.
Supported syntax
15-3534--35Ranges can be mixed with individual IDs freely:
wp post delete 3 7 12-24 19 34- --forceOnly existing IDs within a range are processed — no warnings for gaps.
Implementation
src/WP_CLI/ExpandsIdRanges.php— New trait withexpand_id_ranges(array $args, callable $get_ids_in_range): array. Parses range notation and delegates DB lookup to a command-specific callback. Results are deduplicated. Each range expansion emits aWP_CLI::debug()message (group:range-expansion) showing how many IDs were resolved, making large open-ended expansions visible via--debug=range-expansion.CommandWithDBObject— Uses the trait, making it available to Post, Comment, User, and Site commands.Term_Command— Uses the trait directly (extendsWP_CLI_Command, notCommandWithDBObject). Range expansion is skipped when--by=slugis passed.Per-command DB helpers — Each command gets a
get_*_ids_in_range(int $start, ?int $end): arraymethod querying its table via$wpdb->prepare()withBETWEEN/>=clauses.Commands updated
post delete,post updatecomment delete,trash,untrash,spam,unspam,approve,unapprove,updateuser delete,update,reset-password,spam,unspamterm deletesite activate,archive,deactivate,mature,unmature,private,public,spam,unspam,unarchiveTest coverage
Behat scenarios cover range notation for:
wp post delete,wp post updatewp comment delete,wp comment trash(non-delete path)wp term deletewp user delete,wp user reset-password(non-delete path)wp site archive(multisite path)Original prompt
This section details on the original issue you should resolve
<issue_title>Add ranges to commands that accept one or more IDs</issue_title>
<issue_description>For commands that accept one or more IDs as positional parameters, I would like to investigate adding ranges and partial ranges as valid IDs.
A range would take the following form:
<range start>-<range end>. This would mean that all existing IDs that fall into the range that starts at<range start>and ends at<range end>would be included.Example:
15-35would mean all IDs starting from 15 and going up to and including 35 would be processed. Only valid existing IDs within that range would be processed, so that no warnings are being thrown for missing IDs in-between.A partial range would take one of these forms:
<range start>-or-<range-end>. This would mean that either all existing IDs starting from<range start>without any upper bound or all existing IDs from1onwards up to and including<range end>would be included.Ranges and individual IDs can be combined in any way, so you can use something like the following:
3 7 12-24 19 34-.Commands that could profit from this range notation:
comment approvecomment deletecomment recountcomment spamcomment trashcomment unapprovecomment unspamcomment untrashcomment updateembed cache clear(*)embed cache trigger(*)media regeneratepost deletepost meta delete(*)post meta update(*)post updatesite activatesite archivesite deactivatesite delete(*)site maturesite privatesite publicsite spamsite unarchivesite unmaturesite unspamterm deleteterm meta delete(*)term meta update(*)term update(*)user add-cap(*)user add-role(*)user deleteuser meta delete(*)user meta update(*)user remove-cap(*)user remove-role(*)user reset-passworduser set-role(*)user spamuser unspamuser updatewidget deletewidget move(*)widget update(*)(*) => not variadic yet</issue_description>
Comments on the Issue (you are @copilot in this section)
@schlessera @danielbachhuber, @johnbillion Would love your thoughts on the above.I know that we can already use
xargsfor doing similar things, but I do think this makes it much easier for people to execute for complex operations, and lets you more easily fine-tune the IDs you're targeting.</comment_new><comment_new>@danielbachhuber
I'm 👎 on this for a few reasons:
xargsand other shell tools in conjunction with WP-CLI.</comment_new><comment_new>@schlessera
Responding to @danielbachhuber's cons:
Composability is still available, but extended so that you can also compose ranges. So, you can do something like this:
I don't see this as breaking that principle, it is a mere extension to make the syntax more expressive.
Yes, I agree. I don't think this would be a big issue, because a) the syntax is actually far from complex and b) it is purely optional, you can still just use one regular ID without ever noticing something was changed.
I agree with that, but I don't think skipping a valuable performance and expressiveness improvement for that sake is a good move or a service to users. For a lot of users, this syntax change might actually mean they will never even have a need for
xargsand don't need to deal with its much more complex syntax.Responding to @johnbillion's questions:
The original use case that triggered this idea is wp-cli/media-command#82
It will actually be more performant than the
xargsright out of the box, because the IDs can be retrieved with 1 query to add them to the loop. So, a range like1-300will entail 1 query and 1 loading of WordPress only.</comment_new><comment_new>@danielbachhuber
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.