Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b34851316 | |||
| b30baeddf0 | |||
| 13c4a1bcb9 | |||
| 692b23aed9 | |||
| 8ee3dce0fe | |||
| 2aa9058ee9 | |||
| 66db9e4c56 | |||
| 2ed48c3ba3 | |||
| eac0955b76 | |||
| b2725b219d |
@@ -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__ ) );
|
||||
|
||||
@@ -7,6 +7,52 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] — 2026-09-28 — 百萬級 listing 的路由排序與日期查詢(hardening 2.4)
|
||||
|
||||
**性質**:效能 + 修正。無 schema 變更;post status/date 副本的索引改為三欄(見「升級後」)。
|
||||
搭配 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)
|
||||
|
||||
### 升級後
|
||||
|
||||
- 只需替換索引:對 `tmdo_post_status_columns` 列出的每個 group 表執行
|
||||
`ALTER TABLE <表> ADD INDEX idx_post_status_date_id (post_status, post_date, post_id), DROP INDEX idx_post_status_date`
|
||||
(空表瞬間完成,dev36 130 萬列約 5 s)。未替換前 copy route 照常運作,只是維持 1.3.0 的速度。
|
||||
- **不要為了換索引執行 `wp tmdo post-status-sync`**:它除了替換索引,還會替**所有**尚未有副本的
|
||||
post group 加上 `post_status` / `post_date` 欄位並逐列回填(大表 ALTER + 全表 UPDATE;dev36
|
||||
130 萬列約 75 s,上千萬列的站更久)。只有要讓更多 group 啟用 copy route 時才執行,並先在
|
||||
離峰時段以 `--dry-run` 確認列數。
|
||||
- 效能數字(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 就地加上)。
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 );
|
||||
|
||||
+8
-1
@@ -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 (replace the index on the tables listed in `tmdo_post_status_columns`; `wp tmdo post-status-sync` also adds the copy columns to every other post group)
|
||||
* 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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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' ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string[]> 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' );
|
||||
|
||||
@@ -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() ) );
|
||||
|
||||
@@ -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 ) );
|
||||
|
||||
@@ -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' ) ) ) );
|
||||
|
||||
Reference in New Issue
Block a user