Performance Bug: Triple self-join meta_query in global variable rename and class usage scan
Summary
Several Bricks functions use WP_Query with a meta_query using relation => 'OR' and compare => 'EXISTS' across three meta keys. WordPress translates this into a triple INNER JOIN on wp_postmeta, which creates a cartesian product that examines millions of rows even on small sites. This causes multi-minute query times and can monopolize a shared database server’s CPU.
Affected Functions
Helpers::update_global_variable_references()(includes/helpers.php ~line 4318)Helpers::get_global_classes_usage()(includes/helpers.php ~line 3700)
Both contain this query pattern:
$query = new \WP_Query([
'post_type' => $post_types,
'posts_per_page' => -1,
'fields' => 'ids',
'post_status' => 'any',
'meta_query' => [
'relation' => 'OR',
['key' => BRICKS_DB_PAGE_HEADER, 'compare' => 'EXISTS'],
['key' => BRICKS_DB_PAGE_CONTENT, 'compare' => 'EXISTS'],
['key' => BRICKS_DB_PAGE_FOOTER, 'compare' => 'EXISTS'],
],
]);
The Problem
WordPress’s WP_Meta_Query translates each OR clause into a separate INNER JOIN on wp_postmeta, producing this SQL:
SELECT wp_posts.ID
FROM wp_posts
INNER JOIN wp_postmeta ON (wp_posts.ID = wp_postmeta.post_id)
INNER JOIN wp_postmeta AS mt1 ON (wp_posts.ID = mt1.post_id)
INNER JOIN wp_postmeta AS mt2 ON (wp_posts.ID = mt2.post_id)
WHERE 1=1
AND (wp_postmeta.meta_key = '_bricks_page_header_2'
OR mt1.meta_key = '_bricks_page_content_2'
OR mt2.meta_key = '_bricks_page_footer_2')
AND wp_posts.post_type IN ('bricks_template', 'page', 'post')
AND ((wp_posts.post_status <> 'trash' AND wp_posts.post_status <> 'auto-draft'))
GROUP BY wp_posts.ID
ORDER BY wp_posts.post_date DESC
Each INNER JOIN matches every postmeta row for a given post (not just the target meta_key), because the OR condition spans different table aliases and MySQL cannot push the filter down into individual joins. With an average of ~56 meta rows per post, the joins multiply: 56 x 56 x 56 = ~175,000 row combinations per post.
Measured impact on a small site (386 posts, 34K postmeta rows)
| Metric | Value |
|---|---|
| Posts scanned | 313 |
| Rows examined per post | 51 x 51 x 51 = ~132,651 |
| Total rows examined | ~41,500,000 |
| Query execution time | ~4 minutes |
| Requires temporary table | Yes |
| Requires filesort | Yes |
MySQL’s EXPLAIN output:
id table type key rows Extra
1 wp_posts index PRIMARY 313 Using where; Using temporary; Using filesort
1 wp_postmeta ref post_id 51
1 mt1 ref post_id 51
1 mt2 ref post_id 51 Using where
Additionally, update_global_variable_references() is called once per renamed variable from ajax.php (~line 2035), so renaming N variables runs this query N times.
The Fix
The three OR / EXISTS clauses are semantically identical to a single IN() query. Replace:
$query = new \WP_Query([
'post_type' => $post_types,
'posts_per_page' => -1,
'fields' => 'ids',
'post_status' => 'any',
'meta_query' => [
'relation' => 'OR',
['key' => BRICKS_DB_PAGE_HEADER, 'compare' => 'EXISTS'],
['key' => BRICKS_DB_PAGE_CONTENT, 'compare' => 'EXISTS'],
['key' => BRICKS_DB_PAGE_FOOTER, 'compare' => 'EXISTS'],
],
]);
$post_ids = $query->posts;
With a direct $wpdb query:
global $wpdb;
$post_type_placeholders = implode(', ', array_fill(0, count($post_types), '%s'));
$sql = $wpdb->prepare(
"SELECT DISTINCT p.ID
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id
WHERE pm.meta_key IN (%s, %s, %s)
AND p.post_type IN ({$post_type_placeholders})
AND p.post_status NOT IN ('trash', 'auto-draft')
ORDER BY p.post_date DESC",
BRICKS_DB_PAGE_HEADER,
BRICKS_DB_PAGE_CONTENT,
BRICKS_DB_PAGE_FOOTER,
...$post_types
);
$post_ids = $wpdb->get_col($sql);
Performance comparison
| Original (triple JOIN + OR) | Fixed (single JOIN + IN) | |
|---|---|---|
| Rows examined | ~41,500,000 | 29 |
| Execution time | ~4 minutes | ~1 millisecond |
| Temporary table | Yes | No |
MySQL’s EXPLAIN for the fixed query:
id table type key rows Extra
1 pm range meta_key_value 29 Using where; Using index; Using temporary; Using filesort
1 p eq_ref PRIMARY 1 Using where
Additional optimization
In ajax.php (~line 2022), the loop that calls update_global_variable_references() once per renamed variable could be further optimized by querying the post IDs once outside the loop and passing them in, rather than re-querying for every variable rename.
Environment
- Bricks 2.2
- WordPress 6.9.1
- MySQL 8.0
- Site with 386 posts and 34,191 postmeta rows
Workaround
Until this is fixed, the query can be intercepted and rewritten via the posts_clauses filter in a child theme.