Skip to content

Instantly share code, notes, and snippets.

@joemaller
Last active November 1, 2024 17:17
Show Gist options
  • Save joemaller/94957e2ce14ec5252a8077942a5e90a2 to your computer and use it in GitHub Desktop.
Save joemaller/94957e2ce14ec5252a8077942a5e90a2 to your computer and use it in GitHub Desktop.
Fix term counts so wp-admin Taxonomy listings show the correct number of items for the selected post_type.

Fixing post counts for WordPress taxonomies

The problem

For WordPress sites using Custom Post Types, Taxonomy admin listings show cumulative term-assignment totals for all post_types instead of counts specific to the selected post_type. Since Taxonomy listings are always attached to a single post_type, totals for shared taxonomies don't make sense.

The Goal

Fix term counts so wp-admin Taxonomy listings show the correct number of items for the selected post_type.

Post_type-specific counts should not replace WordPress's built-in, cumulative counts. Add functionality, don't remove any.

Post_type-specific counts should also be available to templates.

Some background on term counts

Since a term's post-counts don't change frequently and could be moderately costly to collect, WordPress stores term counts in the database. Those numbers are updated whenever a term is edited or assigned to a post. Likely because these counts pre-date the addition of term metadata in WordPress 4.4, term counts are stored in the wp_term_taxonomy table, not wp_termmeta.

Taxonomies can have an update_count_callback, which is sort of like a hook that's called when term-counts are modified. Unfortunately, if that callback exists the logic inside wp_update_term_count_now() forks and skips the two native, private term counting functions: _update_post_term_count() and _update_generic_term_count(). There doesn't seem to be any way of maintaining existing term count functionality and calling those without doing something regrettable like duplicating the dozen-line else clause from wp_update_term_count_now().

But digging deeper, it turns out to there's a common action named edited_term_taxonomy called from both _update_post_term_count() and _update_generic_term_count(). This offers a much cleaner, future-proof hook for adding additional counts whenever the native counts are updated.

The solution

Whenever a term is modified or assigned to a post, the edited_term_taxonomy hook generates a post_type-specific count and stores that value in the term's metadata.

Those counts are exposed to every matching WP_Term_Query by the pre_get_terms hook so WordPress can correctly order the results.

Metadata keys and orderby attributes both follow the pattern count_{$post_type}.

A new, post_type-specific Count column replaces the native, cumulative count column on Taxonomy admin screens. Finally, a Refresh Count bulk action so counts can be added to existing terms.

Usage

Once the class is instantiated, wp-admin will display the new Count column. On the frontend, define orderby in query_vars equal to something like count_{$post_type}.

Implementation

This code will live in a single PHP class. This makes it easy to include in a custom theme or wrap as a plugin without having to refactor anything. Because of this, some of the function/method nces look different from conventional WordPress code.

The class constructor sets up the three primary actions:

class TaxonomyCountColumn
{
    public function __construct()
    {
        add_action('edited_term_taxonomy', [$this, 'updateCounts'], 100, 2);
        add_action('pre_get_terms', [$this, 'orderByMeta']);
        add_action('wp_loaded', [$this, 'setupTaxonomyAdmin']);
    }

    // other methods go here
}

The updateCounts method

This method collects post counts for a term and stores the value in the term's metadata.

    public function updateCounts($termId, $taxName)
    {
        global $post_type;

        $post_types = (array) ($post_type ?? $_REQUEST['post_type'] ?? get_taxonomy($taxName)->object_type);
        $guid = sha1(json_encode([$termId, $taxName, $post_types]));

        if (get_transient($guid) === false) {
            foreach ($post_types as $type) {
                $args = [
                    'posts_per_page' => -1,
                    'post_type' => $type,
                    'tax_query' => [['taxonomy' => $taxName, 'terms' => $termId]],
                ];
                $countQuery = new \WP_Query($args);

                update_term_meta($termId, "count_{$type}", $countQuery->found_posts);
            }
            set_transient($guid, true, 60);
        }
    }

