Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@
"term meta pluck",
"term meta update",
"term recount",
"term prune",
"term update",
"user",
"user add-cap",
Expand Down
137 changes: 137 additions & 0 deletions features/term-prune.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
Feature: Prune unused taxonomy terms

Background:
Given a WP install

Scenario: Prune terms with no published posts
When I run `wp term create post_tag 'Unused Tag' --slug=unused-tag --porcelain`
Then STDOUT should be a number
And save STDOUT as {TERM_ID}

When I run `wp term prune post_tag`
Then STDOUT should contain:
"""
Deleted post_tag {TERM_ID}.
"""
And STDOUT should contain:
"""
Success:
"""
And the return code should be 0

When I try `wp term get post_tag {TERM_ID}`
Then STDERR should contain:
"""
Error: Term doesn't exist.
"""

Scenario: Does not prune terms with more than one published post
When I run `wp term create post_tag 'Popular Tag' --slug=popular-tag --porcelain`
Then STDOUT should be a number
And save STDOUT as {TERM_ID}

When I run `wp post create --post_title='Post 1' --post_status=publish --porcelain`
Then STDOUT should be a number
And save STDOUT as {POST_ID_1}

When I run `wp post create --post_title='Post 2' --post_status=publish --porcelain`
Then STDOUT should be a number
And save STDOUT as {POST_ID_2}

When I run `wp post term set {POST_ID_1} post_tag {TERM_ID} --by=id`
Then STDOUT should not be empty

When I run `wp post term set {POST_ID_2} post_tag {TERM_ID} --by=id`
Then STDOUT should not be empty

When I run `wp term prune post_tag`
Then STDOUT should not contain:
"""
Deleted post_tag {TERM_ID}.
"""

When I run `wp term get post_tag {TERM_ID} --field=name`
Then STDOUT should be:
"""
Popular Tag
"""

Scenario: Prune terms with exactly one published post
When I run `wp term create post_tag 'Single Post Tag' --slug=single-post-tag --porcelain`
Then STDOUT should be a number
And save STDOUT as {TERM_ID}

When I run `wp post create --post_title='Post 1' --post_status=publish --porcelain`
Then STDOUT should be a number
And save STDOUT as {POST_ID}

When I run `wp post term set {POST_ID} post_tag {TERM_ID} --by=id`
Then STDOUT should not be empty

When I run `wp term prune post_tag`
Then STDOUT should contain:
"""
Deleted post_tag {TERM_ID}.
"""
And the return code should be 0

When I try `wp term get post_tag {TERM_ID}`
Then STDERR should contain:
"""
Error: Term doesn't exist.
"""

Scenario: Dry run previews terms without deleting them
When I run `wp term create post_tag 'Unused Tag' --slug=unused-tag --porcelain`
Then STDOUT should be a number
And save STDOUT as {TERM_ID}

When I run `wp term prune post_tag --dry-run`
Then STDOUT should contain:
"""
Would delete post_tag {TERM_ID}.
"""
And STDOUT should contain:
"""
Success:
"""
And the return code should be 0

When I run `wp term get post_tag {TERM_ID} --field=name`
Then STDOUT should be:
"""
Unused Tag
"""

Scenario: Prune with an invalid taxonomy
When I try `wp term prune nonexistent_taxonomy`
Then STDERR should be:
"""
Error: Taxonomy nonexistent_taxonomy doesn't exist.
"""
And the return code should be 1

Scenario: Prune multiple taxonomies at once
# Assign an extra post to the default Uncategorized category so its count
# exceeds the prune threshold and it won't interfere with the test.
When I run `wp post create --post_title='Extra Post' --post_status=publish --post_category=1 --porcelain`
Then STDOUT should be a number

When I run `wp term create post_tag 'Unused Tag' --slug=unused-tag --porcelain`
Then STDOUT should be a number
And save STDOUT as {TAG_TERM_ID}

When I run `wp term create category 'Unused Category' --slug=unused-category --porcelain`
Then STDOUT should be a number
And save STDOUT as {CAT_TERM_ID}

When I run `wp term prune post_tag category`
Then STDOUT should contain:
"""
Deleted post_tag {TAG_TERM_ID}.
"""
And STDOUT should contain:
"""
Deleted category {CAT_TERM_ID}.
"""
And the return code should be 0
102 changes: 102 additions & 0 deletions src/Term_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
* Success: Updated category term count
* Success: Updated post_tag term count
*
* # Prune terms with 0 or 1 published posts
* $ wp term prune post_tag
* Deleted post_tag 15.
* Success: Pruned 1 of 5 terms.
*
* @package wp-cli
*/
class Term_Command extends WP_CLI_Command {
Expand Down Expand Up @@ -682,6 +687,103 @@ public function recount( $args ) {
}
}

