set( 'wpdo_post_clauses', $routed ) * 2. posts_join — LEFT JOIN wp_wpdo_post_ per routed post_type * 3. posts_where — append WHERE conditions targeting flat columns * 4. posts_groupby — ensure GROUP BY posts.ID to dedup join multiplication * * 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, orders by the copied * post_date and skips GROUP BY (one row per post), so MySQL can walk * `idx_post_status_date` and stop at the LIMIT. * * 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 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'; /** * Per-request answer to "are this group's copies usable?". * * @var array */ private static array $copies_usable = array(); /** * Register all WP_Query hooks. * * @return void */ public function register_hooks(): void { add_action( 'pre_get_posts', array( $this, 'pre_get_posts' ), 10, 1 ); add_filter( 'posts_join', array( $this, 'posts_join' ), 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_request', array( $this, 'posts_request' ), 10, 2 ); add_filter( 'found_posts_query', array( $this, 'found_posts_query' ), 10, 2 ); } /** * 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; } $post_types = (array) $query->get( 'post_type' ); if ( empty( $post_types ) ) { return; } $raw_meta_query = (array) $query->get( 'meta_query' ); if ( empty( $raw_meta_query ) ) { return; } $routed = array(); // Keyed by post_type. $remaining = array(); foreach ( $raw_meta_query as $k => $clause ) { if ( 'relation' === $k || ! is_array( $clause ) || ! isset( $clause['key'] ) ) { $remaining[ $k ] = $clause; continue; } $matched = false; foreach ( $post_types as $pt ) { $field = TMDO_Entity_Registry::get_field( 'post', $clause['key'], (string) $pt ); if ( ! $field ) { continue; } // Confirm the field's group is appropriate for this post_type: // either cross-cutting wp_core (any) or the post_type-specific group. $group = $field['group'] ?? ''; $is_appropriate = ( 'wp_core' === $group ) || in_array( $group, TMDO_Post_Fields::groups_for_post_type( (string) $pt ), true ); if ( ! $is_appropriate ) { continue; } $column = TMDO_Schema_Manager::sanitize_column_name( $clause['key'] ); $routed[ $pt ][] = array( 'group' => $group, 'column' => $column, 'value' => $clause['value'] ?? '', 'compare' => strtoupper( trim( (string) ( $clause['compare'] ?? '=' ) ) ), 'type' => strtoupper( trim( (string) ( $clause['type'] ?? 'CHAR' ) ) ), ); $matched = true; break; } if ( ! $matched ) { $remaining[ $k ] = $clause; } } if ( empty( $routed ) ) { return; } // 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 flat tables for each routed post_type. * * @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 ); if ( empty( $routed ) || ! is_array( $routed ) ) { return $join; } global $wpdb; foreach ( $routed as $post_type => $clauses ) { // Each routed clause carries its group; use the first clause's group // (all clauses for a given post_type currently target one group). $group = $clauses[0]['group'] ?? ''; if ( '' === $group ) { continue; } $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; } $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 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 ?? '' ); $copy = $query->get( self::COPY_VAR ); if ( empty( $copy ) ) { return $orderby; } 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. * * @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 ?? '' ); if ( empty( $query->get( self::QUERY_VAR ) ) || false === strpos( $request, 'SQL_CALC_FOUND_ROWS' ) ) { return $request; } $request = preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 ); $matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request ); $query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" ); return $request; } /** * 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 ?? '' ); } $key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' ); $total = wp_cache_get( $key, self::COUNT_CACHE_GROUP ); if ( false === $total ) { global $wpdb; $total = (int) $wpdb->get_var( $count_sql ); wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS ); } 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(); } /** * The status / date copy route for a routed query, or [] when it does not apply. * * @param \WP_Query $query Current WP_Query. * @param array>> $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 ) ) { 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 ]; } /** * 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 { $allowed = array( '=', '!=', '<>', '<', '<=', '>', '>=', 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', 'BETWEEN', 'NOT BETWEEN', 'EXISTS', 'NOT EXISTS' ); $compare = strtoupper( trim( $compare ) ); return in_array( $compare, $allowed, 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; } return ' AND ' . $col_sql . ' ' . $compare . ' ' . $wpdb->prepare( '%s', (string) $value ); } }