Skip to content
Open
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
53 changes: 45 additions & 8 deletions src/wp-includes/class-wp-term-query.php
Original file line number Diff line number Diff line change
Expand Up @@ -1161,19 +1161,56 @@ protected function populate_terms( $terms ) {
*/
protected function generate_cache_key( array $args, $sql ) {
global $wpdb;
// $args can be anything. Only use the args defined in defaults to compute the key.
$cache_args = wp_array_slice_assoc( $args, array_keys( $this->query_var_defaults ) );

unset( $cache_args['cache_results'], $cache_args['update_term_meta_cache'] );
// Replace wpdb placeholder in the SQL statement used by the cache key.
$sql = $wpdb->remove_placeholder_escape( $sql );

if ( 'count' !== $args['fields'] && 'all_with_object_id' !== $args['fields'] ) {
$cache_args['fields'] = 'all';
/*
* The SQL statement already captures everything that drives the database query
* (taxonomy, orderby, hide_empty + hierarchical combos, LIMIT, JOINs, etc.).
* Only include args that affect PHP-level post-processing of the query results,
* since those are not reflected in the SQL.
*/

// child_of is filtered in PHP via _get_term_children(); it is not always in the SQL.
$php_cache_args = array(
'child_of' => (int) $args['child_of'],
);

// cache_domain is a caller-controlled key that intentionally separates cache
// entries for the same query. It must be included so that callers can opt into
// distinct cache buckets even when the SQL is identical.
$php_cache_args['cache_domain'] = $args['cache_domain'];

// pad_counts changes result values via _pad_term_counts() and also changes the
// shape of what is stored in the cache ({term_id, count} objects vs. term_ids).
$php_cache_args['pad_counts'] = (bool) $args['pad_counts'];

/*
* PHP empty-term pruning runs only when BOTH hierarchical and hide_empty are truthy
* (see the `$hierarchical && $args['hide_empty']` check in get_terms()). The
* individual values are irrelevant; only the combined boolean result matters.
*/
$php_cache_args['prune_empty_terms'] = (bool) ( $args['hierarchical'] && $args['hide_empty'] );

/*
* When a query is hierarchical, SQL does not apply LIMIT/OFFSET (to avoid
* cutting off branches). In that case PHP slices the result set afterward, so
* the actual number and offset values must be part of the key.
*/
if ( $args['hierarchical'] && $args['number'] ) {
$php_cache_args['number'] = (int) $args['number'];
$php_cache_args['offset'] = (int) $args['offset'];
}

// Replace wpdb placeholder in the SQL statement used by the cache key.
$sql = $wpdb->remove_placeholder_escape( $sql );
// fields determines the shape of data stored in, and read from, the cache.
if ( 'count' !== $args['fields'] && 'all_with_object_id' !== $args['fields'] ) {
$php_cache_args['fields'] = 'all';
} else {
$php_cache_args['fields'] = $args['fields'];
}

$key = md5( serialize( $cache_args ) . $sql );
$key = md5( $sql . serialize( $php_cache_args ) );

return "get_terms:$key";
}
Expand Down
216 changes: 216 additions & 0 deletions tests/phpunit/tests/term/query.php
Original file line number Diff line number Diff line change
Expand Up @@ -1207,4 +1207,220 @@ public function test_query_does_not_have_leading_whitespace() {

$this->assertSame( ltrim( $q->request ), $q->request, 'The query has leading whitespace' );
}

/**
* Queries producing equivalent SQL and identical PHP post-processing should share
* a single cache entry regardless of how their input args are expressed.
*
* The motivating case: wp_dropdown_categories() passes `hierarchical=1, hide_empty=0`
* to get_terms(), while wp_terms_checklist() passes `get='all'` which normalises to
* `hierarchical=false, hide_empty=0`. Both produce the same SQL and the same PHP
* post-processing behaviour, so they should hit the same cache entry and avoid the
* duplicate database query reported in ticket #64038.
*
* @ticket 64038
* @group cache
*
* @covers WP_Term_Query::generate_cache_key
*/
public function test_equivalent_queries_share_cache_entry() {
register_taxonomy( 'wptests_tax_1', 'post' );
self::factory()->term->create_many( 3, array( 'taxonomy' => 'wptests_tax_1' ) );

// First query: explicit hierarchical=1, hide_empty=0 (wp_dropdown_categories style).
$query1 = new WP_Term_Query(
array(
'taxonomy' => 'wptests_tax_1',
'hide_empty' => 0,
'hierarchical' => 1,
'fields' => 'ids',
)
);

$terms1 = $query1->get_terms();

$num_queries_after_first = get_num_queries();

// Second query: get='all' (wp_terms_checklist style) – normalises to the same SQL
// and the same PHP post-processing path as the first query.
$query2 = new WP_Term_Query(
array(
'taxonomy' => 'wptests_tax_1',
'get' => 'all',
'fields' => 'ids',
)
);

$terms2 = $query2->get_terms();

$this->assertSame(
$num_queries_after_first,
get_num_queries(),
'A second equivalent term query should be served from cache without an extra database query.'
);

$this->assertSameSets(
$terms1,
$terms2,
'Both queries should return the same term IDs.'
);
}

/**
* Two queries that produce the same SQL but differ in whether PHP empty-term pruning
* applies (`hierarchical=true && hide_empty=true` vs. `hide_empty=false`) must NOT
* share a cache entry, because the stored result sets may be different.
*
* @ticket 64038
* @group cache
*
* @covers WP_Term_Query::generate_cache_key
*/
public function test_queries_with_different_prune_empty_terms_get_separate_cache_entries() {
register_taxonomy( 'wptests_tax_1', 'post', array( 'hierarchical' => true ) );

$reflection = new ReflectionMethod( new WP_Term_Query(), 'generate_cache_key' );
if ( PHP_VERSION_ID < 80100 ) {
$reflection->setAccessible( true );
}

// Query A: hierarchical=true + hide_empty=true -> PHP pruning IS active.
$query_a = new WP_Term_Query();
$query_a->query(
array(
'taxonomy' => 'wptests_tax_1',
'hierarchical' => true,
'hide_empty' => true,
'fields' => 'ids',
)
);

$key_a = $reflection->invoke( $query_a, $query_a->query_vars, $query_a->request );

// Query B: hide_empty=false -> PHP pruning is NOT active (hierarchical irrelevant).
$query_b = new WP_Term_Query();
$query_b->query(
array(
'taxonomy' => 'wptests_tax_1',
'hierarchical' => false,
'hide_empty' => false,
'fields' => 'ids',
)
);

$key_b = $reflection->invoke( $query_b, $query_b->query_vars, $query_b->request );

$this->assertNotSame(
$key_a,
$key_b,
'Queries with different PHP empty-term pruning behaviour must have distinct cache keys.'
);
}

/**
* Two queries with the same SQL but different child_of values must NOT share
* a cache entry; child_of filtering is applied in PHP, not SQL.
*
* @ticket 64038
* @group cache
*
* @covers WP_Term_Query::generate_cache_key
*/
public function test_queries_with_different_child_of_get_separate_cache_entries() {
register_taxonomy( 'wptests_tax_1', 'post', array( 'hierarchical' => true ) );

$parent = self::factory()->term->create( array( 'taxonomy' => 'wptests_tax_1' ) );
self::factory()->term->create(
array(
'taxonomy' => 'wptests_tax_1',
'parent' => $parent,
)
);

$reflection = new ReflectionMethod( new WP_Term_Query(), 'generate_cache_key' );
if ( PHP_VERSION_ID < 80100 ) {
$reflection->setAccessible( true );
}

// Query A: no child_of filter.
$query_a = new WP_Term_Query();
$query_a->query(
array(
'taxonomy' => 'wptests_tax_1',
'hide_empty' => false,
'fields' => 'ids',
)
);

$key_a = $reflection->invoke( $query_a, $query_a->query_vars, $query_a->request );

// Query B: child_of filtering.
$query_b = new WP_Term_Query();
$query_b->query(
array(
'taxonomy' => 'wptests_tax_1',
'hide_empty' => false,
'child_of' => $parent,
'fields' => 'ids',
)
);

$key_b = $reflection->invoke( $query_b, $query_b->query_vars, $query_b->request );

$this->assertNotSame(
$key_a,
$key_b,
'Queries with different child_of values must have distinct cache keys.'
);
}

/**
* Two queries with the same SQL but different pad_counts values must NOT share
* a cache entry; pad_counts is applied in PHP and changes the cache data shape.
*
* @ticket 64038
* @group cache
*
* @covers WP_Term_Query::generate_cache_key
*/
public function test_queries_with_different_pad_counts_get_separate_cache_entries() {
register_taxonomy( 'wptests_tax_1', 'post', array( 'hierarchical' => true ) );

$reflection = new ReflectionMethod( new WP_Term_Query(), 'generate_cache_key' );
if ( PHP_VERSION_ID < 80100 ) {
$reflection->setAccessible( true );
}

$query_a = new WP_Term_Query();
$query_a->query(
array(
'taxonomy' => 'wptests_tax_1',
'hide_empty' => false,
'hierarchical' => false,
'pad_counts' => false,
'fields' => 'all',
)
);

$key_a = $reflection->invoke( $query_a, $query_a->query_vars, $query_a->request );

$query_b = new WP_Term_Query();
$query_b->query(
array(
'taxonomy' => 'wptests_tax_1',
'hide_empty' => false,
'hierarchical' => false,
'pad_counts' => true,
'fields' => 'all',
)
);

$key_b = $reflection->invoke( $query_b, $query_b->query_vars, $query_b->request );

$this->assertNotSame(
$key_a,
$key_b,
'Queries with different pad_counts values must have distinct cache keys.'
);
}
}
Loading