To reduce server load and guard against multiple calls, the rest of this method is wrapped up in a 60-second transient. The transient $guid is a just quick hash of the method arguments and $post_types.

Some AJAX requests may be missing a $post_type, so a stack of possible $post_type sources are coalesced into the $post_types array.

For each $post_type (usually just one), $countQuery collects all posts matching the current term and $post_type then stores the post_count value for each term with update_term_meta().

The orderByMeta method

For WordPress to be able to sort get_terms() results by the new post_type-counts, the count metadata needs to be exposed. This method attaches to the pre_get_terms hook and adds a new WP_Meta_Query which provides WordPress with the post_type-specific count values it needs for ordering.

    public function orderByMeta(\WP_Term_Query $query)
    {
        global $post_type;

        $orderby = $query->query_vars['orderby'] ?? false;

        if ($orderby && preg_match('/^count_[-a-z]+/', $orderby)) {
            $meta_query = new \WP_Meta_Query([
                "count_$post_type" => [
                    'relation' => 'OR',
                    ['key' => "count_$post_type", 'type' => 'NUMERIC'],
                    ['key' => "count_$post_type", 'compare' => 'NOT EXISTS'],
                ],
            ]);
            $query->meta_query = $meta_query;
        }
    }

Because it's possible a term doesn't yet have post_type-count metadata yet, the meta_query matches terms where the key does and doesn't exist. It's a little bit messier, but otherwise terms without counts would be omitted.

Note: The $query argument is passed by reference and doesn't need to be returned.

setupTaxonomyAdmin and Everything Else

The remaining methods are mostly just wp-admin boilerplate. But one exception is setupTaxonomyAdmin which adds the dynamic admin hooks for each taxonomy.

    public function setupTaxonomyAdmin()
    {
        $taxonomies = get_taxonomies(['public' => true], 'names');

        foreach ($taxonomies as $taxonomy) {
            add_filter("manage_edit-{$taxonomy}_columns", [$this, 'addCountColumn'], 100);
            add_filter("manage_edit-{$taxonomy}_sortable_columns", [$this, 'makeCountColumnSortable'], 100);
            add_action("manage_{$taxonomy}_custom_column", [$this, 'renderCountColumn'], 100, 3);

            add_filter("bulk_actions-edit-{$taxonomy}", [$this, 'addResetBulkAction']);
            add_filter("handle_bulk_actions-edit-{$taxonomy}", [$this, 'bulkActionHandler'], 100, 3);
        }
        add_action('admin_notices', [$this, 'addCountUpdateNotice']);
        add_action('admin_enqueue_scripts', [$this, 'adminCountColumnStyles'], 100);
    }

The five dynamic hooks for creating columns and bulk_actions are specific to each taxonomy, but the remaining two actions are generic and only need to be called once.

I have no good reason for calling everything at priority 100. The default is 10, and I just wanted to slip these in at the end.

Conclusion

The complete code is available in this Gist.

I should probably wrap it up in a plugin, if no one else gets to it first.

Known issues

There is a slight discrepancy between how wp-admin tables and get_terms() resolve multiple terms with the same orderby value. I yet don't know what extra step the admin table is taking and haven't been able to get compound orderby statements working in a WP_Term_Query.

notes

  • The native "Count" column uses the key posts.
  • Chesterton's Fence is one of the best things I learned on Wikipedia.
  • Why store counts in the database? Well, on a taxonomy admin page listing 13 items, the get_{$taxonomy} filter is called 156 times. No idea why, that's 12 times per term -- with only 4 visible columns.
