Compare commits

6 Commits

Author SHA1 Message Date
wpdev 81324903a6 chore(2meet-data-optimizer): 1.4.1 release notes and version
Anti-EAV Lint + Quality Gate / anti-eav-lint (pull_request) Successful in 47s
Tests / PHP Lint (pull_request) Successful in 53s
Tests / Unit Tests (pull_request) Successful in 51s
Tests / Integration Tests (pull_request) Successful in 1m54s
Tests / PHPCS (pull_request) Successful in 36s
Tests / PHPStan (pull_request) Successful in 1m12s
- CHANGELOG / readme: PR-130..133 (2.4 acceptance review fixes)
- Version 1.4.0 -> 1.4.1 (header, TMDO_VERSION, Stable tag)
- PR: PR-134
2026-09-28 00:46:56 +08:00
wpdev 3e96357459 fix(2meet-data-optimizer): give new posts' flat rows their status/date copies; sync drops cached counts
- add_flat_rows (wp_insert_post, after transition_post_status) inserts the
  copies with the empty row: a NULL copy turned the copy route off for the
  whole group until the next sync
- wp tmdo post-status-sync bumps the routed count version (not on dry runs)
- Test bootstrap: minimal WP_Post stub
- Found in the 2.4 correctness review (residual risks)
- Verify: 2 new integration tests; integration 606, unit 704, phpcs 0, stan OK
- PR: PR-133
2026-09-28 00:32:59 +08:00
wpdev 80343ec282 fix(2meet-data-optimizer): drop router query vars a caller passed in
- pre_get_posts clears wpdo_post_clauses / _sort / _copy_route / _count_sql
  before anything else: new WP_Query( array( … ) ) could preset them
- posts_orderby whitelists the copy route's direction
- Found in the 2.4 security review (Low L2; not reachable from URLs)
- Verify: PostQueryRouterCopyTest (1 new); integration 604, unit 704, phpcs 0
- PR: PR-132
2026-09-28 00:31:10 +08:00
wpdev 33addd1e49 fix(2meet-data-optimizer): rewrite only standalone posts.ID tie-breaks in ORDER BY
- flat_first and the copy route share ties_on_post_id(): a posts.ID term
  alone (optionally with a direction) reads the flat post_id; posts.ID inside
  an expression a plugin added (FIELD(), IN) is left alone — inserting a
  direction there made the SQL invalid and the page empty
- PCRE failures return the text given instead of '' (the query kept its
  shape rather than becoming an empty request)
- Found in the 2.4 correctness review (Medium) and security review (Low L1)
- Verify: 2 new integration tests (the flat-first one runs the SQL), router suites 69 OK
- PR: PR-131
2026-09-28 00:29:53 +08:00
wpdev bed3f906da fix(2meet-data-optimizer): keep meta_key native when the query still orders by it
- meta_key is routed as EXISTS only when no orderby entry names meta_value,
  meta_value_num or the key (several orderings or an array included);
  clearing it made WordPress drop the price ordering
- A sort group is dropped as unused only when orderby names neither it,
  meta_value(_num) nor its key (it is then the primary clause)
- meta_compare_key other than '=' keeps meta_key native (LIKE matches other keys)
- Found in the 2.4 correctness review (regressions from PR-122)
- Verify: PostQueryRouterTest (3 new), router suites 67 OK
- PR: PR-130
2026-09-28 00:27:56 +08:00
wpdev 7ad65eab95 Merge pull request 'docs: correct the 1.4.0 upgrade step for the copy index' (#9) from docs/1.4.0-upgrade-note into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 11s
Tests / PHP Lint (push) Successful in 22s
Tests / Unit Tests (push) Successful in 1m12s
Tests / Integration Tests (push) Successful in 2m7s
Tests / PHPCS (push) Successful in 1m9s
Tests / PHPStan (push) Successful in 1m10s
2026-09-28 00:08:56 +08:00
10 changed files with 276 additions and 38 deletions
+2 -2
View File
@@ -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__ ) );
+31
View File
@@ -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 副本的索引改為三欄(見「升級後」)。
@@ -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;
}
+86 -35
View File
@@ -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 );
}
/**
+7 -1
View File
@@ -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
@@ -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)" );
@@ -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();
+55
View File
@@ -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();
+10
View File
@@ -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();
+13
View File
@@ -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'; } }