set( 'wpdo_post_clauses', $routed ) * 2. posts_join — LEFT JOIN wp_wpdo_post_ 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 (ID tie-breaks read as the flat post_id), so MySQL can walk * `idx_post_status_date_id` 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 a post is saved or deleted, a * flat write goes through TMDO_Hook_Bus (bump_count_version()) or terms' * last_changed moves. Not posts' last_changed: WordPress moves it on any post * meta write, which themes do on page views (Blocksy's dynamic CSS), and a * routed search reads no unmanaged meta. * * 🔒 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'; /** The last_changed group the cached counts follow (bump_count_version()). */ public const COUNT_VERSION_GROUP = 'tmdo_posts'; /** 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 */ 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 ); } // meta_key alone (no meta_value, not a meta_value sort): WP_Query adds a // clause that tests the key exists — HivePress's featured-listing lookup. $exists_key = ! $take_key && is_string( $meta_key ) && '' !== $meta_key && '' === (string) $query->get( 'meta_value' ) && '' === (string) $query->get( 'meta_compare' ) && ! in_array( $sort_name, array( 'meta_value', 'meta_value_num' ), true ) && null !== self::field_for( array( 'key' => $meta_key ), $pt ); if ( $exists_key ) { $route = self::route_clause( array( 'key' => $meta_key, 'compare' => 'EXISTS', ), self::field_for( array( 'key' => $meta_key ), $pt ) ); if ( ! $route ) { return; } $routed[ $pt ][] = $route; } 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 ) : ''; if ( '' !== $name && ! in_array( $name, $named, true ) && self::field_for( $clause[ $name ], $pt ) ) { continue; // Matches every post and orders nothing here (a copy of a search's meta query). } $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 || $exists_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 $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, '' ); // Keyword searches carry wpdb's placeholder escape for "%", which is new // in every request: without it the key would never match again. $stable = method_exists( $wpdb, 'remove_placeholder_escape' ) ? $wpdb->remove_placeholder_escape( $count_sql ) : $count_sql; $key = md5( $stable ) . ':' . wp_cache_get_last_changed( self::COUNT_VERSION_GROUP ) . ':' . wp_cache_get_last_changed( 'terms' ); $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 // ───────────────────────────────────────────────────────────────────────── /** * A post was saved or deleted, or a managed key written: cached routed * counts are stale. Hooked on clean_post_cache whatever the mode, so a * count cached before the router was last active is not reused. * * @return void */ public static function bump_count_version(): void { wp_cache_set_last_changed( self::COUNT_VERSION_GROUP ); } /** * 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>> $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 $clause Meta query clause. * @param string $post_type Post type slug. * @return array|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 $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 $clause Meta query clause. * @param array $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'] ?? '=' ) ) ); // Without 'value', WP_Meta_Query checks only that the key exists (it adds a // value condition only when one is set): HivePress's featured-listing // lookup, run on every search page, is array( 'key' => 'hp_featured' ). if ( ! array_key_exists( 'value', $clause ) && ! in_array( $compare, array( 'EXISTS', 'NOT EXISTS' ), true ) ) { $compare = 'EXISTS'; } $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::copy_ordered( self::flat_first( self::without_found_rows( $request ), $query ), $query ); } /** * The copy route's request without WordPress's GROUP BY (when the flat row * is the only join) and with posts.ID tie-breaks read as the flat post_id, * each in its own direction, once. MariaDB then walks the * (post_status, post_date, post_id) index and stops at the LIMIT instead * of sorting every match (dev36, 235k of 1.3M listings: 1.7 s → 3 ms). * * @param string $request SELECT statement. * @param \WP_Query $query Current WP_Query. * @return string */ private static function copy_ordered( string $request, \WP_Query $query ): string { $copy = $query->get( self::COPY_VAR ); if ( empty( $copy ) ) { return $request; } global $wpdb; $tie = '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_id` '; return (string) preg_replace_callback( '/\bORDER BY\b(?!.*\bORDER BY\b).*$/s', static function ( $m ) use ( $wpdb, $tie ) { $order = (string) preg_replace_callback( '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)(?:\s+(ASC|DESC)\b)?/i', static fn( $id ) => $tie . ( 'DESC' === strtoupper( $id[1] ?? '' ) ? 'DESC' : 'ASC' ), $m[0] ); return (string) preg_replace( '/(,\s*' . preg_quote( $tie, '/' ) . '(ASC|DESC))(\s*,\s*' . preg_quote( $tie, '/' ) . '\2)+/', '$1', $order ); }, self::without_group_by( $request ) ); } /** * WordPress groups by posts.ID for any tax or meta query. With a 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). * * @param string $request SELECT statement. * @return string */ private static function without_group_by( string $request ): string { global $wpdb; if ( 1 !== preg_match_all( '/\bJOIN\b/i', $request ) ) { return $request; } return (string) preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', $request, 1 ); } /** * 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; } $flat_first = self::without_group_by( (string) $flat_first ); // Tie-breaks on posts.ID (ours, and any a plugin appended) read the flat // post_id, once, in the sort's direction: a (column, post_id) index then // returns rows in order with no filesort. Ties are ordered by ID the same // way as the column (natively ID DESC); pagination stays stable. $tie = "`{$alias}`.`post_id` " . ( 'ASC' === strtoupper( (string) $sort['order'] ) ? 'ASC' : 'DESC' ); $flat_first = preg_replace_callback( '/\bORDER BY\b(?!.*\bORDER BY\b).*$/s', static function ( $m ) use ( $wpdb, $tie ) { $order = (string) preg_replace( '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)(\s+(ASC|DESC)\b)?/i', $tie, $m[0] ); return (string) preg_replace( '/(,\s*' . preg_quote( $tie, '/' ) . ')(\s*,\s*' . preg_quote( $tie, '/' ) . ')+/', '$1', $order ); }, (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 $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 $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 ); } }