<?php
namespace ideasonpurpose;
class TaxonomyCountColumn
{
public function __construct()
{
add_action('edited_term_taxonomy', [$this, 'updateCounts'], 100, 2);
add_action('pre_get_terms', [$this, 'orderByMeta']);
add_action('wp_loaded', [$this, 'setupTaxonomyAdmin']);
}
/**
* Update a term's 'post_type_counts' termmeta value
*
* The WP_Query and update_term_meta calls are wrapped in a 60-second transient to reduce the
* load on the server and to guard against multiple calls.
*/
public function updateCounts($termId, $taxName)
{
global $post_type;
$post_types = (array) ($post_type ?? $_REQUEST['post_type'] ?? get_taxonomy($taxName)->object_type);
$guid = sha1(json_encode([$termId, $taxName, $post_types]));
if (get_transient($guid) === false) {
foreach ($post_types as $type) {
$args = [
'posts_per_page' => -1,
'post_type' => $type,
'tax_query' => [['taxonomy' => $taxName, 'terms' => $termId]],
];
$countQuery = new \WP_Query($args);
update_term_meta($termId, "count_{$type}", $countQuery->found_posts);
}
set_transient($guid, true, 60);
}
}
/**
* Adds a meta_query exposing the count_{$post_type} field to the Term Query so there are values to
* order by. Since the query_var has the same name as the termmeta field, we can rely on WordPress
* to sanitize the input.
*
* The query returns whether a number or null depending on whether the key exists.
*
* ref: https://core.trac.wordpress.org/ticket/40335
* ref: https://stackoverflow.com/a/47224730/503463
*/
public function orderByMeta(\WP_Term_Query $query)
{
$orderby = $query->query_vars['orderby'] ?? false;
if (preg_match('/^count_[-a-z]+/', $orderby)) {
$meta_query = new \WP_Meta_Query([
$orderby => [
'relation' => 'OR',
['key' => $orderby, 'type' => 'NUMERIC'],
['key' => $orderby, 'compare' => 'NOT EXISTS'],
],
]);
$query->meta_query = $meta_query;
}
}
/**
* Adds a sortable Count column and 'Refresh Counts' bulk action to the Taxonomy admin interface
*/
public function setupTaxonomyAdmin()
{
$taxonomies = get_taxonomies(['public' => true], 'names');
foreach ($taxonomies as $taxonomy) {
add_filter("manage_edit-{$taxonomy}_columns", [$this, 'addCountColumn'], 100);
add_filter("manage_edit-{$taxonomy}_sortable_columns", [$this, 'makeCountColumnSortable'], 100);
add_action("manage_{$taxonomy}_custom_column", [$this, 'renderCountColumn'], 100, 3);
add_filter("bulk_actions-edit-{$taxonomy}", [$this, 'addResetBulkAction']);
add_filter("handle_bulk_actions-edit-{$taxonomy}", [$this, 'bulkActionHandler'], 100, 3);
}
add_action('admin_notices', [$this, 'addCountUpdateNotice']);
add_action('admin_enqueue_scripts', [$this, 'adminCountColumnStyles'], 100);
}
public function addCountColumn($cols)
{
$newCols = $cols;
unset($newCols['posts']);
$newCols['post_type_count'] = 'Count';
return $newCols;
}
public function makeCountColumnSortable($cols)
{
global $post_type;
$newCols = $cols;
$newCols['post_type_count'] = "count_$post_type";
return $newCols;
}
public function renderCountColumn($content, $name, $id)
{
$output = $content;
if ($name === 'post_type_count') {
$screen = get_current_screen();
$term = get_term($id);
$count = get_term_meta($id, "count_{$screen->post_type}", true);
$viewHref = add_query_arg(
[$screen->taxonomy => $term->slug, 'post_type' => $screen->post_type],
'edit.php',
);
$output .= strlen($count) ? sprintf('<a href="%s">%s</a>', $viewHref, $count) : '--';
}
return $output;
}
public function addResetBulkAction($actions)
{
$newActions = ['reset_post_type_counts' => 'Refresh Counts'];
return array_merge($newActions, $actions);
}
public function bulkActionHandler($redirect, $action, $ids)
{
$screen = get_current_screen();
if (strlen($screen->taxonomy)) {
if (count($ids)) {
wp_update_term_count_now($ids, $screen->taxonomy);
$redirect = add_query_arg(['post_type_count_updated' => count($ids)], $redirect);
}
}
return $redirect;
}
/**
* Note: This method outputs an update message into the admin
*/
public function addCountUpdateNotice()
{
if (!empty($_REQUEST['post_type_count_updated'])) {
$screen = get_current_screen();
$taxonomy = get_taxonomy($screen->taxonomy);
$term = strtolower($taxonomy->labels->singular_name);
$terms = strtolower($taxonomy->labels->name);
$count = intval($_REQUEST['post_type_count_updated']);
$msg = _n("Updated count for {$count} {$term}.", "Updated counts for {$count} {$terms}.", $count);
printf('<div class="notice notice-success is-dismissible"><p>%s</p></div>', $msg);
}
}
public function adminCountColumnStyles()
{
$css = "
.column-post_type_count {
width: 74px;
text-align: center;
}
";
wp_add_inline_style('wp-admin', $css);
}
}
@pixeline
Copy link

