diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index 09ed6bd..ec92a55 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.4.0 + * Version: 1.4.1 * 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.4.0' ); +define( 'TMDO_VERSION', '1.4.1' ); 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 80d7f35..cc5f5cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## [1.4.1] — 2026-09-28 — 2.4 驗收修正 + +**性質**:修正。無 schema 變更。來源:2.4 全面驗收的 security review 與 correctness review。 + +### Fixed + +- **meta_key 的排序不再被清掉**:查詢帶 `meta_key`,且 orderby 在多個排序(陣列或 + `'meta_value date'`)中仍有 `meta_value`、`meta_value_num` 或該 key 名稱時,1.4.0 會把 + meta_key 當 EXISTS 路由並清空,WordPress 因此丟掉價格排序。現在只在 orderby 完全沒有引用它時 + 才路由;`meta_compare_key` 不是 `=`(例如 LIKE 會比對到其他 key)時維持原生;排序形狀的 + group 只有在 orderby 沒有引用它、`meta_value(_num)` 或它的 key 時才當成未使用而略過(沒有 + meta_key 時它是 WordPress 的主要 clause)。(PR-130) +- **ORDER BY 只改寫單獨的 `posts.ID`**:flat-first 與 copy route 共用 `ties_on_post_id()`; + 外掛加上的 `FIELD(wp_posts.ID, …)`、`(wp_posts.ID IN (…))` 等運算式保持原樣。1.4.0 會在 + 運算式內插入方向,SQL 語法錯誤、頁面沒有結果。正則失敗(PCRE 回溯或 JIT 上限)時回傳原 + SQL,不再變成空字串。(PR-131) +- 呼叫端以 `new WP_Query( array( … ) )` 預設的 router 內部 query var(`wpdo_post_clauses`、 + `wpdo_post_sort`、`wpdo_post_copy_route`、`wpdo_post_count_sql`)一律清除;copy route 的 + 排序方向改用白名單。無法從網址觸發。(PR-132) +- 新文章建立的空 flat 列一併寫入 `post_status` / `post_date` 副本(`wp_insert_post` 在 + `transition_post_status` 之後才執行,原本留下 NULL,會讓整個 group 關閉 copy route); + `wp tmdo post-status-sync` 完成後遞增路由計數版本(dry run 不遞增)。(PR-133) + +### 已知、未修改 + +- 1.4.0 的 flat-first 排序同值時依 ID 與排序同方向(原生為 ID DESC),集合與總數相同(已接受)。 +- 若日後有 group 註冊名為 `post_status` / `post_date` 的欄位,遷移引擎寫副本時 `$data` 與 + `$formats` 會錯位;目前沒有這樣的 group。 + +--- + ## [1.4.0] — 2026-09-28 — 百萬級 listing 的路由排序與日期查詢(hardening 2.4) **性質**:效能 + 修正。無 schema 變更;post status/date 副本的索引改為三欄(見「升級後」)。 diff --git a/includes/engine/class-tmdo-post-status-sync.php b/includes/engine/class-tmdo-post-status-sync.php index 5d3cb5b..e3fdb90 100644 --- a/includes/engine/class-tmdo-post-status-sync.php +++ b/includes/engine/class-tmdo-post-status-sync.php @@ -163,6 +163,10 @@ final class TMDO_Post_Status_Sync { } self::set_state( $group, 'ready' ); } + // Routed counts cached while the copies were stale must go. + if ( ! $dry_run && class_exists( 'TMDO_Post_Query_Router' ) ) { + TMDO_Post_Query_Router::bump_count_version(); + } return $report; } diff --git a/includes/query/class-tmdo-post-query-router.php b/includes/query/class-tmdo-post-query-router.php index db3d49e..794363a 100644 --- a/includes/query/class-tmdo-post-query-router.php +++ b/includes/query/class-tmdo-post-query-router.php @@ -125,9 +125,17 @@ class TMDO_Post_Query_Router { 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 + if ( ! TMDO_Schema_Manager::table_exists( $table ) ) { + continue; } + // With the status / date copies: this runs after transition_post_status, + // and one NULL copy turns the copy route off for the whole group. + $copies = class_exists( 'TMDO_Post_Status_Sync' ) ? TMDO_Post_Status_Sync::insert_columns( (int) $post_id, $group ) : array(); + if ( $copies ) { + $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO `{$table}` (`post_id`, `post_status`, `post_date`) VALUES (%d, %s, %s)", (int) $post_id, $copies['post_status'], $copies['post_date'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + continue; + } + $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 } } @@ -138,6 +146,11 @@ class TMDO_Post_Query_Router { * @return void */ public function pre_get_posts( \WP_Query $query ): void { + // The router's query vars are its own: drop any a caller passed in. + foreach ( array( self::QUERY_VAR, self::SORT_VAR, self::COPY_VAR, self::COUNT_VAR ) as $var ) { + $query->set( $var, '' ); + } + // Mode gate: only run when post mode is reads_from_flat. if ( ! self::is_router_active() ) { return; @@ -172,8 +185,11 @@ class TMDO_Post_Query_Router { // 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 ) + // A key comparison other than equality (meta_compare_key LIKE …) matches + // other keys too: not a test on one flat column. + $meta_key = $query->get( 'meta_key' ); + $key_exact = in_array( (string) $query->get( 'meta_compare_key' ), array( '', '=' ), true ); + $take_key = $key_exact && 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 ); @@ -192,11 +208,13 @@ 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 + // meta_key alone (no meta_value, not ordered by): WP_Query adds a clause + // that tests the key exists — HivePress's featured-listing lookup. Any + // orderby naming meta_value, meta_value_num or the key sorts by that + // clause, which clearing meta_key would drop. + $exists_key = ! $take_key && $key_exact && 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 ) + && ! array_intersect( $named, array( 'meta_value', 'meta_value_num', $meta_key ) ) && null !== self::field_for( array( 'key' => $meta_key ), $pt ); if ( $exists_key ) { $route = self::route_clause( @@ -220,8 +238,12 @@ 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 ) : ''; - 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). + // Matches every post and orders nothing here (a copy of a search's meta + // query) — unless orderby names it, or meta_value(_num) / its key, which + // sort by it when it is WordPress's primary (first) clause. + if ( '' !== $name && ! array_intersect( $named, array( $name, 'meta_value', 'meta_value_num', (string) ( $clause[ $name ]['key'] ?? '' ) ) ) + && self::field_for( $clause[ $name ], $pt ) ) { + continue; } $field = '' !== $name && $name === $sort_name ? self::field_for( $clause[ $name ], $pt ) : null; if ( $field ) { @@ -380,7 +402,7 @@ class TMDO_Post_Query_Router { if ( empty( $copy ) ) { return $orderby; } - return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . $copy['order']; + return '`' . self::table_alias( $copy['post_type'], $copy['group'] ) . '`.`post_date` ' . ( 'ASC' === $copy['order'] ? 'ASC' : 'DESC' ); } /** @@ -769,22 +791,58 @@ class TMDO_Post_Query_Router { if ( empty( $copy ) ) { return $request; } + return self::ties_on_post_id( self::without_group_by( $request ), self::table_alias( $copy['post_type'], $copy['group'] ), '' ); + } + + /** + * ORDER BY terms that are posts.ID alone (with or without a direction) + * read as the equal flat post_id — in $direction, or each in its own when + * $direction is '' — and only once. posts.ID inside an expression a plugin + * added (FIELD(), IN …) is left alone: a direction inserted there breaks + * the SQL. + * + * @param string $request SELECT statement. + * @param string $alias Flat table alias. + * @param string $direction 'ASC', 'DESC' or '' (keep each term's own). + * @return string + */ + private static function ties_on_post_id( string $request, string $alias, string $direction ): string { 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 ) + $tie = "`{$alias}`.`post_id` "; + $id = '`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?'; + return self::replaced( + preg_replace_callback( + '/\bORDER BY\b(?!.*\bORDER BY\b).*$/s', + static function ( $m ) use ( $id, $tie, $direction ) { + $order = self::replaced( + preg_replace_callback( + '/(?<=ORDER BY |,)(\s*)' . $id . '(?:\s+(ASC|DESC)\b)?(?=\s*(?:,|\bLIMIT\b|$))/i', + static fn( $t ) => $t[1] . $tie . ( '' !== $direction ? $direction : ( 'DESC' === strtoupper( $t[2] ?? '' ) ? 'DESC' : 'ASC' ) ), + $m[0] + ), + $m[0] + ); + return self::replaced( preg_replace( '/(,\s*' . preg_quote( $tie, '/' ) . '(ASC|DESC))(\s*,\s*' . preg_quote( $tie, '/' ) . '\2)+/', '$1', $order ), $order ); + }, + $request + ), + $request ); } + /** + * A preg_* result, or the text it was given when PCRE failed (backtrack + * or JIT stack limit on a very long request): the query stays unrouted + * in shape rather than empty. + * + * @param string|null $result preg_* return value. + * @param string $fallback Input text. + * @return string + */ + private static function replaced( ?string $result, string $fallback ): string { + return $result ?? $fallback; + } + /** * 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 @@ -799,7 +857,7 @@ class TMDO_Post_Query_Router { 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 ); + return self::replaced( preg_replace( '/\s+GROUP BY\s+`?' . preg_quote( $wpdb->posts, '/' ) . '`?\.`?ID`?(?=\s)/', ' ', $request, 1 ), $request ); } /** @@ -873,16 +931,9 @@ class TMDO_Post_Query_Router { // 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 ); + $direction = 'ASC' === strtoupper( (string) $sort['order'] ) ? 'ASC' : 'DESC'; + $flat_first = self::ties_on_post_id( $flat_first, $alias, $direction ); + return self::replaced( preg_replace( '/^\s*SELECT\s/', 'SELECT STRAIGHT_JOIN ', $flat_first, 1 ), $request ); } /** @@ -892,7 +943,7 @@ class TMDO_Post_Query_Router { * @return string */ private static function without_found_rows( string $request ): string { - return (string) preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 ); + return self::replaced( preg_replace( '/\bSQL_CALC_FOUND_ROWS\s+/', '', $request, 1 ), $request ); } /** diff --git a/readme.txt b/readme.txt index 2b6ea07..f8df125 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.4.0 +Stable tag: 1.4.1 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -107,6 +107,12 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency, == Changelog == += 1.4.1 = +* A query ordered by its meta_key among several orderings (or by the key's name) keeps meta_key native; meta_compare_key other than '=' is not routed; a sort group stays when orderby may use it as the primary clause +* Only standalone posts.ID terms in ORDER BY become flat post_id tie-breaks (FIELD()/IN expressions from other plugins are left alone); PCRE failures keep the original SQL +* Router query vars preset by callers are dropped +* New posts' flat rows get their status/date copies; post-status-sync invalidates cached routed counts + = 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 diff --git a/tests/integration/PostQueryRouterCopyTest.php b/tests/integration/PostQueryRouterCopyTest.php index 629b928..84f2340 100644 --- a/tests/integration/PostQueryRouterCopyTest.php +++ b/tests/integration/PostQueryRouterCopyTest.php @@ -100,6 +100,55 @@ class PostQueryRouterCopyTest extends TestCase { $this->assertStringEndsWith( 'ORDER BY ' . self::ALIAS . '.`post_date` DESC LIMIT 0, 10', $this->request( '', '' ) ); } + public function test_copy_route_leaves_posts_id_inside_expressions_alone(): void { + global $wpdb; + $sql = $this->request( '', ', FIELD({posts}.ID,5,9) DESC, ({posts}.ID IN (3,4)) DESC, {posts}.ID DESC' ); + + $this->assertStringEndsWith( "`post_date` DESC, FIELD({$wpdb->posts}.ID,5,9) DESC, ({$wpdb->posts}.ID IN (3,4)) DESC, " . self::ALIAS . '.`post_id` DESC LIMIT 0, 10', $sql ); + } + + /** + * The router's query vars are its own: values a caller passed in + * (new WP_Query( array( 'wpdo_post_copy_route' => … ) )) are dropped + * (2.4 security review L2). + */ + public function test_query_vars_passed_by_a_caller_are_dropped(): void { + $query = new WP_Query(); + foreach ( array( + 'post_type' => 'rc_item', + 'orderby' => 'title', + 'wpdo_post_copy_route' => array( 'post_type' => 'rc_item', 'group' => 'rc_core', 'statuses' => array( 'publish' ), 'order' => 'DESC, (SELECT 1)' ), + 'wpdo_post_sort' => array( 'post_type' => 'rc_item', 'group' => 'rc_core', 'column' => 'rc_price', 'order' => 'ASC' ), + 'wpdo_post_clauses' => array( 'rc_item' => array() ), + 'wpdo_post_count_sql' => 'SELECT 1', + ) as $k => $v ) { + $query->set( $k, $v ); + } + $router = new TMDO_Post_Query_Router(); + $router->pre_get_posts( $query ); + + foreach ( array( 'wpdo_post_copy_route', 'wpdo_post_sort', 'wpdo_post_clauses', 'wpdo_post_count_sql' ) as $var ) { + $this->assertEmpty( $query->get( $var ), $var ); + } + $this->assertSame( 'wp_posts.post_title ASC', $router->posts_orderby( 'wp_posts.post_title ASC', $query ) ); + } + + /** + * wp_insert_post runs after transition_post_status: a new post's empty flat + * row must carry the copies itself, or one NULL turns the copy route off for + * the whole group (2.4 correctness review). + */ + public function test_a_new_post_gets_its_flat_row_with_the_copies(): void { + global $wpdb; + $post = new WP_Post( (object) array( 'ID' => 7, 'post_type' => 'rc_item', 'post_status' => 'draft', 'post_date' => '2026-02-02 00:00:00' ) ); + $GLOBALS['_wp_posts'][7] = $post; + + ( new TMDO_Post_Query_Router() )->add_flat_rows( 7, $post, false ); + + $this->assertSame( array( 'post_status' => 'draft', 'post_date' => '2026-02-02 00:00:00' ), $wpdb->get_row( 'SELECT post_status, post_date FROM `' . self::TABLE . '` WHERE post_id = 7', ARRAY_A ) ); + unset( $GLOBALS['_wp_posts'][7] ); + } + 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)" ); diff --git a/tests/integration/PostQueryRouterFoundRowsTest.php b/tests/integration/PostQueryRouterFoundRowsTest.php index 3dd49db..661f12e 100644 --- a/tests/integration/PostQueryRouterFoundRowsTest.php +++ b/tests/integration/PostQueryRouterFoundRowsTest.php @@ -272,6 +272,25 @@ class PostQueryRouterFoundRowsTest extends TestCase { $this->assertSame( array( '2', '3' ), $wpdb->get_col( $sql ) ); } + /** + * Only standalone posts.ID terms are tie-breaks: inside an expression a + * plugin added (pinning posts with FIELD() or IN) the direction must not be + * inserted, or the SQL breaks (2.4 correctness review). + */ + public function test_flat_first_leaves_posts_id_inside_expressions_alone(): void { + global $wpdb; + list( $router, $query, $request ) = $this->sorted(); + $pinned = str_replace( ' LIMIT 0, 10', ", FIELD({$wpdb->posts}.ID,3,2) DESC, ({$wpdb->posts}.ID IN (2)) DESC LIMIT 0, 10", $request ); + + $sql = $router->posts_request( $pinned, $query ); + + $this->assertStringStartsWith( 'SELECT STRAIGHT_JOIN ', $sql ); + $this->assertStringContainsString( "FIELD({$wpdb->posts}.ID,3,2) DESC, ({$wpdb->posts}.ID IN (2)) DESC LIMIT", $sql ); + $wpdb->last_error = ''; + $this->assertSame( $wpdb->get_col( str_replace( 'SQL_CALC_FOUND_ROWS ', '', $pinned ) ), $wpdb->get_col( $sql ) ); + $this->assertSame( '', $wpdb->last_error ); + } + 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 7286a68..91a5829 100644 --- a/tests/integration/PostQueryRouterTest.php +++ b/tests/integration/PostQueryRouterTest.php @@ -471,6 +471,61 @@ class PostQueryRouterTest extends TestCase { $this->assertStringContainsString( 'BETWEEN', $where ); } + /** + * WordPress orders by the primary meta clause (meta_key) for meta_value, + * meta_value_num or the key's name anywhere in orderby; routing the key and + * clearing meta_key would drop that ordering (2.4 correctness review). + */ + public function test_meta_key_ordered_by_among_several_orderings_stays_native(): void { + foreach ( array( + array( 'meta_value_num' => 'ASC', 'title' => 'ASC' ), + 'meta_value date', + '_stock', + ) as $orderby ) { + $this->set_post_mode( 'aeav_only' ); + $query = $this->make_query( array( 'post_type' => 'product', 'meta_key' => '_stock', 'orderby' => $orderby ) ); + ( new WPDO_Post_Query_Router() )->pre_get_posts( $query ); + + $label = is_array( $orderby ) ? implode( ',', array_keys( $orderby ) ) : $orderby; + $this->assertSame( '_stock', $query->get( 'meta_key' ), $label ); + $this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $label ); + } + } + + /** meta_compare_key LIKE matches other keys too: not a test on one flat column. */ + public function test_meta_key_with_a_key_comparison_stays_native(): void { + foreach ( array( 'rand', 'meta_value_num' ) as $orderby ) { + $this->set_post_mode( 'aeav_only' ); + $query = $this->make_query( array( 'post_type' => 'product', 'meta_key' => '_stock', 'meta_compare_key' => 'LIKE', 'orderby' => $orderby ) ); + ( new WPDO_Post_Query_Router() )->pre_get_posts( $query ); + + $this->assertSame( '_stock', $query->get( 'meta_key' ), $orderby ); + $this->assertSame( '', $query->get( 'wpdo_post_clauses' ), $orderby ); + } + } + + /** + * Without meta_key the first clause is WordPress's primary one: orderby + * meta_value_num sorts by the sort group's key, so the group stays. + */ + public function test_sort_group_ordered_as_the_primary_clause_is_not_dropped(): void { + $this->assert_not_routed( + array( + 'post_type' => 'product', + 'orderby' => 'meta_value_num', + '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' => '_stock', 'value' => 1, 'compare' => '>=', 'type' => 'NUMERIC' ), + ), + ), + 'primary sort group' + ); + } + public function test_meta_key_with_a_value_is_left_alone(): void { $this->set_post_mode( 'aeav_only' ); $router = new WPDO_Post_Query_Router(); diff --git a/tests/integration/PostStatusSyncTest.php b/tests/integration/PostStatusSyncTest.php index 3aaf18f..02d65e8 100644 --- a/tests/integration/PostStatusSyncTest.php +++ b/tests/integration/PostStatusSyncTest.php @@ -132,6 +132,16 @@ class PostStatusSyncTest extends TestCase { $this->assertSame( 2, $report['ps_a']['mismatched'] ); } + /** Routed counts cached while copies were stale are dropped by a sync (2.4 correctness review). */ + public function test_a_sync_invalidates_cached_routed_counts_but_a_dry_run_does_not(): void { + $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] = 'before'; + TMDO_Post_Status_Sync::run( true ); + $this->assertSame( 'before', $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] ); + + TMDO_Post_Status_Sync::run(); + $this->assertNotSame( 'before', $GLOBALS['_wp_last_changed'][ TMDO_Post_Query_Router::COUNT_VERSION_GROUP ] ); + } + public function test_copies_use_the_posts_collation_so_they_compare_with_posts(): void { global $wpdb; TMDO_Post_Status_Sync::run(); diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index eddae3c..76fcb96 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -354,6 +354,19 @@ if ( ! function_exists( 'get_term_meta' ) ) { function get_term_meta( int $tid, if ( ! function_exists( 'update_term_meta' ) ) { function update_term_meta( int $tid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_termmeta'][ $tid ][ $key ] = $value; return true; } } if ( ! function_exists( 'get_comment_meta' ) ) { function get_comment_meta( int $cid, string $key = '', bool $single = false ) { return $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] ?? ( $single ? '' : [] ); } } if ( ! function_exists( 'update_comment_meta' ) ) { function update_comment_meta( int $cid, string $key, $value, $prev = '' ): bool { $GLOBALS['_wp_commentmeta'][ $cid ][ $key ] = $value; return true; } } +if ( ! class_exists( 'WP_Post' ) ) { + final class WP_Post { + public $ID = 0; + public $post_type = 'post'; + public $post_status = 'publish'; + public $post_date = '0000-00-00 00:00:00'; + public function __construct( $post ) { + foreach ( get_object_vars( $post ) as $k => $v ) { + $this->$k = $v; + } + } + } +} if ( ! function_exists( 'get_post' ) ) { function get_post( $post_id ) { return $GLOBALS['_wp_posts'][ (int) $post_id ] ?? null; } } if ( ! function_exists( 'get_post_type' ) ) { function get_post_type( $post_id ) { return $GLOBALS['_wp_post_types'][ (int) $post_id ] ?? false; } } if ( ! function_exists( 'get_post_status' ) ) { function get_post_status( $post_id ) { return $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish'; } }