diff --git a/features/comment.feature b/features/comment.feature
index 290954e02..483847a8e 100644
--- a/features/comment.feature
+++ b/features/comment.feature
@@ -472,3 +472,32 @@ Feature: Manage WordPress comments
And I run `wp comment unspam {COMMENT_ID} --url=www.example.com`
And I run `wp comment trash {COMMENT_ID} --url=www.example.com`
And I run `wp comment untrash {COMMENT_ID} --url=www.example.com`
+
+ Scenario: Delete comments using ID ranges
+ Given a WP install
+
+ When I run `wp comment create --comment_post_ID=1 --comment_content='Comment A' --comment_author='A' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {COMMENT_ID_1}
+
+ When I run `wp comment create --comment_post_ID=1 --comment_content='Comment B' --comment_author='B' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {COMMENT_ID_2}
+
+ When I run `wp comment create --comment_post_ID=1 --comment_content='Comment C' --comment_author='C' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {COMMENT_ID_3}
+
+ When I run `wp comment delete {COMMENT_ID_1}-{COMMENT_ID_3} --force`
+ Then STDOUT should contain:
+ """
+ Deleted comment {COMMENT_ID_1}.
+ """
+ And STDOUT should contain:
+ """
+ Deleted comment {COMMENT_ID_2}.
+ """
+ And STDOUT should contain:
+ """
+ Deleted comment {COMMENT_ID_3}.
+ """
diff --git a/features/post.feature b/features/post.feature
index c480a6f93..3826aaeea 100644
--- a/features/post.feature
+++ b/features/post.feature
@@ -602,3 +602,61 @@ Feature: Manage WordPress posts
"""
{"block_version":1}
"""
+
+ Scenario: Delete posts using ID ranges
+ Given a WP install
+
+ When I run `wp post create --post_title='Post A' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID_1}
+
+ When I run `wp post create --post_title='Post B' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID_2}
+
+ When I run `wp post create --post_title='Post C' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID_3}
+
+ When I run `wp post delete {POST_ID_1}-{POST_ID_3} --force`
+ Then STDOUT should contain:
+ """
+ Deleted post {POST_ID_1}.
+ """
+ And STDOUT should contain:
+ """
+ Deleted post {POST_ID_2}.
+ """
+ And STDOUT should contain:
+ """
+ Deleted post {POST_ID_3}.
+ """
+
+ Scenario: Update posts using ID ranges
+ Given a WP install
+
+ When I run `wp post create --post_title='Post A' --post_status=draft --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID_1}
+
+ When I run `wp post create --post_title='Post B' --post_status=draft --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID_2}
+
+ When I run `wp post create --post_title='Post C' --post_status=draft --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {POST_ID_3}
+
+ When I run `wp post update {POST_ID_1}-{POST_ID_3} --post_status=publish`
+ Then STDOUT should contain:
+ """
+ Updated post {POST_ID_1}.
+ """
+ And STDOUT should contain:
+ """
+ Updated post {POST_ID_2}.
+ """
+ And STDOUT should contain:
+ """
+ Updated post {POST_ID_3}.
+ """
diff --git a/features/term.feature b/features/term.feature
index 574d11219..c96b4498f 100644
--- a/features/term.feature
+++ b/features/term.feature
@@ -298,3 +298,32 @@ Feature: Manage WordPress terms
"term_group":0
}]
"""
+
+ Scenario: Delete terms using ID ranges
+ Given a WP install
+
+ When I run `wp term create category 'Range Term A' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {TERM_ID_1}
+
+ When I run `wp term create category 'Range Term B' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {TERM_ID_2}
+
+ When I run `wp term create category 'Range Term C' --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {TERM_ID_3}
+
+ When I run `wp term delete category {TERM_ID_1}-{TERM_ID_3}`
+ Then STDOUT should contain:
+ """
+ Deleted category {TERM_ID_1}.
+ """
+ And STDOUT should contain:
+ """
+ Deleted category {TERM_ID_2}.
+ """
+ And STDOUT should contain:
+ """
+ Deleted category {TERM_ID_3}.
+ """
diff --git a/features/user.feature b/features/user.feature
index cd22acdd6..46430f053 100644
--- a/features/user.feature
+++ b/features/user.feature
@@ -774,3 +774,32 @@ Feature: Manage WordPress users
"""
newtestuser
"""
+
+ Scenario: Delete users using ID ranges
+ Given a WP install
+
+ When I run `wp user create testrange1 testrange1@example.com --role=subscriber --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {USER_ID_1}
+
+ When I run `wp user create testrange2 testrange2@example.com --role=subscriber --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {USER_ID_2}
+
+ When I run `wp user create testrange3 testrange3@example.com --role=subscriber --porcelain`
+ Then STDOUT should be a number
+ And save STDOUT as {USER_ID_3}
+
+ When I run `wp user delete {USER_ID_1}-{USER_ID_3} --yes`
+ Then STDOUT should contain:
+ """
+ Removed user {USER_ID_1}
+ """
+ And STDOUT should contain:
+ """
+ Removed user {USER_ID_2}
+ """
+ And STDOUT should contain:
+ """
+ Removed user {USER_ID_3}
+ """
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 5edb5334a..4879736fe 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -56,6 +56,7 @@
*/src/WP_CLI/Fetchers/(Comment|Post|Signup|Site|User)\.php$
*/src/WP_CLI/CommandWith(DBObject|Meta|Terms)\.php$
+ */src/WP_CLI/ExpandsIdRanges\.php$
diff --git a/src/Comment_Command.php b/src/Comment_Command.php
index 3b1578017..ba32097cd 100644
--- a/src/Comment_Command.php
+++ b/src/Comment_Command.php
@@ -150,6 +150,7 @@ function ( $params ) {
* @param array $assoc_args Associative arguments.
*/
public function update( $args, $assoc_args ) {
+ $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] );
$assoc_args = wp_slash( $assoc_args );
parent::_update(
$args,
@@ -486,6 +487,7 @@ function ( $comment ) {
* Success: Deleted comment 2341.
*/
public function delete( $args, $assoc_args ) {
+ $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] );
parent::_delete(
$args,
$assoc_args,
@@ -556,6 +558,7 @@ private function check_server_name() {
* Success: Trashed comment 1337.
*/
public function trash( $args, $assoc_args ) {
+ $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] );
foreach ( $args as $id ) {
$this->call( $id, __FUNCTION__, 'Trashed %s.', 'Failed trashing %s.' );
}
@@ -577,6 +580,7 @@ public function trash( $args, $assoc_args ) {
*/
public function untrash( $args, $assoc_args ) {
$this->check_server_name();
+ $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] );
foreach ( $args as $id ) {
$this->call( $id, __FUNCTION__, 'Untrashed %s.', 'Failed untrashing %s.' );
}
@@ -597,6 +601,7 @@ public function untrash( $args, $assoc_args ) {
* Success: Marked as spam comment 1337.
*/
public function spam( $args, $assoc_args ) {
+ $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] );
foreach ( $args as $id ) {
$this->call( $id, __FUNCTION__, 'Marked %s as spam.', 'Failed marking %s as spam.' );
}
@@ -618,6 +623,7 @@ public function spam( $args, $assoc_args ) {
*/
public function unspam( $args, $assoc_args ) {
$this->check_server_name();
+ $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] );
foreach ( $args as $id ) {
$this->call( $id, __FUNCTION__, 'Unspammed %s.', 'Failed unspamming %s.' );
}
@@ -639,6 +645,7 @@ public function unspam( $args, $assoc_args ) {
*/
public function approve( $args, $assoc_args ) {
$this->check_server_name();
+ $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] );
foreach ( $args as $id ) {
$this->set_status( $id, 'approve', 'Approved' );
}
@@ -660,6 +667,7 @@ public function approve( $args, $assoc_args ) {
*/
public function unapprove( $args, $assoc_args ) {
$this->check_server_name();
+ $args = self::expand_id_ranges( $args, [ $this, 'get_comment_ids_in_range' ] );
foreach ( $args as $id ) {
$this->set_status( $id, 'hold', 'Unapproved' );
}
@@ -786,4 +794,27 @@ public function exists( $args ) {
WP_CLI::success( "Comment with ID {$args[0]} exists." );
}
}
+
+ /**
+ * Returns existing comment IDs within the given range.
+ *
+ * @param int $start Start of the ID range (inclusive).
+ * @param int|null $end End of the ID range (inclusive), or null for no upper bound.
+ * @return int[] List of existing comment IDs.
+ */
+ 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 ) )
+ );
+ }
}
diff --git a/src/Post_Command.php b/src/Post_Command.php
index 5e10c546b..d8c3577b9 100644
--- a/src/Post_Command.php
+++ b/src/Post_Command.php
@@ -371,6 +371,7 @@ function ( $params ) {
* Success: Updated post 456.
*/
public function update( $args, $assoc_args ) {
+ $args = self::expand_id_ranges( $args, [ $this, 'get_post_ids_in_range' ] );
foreach ( $args as $key => $arg ) {
if ( is_numeric( $arg ) ) {
@@ -561,6 +562,7 @@ public function get( $args, $assoc_args ) {
* Success: Deleted post 1294.
*/
public function delete( $args, $assoc_args ) {
+ $args = self::expand_id_ranges( $args, [ $this, 'get_post_ids_in_range' ] );
$defaults = [ 'force' => false ];
$assoc_args = array_merge( $defaults, $assoc_args );
@@ -1241,4 +1243,27 @@ private function maybe_convert_hyphenated_date_format( $date_string ) {
}
return $date_string;
}
+
+ /**
+ * Returns existing post IDs within the given range.
+ *
+ * @param int $start Start of the ID range (inclusive).
+ * @param int|null $end End of the ID range (inclusive), or null for no upper bound.
+ * @return int[] List of existing post IDs.
+ */
+ 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 ) )
+ );
+ }
}
diff --git a/src/Site_Command.php b/src/Site_Command.php
index 11c0d6ebf..b2234eeb8 100644
--- a/src/Site_Command.php
+++ b/src/Site_Command.php
@@ -1463,7 +1463,7 @@ private function get_sites_ids( $args, $assoc_args ) {
return [ $blog_id ];
}
- return $args;
+ return self::expand_id_ranges( $args, [ $this, 'get_site_ids_in_range' ] );
}
/**
@@ -1482,4 +1482,27 @@ private function check_site_ids_and_slug( $args, $assoc_args ) {
return true;
}
+
+ /**
+ * Returns existing site IDs within the given range.
+ *
+ * @param int $start Start of the ID range (inclusive).
+ * @param int|null $end End of the ID range (inclusive), or null for no upper bound.
+ * @return int[] List of existing site IDs.
+ */
+ 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 ) )
+ );
+ }
}
diff --git a/src/Term_Command.php b/src/Term_Command.php
index a3dbfac40..a1bb3f1d0 100644
--- a/src/Term_Command.php
+++ b/src/Term_Command.php
@@ -1,5 +1,6 @@
get_term_ids_in_range( $taxonomy, $start, $end );
+ }
+ );
+ }
+
$successes = 0;
$errors = 0;
foreach ( $args as $term_id ) {
@@ -896,4 +909,41 @@ private function maybe_reset_depth() {
private function get_formatter( &$assoc_args ) {
return new Formatter( $assoc_args, $this->fields, 'term' );
}
+
+ /**
+ * Returns existing term IDs within the given range for a specific taxonomy.
+ *
+ * @param string $taxonomy Taxonomy to filter terms by.
+ * @param int $start Start of the ID range (inclusive).
+ * @param int|null $end End of the ID range (inclusive), or null for no upper bound.
+ * @return int[] List of existing term IDs.
+ */
+ 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
+ )
+ )
+ );
+ }
}
diff --git a/src/User_Command.php b/src/User_Command.php
index e0c8654be..f7d1cfbe6 100644
--- a/src/User_Command.php
+++ b/src/User_Command.php
@@ -282,6 +282,31 @@ public function get( $args, $assoc_args ) {
* $ wp user delete $(wp user list --role=contributor --field=ID | head -n 100)
*/
public function delete( $args, $assoc_args ) {
+ // Only expand arguments that look like numeric ID ranges, and only if no user
+ // exists with that exact login or email. This avoids misinterpreting a valid
+ // user_login like "12-24" as an ID range.
+ $expanded_args = [];
+
+ 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;
+ }
+ }
+
+ $args = $expanded_args;
$network = Utils\get_flag_value( $assoc_args, 'network' ) && is_multisite();
/**
@@ -559,6 +584,8 @@ public function create( $args, $assoc_args ) {
* @param array $assoc_args Associative arguments.
*/
public function update( $args, $assoc_args ) {
+ $args = self::expand_id_ranges( $args, [ $this, 'get_user_ids_in_range' ] );
+
if ( isset( $assoc_args['user_login'] ) ) {
WP_CLI::warning( "User logins can't be changed." );
unset( $assoc_args['user_login'] );
@@ -1327,6 +1354,7 @@ public function import_csv( $args, $assoc_args ) {
* @subcommand reset-password
*/
public function reset_password( $args, $assoc_args ) {
+ $args = self::expand_id_ranges( $args, [ $this, 'get_user_ids_in_range' ] );
$porcelain = Utils\get_flag_value( $assoc_args, 'porcelain' );
$skip_email = (bool) Utils\get_flag_value( $assoc_args, 'skip-email' );
$show_new_pass = Utils\get_flag_value( $assoc_args, 'show-password' );
@@ -1410,6 +1438,7 @@ public static function wp_new_user_notification( $user_id, $password ) {
* Success: Spammed 1 of 1 users.
*/
public function spam( $args ) {
+ $args = self::expand_id_ranges( $args, [ $this, 'get_user_ids_in_range' ] );
$this->update_msuser_status( $args, 'spam', '1' );
}
@@ -1429,6 +1458,7 @@ public function spam( $args ) {
* Success: Unspamed 1 of 1 users.
*/
public function unspam( $args ) {
+ $args = self::expand_id_ranges( $args, [ $this, 'get_user_ids_in_range' ] );
$this->update_msuser_status( $args, 'spam', '0' );
}
@@ -1548,4 +1578,27 @@ public function check_password( $args, $assoc_args ) {
WP_CLI::halt( 1 );
}
}
+
+ /**
+ * Returns existing user IDs within the given range.
+ *
+ * @param int $start Start of the ID range (inclusive).
+ * @param int|null $end End of the ID range (inclusive), or null for no upper bound.
+ * @return int[] List of existing user IDs.
+ */
+ protected function get_user_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->users} WHERE ID >= %d ORDER BY ID ASC", $start ) )
+ );
+ }
+
+ return array_map(
+ 'intval',
+ $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->users} WHERE ID BETWEEN %d AND %d ORDER BY ID ASC", $start, $end ) )
+ );
+ }
}
diff --git a/src/WP_CLI/CommandWithDBObject.php b/src/WP_CLI/CommandWithDBObject.php
index 767a0bb27..d268fc839 100644
--- a/src/WP_CLI/CommandWithDBObject.php
+++ b/src/WP_CLI/CommandWithDBObject.php
@@ -14,6 +14,8 @@
*/
abstract class CommandWithDBObject extends WP_CLI_Command {
+ use ExpandsIdRanges;
+
/**
* @var string $object_type WordPress' expected name for the object.
*/
diff --git a/src/WP_CLI/ExpandsIdRanges.php b/src/WP_CLI/ExpandsIdRanges.php
new file mode 100644
index 000000000..244321cb4
--- /dev/null
+++ b/src/WP_CLI/ExpandsIdRanges.php
@@ -0,0 +1,56 @@
+ $end ) {
+ // Normalize reversed ranges like "35-15" to "15-35".
+ $temp = $start;
+ $start = $end;
+ $end = $temp;
+ }
+ $ids = array_merge( $ids, $get_ids_in_range( $start, $end ) );
+ } elseif ( preg_match( '/^(\d+)-$/', $arg, $matches ) ) {
+ // Open-ended range: "34-"
+ $ids = array_merge( $ids, $get_ids_in_range( (int) $matches[1], null ) );
+ } elseif ( preg_match( '/^-(\d+)$/', $arg, $matches ) ) {
+ // Lower-bounded range: "-35"
+ $ids = array_merge( $ids, $get_ids_in_range( 1, (int) $matches[1] ) );
+ } else {
+ $ids[] = $arg;
+ }
+ }
+
+ return array_values( array_unique( $ids ) );
+ }
+}