Thank you SO much for providing such an insightful patch!
I would just like to propose one small improvement:

line 91: $newCols['post_type_count'] = 'Count '. ucfirst(get_current_screen()->post_type) . 's';
so that it is clear what is being counted. Thanks again!

@AnadarProSvcs
Copy link

I was trying to use it with a taxonomy applied to an attachment. I had to make some changes to get it to work due to two issues:

  1. The post type was showing as post
  2. The post_status of an attachment is actually 'inherit'

In case this helps someone in the future, I made the following adjustments:
`$post_types = get_taxonomy($taxName)->object_type;

	$guid = sha1(json_encode([$termId, $taxName, $post_types]));

	if (get_transient($guid) === false) {
		foreach ($post_types as $type) {
			$args = [
				'posts_per_page' => -1,
				'post_type' => $type,
				'tax_query' => [['taxonomy' => $taxName, 'terms' => $termId]],
			];
			if($type==='attachment'){
				$args['post_status']='inherit';
			}
			$countQuery = new \WP_Query($args);


			update_term_meta($termId, "count_{$type}", $countQuery->found_posts);
		}
               }`

@salim523
Copy link

salim523 commented Nov 1, 2024

This is an excellent workaround for the count, and I think WordPress should adopt it. I suggest adding the post_status field to publish in the update_counts method to match the WordPress count style. You can extend the filter WordPress uses in _update_post_term_count function update_post_term_count_statuses https://github.com/WordPress/wordpress-develop/blob/6.6.2/src/wp-includes/taxonomy.php#L4158.

public function update_counts( $term_id, $tax_name ) {
	global $post_type;

	$post_statuses = array( 'publish' );
	$post_types    = (array) ( $post_type ?? $_REQUEST['post_type'] ?? get_taxonomy( $tax_name )->object_type );

	/**
	 * Filter the post statuses used to update the term count.
	 *
	 * @see https://github.com/WordPress/wordpress-develop/blob/6.6.2/src/wp-includes/taxonomy.php#L4158
	 */
	$post_statuses = esc_sql( apply_filters( 'update_post_term_count_statuses', $post_statuses, $tax_name ) );

	$guid = sha1( json_encode( array( $term_id, $tax_name, $post_types ) ) );

	if ( get_transient( $guid ) === false ) {
		foreach ( $post_types as $type ) {
			$args        = array(
				'posts_per_page' => 1,
				'post_type'      => $type,
				'post_status'    => $post_statuses,
				'tax_query'      => array(
					array(
						'taxonomy' => $tax_name,
						'terms'    => $term_id,
					),
				),
			);
			$count_query = new \WP_Query( $args );

			update_term_meta( $term_id, "count_{$type}", $count_query->found_posts );
		}
		set_transient( $guid, true, 60 );
	}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment