Files
2meet-data-optimizer/includes/query/class-tmdo-post-query-router.php
T
wpdev e18030b206 fix(2meet-data-optimizer): upgrade existing flat tables from WP-CLI or WP-Cron only
- a changed group definition no longer runs dbDelta on an existing table
  inside a web request (seconds to minutes on a large table, and every
  concurrent request tried it): the group is recorded in tmdo_schema_deferred
  and tmdo_ensure_group_indexes is scheduled, whose request upgrades it
- meanwhile the group stays native: Hook Bus field lookups return no field and
  the router treats it as drifting; afterwards a drift fix
  (tmdo_drift_resync) rebuilds the writes that skipped the flat table
- new tables are still created at once; aeav_only upgrades at once (the flat
  table is the only copy); doctor lists deferred groups
- Verify: SchemaUpgradeDeferralTest, Hook Bus and router tests, integration
  582, unit 701, phpcs, stan; dev36 live cycle in the 2.3 acceptance
- PR: PR-107
2026-09-27 17:07:48 +08:00

936 lines
36 KiB
PHP

<?php
/**
* TMDO_Post_Query_Router — Post entity meta_query → flat table JOIN rewriter (v2.10.1).
*
* Companion to legacy TMDO_Query_Router (which targets zone hot tables).
* Independent class — does NOT modify the user side path or the legacy zone
* router. Both routers can coexist on the same WP_Query without conflict
* because they extract clauses on disjoint criteria:
*
* - TMDO_Query_Router → only zone-registered keys with feature_flag query_active
* - TMDO_Post_Query_Router → only entity-registered keys with mode=reads_from_flat
*
* Mode-gated: only rewrites when post mode is `shadow_read` or `aeav_only`.
* In `disabled` / `dual_write` modes the router is a no-op.
*
* Hook order:
* 1. pre_get_posts — strip registered keys from meta_query, store in
* $query->set( 'wpdo_post_clauses', $routed )
* 2. posts_join — LEFT JOIN wp_wpdo_post_<group> per routed group
* 3. posts_where — append WHERE conditions targeting flat columns
*
* 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 /
* post_date copies are ready (TMDO_Post_Status_Sync) and none is missing,
* a single-post-type search with explicit statuses and date ordering
* INNER JOINs the table, filters the copies and orders by the copied
* post_date, so MySQL can walk `idx_post_status_date` and stop at the
* 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
* match inside the page query); found_posts_query answers from a COUNT of the
* same query, kept in the object cache until posts' last_changed moves (core
* post saves, and flat writes via TMDO_Hook_Bus).
*
* 🔒 v2.9.x → v2.10.x frozen contract: never touches user entity. Routes
* only entity_type='post'.
*
* @package WP_Data_Optimizer
* @since 2.10.1
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Internal query rewriter: table names go through Schema_Manager::sanitize_column_name, column names go through esc_sql, user-controlled values use $wpdb->prepare() in build_condition().
/**
* Rewrites WP_Query meta_query clauses to JOIN against post entity flat tables.
*/
class TMDO_Post_Query_Router {
/** Query var key for capturing routed clauses (consumed by posts_join/where). */
private const QUERY_VAR = 'wpdo_post_clauses';
/** Query var holding the status / date copy route, when one applies. */
private const COPY_VAR = 'wpdo_post_copy_route';
/** Query var holding the flat column a routed meta orderby sorts on. */
private const SORT_VAR = 'wpdo_post_sort';
/** Query var holding the COUNT for found_posts_query. */
private const COUNT_VAR = 'wpdo_post_count_sql';
/** Object cache group for found-rows counts. */
private const COUNT_CACHE_GROUP = 'tmdo_found_rows';
/** Seconds a "every post of the type has a flat row" answer is reused. */
private const COVERAGE_TTL = 900;
/** Comparison operators the router can express on a flat column. */
private const COMPARES = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' );
/**
* Per-request answer to "are this group's copies usable?".
*
* @var array<string, bool>
*/
private static array $copies_usable = array();
/**
* Register all WP_Query hooks.
*
* @return void
*/
public function register_hooks(): void {
// Late, so meta clauses other plugins add at the default priority
// (e.g. HivePress search filters and sorting) are already in the query.
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_where', array( $this, 'posts_where' ), 10, 2 );
add_filter( 'posts_orderby', array( $this, 'posts_orderby' ), 10, 2 );
add_filter( 'posts_request', array( $this, 'posts_request' ), 10, 2 );
add_filter( 'split_the_query', array( $this, 'split_the_query' ), 10, 4 );
add_filter( 'posts_request_ids', array( $this, 'posts_request' ), 10, 2 );
add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 );
add_action( 'wp_insert_post', array( $this, 'add_flat_rows' ), 10, 3 );
}
/**
* A new post of a type with its own groups gets an empty row in each, so
* that "every post of the type has a flat row" keeps holding and sort-only
* searches can drive from the flat table (see flat_first()).
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post.
* @param bool $update Whether an existing post was updated.
* @return void
*/
public function add_flat_rows( $post_id, $post, $update ): void {
if ( $update || ! $post instanceof \WP_Post ) {
return;
}
global $wpdb;
foreach ( TMDO_Post_Fields::groups_for_post_type( $post->post_type ) as $group ) {
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
if ( TMDO_Schema_Manager::table_exists( $table ) ) {
$wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$table}` (`post_id`) VALUES (%d)", (int) $post_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
}
}
}
/**
* Strip Entity-Registry-managed meta_query keys; store routed clauses.
*
* @param \WP_Query $query Current WP_Query.
* @return void
*/
public function pre_get_posts( \WP_Query $query ): void {
// Mode gate: only run when post mode is reads_from_flat.
if ( ! self::is_router_active() ) {
return;
}
// is_admin guard (matches legacy TMDO_Query_Router behavior).
if ( is_admin() && ! wp_doing_ajax() ) {
return;
}
// One post type only: with several, a clause may belong to one type's
// group and the others must still match through wp_postmeta.
$post_types = (array) $query->get( 'post_type' );
if ( 1 !== count( $post_types ) ) {
return;
}
$pt = (string) reset( $post_types );
$raw_meta_query = (array) $query->get( 'meta_query' );
// All or nothing: routed clauses are ANDed into the WHERE, so the
// query is routed only when every registered clause can be, and
// the remaining (unregistered) clauses are ANDed too.
$relation = strtoupper( (string) ( $raw_meta_query['relation'] ?? 'AND' ) );
$orderby = $query->get( 'orderby' );
$named = is_array( $orderby ) ? array_keys( $orderby ) : preg_split( '/[\s,]+/', trim( (string) $orderby ) );
$sort_name = 1 === count( $named ) ? (string) reset( $named ) : '';
$direction = 'ASC' === strtoupper( (string) ( is_array( $orderby ) ? reset( $orderby ) : $query->get( 'order' ) ) ) ? 'ASC' : 'DESC';
$routed = array(); // Keyed by post_type.
$remaining = array();
$sort = array();
// meta_key + orderby meta_value(_num): native INNER JOINs the key
// (EXISTS) and orders by meta_value+0 (NUMERIC) or meta_value.
$meta_key = $query->get( 'meta_key' );
$take_key = is_string( $meta_key ) && in_array( $sort_name, array( 'meta_value', 'meta_value_num' ), true )
&& null !== self::field_for( array( 'key' => $meta_key ), $pt );
if ( $take_key ) {
$field = self::field_for( array( 'key' => $meta_key ), $pt );
$type = 'meta_value_num' === $sort_name ? 'NUMERIC' : (string) $query->get( 'meta_type' );
$route = self::route_clause(
array(
'key' => $meta_key,
'compare' => 'EXISTS',
'type' => $type,
),
$field
);
if ( ! $route || ! self::sortable( $route, $field ) || '' !== $query->get( 'meta_value' ) || '' !== $query->get( 'meta_compare' ) ) {
return;
}
$routed[ $pt ][] = $route;
$sort = self::sort_for( $pt, $route, $direction );
}
foreach ( $raw_meta_query as $k => $clause ) {
if ( 'relation' === $k || ! is_array( $clause ) ) {
$remaining[ $k ] = $clause;
continue;
}
if ( ! isset( $clause['key'] ) ) {
// Sort shape: matches every post, only orders by the key.
$name = 'AND' === $relation ? self::sort_shape( $clause ) : '';
$field = '' !== $name && $name === $sort_name ? self::field_for( $clause[ $name ], $pt ) : null;
if ( $field ) {
$route = self::route_clause( array( 'compare' => 'EXISTS' ) + $clause[ $name ], $field );
if ( ! $route || ! self::sortable( $route, $field ) ) {
return;
}
$sort = self::sort_for( $pt, $route, $direction );
continue;
}
if ( self::has_registered_key( $clause, $pt ) ) {
return; // Nested group.
}
$remaining[ $k ] = $clause;
continue;
}
$field = self::field_for( $clause, $pt );
if ( ! $field ) {
$remaining[ $k ] = $clause;
continue;
}
$route = self::route_clause( $clause, $field );
if ( ! $route || 'AND' !== $relation ) {
return;
}
// Named by orderby: only as the sole ordering, on a column that
// orders like native (otherwise the name would be lost).
if ( is_string( $k ) && in_array( $k, $named, true ) ) {
if ( $k !== $sort_name || ! self::sortable( $route, $field ) ) {
return;
}
$sort = self::sort_for( $pt, $route, $direction );
}
$routed[ $pt ][] = $route;
}
if ( empty( $routed ) && empty( $sort ) ) {
return;
}
if ( $take_key ) {
$query->set( 'meta_key', '' );
}
if ( $sort ) {
$query->set( self::SORT_VAR, $sort );
}
// Preserve relation if surviving clauses still have it.
if ( isset( $raw_meta_query['relation'] ) && ! isset( $remaining['relation'] ) ) {
$remaining['relation'] = $raw_meta_query['relation'];
}
$query->set( 'meta_query', $remaining );
$query->set( self::QUERY_VAR, $routed );
$copy = self::copy_route( $query, $routed );
if ( $copy ) {
$query->set( self::COPY_VAR, $copy );
}
}
/**
* LEFT JOIN the flat table of each routed group.
*
* @param string|null $join Current JOIN SQL.
* @param \WP_Query $query Current WP_Query.
* @return string Modified JOIN SQL.
*/
public function posts_join( ?string $join, \WP_Query $query ): string {
$join = (string) ( $join ?? '' );
$routed = $query->get( self::QUERY_VAR );
$sort = $query->get( self::SORT_VAR );
$pairs = array(); // Pairs of post type and group.
foreach ( is_array( $routed ) ? $routed : array() as $post_type => $clauses ) {
foreach ( array_column( $clauses, 'group' ) as $group ) {
$pairs[] = array( $post_type, $group );
}
}
if ( ! empty( $sort ) ) {
$pairs[] = array( $sort['post_type'], $sort['group'] );
}
global $wpdb;
foreach ( $pairs as list( $post_type, $group ) ) {
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
$alias = self::table_alias( $post_type, $group );
// Don't double-join.
if ( false !== strpos( $join, "`{$alias}`" ) ) {
continue;
}
// The copy route only applies to single-group searches.
$type = self::copy_route_for( $query, $post_type ) ? 'INNER' : 'LEFT';
$join .= " {$type} JOIN `{$table}` AS `{$alias}` "
. "ON (`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
}
return $join;
}
/**
* Append WHERE conditions for routed clauses.
*
* @param string|null $where Current WHERE SQL.
* @param \WP_Query $query Current WP_Query.
* @return string Modified WHERE SQL.
*/
public function posts_where( ?string $where, \WP_Query $query ): string {
$where = (string) ( $where ?? '' );
$routed = $query->get( self::QUERY_VAR );
if ( empty( $routed ) || ! is_array( $routed ) ) {
return $where;
}
global $wpdb;
foreach ( $routed as $post_type => $clauses ) {
foreach ( $clauses as $clause ) {
$alias = self::table_alias( $post_type, $clause['group'] );
$col = '`' . $alias . '`.`' . esc_sql( $clause['column'] ) . '`';
$compare = self::sanitize_compare( $clause['compare'] );
$value = $clause['value'];
$where .= self::build_condition( $col, $compare, $value );
}
}
$copy = $query->get( self::COPY_VAR );
if ( ! empty( $copy ) ) {
$alias = self::table_alias( $copy['post_type'], $copy['group'] );
$where .= self::build_condition( "`{$alias}`.`post_status`", 'IN', $copy['statuses'] );
}
return $where;
}
/**
* Order by the routed meta orderby column (ties by ID), or by the flat
* table's post_date copy when the copy route applies.
*
* @param string|null $orderby Current ORDER BY SQL.
* @param \WP_Query $query Current WP_Query.
* @return string Modified ORDER BY SQL.
*/
public function posts_orderby( ?string $orderby, \WP_Query $query ): string {
$orderby = (string) ( $orderby ?? '' );
$sort = $query->get( self::SORT_VAR );
if ( ! empty( $sort ) ) {
global $wpdb;
return '`' . self::table_alias( $sort['post_type'], $sort['group'] ) . '`.`' . esc_sql( $sort['column'] ) . '` '
. $sort['order'] . ", `{$wpdb->posts}`.`ID` DESC";
}
$copy = $query->get( self::COPY_VAR );
if ( empty( $copy ) ) {
return $orderby;
}
return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order'];
}
/**
* Drop SQL_CALC_FOUND_ROWS from a routed search; keep its COUNT for later.
*
* @param string|null $request Full SELECT.
* @param \WP_Query $query Current WP_Query.
* @return string
*/
public function posts_request( ?string $request, \WP_Query $query ): string {
$request = (string) ( $request ?? '' );
$routed = ! empty( $query->get( self::QUERY_VAR ) ) || ! empty( $query->get( self::SORT_VAR ) );
if ( ! $routed ) {
return $request;
}
$counted = false !== strpos( $request, 'SQL_CALC_FOUND_ROWS' );
$request = self::routed_request( $request, $query );
if ( ! $counted ) {
return $request;
}
global $wpdb;
$matches = (string) preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
$not_in = " AND {$wpdb->posts}.ID NOT IN (";
// post__not_in (HivePress sends a random set per page) would give every
// search its own cache key: count without it (cached) minus the excluded
// posts that match (primary-key lookups, so no forced flat-first order).
if ( 1 === substr_count( $matches, $not_in ) && preg_match( '/' . preg_quote( $not_in, '/' ) . '([\d,]+)\)/', $matches, $m ) ) {
$excluded = str_replace( $m[0], " AND {$wpdb->posts}.ID IN ({$m[1]})", (string) preg_replace( '/^SELECT STRAIGHT_JOIN /', 'SELECT ', $matches ) );
$query->set(
self::COUNT_VAR,
array(
'SELECT COUNT(*) FROM ( ' . str_replace( $m[0], '', $matches ) . ' ) AS tmdo_found',
"SELECT COUNT(*) FROM ( {$excluded} ) AS tmdo_found",
)
);
return $request;
}
$query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" );
return $request;
}
/**
* Keep WordPress's split query (IDs first, then cached post objects) on a
* routed search: posts_request only dropped SQL_CALC_FOUND_ROWS, yet
* WordPress takes any change for a filtered request and selects full rows
* (a temporary table sorting whole posts). The split ID request passes
* through posts_request_ids, which drops SQL_CALC_FOUND_ROWS again.
*
* @param bool $split Core decision.
* @param \WP_Query $query Current WP_Query.
* @param string $old_request Request before posts_request.
* @param array<string, string> $clauses Query clauses (fields, limits …).
* @return bool
*/
public function split_the_query( $split, \WP_Query $query, $old_request = '', $clauses = array() ): bool {
$routed = ! empty( $query->get( self::QUERY_VAR ) ) || ! empty( $query->get( self::SORT_VAR ) );
if ( $split || ! $routed ) {
return (bool) $split;
}
global $wpdb;
// Core's other conditions: full rows, and a LIMIT under 500 or an external object cache.
return "{$wpdb->posts}.*" === ( $clauses['fields'] ?? '' )
&& self::routed_request( (string) $old_request, $query ) === $query->request
&& ( ( '' !== ( $clauses['limits'] ?? '' ) && (int) $query->get( 'posts_per_page' ) < 500 ) || wp_using_ext_object_cache() );
}
/**
* Answer found rows from the cached COUNT of a routed search.
*
* @param string|null $sql Default 'SELECT FOUND_ROWS()'.
* @param \WP_Query $query Current WP_Query.
* @return string
*/
public function found_posts_query( ?string $sql, \WP_Query $query ): string {
$count_sql = $query->get( self::COUNT_VAR );
if ( empty( $count_sql ) ) {
return (string) ( $sql ?? '' );
}
global $wpdb;
list( $count_sql, $excluded_sql ) = array_pad( (array) $count_sql, 2, '' );
$key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' );
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP );
if ( false === $total ) {
$total = (int) $wpdb->get_var( $count_sql );
wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS );
}
if ( '' !== $excluded_sql ) {
$total = max( 0, (int) $total - (int) $wpdb->get_var( $excluded_sql ) );
}
return 'SELECT ' . (int) $total;
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* Whether the router is mode-active (post mode is shadow_read or aeav_only).
*
* @return bool
*/
public static function is_router_active(): bool {
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
return false;
}
return TMDO_Mode_Manager::reads_from_flat( 'post' );
}
/**
* Forget per-request copy checks (tests, or after `post-status-sync`).
*
* @return void
*/
public static function flush_copy_cache(): void {
self::$copies_usable = array();
}
/**
* Every post of the type has a row in the group's flat table. One
* anti-join per group, reused for COVERAGE_TTL and per post-mode change.
* A post inserted by direct SQL (no wp_insert_post) is seen at the next
* check at the latest; the `tmdo_router_flat_first` filter turns the
* flat-first route off.
*
* @param string $post_type Post type.
* @param string $group Entity group name.
* @return bool
*/
private static function covers_all( string $post_type, string $group ): bool {
$key = self::coverage_key( $post_type, $group );
$hit = get_transient( $key );
if ( false === $hit ) {
global $wpdb;
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
$missing = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->posts} p LEFT JOIN `{$table}` f ON f.post_id = p.ID WHERE p.post_type = %s AND f.post_id IS NULL LIMIT 1", $post_type ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$hit = null === $missing ? 'yes' : 'no';
set_transient( $key, $hit, self::COVERAGE_TTL );
}
return 'yes' === $hit;
}
/**
* Forget every cached coverage answer (tests, or after bulk writes that
* bypass wp_insert_post).
*
* @return void
*/
public static function forget_coverage(): void {
foreach ( TMDO_Entity_Registry::get_groups_for_type( 'post' ) as $group ) {
foreach ( TMDO_Entity_Registry::get_group_post_types( 'post', $group ) as $post_type ) {
delete_transient( self::coverage_key( $post_type, $group ) );
}
}
}
/**
* Transient name of a coverage answer; a post-mode change starts a new one.
*
* @param string $post_type Post type.
* @param string $group Entity group name.
* @return string
*/
private static function coverage_key( string $post_type, string $group ): string {
$entered = get_option( TMDO_Mode_Manager::OPT_ENTERED_AT, array() );
$since = is_array( $entered ) ? (int) ( $entered['post'] ?? 0 ) : 0;
return 'tmdo_cov_' . md5( "{$post_type}:{$group}:{$since}" );
}
/**
* The status / date copy route for a routed query, or [] when it does not apply.
*
* @param \WP_Query $query Current WP_Query.
* @param array<string, array<int, array<string, mixed>>> $routed Routed clauses keyed by post type.
* @return array{post_type:string, group:string, statuses:string[], order:string}|array{}
*/
private static function copy_route( \WP_Query $query, array $routed ): array {
if ( 1 !== count( $routed ) ) {
return array();
}
$post_type = (string) array_key_first( $routed );
$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 )
|| in_array( 'NOT EXISTS', array_column( $routed[ $post_type ], 'compare' ), true ) ) {
return array();
}
$statuses = $query->get( 'post_status' );
$statuses = array_values( array_filter( array_map( 'sanitize_key', is_array( $statuses ) ? $statuses : explode( ',', (string) $statuses ) ) ) );
if ( empty( $statuses ) || in_array( 'any', $statuses, true ) || ! self::copies_usable( $groups[0] ) ) {
return array();
}
return array(
'post_type' => $post_type,
'group' => $groups[0],
'statuses' => $statuses,
'order' => 'ASC' === strtoupper( (string) $query->get( 'order' ) ) ? 'ASC' : 'DESC',
);
}
/**
* Whether the copy route applies to this post type's join.
*
* @param \WP_Query $query Current WP_Query.
* @param string $post_type Post type slug.
* @return bool
*/
private static function copy_route_for( \WP_Query $query, string $post_type ): bool {
$copy = $query->get( self::COPY_VAR );
return ! empty( $copy ) && $copy['post_type'] === $post_type;
}
/**
* Copies are marked ready and no row lacks them (bulk writes that bypass
* the Hook Bus leave NULLs until `wp tmdo post-status-sync` runs again).
* One index probe per group per request.
*
* @param string $group Entity group name.
* @return bool
*/
private static function copies_usable( string $group ): bool {
if ( ! isset( self::$copies_usable[ $group ] ) ) {
global $wpdb;
$table = TMDO_Schema_Manager::get_table_name( 'post', $group );
self::$copies_usable[ $group ] = class_exists( 'TMDO_Post_Status_Sync' )
&& 'ready' === TMDO_Post_Status_Sync::state( $group )
&& TMDO_Schema_Manager::table_exists( $table )
&& null === $wpdb->get_var( "SELECT 1 FROM `{$table}` WHERE `post_status` IS NULL LIMIT 1" );
}
return self::$copies_usable[ $group ];
}
/**
* The registered field a clause targets for this post type, or null.
*
* @param array<string, mixed> $clause Meta query clause.
* @param string $post_type Post type slug.
* @return array<string, mixed>|null
*/
private static function field_for( array $clause, string $post_type ): ?array {
if ( ! is_string( $clause['key'] ?? null ) || ! in_array( $clause['compare_key'] ?? '=', array( '=', '' ), true ) ) {
return null;
}
$field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], $post_type );
if ( ! $field ) {
return null;
}
// Either cross-cutting wp_core or one of the post type's own groups.
$group = $field['group'] ?? '';
return ( 'wp_core' === $group || in_array( $group, TMDO_Post_Fields::groups_for_post_type( $post_type ), true ) ) ? $field : null;
}
/**
* Whether a nested meta query group references a registered key.
*
* @param array<int|string, mixed> $group Nested group.
* @param string $post_type Post type slug.
* @return bool
*/
private static function has_registered_key( array $group, string $post_type ): bool {
foreach ( $group as $clause ) {
if ( ! is_array( $clause ) ) {
continue;
}
if ( isset( $clause['key'] ) ? null !== self::field_for( $clause, $post_type ) : self::has_registered_key( $clause, $post_type ) ) {
return true;
}
}
return false;
}
/**
* The last `wp tmdo drift-check` found the group out of step with native
* meta. A group never checked keeps routing, and so does every group in
* aeav_only, where native is stale by design and no fallback.
*
* @param string $group Entity group name.
* @return bool
*/
private static function drifting( string $group ): bool {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY === TMDO_Mode_Manager::get( 'post' ) ) {
return false;
}
if ( TMDO_Schema_Manager::is_deferred( 'post', $group ) ) {
return true; // Table upgrade waiting for WP-CLI / WP-Cron.
}
$status = get_option( TMDO_Drift_Check::OPTION, array() );
$last = is_array( $status ) ? ( $status['post'][ $group ] ?? null ) : null;
return is_array( $last ) && (int) ( $last['miss'] ?? 0 ) + (int) ( $last['diff'] ?? 0 ) + (int) ( $last['extra'] ?? 0 ) > 0;
}
/**
* The routed form of a clause, or null when the flat column would not
* compare the way WordPress compares the meta_value string, or when the
* group is known to drift from native meta.
*
* Native casts meta_value by the clause type (CHAR when absent); flat
* columns are typed. Routed: EXISTS / NOT EXISTS; string columns with
* CHAR; numeric columns with a numeric type (no LIKE); integer columns
* with CHAR =, !=, IN, NOT IN on plain integers ("10" = "10" either way).
*
* @param array<string, mixed> $clause Meta query clause.
* @param array<string, mixed> $field Registered field.
* @return array{group:string, column:string, value:mixed, compare:string, type:string}|null
*/
private static function route_clause( array $clause, array $field ): ?array {
$compare = strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) );
$type = strtoupper( trim( (string) ( $clause['type'] ?? '' ) ) );
$type = '' === $type ? 'CHAR' : $type;
$value = $clause['value'] ?? '';
$kind = $field['type'] ?? 'text';
$multi = in_array( $compare, array( 'IN', 'NOT IN', 'BETWEEN', 'NOT BETWEEN' ), true );
if ( ! in_array( $compare, self::COMPARES, true ) || ( ! $multi && is_array( $value ) ) ) {
return null;
}
$numeric_type = self::is_numeric_type( $type );
if ( in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
$ok = in_array( $kind, array( 'text', 'textarea', 'enum', 'integer', 'decimal', 'boolean' ), true );
} elseif ( in_array( $kind, array( 'text', 'textarea', 'enum' ), true ) ) {
$ok = 'CHAR' === $type;
} elseif ( in_array( $kind, array( 'integer', 'decimal', 'boolean' ), true ) && $numeric_type ) {
$ok = ! in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true );
} else {
$ok = in_array( $kind, array( 'integer', 'boolean' ), true ) && 'CHAR' === $type
&& in_array( $compare, array( '=', '!=', '<>', 'IN', 'NOT IN' ), true )
&& array() === array_filter( (array) $value, static fn( $v ) => ! is_scalar( $v ) || (string) (int) $v !== (string) $v );
}
if ( ! $ok || self::drifting( (string) $field['group'] ) ) {
return null;
}
return array(
'group' => (string) $field['group'],
'column' => TMDO_Schema_Manager::sanitize_column_name( $clause['key'] ),
'value' => $value,
'compare' => $compare,
'type' => $type,
);
}
/**
* What posts_request makes of a routed request.
*
* @param string $request SELECT statement.
* @param \WP_Query $query Current WP_Query.
* @return string
*/
private static function routed_request( string $request, \WP_Query $query ): string {
return self::flat_first( self::without_found_rows( $request ), $query );
}
/**
* Drive a filtered search ordered by a flat column from its flat table.
*
* MariaDB starts from wp_posts (the type / status index), looks every
* candidate up in the flat table and sorts in a temporary table. From the
* flat table, with the posts.ID tie-break read as the equal flat post_id,
* it sorts one table (dev36, 100k listings: meta_value_num 263 → 147 ms,
* HivePress sort + filter 300 → 36 ms). Only when the routed conditions
* already require a flat row (LEFT JOIN = INNER JOIN), all on the sort
* group, without a selective post filter; `tmdo_router_flat_first` can
* turn it off.
*
* @param string $request SELECT statement.
* @param \WP_Query $query Current WP_Query.
* @return string
*/
private static function flat_first( string $request, \WP_Query $query ): string {
$routed = $query->get( self::QUERY_VAR );
$sort = $query->get( self::SORT_VAR );
if ( empty( $sort ) || ! is_array( $sort ) || ! empty( $query->get( self::COPY_VAR ) ) ) {
return $request;
}
if ( empty( $routed ) || ! is_array( $routed ) ) {
// Sort only: the LEFT JOIN keeps posts without a flat row, so the
// flat table may drive only when every post of the type has one.
$post_type = (string) $sort['post_type'];
$group = (string) $sort['group'];
if ( ! self::covers_all( $post_type, $group ) ) {
return $request;
}
} else {
$post_type = (string) array_key_first( $routed );
$groups = array_unique( array_column( $routed[ $post_type ], 'group' ) );
$compares = array_diff( array_column( $routed[ $post_type ], 'compare' ), array( 'NOT EXISTS' ) );
if ( 1 !== count( $groups ) || empty( $compares ) || $sort['group'] !== $groups[0] ) {
return $request;
}
$group = (string) $groups[0];
}
foreach ( array( 'p', 'page_id', 'name', 'pagename', 'attachment_id', 'post__in', 'post_parent', 'post_parent__in', 'author', 'author__in', 'author_name' ) as $var ) {
if ( ! empty( $query->get( $var ) ) ) {
return $request;
}
}
if ( ! apply_filters( 'tmdo_router_flat_first', true, $query ) ) {
return $request;
}
global $wpdb;
$table = $wpdb->prefix . 'wpdo_post_' . sanitize_key( $group );
$alias = self::table_alias( $post_type, $group );
$on = "(`{$wpdb->posts}`.`ID` = `{$alias}`.`post_id`)";
$join = " LEFT JOIN `{$table}` AS `{$alias}` ON {$on}";
if ( 1 !== substr_count( $request, $join ) ) {
return $request;
}
$flat_first = preg_replace(
'/\bFROM\s+' . preg_quote( $wpdb->posts, '/' ) . '\b/',
"FROM `{$table}` AS `{$alias}` INNER JOIN {$wpdb->posts} ON {$on}",
str_replace( $join, '', $request ),
1,
$replaced
);
if ( 1 !== $replaced ) {
return $request;
}
// WordPress groups by posts.ID for any tax or meta query. With the flat row
// (one per post) as the only join there is nothing to collapse, and the
// GROUP BY makes MySQL sort the whole match in a temporary table
// (WooCommerce's product_visibility NOT IN on searches is a subquery).
if ( 1 === preg_match_all( '/\bJOIN\b/i', (string) $flat_first ) ) {
$flat_first = preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', (string) $flat_first, 1 );
}
// Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat post_id.
$flat_first = preg_replace_callback(
'/\bORDER BY\b(?!.*\bORDER BY\b).*$/s',
static fn( $m ) => preg_replace( '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)/', "`{$alias}`.`post_id`", $m[0] ),
(string) $flat_first
);
return (string) preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', (string) $flat_first, 1 );
}
/**
* A request without its SQL_CALC_FOUND_ROWS.
*
* @param string $request SELECT statement.
* @return string
*/
private static function without_found_rows( string $request ): string {
return (string) preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 );
}
/**
* Whether a meta query type casts to a number (NUMERIC, SIGNED, DECIMAL(p,s) …).
*
* @param string $type Upper-cased clause type.
* @return bool
*/
private static function is_numeric_type( string $type ): bool {
return 1 === preg_match( '/^(NUMERIC|SIGNED|UNSIGNED|DECIMAL)(\(\d+(,\s?\d+)?\))?$/', $type );
}
/**
* Whether ordering by the flat column matches native ordering by
* CAST(meta_value AS type): numbers by a numeric type, strings by CHAR.
*
* @param array{type:string} $route Routed clause.
* @param array<string, mixed> $field Registered field.
* @return bool
*/
private static function sortable( array $route, array $field ): bool {
$kind = $field['type'] ?? 'text';
if ( in_array( $kind, array( 'integer', 'decimal', 'boolean' ), true ) ) {
return self::is_numeric_type( $route['type'] );
}
return in_array( $kind, array( 'text', 'textarea', 'enum' ), true ) && 'CHAR' === $route['type'];
}
/**
* The ordering a routed meta orderby resolves to.
*
* @param string $post_type Post type slug.
* @param array{group:string, column:string} $route Routed clause.
* @param string $direction ASC or DESC.
* @return array{post_type:string, group:string, column:string, order:string}
*/
private static function sort_for( string $post_type, array $route, string $direction ): array {
return array(
'post_type' => $post_type,
'group' => $route['group'],
'column' => $route['column'],
'order' => $direction,
);
}
/**
* The clause name of a "sort by a key, keep posts without it" group —
* relation OR of exactly NOT EXISTS and EXISTS on one key and type, one
* of them named (HivePress search sorting) — or '' for any other group.
*
* @param array<int|string, mixed> $group Nested meta query group.
* @return string
*/
private static function sort_shape( array $group ): string {
if ( 'OR' !== strtoupper( (string) ( $group['relation'] ?? '' ) ) ) {
return '';
}
unset( $group['relation'] );
if ( 2 !== count( $group ) ) {
return '';
}
$name = '';
$compares = array();
foreach ( $group as $k => $clause ) {
if ( ! is_array( $clause ) || ! is_string( $clause['key'] ?? null ) ) {
return '';
}
$compares[] = strtoupper( (string) ( $clause['compare'] ?? '=' ) );
$name = is_string( $k ) ? $k : $name;
}
list( $a, $b ) = array_values( $group );
sort( $compares );
$same = $a['key'] === $b['key'] && strtoupper( (string) ( $a['type'] ?? '' ) ) === strtoupper( (string) ( $b['type'] ?? '' ) );
return array( 'EXISTS', 'NOT EXISTS' ) === $compares && $same ? $name : '';
}
/**
* Generate a deterministic JOIN alias for a (post_type, group) pair.
*
* @param string $post_type Post type slug.
* @param string $group Entity group name.
* @return string SQL alias.
*/
private static function table_alias( string $post_type, string $group ): string {
return 'wpdoflat_' . sanitize_key( $post_type ) . '_' . sanitize_key( $group );
}
/**
* Allow-list of comparison operators.
*
* @param string $compare Raw operator from clause.
* @return string Sanitized operator (defaults to '=').
*/
private static function sanitize_compare( string $compare ): string {
$compare = strtoupper( trim( $compare ) );
return in_array( $compare, self::COMPARES, true ) ? $compare : '=';
}
/**
* Build a single WHERE condition fragment (always prepended with " AND ").
*
* @param string $col_sql Quoted column reference.
* @param string $compare Sanitized comparison operator.
* @param mixed $value Raw value (scalar or array).
* @return string
*/
private static function build_condition( string $col_sql, string $compare, $value ): string {
global $wpdb;
if ( in_array( $compare, array( 'IN', 'NOT IN' ), true ) ) {
$values = (array) $value;
if ( empty( $values ) ) {
return '';
}
$placeholders = implode( ',', array_fill( 0, count( $values ), '%s' ) );
return ' AND ' . $col_sql . ' ' . $compare . ' (' . $wpdb->prepare( $placeholders, ...$values ) . ')';
}
if ( in_array( $compare, array( 'BETWEEN', 'NOT BETWEEN' ), true ) ) {
$values = (array) $value;
if ( count( $values ) < 2 ) {
return '';
}
return ' AND ' . $col_sql . ' ' . $compare . ' '
. $wpdb->prepare( '%s AND %s', $values[0], $values[1] );
}
if ( in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) {
$op = 'EXISTS' === $compare ? 'IS NOT NULL' : 'IS NULL';
return ' AND ' . $col_sql . ' ' . $op;
}
if ( in_array( $compare, array( 'LIKE', 'NOT LIKE' ), true ) ) {
$value = '%' . $wpdb->esc_like( (string) $value ) . '%';
}
return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value );
}
}