Custom Taxonomy: Parent still counting deleted Child

The problem

There’s on (imho) serious issue with WordPress and Taxonomies and their term hierarchy (and children): They aren’t really fetched from the actual state, but someone who thought (s)he might be really “smart” stuffed that into the *_options table.

Just take a look at the source of get_term_children(): It makes a call to _get_term_hierarchy(). And in there the following is used to retrieve the children of a taxonomy:

get_option( "{$taxonomy}_children" )

Now it might sound smart at first to leverage a static array instead of a real count, but at the long term it’s a maintenance nightmare. WordPress has to keep that value updated and in case someone forgets to trigger that update programmatically, it gets out of sync and fails.

Real world example

To get a proof for that, you can do a plain DB call in (for e.g.) phpMyAdmin (or whatever you use): Just replace {$wpdb->prefix} with your DB table prefix.

SELECT * 
FROM  `{$wpdb->prefix}_options` 
WHERE  `option_name` LIKE  '%children'
ORDER BY  `{$wpdb->prefix}_options`.`option_name` ASC 
LIMIT 0 , 1000

The result will be something like the following – a serialized array:

a:2:{i:23;a:5:{i:0;i:38;i:1;i:39;i:2;i:40;i:3;i:41;i:4;i:42;}i:40;a:1:{i:0;i:43;}}

Or when deserialized:

array (
  23 => 
  array (
    0 => 38,
    1 => 39,
    2 => 40,
    3 => 41,
    4 => 42,
  ),
  40 => 
  array (
    0 => 43,
  ),
)

So the keys are the parent terms, while the sub arrays are the associated child terms.

What’s wrong?

First off: I’m not completely sure about this, as WP uses plenty of plain functions and DB calls and tracing all routes is quite tough – maybe I got lost in some rabbit hole.

When I look at wp_delete_term() it seems that there’s nowhere an update of the actual option happening. And wp_delete_category() is just a wrapper for that. So in order to keep your children in sync, you’ll have to manually update that option.

I’ve written an importer once, where I stumbled upon the same problem and had to update the option manually as well. So unless no one smarter than me enters the stage, I think this is the only way to go: update_option():

$taxonomy = 'your_current_taxonomy';
$children = get_option( "{$taxonomy}_children" );
// Merge here
update_option( "{$taxonomy}_children", $your_new_value );

EDIT (1)

One more thing for everyone who wants to reproduce that or get some proof: There’s no way to actually see that the children are out of synch, unless you take a look at the taxonomy list table screen in admin or comparing the option return value manually. Everything else will just work as expected and look ok.

EDIT (2)

As @MannyFleurmond just commented, there might be another option: Deleting the option and let WP regenerate it for you. I searched core and looked into wp_delete_term() in ~/wp-includes/taxonomy.php… and found something: clean_term_cacheSource really has a part that does this:

    if ( $clean_taxonomy ) {
        wp_cache_delete('all_ids', $taxonomy);
        wp_cache_delete('get', $taxonomy);
        delete_option("{$taxonomy}_children");
        // Regenerate {$taxonomy}_children
        _get_term_hierarchy($taxonomy);
    }

Point still is, that it doesn’t trigger. The reason is simple: The 3rd argument is true per default. And therefore – see source – it won’t trigger an update.

A possible solution might be the following: Use a hook and clean it manually to trigger an update:

// The last hook before everything happens in core:
do_action( 'deleted_term_taxonomy', $tt_id );

So we could use that:

<?php
defined( 'ABSPATH' ) OR exit;
/** Plugin Name: (#117373) Fix Child term relationships */

add_action( 'deleted_term_taxonomy', 'wpse117374UpdateTaxChildCount' );
function wpse117374UpdateTaxChildCount( $termID )
{
    $tax = get_current_screen()->taxonomy;
    delete_option( "{$tax}_children" );
    # OR: ... alternate solution
    // clear_term_cache( $termID, get_current_screen()->taxonomy, false );
}

Keep in mind that above plugin isn’t tested. But if it works, please leave a comment so people can simply use this as mu-plugin to automatically fix this crap 🙂