diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index 88e3549..09ed6bd 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -3,7 +3,7 @@ * Plugin Name: 2meet Data Optimizer * Plugin URI: https://2meet.io/2meet-data-optimizer * Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。 - * Version: 1.3.0 + * Version: 1.4.0 * Requires at least: 6.0 * Tested up to: 6.9.4 * Requires PHP: 8.1 @@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) { } // ── Constants ────────────────────────────────────────────────────────────── -define( 'TMDO_VERSION', '1.3.0' ); +define( 'TMDO_VERSION', '1.4.0' ); define( 'TMDO_DB_VERSION', '2.1.0' ); define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_URL', plugin_dir_url( __FILE__ ) ); diff --git a/CHANGELOG.md b/CHANGELOG.md index ad89ed2..8e55d0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,48 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## [1.4.0] — 2026-09-28 — 百萬級 listing 的路由排序與日期查詢(hardening 2.4) + +**性質**:效能 + 修正。無 schema 變更;post status/date 副本的索引改為三欄(`wp tmdo post-status-sync` 就地替換)。 +搭配 HivePress AddOn 0.5.0。來源:dev36 第 3 階壓測(130 萬 listing、121 萬 product、6.2 萬 vendor)。 + +### Changed + +- **flat-first 排序讀 flat 的 post_id 作 tie-break**:`posts.ID` 的 tie-break(ours 與外掛追加的) + 改讀相等的 `flat.post_id`,方向與排序一致且只出現一次;group 宣告的索引可以 adapter 的 + entity id 欄結尾,`(欄, post_id)` 索引可直接依序讀取、不需 filesort(主查詢 850 ms → 2 ms)。 + 同值的順序改為依 ID 與排序同方向(原生為 ID DESC);集合、總數不變,翻頁穩定。(PR-118) +- **沒有 value 的 meta 條件以 EXISTS 路由**:clause 沒有 `value` 時(HivePress 的精選 listing + 查詢)視同 EXISTS;只帶 `meta_key`、沒有 `meta_value` 也不以 meta_value 排序的查詢亦同; + 沒有被 orderby 引用的排序形狀 group 直接移除。HivePress 每個搜尋頁都會跑的精選 listing + 查詢(`orderby rand`)改走扁平表:2.26 s → 0.2 s。(PR-122) +- **status/date copy route 在 LIMIT 停下**:只有扁平表一個 JOIN 時去掉 WordPress 的 + `GROUP BY posts.ID`,`posts.ID` tie-break 改讀 `flat.post_id`(各自保留方向、重複合併);副本索引改為 + `idx_post_status_date_id (post_status, post_date, post_id)`,取代兩欄的 `idx_post_status_date`。 + dev36 價格區間(130 萬中 23.5 萬筆符合)主查詢 1.7 s → 3 ms,頁面 2.7 s → 0.34 s。(PR-128) + +### Fixed + +- 遷移引擎(backfill、`drift-check --fix`)以 REPLACE 重建 post 列時保留 `post_status` / + `post_date` 副本;原本重建後為 NULL,一筆 NULL 就讓整個 group 關閉 copy route。(PR-127) +- 遷移引擎重建 post 列後遞增路由計數版本(每次呼叫/每批一次,dry run 不遞增), + 修正後的 found rows 不再沿用舊的快取計數。(PR-129) + +### Dev + +- live 測試工具(`mut.php`、`qbench.php`)不再清空物件快取(Redis Object Cache 以伺服器端腳本 + 掃描,會卡住共用 Redis 5–20 s)。(PR-123) + +### 升級後 + +- 已啟用 post status/date 副本的站(`tmdo_post_status_columns` 有 `ready` 的 group)執行一次 + `wp tmdo post-status-sync`:替換索引並重新同步副本(dev36 130 萬列約 75 s)。未執行前 + copy route 照常運作,只是維持 1.3.0 的速度。 +- 效能數字(dev36 前台,routed):價格區間 0.34 s、價格排序 0.37 s、時薪排序 0.35 s、 + 評分排序 0.51 s、關鍵字 + 價格 0.59 s(原生 6–43 s)。 + +--- + ## [1.3.0] — 2026-09-27 — 大表索引、路由計數與稽核修正(hardening 2.3) **性質**:效能 + 稽核修正。無 schema 變更(宣告的複合索引以 ALTER ADD INDEX 就地加上)。 diff --git a/includes/engine/class-tmdo-entity-migration-engine.php b/includes/engine/class-tmdo-entity-migration-engine.php index 72855f1..cdf6308 100644 --- a/includes/engine/class-tmdo-entity-migration-engine.php +++ b/includes/engine/class-tmdo-entity-migration-engine.php @@ -176,6 +176,9 @@ final class TMDO_Entity_Migration_Engine { // 標記完成 if ( ! $options['dry_run'] ) { self::mark_migration_completed( $entity_type, $group_name ); + if ( $stats['migrated'] > 0 ) { + self::counts_changed( $entity_type ); + } } $stats['elapsed_sec'] = round( microtime( true ) - $start_time, 2 ); @@ -183,6 +186,19 @@ final class TMDO_Entity_Migration_Engine { return $stats; } + /** + * Rows rebuilt outside the Hook Bus (backfill, drift-check --fix): routed + * post counts cached for the old rows are stale (2.4 PR-129). + * + * @param string $entity_type Entity type. + * @return void + */ + private static function counts_changed( string $entity_type ): void { + if ( 'post' === $entity_type && class_exists( 'TMDO_Post_Query_Router' ) ) { + TMDO_Post_Query_Router::bump_count_version(); + } + } + /** * Rebuild one entity's row of a group from native meta. * @@ -194,7 +210,7 @@ final class TMDO_Entity_Migration_Engine { if ( ! $adapter || empty( $group_fields ) ) { return false; } - return self::migrate_single_entity( + $migrated = self::migrate_single_entity( $entity_type, $group_name, $entity_id, @@ -203,6 +219,10 @@ final class TMDO_Entity_Migration_Engine { $adapter, false ); + if ( $migrated ) { + self::counts_changed( $entity_type ); + } + return $migrated; } /** @@ -278,6 +298,15 @@ final class TMDO_Entity_Migration_Engine { } } + // REPLACE drops columns it is not given: keep a post row's status/date + // copies, or one NULL row turns the copy route off for the group (2.4 PR-127). + if ( 'post' === $entity_type && class_exists( 'TMDO_Post_Status_Sync' ) ) { + foreach ( TMDO_Post_Status_Sync::insert_columns( $entity_id, $group_name ) as $col => $value ) { + $data[ $col ] = $value; + $formats[] = '%s'; + } + } + if ( $dry_run ) { return true; } @@ -400,6 +429,9 @@ final class TMDO_Entity_Migration_Engine { $total = $prev_total + $batch_migrated; $done = count( $entity_ids ) < $batch_size; + if ( $batch_migrated > 0 ) { + self::counts_changed( $entity_type ); + } if ( $done ) { self::mark_migration_completed( $entity_type, $group_name ); diff --git a/includes/engine/class-tmdo-entity-registry.php b/includes/engine/class-tmdo-entity-registry.php index 6e4cdfe..cc86021 100644 --- a/includes/engine/class-tmdo-entity-registry.php +++ b/includes/engine/class-tmdo-entity-registry.php @@ -159,9 +159,12 @@ final class TMDO_Entity_Registry { self::$scopes[ $entity_type ][ $group_name ] = $post_types; } $keys = array_column( $normalized, 'key' ); + // A sort index may end with the entity ID (flat-first tie-break, 2.4 PR-118). + $id_col = (string) self::$adapters[ $entity_type ]->get_entity_id_column(); foreach ( (array) ( $args['indexes'] ?? array() ) as $index_name => $columns ) { $columns = array_values( array_map( 'strval', (array) $columns ) ); - if ( $columns && ! array_diff( $columns, $keys ) ) { + $fields = '' !== $id_col && end( $columns ) === $id_col ? array_slice( $columns, 0, -1 ) : $columns; + if ( $fields && ! array_diff( $fields, $keys ) ) { self::$indexes[ $entity_type ][ $group_name ][ (string) $index_name ] = $columns; } } diff --git a/includes/engine/class-tmdo-post-status-sync.php b/includes/engine/class-tmdo-post-status-sync.php index 1e91217..5d3cb5b 100644 --- a/includes/engine/class-tmdo-post-status-sync.php +++ b/includes/engine/class-tmdo-post-status-sync.php @@ -29,7 +29,10 @@ final class TMDO_Post_Status_Sync { public const OPTION = 'tmdo_post_status_columns'; - private const INDEX = 'idx_post_status_date'; + private const INDEX = 'idx_post_status_date_id'; + + /** The 2.2–2.3 (post_status, post_date) index, replaced by INDEX. */ + private const LEGACY_INDEX = 'idx_post_status_date'; /** * Hook the sync into WordPress. @@ -203,7 +206,12 @@ final class TMDO_Post_Status_Sync { $changes[] = 'ADD COLUMN `post_date` DATETIME DEFAULT NULL'; } if ( ! in_array( self::INDEX, $indexes, true ) ) { - $changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`)'; + // Ends with post_id: the router reads the date route's ID tie-break + // from it, so MySQL walks the index and stops at the LIMIT (2.4 PR-128). + $changes[] = 'ADD INDEX `' . self::INDEX . '` (`post_status`, `post_date`, `post_id`)'; + } + if ( in_array( self::LEGACY_INDEX, $indexes, true ) ) { + $changes[] = 'DROP INDEX `' . self::LEGACY_INDEX . '`'; } if ( $changes && false === $wpdb->query( "ALTER TABLE `{$table}` " . implode( ', ', $changes ) ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Fixed DDL on a registry table name. return false; diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index ede1648..db3d49e 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -26,8 +26,9 @@ * 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. + * 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 @@ -191,6 +192,25 @@ class TMDO_Post_Query_Router { $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 ) ) { @@ -199,7 +219,10 @@ class TMDO_Post_Query_Router { } if ( ! isset( $clause['key'] ) ) { // Sort shape: matches every post, only orders by the key. - $name = 'AND' === $relation ? self::sort_shape( $clause ) : ''; + $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 ); @@ -239,7 +262,7 @@ class TMDO_Post_Query_Router { if ( empty( $routed ) && empty( $sort ) ) { return; } - if ( $take_key ) { + if ( $take_key || $exists_key ) { $query->set( 'meta_key', '' ); } if ( $sort ) { @@ -678,11 +701,17 @@ class TMDO_Post_Query_Router { */ 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 ); + // 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; @@ -721,7 +750,56 @@ class TMDO_Post_Query_Router { * @return string */ private static function routed_request( string $request, \WP_Query $query ): string { - return self::flat_first( self::without_found_rows( $request ), $query ); + 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 ); } /** @@ -790,17 +868,18 @@ class TMDO_Post_Query_Router { 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 = 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 fn( $m ) => preg_replace( '/`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?!\w)/', "`{$alias}`.`post_id`", $m[0] ), + 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 ); diff --git a/readme.txt b/readme.txt index 5fd9835..e10051e 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress Requires at least: 6.0 Tested up to: 6.9 Requires PHP: 8.1 -Stable tag: 1.3.0 +Stable tag: 1.4.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -107,6 +107,13 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency, == Changelog == += 1.4.0 = +* Flat-first sorts read the ID tie-break from the flat post_id, so a declared (column, post_id) index returns rows in order; ties now follow the sort direction +* Meta clauses without a value route as EXISTS; HivePress's featured-listing lookup on every search page uses the flat table +* Date-ordered routed searches drop the redundant GROUP BY and use a (post_status, post_date, post_id) index (run `wp tmdo post-status-sync` once to replace the index) +* Rows rebuilt by the migration engine keep their status/date copies and invalidate cached routed counts +* Live test tools no longer flush the object cache + = 1.3.0 = * Declared flat-table indexes are added only from WP-CLI or WP-Cron, one process at a time (MySQL GET_LOCK); web requests schedule `tmdo_ensure_group_indexes`, and doctor lists indexes not added yet * Routed searches with post__not_in count from a cached base count minus the excluded posts; the cache key ignores wpdb's per-request placeholder escape and follows post saves and managed-key writes only, not every post meta write (HivePress keyword + price search no longer slower than native) diff --git a/tests/integration/PostQueryRouterCopyTest.php b/tests/integration/PostQueryRouterCopyTest.php index 2ce525a..629b928 100644 --- a/tests/integration/PostQueryRouterCopyTest.php +++ b/tests/integration/PostQueryRouterCopyTest.php @@ -31,7 +31,7 @@ class PostQueryRouterCopyTest extends TestCase { protected function setUp(): void { global $wpdb; $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); - $wpdb->query( 'CREATE TABLE `' . self::TABLE . "` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date (post_status, post_date) ) ENGINE=InnoDB" ); + $wpdb->query( 'CREATE TABLE `' . self::TABLE . "` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, rc_price DECIMAL(18,6) DEFAULT NULL, post_status VARCHAR(20) DEFAULT NULL, post_date DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id), KEY idx_post_status_date_id (post_status, post_date, post_id) ) ENGINE=InnoDB" ); $wpdb->query( 'INSERT INTO `' . self::TABLE . "` (post_id, rc_price, post_status, post_date) VALUES (1, 10, 'publish', '2026-01-01 00:00:00')" ); $GLOBALS['_wp_options'] = array( TMDO_Post_Status_Sync::OPTION => array( 'rc_core' => 'ready' ) ); @@ -58,6 +58,55 @@ class PostQueryRouterCopyTest extends TestCase { $mode->setValue( null, null ); } + /** + * The full request WordPress would build for the routed search, after posts_request. + * + * @param string $extra_join Another JOIN (a tax query's). + * @param string $ties ID tie-breaks plugins appended. + */ + private function request( string $extra_join = '', string $ties = ', {posts}.ID DESC, {posts}.ID DESC' ): string { + global $wpdb; + $ties = str_replace( '{posts}', $wpdb->posts, $ties ); + $query = new WP_Query(); + foreach ( array( + 'post_type' => 'rc_item', + 'post_status' => 'publish', + 'meta_query' => array( array( 'key' => 'rc_price', 'value' => array( 5, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ), + ) as $k => $v ) { + $query->set( $k, $v ); + } + $router = new TMDO_Post_Query_Router(); + $router->pre_get_posts( $query ); + $request = 'SELECT SQL_CALC_FOUND_ROWS wp_posts.ID FROM wp_posts ' . $router->posts_join( $extra_join, $query ) + . ' WHERE 1=1 ' . $router->posts_where( " AND wp_posts.post_status = 'publish'", $query ) + . " GROUP BY {$wpdb->posts}.ID ORDER BY " . $router->posts_orderby( 'wp_posts.post_date DESC', $query ) . $ties . ' LIMIT 0, 10'; + return $router->posts_request( $request, $query ); + } + + /** + * 2.4 PR-128: with GROUP BY or a posts.ID tie-break MariaDB sorts every + * match (dev36, 235k of 1.3M listings: 1.7 s); without them it walks + * (post_status, post_date, post_id) backwards and stops at the LIMIT (3 ms). + */ + public function test_copy_route_orders_by_the_flat_date_and_post_id_without_group_by(): void { + $sql = $this->request(); + + $this->assertStringNotContainsString( 'GROUP BY', $sql, 'one flat row per post: nothing to collapse' ); + $this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` DESC LIMIT 0, 10', $sql ); + } + + public function test_copy_route_keeps_each_tie_break_direction(): void { + $this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC, ' . self::ALIAS . '.`post_id` ASC LIMIT 0, 10', $this->request( '', ', {posts}.ID ASC' ) ); + $this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC LIMIT 0, 10', $this->request( '', '' ) ); + } + + public function test_copy_route_keeps_group_by_with_another_join(): void { + global $wpdb; + $sql = $this->request( " LEFT JOIN wp_term_relationships ON ({$wpdb->posts}.ID = wp_term_relationships.object_id)" ); + + $this->assertStringContainsString( "GROUP BY {$wpdb->posts}.ID", $sql ); + } + /** @return array{join:string, where:string, orderby:string} */ private function sql( array $vars ): array { $query = new WP_Query(); diff --git a/tests/integration/PostQueryRouterFoundRowsTest.php b/tests/integration/PostQueryRouterFoundRowsTest.php index a806a54..3dd49db 100644 --- a/tests/integration/PostQueryRouterFoundRowsTest.php +++ b/tests/integration/PostQueryRouterFoundRowsTest.php @@ -226,14 +226,14 @@ class PostQueryRouterFoundRowsTest extends TestCase { * * @return array{0:TMDO_Post_Query_Router, 1:WP_Query, 2:string} */ - private function sorted( string $compare = '>=' ): array { + private function sorted( string $compare = '>=', string $order = 'DESC' ): array { global $wpdb; $query = new WP_Query(); $query->set( 'post_type', 'fr_item' ); $query->set( 'post_status', 'publish' ); $query->set( 'meta_query', array( 'price' => array( 'key' => 'fr_price', 'value' => 15, 'compare' => $compare, 'type' => 'NUMERIC' ) ) ); $query->set( 'orderby', 'price' ); - $query->set( 'order', 'DESC' ); + $query->set( 'order', $order ); $router = new TMDO_Post_Query_Router(); $router->pre_get_posts( $query ); @@ -252,11 +252,26 @@ class PostQueryRouterFoundRowsTest extends TestCase { $this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $sql ); $this->assertStringContainsString( 'FROM `' . self::TABLE . '` AS `wpdoflat_fr_item_fr_core` INNER JOIN ' . $wpdb->posts . ' ON', $sql ); $this->assertStringNotContainsString( 'LEFT JOIN `' . self::TABLE . '`', $sql ); - $this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id' ); + $this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` DESC, `wpdoflat_fr_item_fr_core`.`post_id` DESC LIMIT 0, 10', $sql, 'posts.ID tie-breaks read the flat post_id, once' ); $this->assertSame( array( '3', '2' ), $wpdb->get_col( $sql ) ); $this->assertSame( $wpdb->get_col( str_replace( 'SQL_CALC_FOUND_ROWS ', '', $request ) ), $wpdb->get_col( $sql ) ); } + /** + * The ID tie-break follows the sort direction, so a (column, post_id) + * index returns rows in order with no filesort (2.4 PR-118); ties are + * ordered by ID the same way as the column. + */ + public function test_flat_first_tie_break_follows_the_sort_direction(): void { + global $wpdb; + list( $router, $query, $request ) = $this->sorted( '>=', 'ASC' ); + + $sql = $router->posts_request( $request, $query ); + + $this->assertStringEndsWith( 'ORDER BY `wpdoflat_fr_item_fr_core`.`fr_price` ASC, `wpdoflat_fr_item_fr_core`.`post_id` ASC LIMIT 0, 10', $sql ); + $this->assertSame( array( '2', '3' ), $wpdb->get_col( $sql ) ); + } + public function test_date_ordered_filtered_request_keeps_wordpress_join_order(): void { list( $router, $query, $request ) = $this->routed(); diff --git a/tests/integration/PostQueryRouterTest.php b/tests/integration/PostQueryRouterTest.php index 2f82d5b..7286a68 100644 --- a/tests/integration/PostQueryRouterTest.php +++ b/tests/integration/PostQueryRouterTest.php @@ -412,4 +412,71 @@ class PostQueryRouterTest extends TestCase { $this->assertStringContainsString( "LIKE '%B1\\\\_1\\\\%%'", $router->posts_where( '', $query ) ); } + + /** + * A clause with no value tests only that the key exists (WP_Meta_Query adds + * a value condition only when 'value' is set), so it routes as EXISTS. + * HivePress's featured-listing lookup on every search page is one: + * array( 'key' => 'hp_featured' ) — it kept the whole query native (2.4 PR-122). + */ + public function test_a_clause_without_a_value_routes_as_exists(): 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' => '_stock' ), + array( 'key' => '_price', 'value' => array( 10, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ), + ), + ) + ); + $router->pre_get_posts( $query ); + + $this->assertEmpty( $query->get( 'meta_query' ), 'both clauses routed' ); + $this->assertStringContainsString( '`_stock` IS NOT NULL', $router->posts_where( '', $query ) ); + } + + /** + * HivePress's featured-listing lookup (Attribute::set_search_query()): the + * search's meta query, including its sort group, plus meta_key hp_featured, + * ordered at random. The sort group (key NOT EXISTS OR key EXISTS) matches + * every post, so unused for ordering it is dropped; meta_key without a + * value or a meta_value sort tests that the key exists (2.4 PR-122). + */ + public function test_hivepress_featured_lookup_is_routed(): void { + $this->set_post_mode( 'aeav_only' ); + $router = new WPDO_Post_Query_Router(); + $query = $this->make_query( + array( + 'post_type' => 'product', + 'meta_key' => '_stock', + 'orderby' => 'rand', + 'meta_query' => array( + array( + 'relation' => 'OR', + 'price__order' => array( 'key' => '_price', 'compare' => 'NOT EXISTS', 'type' => 'DECIMAL' ), + array( 'key' => '_price', 'compare' => 'EXISTS', 'type' => 'DECIMAL' ), + ), + array( 'key' => '_price', 'value' => array( 10, 20 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ), + ), + ) + ); + $router->pre_get_posts( $query ); + + $this->assertSame( '', $query->get( 'meta_key' ), 'meta_key routed' ); + $this->assertEmpty( array_filter( (array) $query->get( 'meta_query' ), 'is_array' ), 'sort group dropped, filter routed' ); + $where = $router->posts_where( '', $query ); + $this->assertStringContainsString( '`_stock` IS NOT NULL', $where ); + $this->assertStringContainsString( 'BETWEEN', $where ); + } + + public function test_meta_key_with_a_value_is_left_alone(): void { + $this->set_post_mode( 'aeav_only' ); + $router = new WPDO_Post_Query_Router(); + $query = $this->make_query( array( 'post_type' => 'product', 'meta_key' => '_stock', 'meta_value' => '5' ) ); + $router->pre_get_posts( $query ); + + $this->assertSame( '_stock', $query->get( 'meta_key' ) ); + } } diff --git a/tests/integration/PostStatusSyncTest.php b/tests/integration/PostStatusSyncTest.php index 21654bb..3aaf18f 100644 --- a/tests/integration/PostStatusSyncTest.php +++ b/tests/integration/PostStatusSyncTest.php @@ -25,6 +25,9 @@ class PostStatusSyncTest extends TestCase { 'includes/adapters/class-tmdo-adapter-post.php', 'includes/engine/class-tmdo-hook-bus.php', 'includes/engine/class-tmdo-post-status-sync.php', + 'includes/engine/class-tmdo-entity-migration-engine.php', + 'includes/integrations/class-tmdo-post-fields.php', + 'includes/query/class-tmdo-post-query-router.php', ) as $file ) { require_once $base . $file; } @@ -81,9 +84,34 @@ class PostStatusSyncTest extends TestCase { return $out; } - private function has_index( string $table ): bool { + /** @return array index name → columns */ + private function indexes( string $table ): array { global $wpdb; - return in_array( 'idx_post_status_date', array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ), true ); + $out = array(); + foreach ( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ) as $row ) { + $out[ $row['Key_name'] ][ (int) $row['Seq_in_index'] - 1 ] = $row['Column_name']; + } + return $out; + } + + private function has_index( string $table ): bool { + return array( 'post_status', 'post_date', 'post_id' ) === ( $this->indexes( $table )['idx_post_status_date_id'] ?? null ); + } + + /** + * 2.4 PR-128: the date route's ID tie-break is read from post_id, which the + * index must end with; the 2.2 two-column index is replaced, not kept beside it. + */ + public function test_run_replaces_the_two_column_index_of_existing_tables(): void { + global $wpdb; + $wpdb->query( 'ALTER TABLE `' . self::A . '` ADD COLUMN post_status VARCHAR(20) COLLATE utf8mb4_unicode_520_ci DEFAULT NULL, ADD COLUMN post_date DATETIME DEFAULT NULL, ADD INDEX idx_post_status_date (post_status, post_date)' ); + $GLOBALS['_wp_options'][ TMDO_Post_Status_Sync::OPTION ] = array( 'ps_a' => 'ready' ); + + TMDO_Post_Status_Sync::run(); + + $this->assertTrue( $this->has_index( self::A ) ); + $this->assertArrayNotHasKey( 'idx_post_status_date', $this->indexes( self::A ) ); + $this->assertSame( 'ready', TMDO_Post_Status_Sync::state( 'ps_a' ) ); } public function test_run_adds_the_columns_copies_every_row_and_marks_groups_ready(): void { @@ -173,6 +201,64 @@ class PostStatusSyncTest extends TestCase { $this->assertNotContains( 'post_status', $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::A . '`' ) ); } + /** + * The migration engine (backfill, drift-check --fix) REPLACEs whole rows: + * without the copies, a rebuilt row had NULL post_status, which turns the + * status/date copy route off for the whole group (2.4 PR-127). + */ + public function test_a_row_rebuilt_from_native_keeps_its_copies(): void { + global $wpdb; + TMDO_Post_Status_Sync::run(); + $GLOBALS['_wp_posts'][2] = (object) array( 'ID' => 2, 'post_type' => 'hp_listing', 'post_status' => 'draft', 'post_date' => '2026-02-01 10:00:00' ); + $GLOBALS['_wp_posts'][3] = (object) array( 'ID' => 3, 'post_type' => 'hp_listing', 'post_status' => 'pending', 'post_date' => '2026-03-01 10:00:00' ); + $wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" ); + try { + $wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (2, 'k', 'a2-native'), (3, 'k', 'a3-native')" ); + + WPDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 2 ); // Existing row. + WPDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 3 ); // New row. + } finally { + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" ); + } + + $this->assertSame( array( 'draft', '2026-02-01 10:00:00' ), $this->copies( self::A )[2] ); + $this->assertSame( array( 'pending', '2026-03-01 10:00:00' ), $this->copies( self::A )[3] ); + } + + /** + * Backfills and drift-check --fix rebuild rows outside the Hook Bus: the + * routed counts cached for the old rows must go (2.4 PR-129; dev36: a + * vendor search kept counting 21515 of 21522 matches after a fix). + */ + public function test_rebuilding_post_rows_invalidates_cached_routed_counts(): void { + global $wpdb; + $status = $wpdb->prefix . TMDO_TABLE_PREFIX . 'migration_status'; + $wpdb->query( "CREATE TABLE IF NOT EXISTS `{$wpdb->postmeta}` ( meta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, post_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (meta_id) ) ENGINE=InnoDB" ); + $wpdb->query( "CREATE TABLE IF NOT EXISTS `{$status}` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" ); + $version = static fn() => $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] ?? null; + try { + $wpdb->query( "INSERT INTO `{$wpdb->postmeta}` (post_id, meta_key, meta_value) VALUES (2, 'k', 'a2-native')" ); + + $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before'; + TMDO_Entity_Migration_Engine::migrate_group( 'post', 'ps_a', array( 'resume' => false, 'sleep_ms' => 0, 'dry_run' => true ) ); + $this->assertSame( 'before', $version(), 'a dry run writes nothing' ); + + foreach ( array( + 'migrate_entity' => static fn() => TMDO_Entity_Migration_Engine::migrate_entity( 'post', 'ps_a', 2 ), + 'migrate_group' => static fn() => TMDO_Entity_Migration_Engine::migrate_group( 'post', 'ps_a', array( 'resume' => false, 'sleep_ms' => 0 ) ), + 'migrate_group_batch' => static fn() => TMDO_Entity_Migration_Engine::migrate_group_batch( 'post', 'ps_a' ), + ) as $path => $run ) { + TMDO_Entity_Migration_Engine::reset_checkpoint( 'post', 'ps_a' ); + $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before'; + $run(); + $this->assertNotSame( 'before', $version(), $path ); + } + } finally { + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->postmeta}`" ); + $wpdb->query( "DROP TABLE IF EXISTS `{$status}`" ); + } + } + public function test_flat_write_that_creates_a_row_fills_the_copies(): void { TMDO_Post_Status_Sync::run(); $mode = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' ); diff --git a/tests/live/tools/mut.php b/tests/live/tools/mut.php index d55348e..7143f0b 100644 --- a/tests/live/tools/mut.php +++ b/tests/live/tools/mut.php @@ -7,7 +7,8 @@ $mode = TMDO_Mode_Manager::get( 'post' ) . '/' . TMDO_Mode_Manager::get( 'user' function nat( $t, $id, $k ) { global $wpdb; $mt = $t === 'user' ? $wpdb->usermeta : $wpdb->postmeta; $c = $t === 'user' ? 'user_id' : 'post_id'; $v = $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM $mt WHERE $c=%d AND meta_key=%s", $id, $k ) ); return $v ? implode( '|', $v ) : '∅'; } function flat( $t, $g, $id, $k ) { global $wpdb; $c = $t === 'user' ? 'user_id' : 'post_id'; $v = $wpdb->get_row( $wpdb->prepare( "SELECT `$k` v FROM {$wpdb->prefix}wpdo_{$t}_{$g} WHERE $c=%d", $id ) ); return $v ? ( $v->v ?? 'NULL' ) : 'NO-ROW'; } function row( $name, $vals ) { printf( "%-44s %s\n", $name, implode( ' ', array_map( fn( $k, $v ) => "$k=" . ( is_scalar( $v ) || is_null( $v ) ? var_export( $v, true ) : json_encode( $v ) ), array_keys( $vals ), $vals ) ) ); } -function clear_caches( $id ) { wp_cache_flush(); clean_post_cache( $id ); } +// Never wp_cache_flush(): with Redis Object Cache selective flush it is one server-side scan that blocks Redis for every site. +function clear_caches( $id ) { clean_post_cache( $id ); wp_cache_flush_runtime(); } echo "== mode post/user = $mode\n"; // T1: HivePress model update of a listing price. @@ -42,12 +43,12 @@ row( "T5 delete_post_meta hp_featured", array( 'native' => nat( 'post', $lid, 'h // T6: user first_name via wp_update_user. $uid = (int) $wpdb->get_var( "SELECT ID FROM {$wpdb->users} WHERE user_login LIKE 's36v_B1p1_%' ORDER BY ID LIMIT 1" ); -$fn = 'Fn' . mt_rand( 100, 999 ); wp_update_user( array( 'ID' => $uid, 'first_name' => $fn ) ); wp_cache_flush(); +$fn = 'Fn' . mt_rand( 100, 999 ); wp_update_user( array( 'ID' => $uid, 'first_name' => $fn ) ); clean_user_cache( $uid ); wp_cache_flush_runtime(); $u = get_userdata( $uid ); row( "T6 user $uid first_name:=$fn", array( 'native' => nat( 'user', $uid, 'first_name' ), 'flat' => flat( 'user', 'core_profile', $uid, 'first_name' ), 'get_meta' => get_user_meta( $uid, 'first_name', true ), 'WP_User' => $u->first_name, 'wc_customer' => ( new WC_Customer( $uid ) )->get_first_name() ) ); // T7: WC customer billing city via CRUD. -$c = new WC_Customer( $uid ); $city = 'City' . mt_rand( 10, 99 ); $c->set_billing_city( $city ); $c->save(); wp_cache_flush(); +$c = new WC_Customer( $uid ); $city = 'City' . mt_rand( 10, 99 ); $c->set_billing_city( $city ); $c->save(); clean_user_cache( $uid ); wp_cache_flush_runtime(); row( "T7 WC_Customer billing_city:=$city", array( 'native' => nat( 'user', $uid, 'billing_city' ), 'flat' => flat( 'user', 'commerce', $uid, 'billing_city' ), 'get_meta' => get_user_meta( $uid, 'billing_city', true ), 'wc_customer' => ( new WC_Customer( $uid ) )->get_billing_city() ) ); // T8: delete a listing → flat row? @@ -57,5 +58,5 @@ row( "T8 wp_delete_post $d", array( 'posts' => (int) $wpdb->get_var( "SELECT COU // T9: HP review → listing rating recompute. $before = get_post_meta( $lid, 'hp_rating_count', true ); -$rv = ( new Models\Review() )->fill( array( 'text' => 'mut', 'rating' => 5, 'approved' => 1, 'author' => $uid, 'author__display_name' => 'x', 'author__email' => 'x@example.test', 'listing' => $lid ) ); $rv->save(); wp_cache_flush(); +$rv = ( new Models\Review() )->fill( array( 'text' => 'mut', 'rating' => 5, 'approved' => 1, 'author' => $uid, 'author__display_name' => 'x', 'author__email' => 'x@example.test', 'listing' => $lid ) ); $rv->save(); clean_post_cache( $lid ); wp_cache_flush_runtime(); row( "T9 add review (count was $before)", array( 'native' => nat( 'post', $lid, 'hp_rating_count' ), 'flat' => flat( 'post', 'hp_listing_core', $lid, 'hp_rating_count' ), 'hp_model' => Models\Listing::query()->get_by_id( $lid )->get_rating_count() ) ); diff --git a/tests/live/tools/qbench.php b/tests/live/tools/qbench.php index 9ffb3c9..4253830 100644 --- a/tests/live/tools/qbench.php +++ b/tests/live/tools/qbench.php @@ -40,7 +40,12 @@ foreach ( $cases as $name => $qa ) { $err = ''; $sql = ''; for ( $i = 0; $i < $iters; $i++ ) { - wp_cache_flush_group( 'tmdo_found_rows' ); + // Fresh routed counts without wp_cache_flush_group() (a Redis-blocking server-side scan). + if ( method_exists( 'TMDO_Post_Query_Router', 'bump_count_version' ) ) { + TMDO_Post_Query_Router::bump_count_version(); + } else { + wp_cache_set_last_changed( 'posts' ); // Core < 1.3.0 keyed the counts on posts' last_changed. + } $wpdb->last_error = ''; $t = microtime( true ); $q = new WP_Query( $qa + array( 'fields' => 'ids', 'cache_results' => false, 'suppress_filters' => false ) ); diff --git a/tests/unit/EntityRegistryIndexesTest.php b/tests/unit/EntityRegistryIndexesTest.php index cb4a399..02d4103 100644 --- a/tests/unit/EntityRegistryIndexesTest.php +++ b/tests/unit/EntityRegistryIndexesTest.php @@ -14,7 +14,9 @@ class EntityRegistryIndexesTest extends TestCase { protected function setUp(): void { TMDO_Entity_Registry::init(); - TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) ); + $adapter = $this->createMock( TMDO_Entity_Adapter_Interface::class ); + $adapter->method( 'get_entity_id_column' )->willReturn( 'post_id' ); + TMDO_Entity_Registry::register_adapter( 'post', $adapter ); } private function fields(): array { @@ -37,6 +39,16 @@ class EntityRegistryIndexesTest extends TestCase { $this->assertSame( array( 'ok' => array( 'x_price' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ) ); } + /** + * A sort index ends with the entity ID, so a flat-first search ordered by a + * column with the ID tie-break reads the index in order (2.4 PR-118). + */ + public function test_an_index_may_end_with_the_entity_id_column(): void { + TMDO_Entity_Registry::register_group( 'post', 'x_core', $this->fields(), array( 'indexes' => array( 'price_id' => array( 'x_price', 'post_id' ), 'id_first' => array( 'post_id', 'x_price' ) ) ) ); + + $this->assertSame( array( 'price_id' => array( 'x_price', 'post_id' ) ), TMDO_Entity_Registry::get_group_indexes( 'post', 'x_core' ), 'the ID only as the last column' ); + } + public function test_indexes_leave_the_field_definition_and_hash_alone(): void { TMDO_Entity_Registry::register_group( 'post', 'plain', $this->fields() ); TMDO_Entity_Registry::register_group( 'post', 'indexed', $this->fields(), array( 'indexes' => array( 'flag_price' => array( 'x_flag', 'x_price' ) ) ) );