/**
* Removes terms with 0 or 1 published posts from one or more taxonomies.
*
* Useful for cleaning up large sites with many unused or barely-used terms.
* The term count is based on the number of published posts assigned to each
* term.
*
* ## OPTIONS
*
* <taxonomy>...
* : One or more taxonomies to prune.
*
* [--dry-run]
* : Preview the terms to be pruned, without actually deleting them.
*
* ## EXAMPLES
*
* # Prune post tags with 0 or 1 published posts.
* $ wp term prune post_tag
* Deleted post_tag 15.
* Success: Pruned 1 of 5 terms.
*
* # Dry run to preview which terms would be pruned.
* $ wp term prune post_tag --dry-run
* Would delete post_tag 15.
* Success: 1 post_tag term would be pruned.
*
* # Prune multiple taxonomies at once.
* $ wp term prune category post_tag
* Deleted category 8.
* Success: Pruned 1 of 3 terms.
* Deleted post_tag 15.
* Success: Pruned 1 of 5 terms.
*/
public function prune( $args, $assoc_args ) {
$dry_run = (bool) Utils\get_flag_value( $assoc_args, 'dry-run', false );

foreach ( $args as $taxonomy ) {
if ( ! taxonomy_exists( $taxonomy ) ) {
WP_CLI::error( "Taxonomy {$taxonomy} doesn't exist." );
}

$terms = get_terms(
[
'taxonomy' => $taxonomy,
'hide_empty' => false,
]
);

// This should never happen because of the taxonomy_exists check above.
if ( is_wp_error( $terms ) ) {
WP_CLI::warning( "Could not retrieve terms for taxonomy {$taxonomy}." );
continue;
}

/**
* @var \WP_Term[] $terms
*/

$total = count( $terms );
$successes = 0;
$errors = 0;

foreach ( $terms as $term ) {
if ( $term->count > 1 ) {
continue;
}

if ( $dry_run ) {
WP_CLI::log( "Would delete {$taxonomy} {$term->term_id}." );
++$successes;
continue;
}

$result = wp_delete_term( $term->term_id, $taxonomy );

if ( is_wp_error( $result ) ) {
WP_CLI::warning( $result );
++$errors;
} elseif ( $result ) {
WP_CLI::log( "Deleted {$taxonomy} {$term->term_id}." );
++$successes;
} else {
WP_CLI::warning( "Failed to delete {$taxonomy} {$term->term_id}." );
++$errors;
}
}

if ( $dry_run ) {
$term_word = Utils\pluralize( 'term', $successes );
WP_CLI::success( "{$successes} {$taxonomy} {$term_word} would be pruned." );
} else {
Utils\report_batch_operation_results( 'term', 'prune', $total, $successes, $errors );
}
}
Comment on lines +727 to +784
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The prune function can be refactored for better performance, readability, and robustness.

  1. Performance: Instead of fetching all terms with get_terms() and then filtering them in PHP, it's more efficient to query the database directly for only the term IDs that need to be pruned (i.e., those with a count of 1 or less). This will significantly improve performance on sites with a large number of terms.
  2. Readability: The two separate foreach loops iterating over $args can be combined into a single loop. You can perform the taxonomy_exists() check at the beginning of each iteration before proceeding with the pruning logic for that taxonomy.
  3. Robustness: The wp_delete_term() function returns false if the term to be deleted doesn't exist. This edge case (which could happen in a race condition) should be handled by issuing a warning, similar to how the term delete command behaves.

Here is a suggested refactoring that applies these improvements:

        global $wpdb;

		$dry_run = (bool) Utils\get_flag_value( $assoc_args, 'dry-run', false );

		foreach ( $args as $taxonomy ) {
			if ( ! taxonomy_exists( $taxonomy ) ) {
				WP_CLI::error( "Taxonomy {$taxonomy} doesn't exist." );
			}

			$term_ids_to_prune = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT term_id FROM {$wpdb->term_taxonomy} WHERE taxonomy = %s AND count <= 1",
					$taxonomy
				)
			);

			$total     = count( $term_ids_to_prune );
			$successes = 0;
			$errors    = 0;

			if ( $total > 0 ) {
				foreach ( $term_ids_to_prune as $term_id ) {
					if ( $dry_run ) {
						WP_CLI::log( "Would delete {$taxonomy} {$term_id}." );
						++$successes;
						continue;
					}

					$result = wp_delete_term( $term_id, $taxonomy );

					if ( is_wp_error( $result ) ) {
						WP_CLI::warning( $result );
						++$errors;
					} elseif ( $result ) {
						WP_CLI::log( "Deleted {$taxonomy} {$term_id}." );
						++$successes;
					} else {
						WP_CLI::warning( "Term {$term_id} in taxonomy {$taxonomy} doesn't exist." );
					}
				}
			}

			if ( $dry_run ) {
				$term_word = Utils\pluralize( 'term', $successes );
				WP_CLI::success( "{$successes} {$taxonomy} {$term_word} would be pruned." );
			} else {
				Utils\report_batch_operation_results( 'term', 'prune', $total, $successes, $errors );
			}
		}

}

/**
* Migrate a term of a taxonomy to another taxonomy.
*
Expand Down