fix(2meet-data-optimizer): join each routed group and drop the redundant GROUP BY

- posts_join joins the flat table of every routed group (a wp_core key next to a post type group no longer fails with "Unknown column")
- Flat tables are one row per post (UNIQUE post_id): remove the posts_groupby hook; skip the INNER JOIN status/date copy route for NOT EXISTS, which must match posts without a flat row
- Verify: tests/live/run.php router (9/9); qbench Q05 475 == native, no SQL error; price filter EXPLAIN has no Using temporary
- PR: PR-061
This commit is contained in:
2026-09-26 19:47:53 +08:00
parent b338925605
commit f2e261c522
5 changed files with 113 additions and 55 deletions
+22 -45
View File
@@ -16,16 +16,18 @@
* Hook order: * Hook order:
* 1. pre_get_posts — strip registered keys from meta_query, store in * 1. pre_get_posts — strip registered keys from meta_query, store in
* $query->set( 'wpdo_post_clauses', $routed ) * $query->set( 'wpdo_post_clauses', $routed )
* 2. posts_join — LEFT JOIN wp_wpdo_post_<group> per routed post_type * 2. posts_join — LEFT JOIN wp_wpdo_post_<group> per routed group
* 3. posts_where — append WHERE conditions targeting flat columns * 3. posts_where — append WHERE conditions targeting flat columns
* 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication *
* Flat tables hold one row per post (UNIQUE post_id), so the joins never
* multiply rows and no GROUP BY is added.
* *
* Status / date on the flat table (O-1): when the group's post_status / * Status / date on the flat table (O-1): when the group's post_status /
* post_date copies are ready (TMDO_Post_Status_Sync) and none is missing, * post_date copies are ready (TMDO_Post_Status_Sync) and none is missing,
* a single-post-type search with explicit statuses and date ordering * a single-post-type search with explicit statuses and date ordering
* INNER JOINs the table, filters the copies, orders by the copied * INNER JOINs the table, filters the copies and orders by the copied
* post_date and skips GROUP BY (one row per post), so MySQL can walk * post_date, so MySQL can walk `idx_post_status_date` and stop at the
* `idx_post_status_date` and stop at the LIMIT. * LIMIT. Not with NOT EXISTS: posts without a flat row must still match.
* *
* Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every * Found rows (O-4): routed searches drop SQL_CALC_FOUND_ROWS (it counts every
* match inside the page query); found_posts_query answers from a COUNT of the * match inside the page query); found_posts_query answers from a COUNT of the
@@ -85,7 +87,6 @@ class TMDO_Post_Query_Router {
add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 1000, 1 ); add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 1000, 1 );
add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 ); add_filter( 'posts_join', array( $this, 'posts_join' ), 10, 2 );
add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 ); add_filter( 'posts_where', array( $this, 'posts_where' ), 10, 2 );
add_filter( 'posts_groupby', array( $this, 'posts_groupby' ), 10, 2 );
add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 ); add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 );
add_filter( 'posts_request', array( $this, 'posts_request' ), 10, 2 ); add_filter( 'posts_request', array( $this, 'posts_request' ), 10, 2 );
add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 ); add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 );
@@ -175,7 +176,7 @@ class TMDO_Post_Query_Router {
} }
/** /**
* LEFT JOIN flat tables for each routed post_type. * LEFT JOIN the flat table of each routed group.
* *
* @param string|null $join Current JOIN SQL. * @param string|null $join Current JOIN SQL.
* @param \WP_Query $query Current WP_Query. * @param \WP_Query $query Current WP_Query.
@@ -191,23 +192,20 @@ class TMDO_Post_Query_Router {
global $wpdb; global $wpdb;
foreach ( $routed as $post_type => $clauses ) { foreach ( $routed as $post_type => $clauses ) {
// Each routed clause carries its group; use the first clause's group foreach ( array_unique( array_column( $clauses, 'group' ) ) as $group ) {
// (all clauses for a given post_type currently target one group). $table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
$group = $clauses[0]['group'] ?? ''; $alias = self::table_alias( $post_type, $group );
if ( '' === $group ) {
continue;
}
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
$alias = self::table_alias( $post_type, $group );
// Don't double-join. // Don't double-join.
if ( false !== strpos( $join, "`{$alias}`" ) ) { if ( false !== strpos( $join, "`{$alias}`" ) ) {
continue; continue;
} }
$type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT'; // The copy route only applies to single-group searches.
$join .= " {$type} JOIN `{$table}` AS `{$alias}` " $type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT';
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)"; $join .= " {$type} JOIN `{$table}` AS `{$alias}` "
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
}
} }
return $join; return $join;
@@ -265,28 +263,6 @@ class TMDO_Post_Query_Router {
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order']; return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order'];
} }
/**
* Ensure GROUP BY posts.ID to deduplicate JOIN-multiplied rows.
*
* @param string|null $groupby Current GROUP BY SQL.
* @param \WP_Query $query Current WP_Query.
* @return string Modified GROUP BY SQL.
*/
public function posts_groupby( ?string $groupby, \WP_Query $query ): string {
$groupby = (string) ( $groupby ?? '' );
$routed = $query->get( self::QUERY_VAR );
if ( empty( $routed ) || ! is_array( $routed ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
return $groupby;
}
global $wpdb;
if ( '' === trim( $groupby ) ) {
$groupby = "`{$wpdb->posts}`.`ID`";
}
return $groupby;
}
/** /**
* Drop SQL_CALC_FOUND_ROWS from a routed search; keep its COUNT for later. * Drop SQL_CALC_FOUND_ROWS from a routed search; keep its COUNT for later.
* *
@@ -365,7 +341,8 @@ class TMDO_Post_Query_Router {
} }
$post_type = (string) array_key_first( $routed ); $post_type = (string) array_key_first( $routed );
$groups = array_values( array_unique( array_column( $routed[ $post_type ], 'group' ) ) ); $groups = array_values( array_unique( array_column( $routed[ $post_type ], 'group' ) ) );
if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true ) ) { if ( 1 !== count( $groups ) || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true )
|| in_array( 'NOT EXISTS', array_column( $routed[ $post_type ], 'compare' ), true ) ) {
return array(); return array();
} }
$statuses = $query->get( 'post_status' ); $statuses = $query->get( 'post_status' );
@@ -58,7 +58,7 @@ class PostQueryRouterCopyTest extends TestCase {
$mode->setValue( null, null ); $mode->setValue( null, null );
} }
/** @return array{join:string, where:string, orderby:string, groupby:string} */ /** @return array{join:string, where:string, orderby:string} */
private function sql( array $vars ): array { private function sql( array $vars ): array {
$query = new WP_Query(); $query = new WP_Query();
foreach ( array_merge( foreach ( array_merge(
@@ -77,7 +77,6 @@ class PostQueryRouterCopyTest extends TestCase {
'join' => $router->posts_join( '', $query ), 'join' => $router->posts_join( '', $query ),
'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ), 'where' => $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ),
'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ), 'orderby' => $router->posts_orderby( 'wp_posts.post_date DESC', $query ),
'groupby' => $router->posts_groupby( '', $query ),
); );
} }
@@ -85,7 +84,6 @@ class PostQueryRouterCopyTest extends TestCase {
$this->assertStringContainsString( 'LEFT JOIN', $sql['join'] ); $this->assertStringContainsString( 'LEFT JOIN', $sql['join'] );
$this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] ); $this->assertStringNotContainsString( self::ALIAS . '.`post_status`', $sql['where'] );
$this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] ); $this->assertSame( 'wp_posts.post_date DESC', $sql['orderby'] );
$this->assertSame( '`wp_itest_posts`.`ID`', $sql['groupby'] );
} }
public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void { public function test_ready_copies_filter_status_and_order_by_date_on_the_flat_table(): void {
@@ -95,7 +93,6 @@ class PostQueryRouterCopyTest extends TestCase {
$this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] ); $this->assertStringContainsString( self::ALIAS . ".`post_status` IN ('publish')", $sql['where'] );
$this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' ); $this->assertStringContainsString( "wp_posts.post_status = 'publish'", $sql['where'], 'WordPress status clause stays' );
$this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] ); $this->assertSame( self::ALIAS . '.`post_date` ASC', $sql['orderby'] );
$this->assertSame( '', $sql['groupby'], 'one row per post: no GROUP BY, so LIMIT can stop early' );
} }
public function test_several_explicit_statuses_are_kept(): void { public function test_several_explicit_statuses_are_kept(): void {
@@ -123,4 +120,9 @@ class PostQueryRouterCopyTest extends TestCase {
$this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) ); $this->assert_legacy_shape( $this->sql( array( 'post_status' => '' ) ) );
$this->assert_legacy_shape( $this->sql( array( 'post_status' => 'any' ) ) ); $this->assert_legacy_shape( $this->sql( array( 'post_status' => 'any' ) ) );
} }
public function test_not_exists_keeps_the_legacy_shape(): void {
// The INNER JOIN would drop posts without a flat row, which NOT EXISTS must match.
$this->assert_legacy_shape( $this->sql( array( 'meta_query' => array( array( 'key' => 'rc_price', 'compare' => 'NOT EXISTS' ) ) ) ) );
}
} }
+28
View File
@@ -214,6 +214,34 @@ class PostQueryRouterTest extends TestCase {
$this->assertStringContainsString( 'wpdo_post_wc_product', $join ); $this->assertStringContainsString( 'wpdo_post_wc_product', $join );
} }
public function test_posts_join_joins_every_routed_group(): void {
$this->set_post_mode( 'aeav_only' );
$router = new WPDO_Post_Query_Router();
$query = $this->make_query( array(
'post_type' => 'product',
'meta_query' => array(
array( 'key' => '_price', 'value' => '50', 'compare' => '>=', 'type' => 'NUMERIC' ),
array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ),
),
) );
$router->pre_get_posts( $query );
$join = $router->posts_join( '', $query );
$where = $router->posts_where( '', $query );
$this->assertStringContainsString( 'wpdo_post_wc_product', $join );
$this->assertStringContainsString( 'wpdo_post_wp_core', $join );
$this->assertStringContainsString( '`wpdoflat_product_wp_core`.`_thumbnail_id` IS NOT NULL', $where );
}
public function test_no_group_by_is_added_for_one_row_per_post_joins(): void {
unset( $GLOBALS['_wp_filter_callbacks']['posts_groupby'] );
( new WPDO_Post_Query_Router() )->register_hooks();
$this->assertEmpty( $GLOBALS['_wp_filter_callbacks']['posts_groupby'] ?? array() );
}
public function test_posts_where_appends_condition_for_routed_clause(): void { public function test_posts_where_appends_condition_for_routed_clause(): void {
$this->set_post_mode( 'aeav_only' ); $this->set_post_mode( 'aeav_only' );
+1 -1
View File
@@ -39,7 +39,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test:
| `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) | | `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) |
| `serialized` | array meta values are stored serialized in flat tables, never "Array" | | `serialized` | array meta values are stored serialized in flat tables, never "Array" |
| `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) | | `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) |
| `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE) | | `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE, keys of two groups, NOT EXISTS with the status/date copy route); routed SQL has no GROUP BY |
| `zero_side_effect` | zone tables that were never created are never queried on save / delete | | `zero_side_effect` | zone tables that were never created are never queried on save / delete |
## Tools (`tools/`) ## Tools (`tools/`)
+56 -5
View File
@@ -6,7 +6,9 @@
* out of any meta_query and ANDed them, so OR relations (Q04, Q16), queries on * out of any meta_query and ANDed them, so OR relations (Q04, Q16), queries on
* several post types (Q08), untyped comparisons on numeric columns (Q14, * several post types (Q08), untyped comparisons on numeric columns (Q14,
* native compares strings) and orderby on a named clause (Q15) returned other * native compares strings) and orderby on a named clause (Q15) returned other
* posts than WordPress would; LIKE compared without wildcards (Q06). * posts than WordPress would; LIKE compared without wildcards (Q06). A key
* of a second group (wp_core _thumbnail_id, Q05) was never joined, and every
* routed query carried a GROUP BY although flat rows are one per post.
* *
* Every case runs the same query twice on fixture posts — router hooks off, * Every case runs the same query twice on fixture posts — router hooks off,
* then on with post mode shadow_read — and compares the ids in order. * then on with post mode shadow_read — and compares the ids in order.
@@ -22,7 +24,7 @@ declare(strict_types=1);
* @param array<string, mixed> $args Query args (meta_query, post_type, orderby …). * @param array<string, mixed> $args Query args (meta_query, post_type, orderby …).
* @param int[] $ids Fixture posts to search within. * @param int[] $ids Fixture posts to search within.
* @param bool $routed Hook a TMDO_Post_Query_Router for this query. * @param bool $routed Hook a TMDO_Post_Query_Router for this query.
* @return array{ids:int[], routed:bool} * @return array{ids:int[], routed:bool, sql:string}
*/ */
function tmdo_live_router_query( array $args, array $ids, bool $routed ): array { function tmdo_live_router_query( array $args, array $ids, bool $routed ): array {
global $wpdb; global $wpdb;
@@ -46,7 +48,7 @@ function tmdo_live_router_query( array $args, array $ids, bool $routed ): array
) )
); );
} finally { } finally {
foreach ( array( 'pre_get_posts', 'posts_join', 'posts_where', 'posts_groupby', 'posts_orderby', 'posts_request', 'found_posts_query' ) as $hook ) { foreach ( array( 'pre_get_posts', 'posts_join', 'posts_where', 'posts_orderby', 'posts_request', 'found_posts_query' ) as $hook ) {
$priority = has_filter( $hook, array( $router, $hook ) ); $priority = has_filter( $hook, array( $router, $hook ) );
if ( false !== $priority ) { if ( false !== $priority ) {
remove_filter( $hook, array( $router, $hook ), $priority ); remove_filter( $hook, array( $router, $hook ), $priority );
@@ -57,6 +59,7 @@ function tmdo_live_router_query( array $args, array $ids, bool $routed ): array
return array( return array(
'ids' => array_map( 'intval', $q->posts ), 'ids' => array_map( 'intval', $q->posts ),
'routed' => ! empty( $q->get( 'wpdo_post_clauses' ) ), 'routed' => ! empty( $q->get( 'wpdo_post_clauses' ) ),
'sql' => (string) $q->request,
); );
} }
@@ -107,14 +110,22 @@ function tmdo_live_router_listings(): array {
* @param array<string, mixed> $args Query args. * @param array<string, mixed> $args Query args.
* @param int[] $ids Fixture ids. * @param int[] $ids Fixture ids.
* @param bool|null $expect_routed true / false to assert, null to ignore. * @param bool|null $expect_routed true / false to assert, null to ignore.
* @param bool $ordered Compare order too (false when the order has ties).
*/ */
function tmdo_live_router_assert_native( array $args, array $ids, ?bool $expect_routed = null ): void { function tmdo_live_router_assert_native( array $args, array $ids, ?bool $expect_routed = null, bool $ordered = true ): void {
$native = tmdo_live_router_query( $args, $ids, false ); $native = tmdo_live_router_query( $args, $ids, false );
$routed = tmdo_live_router_query( $args, $ids, true ); $routed = tmdo_live_router_query( $args, $ids, true );
tmdo_live_assert_same( $native['ids'], $routed['ids'], 'routed ids (in order) vs native' ); if ( ! $ordered ) {
sort( $native['ids'] );
sort( $routed['ids'] );
}
tmdo_live_assert_same( $native['ids'], $routed['ids'], 'routed ids vs native' );
if ( null !== $expect_routed ) { if ( null !== $expect_routed ) {
tmdo_live_assert_same( $expect_routed, $routed['routed'], 'query routed to the flat table' ); tmdo_live_assert_same( $expect_routed, $routed['routed'], 'query routed to the flat table' );
} }
if ( $routed['routed'] ) {
tmdo_live_assert_true( false === stripos( $routed['sql'], 'GROUP BY' ), 'routed query adds no GROUP BY: ' . $routed['sql'] );
}
} }
TMDO_Live::test( TMDO_Live::test(
@@ -246,3 +257,43 @@ TMDO_Live::test(
} }
} }
); );
TMDO_Live::test(
'Q05 keys of two groups are both joined, routed',
static function () {
$l = tmdo_live_router_listings();
TMDO_Live_Fixtures::field( 'post', '_thumbnail_id', 'hp_listing' );
update_post_meta( $l['L1'], '_thumbnail_id', '123' );
update_post_meta( $l['L3'], '_thumbnail_id', '124' );
tmdo_live_router_assert_native(
array(
'post_type' => 'hp_listing',
'meta_query' => array(
array( 'key' => 'hp_hourly_rate', 'value' => 42, 'compare' => '>=', 'type' => 'NUMERIC' ),
array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ),
),
),
array_values( $l ),
true
);
}
);
TMDO_Live::test(
'NOT EXISTS with explicit statuses and date order keeps posts without a flat row',
static function () {
$l = tmdo_live_router_listings();
tmdo_live_router_assert_native(
array(
'post_type' => 'hp_listing',
'post_status' => array( 'draft' ),
'orderby' => 'date',
'order' => 'DESC',
'meta_query' => array( array( 'key' => 'hp_featured', 'compare' => 'NOT EXISTS' ) ),
),
array_values( $l ),
true,
false
);
}
);