Compare commits

9 Commits

Author SHA1 Message Date
wpdev 303781e68b docs(2meet-data-optimizer): 1.3.0 release notes
- version 1.3.0; CHANGELOG and readme.txt for PR-090a, 091, 093 (verified,
  no change), 094–098, with the post-upgrade steps (first CLI / cron run adds
  the HivePress indexes; INFO lines need WP_DEBUG_LOG)
- Verify: composer test (701)
- PR: PR-101
2026-09-27 16:46:55 +08:00
wpdev d46913de41 chore(2meet-data-optimizer): prefix the global variables in uninstall.php
- $bootstrap_files, $bootstrap_ok, $f, $result, $snapshot_id and $dir become
  $wpdo_* like the file's other globals (WordPress.org Plugin Check)
- Verify: PrefixAllGlobals sniff 0, phpcs, UninstallGuardTest and
  InstallerCleanupTest, unit 701
- PR: PR-098
2026-09-27 16:41:08 +08:00
wpdev 1b50df7cd0 refactor(2meet-data-optimizer): handle admin form posts before the page renders
- the main page's form handlers move unchanged from render_page() to
  handle_post(), hooked on load-{page}: inside render_page() the admin
  header was already sent, so their wp_safe_redirect() calls could not work
- the setup wizard's step 4 snapshot post moves to handle_snapshot_post() on
  its load hook; a failure redirects with snap_failed=1 for the notice
- Verify: unit 701, integration 577, phpcs, stan; dev36 load hook handles a
  posted reset and redirects before output
- PR: PR-097
2026-09-27 16:39:56 +08:00
wpdev a268470b60 fix(2meet-data-optimizer): treat contact and address keys as private in the public REST filter
- rest_exposable() also withholds keys named like an email, phone, address or
  IP, even when a field declares show_in_rest (defence in depth)
- Verify: RestApiTest (private and look-alike public keys), unit 701,
  integration 577, phpcs, stan; dev36 live rest suite
- PR: PR-096
2026-09-27 16:36:51 +08:00
wpdev 1bdd51d9a3 fix(2meet-data-optimizer): log info and notice events only when debug logging is on
- TMDO_Logger::info()/notice() write to the PHP error log only with
  WP_DEBUG_LOG (filter tmdo_log_info); every CLI run and production request
  printed [WPDO][INFO] lines
- debug() keeps its WP_DEBUG gate; warning() and error() are unchanged
- Verify: LoggerTest (2 new), unit 700, integration 577, phpcs, stan
- PR: PR-095
2026-09-27 16:35:13 +08:00
wpdev 22b46ad175 perf(2meet-data-optimizer): stop writing an option for every rate-limited view
- blocked POST /listings/{id}/view requests update wpdo_rl_stats 1 in 10,
  each sample adding 10: anonymous traffic no longer turns 1:1 into DB writes,
  and the admin top list stays representative (unbiased estimate)
- Verify: RestApiTest counts option writes (about 20 for 200 blocked requests),
  unit 698, integration 577, phpcs, stan
- PR: PR-094
2026-09-27 16:33:16 +08:00
wpdev b187c1c4d3 perf(2meet-data-optimizer): count routed searches from a cached base count minus the excluded posts
- a routed search with post__not_in (HivePress sends a random set per page)
  caches the COUNT without the exclusion and subtracts a live primary-key
  count of the excluded posts that match; the total stays exact
- the excluded count drops STRAIGHT_JOIN so MySQL starts from posts.ID
- Verify: PostQueryRouterFoundRowsTest, unit 698, integration 577, phpcs, stan;
  dev36 live router suite and keyword + price search
- PR: PR-091
2026-09-27 16:19:11 +08:00
wpdev ec8d69a6f4 fix(2meet-data-optimizer): add declared flat-table indexes from WP-CLI or WP-Cron only, one process at a time
- ensure_group_indexes() runs the ALTER only under WP-CLI or WP-Cron (filter
  tmdo_schema_ddl_allowed); web requests, including /wp-admin/ before
  auth_redirect() and admin-post.php, schedule tmdo_ensure_group_indexes instead
- MySQL GET_LOCK keeps it to one process (add_option() is INSERT ... ON
  DUPLICATE KEY UPDATE and cannot serve as a lock); released by MySQL if the
  process dies
- the applied signature is recorded only when every ALTER succeeds; failures
  log schema.index_failed and stay in pending_indexes(), which doctor reports
- new tables get their declared cidx_ indexes in CREATE TABLE
- test stub wpdb::query()/insert() return false on SQL errors like wpdb
- Verify: GroupCompositeIndexTest 7/7, unit 698, integration 576, phpcs, stan
- PR: PR-090a
2026-09-27 16:07:23 +08:00
wpdev 28ac6e3cad Merge pull request 'hardening 2.2: 2meet-data-optimizer 1.2.0 (drift detection, scope, CLI and diagnostics)' (#6) from hardening/2.2 into main
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 12s
Tests / Unit Tests (push) Successful in 40s
Tests / PHP Lint (push) Successful in 55s
Tests / Integration Tests (push) Successful in 1m42s
Tests / PHPCS (push) Successful in 33s
Tests / PHPStan (push) Successful in 46s
2026-09-27 10:56:47 +08:00
19 changed files with 480 additions and 67 deletions
+2 -2
View File
@@ -3,7 +3,7 @@
* Plugin Name: 2meet Data Optimizer * Plugin Name: 2meet Data Optimizer
* Plugin URI: https://2meet.io/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。 * Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
* Version: 1.2.0 * Version: 1.3.0
* Requires at least: 6.0 * Requires at least: 6.0
* Tested up to: 6.9.4 * Tested up to: 6.9.4
* Requires PHP: 8.1 * Requires PHP: 8.1
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
} }
// ── Constants ────────────────────────────────────────────────────────────── // ── Constants ──────────────────────────────────────────────────────────────
define( 'TMDO_VERSION', '1.2.0' ); define( 'TMDO_VERSION', '1.3.0' );
define( 'TMDO_DB_VERSION', '2.1.0' ); define( 'TMDO_DB_VERSION', '2.1.0' );
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) ); define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
+44
View File
@@ -7,6 +7,50 @@ Versioning follows [Semantic Versioning](https://semver.org/).
--- ---
## [1.3.0] — 2026-09-27 — 大表索引、路由計數與稽核修正(hardening 2.3)
**性質**:效能 + 稽核修正。無 schema 變更(宣告的複合索引以 ALTER ADD INDEX 就地加上)。
搭配 HivePress AddOn 0.4.0、WooCommerce AddOn 0.3.0。來源:dev36 規模壓測(90 萬 listing)
與系列稽核(2026-09-27)。
### Changed
- **group 宣告的複合索引只在 WP-CLI 或 WP-Cron 加上**:網頁請求(包括 auth_redirect 之前的
`/wp-admin/` 與 `admin-post.php`)改排單次事件 `tmdo_ensure_group_indexes`;以 MySQL
`GET_LOCK` 保證同一時間只有一個程序執行 ALTER;每個 ALTER 都成功才記錄已套用,失敗記
`schema.index_failed` 並保留在 `doctor` 的 `[INDEX]` 待辦;新表在 CREATE TABLE 時就帶上
索引。filter:`tmdo_schema_ddl_allowed`。dev36 90 萬列加兩個索引約 4.7 s。(PR-090a)
- **路由搜尋的 found rows**:帶 `post__not_in` 的查詢(HivePress 每頁送一組隨機排除清單)
改為「不含排除條件的 COUNT(快取)」減去「排除清單中符合條件的篇數(主鍵查詢)」,結果
精確,快取不再被打散。dev36 關鍵字 + 價格:首次 5.25 s、之後 3.2 s(原生 3.35 s)。(PR-091)
- `POST /wpdo/v1/listings/{id}/view` 被限流擋下時,`wpdo_rl_stats` 改為 1/10 抽樣寫入
(每次 +10),匿名請求不再 1:1 放大成 option 寫入。(PR-094)
- `TMDO_Logger::info()` / `notice()` 只在 `WP_DEBUG_LOG` 時寫 error_log
(filter `tmdo_log_info`);`debug()`、`warning()`、`error()` 不變。(PR-095)
- 公開 listings REST 一律不輸出名稱像 email、phone、address、IP 的欄位,即使宣告
`show_in_rest`。(PR-096)
### Fixed
- 後台主頁與設定精靈第 4 步的表單改在 `load-{page}`(輸出前)處理,`wp_safe_redirect()`
可以正常生效;精靈快照失敗改以 `snap_failed=1` 導回。(PR-097)
- `uninstall.php` 的全域變數加上 `$wpdo_` 前綴(WordPress.org Plugin Check)。(PR-098)
### Security(已驗證,無程式變更)
- 快照目錄在 nginx 下的外洩風險(稽核 Medium):快照檔名含 64-bit 隨機值、遷移備份檔名含
12 字元隨機 job id,目錄有 index.php 擋列表;`doctor` 的 HTTP 探測與 nginx 後台提示已存在。
dev31 探測全為 403。不採用「目錄名加隨機後綴」:各站 nginx deny 規則比對 `wpdo-backups/`,
改名反而讓新快照脫離保護。(PR-093)
### 升級後
- 部署後第一個 WP-CLI 指令或 WP-Cron 會為 HivePress AddOn 0.4.0 宣告的索引執行 ALTER
(90 萬列約 5 s);`DISABLE_WP_CRON` 且沒有系統 cron 的站,執行一次 `wp tmdo doctor` 即可。
- CLI 輸出不再夾帶 `[WPDO][INFO]`;要看請定義 `WP_DEBUG_LOG` 或加上 `tmdo_log_info` filter。
---
## [1.2.0] — 2026-09-27 — 漂移偵測、範圍收斂、CLI 與診斷(hardening 2.2) ## [1.2.0] — 2026-09-27 — 漂移偵測、範圍收斂、CLI 與診斷(hardening 2.2)
**性質**:功能 + 資料正確性修正。無 schema 變更。延續 ADR-004 的搜尋索引模式;搭配 **性質**:功能 + 資料正確性修正。無 schema 變更。延續 ADR-004 的搜尋索引模式;搭配
+17 -4
View File
@@ -89,13 +89,16 @@ class TMDO_Admin {
* Register the admin menu page under Tools. * Register the admin menu page under Tools.
*/ */
public static function register_menu(): void { public static function register_menu(): void {
add_management_page( $hook_suffix = add_management_page(
__( 'WP Data Optimizer', '2meet-data-optimizer' ), __( 'WP Data Optimizer', '2meet-data-optimizer' ),
__( 'WP Data Optimizer', '2meet-data-optimizer' ), __( 'WP Data Optimizer', '2meet-data-optimizer' ),
'manage_options', 'manage_options',
self::MENU_SLUG, self::MENU_SLUG,
array( __CLASS__, 'render_page' ) array( __CLASS__, 'render_page' )
); );
if ( $hook_suffix ) {
add_action( 'load-' . $hook_suffix, array( __CLASS__, 'handle_post' ) );
}
} }
/** /**
@@ -326,9 +329,10 @@ class TMDO_Admin {
} }
/** /**
* Render the admin page. * Handle the page's form posts on load-{page}, before any output, so their
* redirects work (inside render_page() the admin header was already sent).
*/ */
public static function render_page(): void { public static function handle_post(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) { if ( ! TMDO_Capability::current_user_can_admin() ) {
return; return;
} }
@@ -752,6 +756,15 @@ class TMDO_Admin {
); );
exit; exit;
} }
}
/**
* Render the admin page.
*/
public static function render_page(): void {
if ( ! TMDO_Capability::current_user_can_admin() ) {
return;
}
$tabs = array( $tabs = array(
'dashboard' => __( '儀表板', '2meet-data-optimizer' ), 'dashboard' => __( '儀表板', '2meet-data-optimizer' ),
@@ -774,7 +787,7 @@ class TMDO_Admin {
'hivepress' => __( 'HivePress 整合', '2meet-data-optimizer' ), 'hivepress' => __( 'HivePress 整合', '2meet-data-optimizer' ),
); );
$tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'dashboard' ) ); $tab = sanitize_key( wp_unslash( $_GET['tab'] ?? 'dashboard' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- tab navigation, no state change.
// v2.11.8: legacy tabs removed — redirect bookmarks to current main maintenance entry. // v2.11.8: legacy tabs removed — redirect bookmarks to current main maintenance entry.
if ( in_array( $tab, array( 'sop', 'migration' ), true ) ) { if ( in_array( $tab, array( 'sop', 'migration' ), true ) ) {
$tab = 'entity-bridge'; $tab = 'entity-bridge';
+30 -15
View File
@@ -106,7 +106,7 @@ class TMDO_Setup_Wizard {
* @return void * @return void
*/ */
public static function register_menu_page(): void { public static function register_menu_page(): void {
add_submenu_page( $hook_suffix = add_submenu_page(
'tools.php', 'tools.php',
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ), __( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
__( 'WPDO 設定嚮導', '2meet-data-optimizer' ), __( 'WPDO 設定嚮導', '2meet-data-optimizer' ),
@@ -114,6 +114,33 @@ class TMDO_Setup_Wizard {
'wpdo-setup-wizard', 'wpdo-setup-wizard',
array( __CLASS__, 'render_page' ) array( __CLASS__, 'render_page' )
); );
if ( $hook_suffix ) {
add_action( 'load-' . $hook_suffix, array( __CLASS__, 'handle_snapshot_post' ) );
}
}
/**
* Step 4 baseline snapshot (POST + nonce, prevents Referer leak — v3.3.x
* P1-6b hardening), handled on load-{page} so the redirect runs before any
* output.
*
* @return void
*/
public static function handle_snapshot_post(): void {
if ( ! isset( $_POST['wpdo_take_snapshot'] ) || ! TMDO_Capability::current_user_can_admin() || ! class_exists( 'TMDO_Snapshot_Manager' ) ) {
return;
}
check_admin_referer( self::NONCE_NAME );
$result = TMDO_Snapshot_Manager::create(
'manual',
array(),
array(
'notes' => 'wizard_baseline',
'retention_days' => 365,
)
);
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&' . ( empty( $result['ok'] ) ? 'snap_failed=1' : 'snap_done=1' ) ) );
exit;
} }
/** /**
@@ -356,20 +383,8 @@ class TMDO_Setup_Wizard {
</p> </p>
</form> </form>
<?php <?php
// Take snapshot if action requested (POST + nonce, prevents Referer leak — v3.3.x P1-6b hardening). // The snapshot itself is taken in handle_snapshot_post(), before any output.
if ( isset( $_POST['wpdo_take_snapshot'] ) && check_admin_referer( self::NONCE_NAME ) && class_exists( 'TMDO_Snapshot_Manager' ) ) { if ( isset( $_GET['snap_failed'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$result = TMDO_Snapshot_Manager::create(
'manual',
array(),
array(
'notes' => 'wizard_baseline',
'retention_days' => 365,
)
);
if ( ! empty( $result['ok'] ) ) {
wp_safe_redirect( admin_url( 'tools.php?page=wpdo-setup-wizard&step=4&snap_done=1' ) );
exit;
}
echo '<div class="notice notice-error"><p>' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '</p></div>'; echo '<div class="notice notice-error"><p>' . esc_html__( 'Snapshot 建立失敗,請查日誌。', '2meet-data-optimizer' ) . '</p></div>';
} }
?> ?>
+4
View File
@@ -142,6 +142,10 @@ class TMDO_CLI {
foreach ( $violations as $entity_type => $integrations ) { foreach ( $violations as $entity_type => $integrations ) {
WP_CLI::warning( " [GUARD] {$entity_type} is aeav_only while " . implode( ', ', $integrations ) . " reads native meta — write flat back with `wp tmdo bridge-restore-native {$entity_type} --demote`." ); WP_CLI::warning( " [GUARD] {$entity_type} is aeav_only while " . implode( ', ', $integrations ) . " reads native meta — write flat back with `wp tmdo bridge-restore-native {$entity_type} --demote`." );
} }
$pending = TMDO_Schema_Manager::pending_indexes();
if ( $pending ) {
WP_CLI::warning( ' [INDEX] declared indexes not added yet: ' . implode( ', ', $pending ) . ' — still missing after this run: another process is adding them, or the ALTER failed (see the `schema.index_failed` log).' );
}
$status = get_option( TMDO_Drift_Check::OPTION, array() ); $status = get_option( TMDO_Drift_Check::OPTION, array() );
$status = is_array( $status ) ? $status : array(); $status = is_array( $status ) ? $status : array();
+3
View File
@@ -306,6 +306,9 @@ class TMDO_Core {
add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 ); add_action( 'add_option_' . TMDO_Hook_Bus_Bridge::OPTION, array( 'TMDO_Drift_Check', 'on_hook_bus_added' ), 10, 2 );
add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) ); add_action( TMDO_Drift_Check::RESYNC_HOOK, array( 'TMDO_Drift_Check', 'run_scheduled_fix' ) );
// 1.3.0: declared indexes a visitor request left for WP-Cron.
add_action( TMDO_Schema_Manager::INDEXES_HOOK, array( 'TMDO_Schema_Manager', 'ensure_pending_indexes' ) );
// v2.3.0 M6: daily snapshot prune cron (paired with health check). // v2.3.0 M6: daily snapshot prune cron (paired with health check).
add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) ); add_action( 'wpdo_snapshot_prune_daily', array( 'TMDO_Snapshot_Pruner', 'cron_run' ) );
+2
View File
@@ -789,6 +789,7 @@ class TMDO_Installer {
wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' ); wp_clear_scheduled_hook( 'wpdo_post_shadow_verify' );
wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' ); wp_clear_scheduled_hook( 'wpdo_term_comment_shadow_verify' );
wp_clear_scheduled_hook( 'tmdo_drift_resync' ); wp_clear_scheduled_hook( 'tmdo_drift_resync' );
wp_clear_scheduled_hook( 'tmdo_ensure_group_indexes' );
wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' ); wp_clear_scheduled_hook( 'wpdo_post_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' ); wp_clear_scheduled_hook( 'wpdo_user_stress_test_batch' );
wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' ); wp_clear_scheduled_hook( 'wpdo_term_stress_test_batch' );
@@ -993,6 +994,7 @@ class TMDO_Installer {
'wpdo_post_shadow_verify', 'wpdo_post_shadow_verify',
'wpdo_term_comment_shadow_verify', 'wpdo_term_comment_shadow_verify',
'tmdo_drift_resync', 'tmdo_drift_resync',
'tmdo_ensure_group_indexes',
'wpdo_post_stress_test_batch', 'wpdo_post_stress_test_batch',
'wpdo_user_stress_test_batch', 'wpdo_user_stress_test_batch',
'wpdo_term_stress_test_batch', 'wpdo_term_stress_test_batch',
+18 -2
View File
@@ -48,7 +48,7 @@ class TMDO_Logger {
/** /**
* Log an INFO-level event (event-based signature, used by v2.0.0 engine code). * Log an INFO-level event (event-based signature, used by v2.0.0 engine code).
* *
* Writes only to PHP error_log; does NOT touch wpdo_errors (which is reserved * Writes only to PHP error_log, and only when debug logging is on; does NOT touch wpdo_errors (which is reserved
* for actual errors). Mode changes / cache flushes / audit prunes call here * for actual errors). Mode changes / cache flushes / audit prunes call here
* many times per request — keeping them out of the DB error table. * many times per request — keeping them out of the DB error table.
* *
@@ -57,6 +57,22 @@ class TMDO_Logger {
* @return void * @return void
*/ */
public static function info( string $event, array $context = array() ): void { public static function info( string $event, array $context = array() ): void {
// Routine events: only with WP_DEBUG_LOG (filter `tmdo_log_info`), or every
// CLI run and production request would flood the log.
if ( ! apply_filters( 'tmdo_log_info', defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) ) {
return;
}
self::write_info( $event, $context );
}
/**
* Write an INFO line to the PHP error log.
*
* @param string $event Event name.
* @param array<string, mixed> $context Structured context.
* @return void
*/
private static function write_info( string $event, array $context ): void {
error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) ); error_log( sprintf( '[WPDO][INFO][%s] %s', $event, $context ? wp_json_encode( $context, JSON_UNESCAPED_UNICODE ) : '' ) );
} }
@@ -97,7 +113,7 @@ class TMDO_Logger {
*/ */
public static function debug( string $event, array $context = array() ): void { public static function debug( string $event, array $context = array() ): void {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
self::info( $event, $context ); self::write_info( $event, $context );
} }
} }
+8 -1
View File
@@ -23,6 +23,9 @@ if ( ! defined( 'ABSPATH' ) ) {
*/ */
class TMDO_REST_API { class TMDO_REST_API {
/** Blocked views are counted 1 in this many (wpdo_rl_stats). */
private const RL_STATS_SAMPLE = 10;
const NAMESPACE = 'wpdo/v1'; const NAMESPACE = 'wpdo/v1';
/** /**
@@ -725,14 +728,18 @@ class TMDO_REST_API {
if ( $cookie_blocked || $ip_blocked ) { if ( $cookie_blocked || $ip_blocked ) {
// Track rate-limit hits per post for admin visibility, capped at 100 entries. // Track rate-limit hits per post for admin visibility, capped at 100 entries.
// Sampled (1 in RL_STATS_SAMPLE, each sample adding RL_STATS_SAMPLE) so
// anonymous requests cannot turn every blocked view into an option write.
if ( 1 === wp_rand( 1, self::RL_STATS_SAMPLE ) ) {
$rl_stats = get_option( 'wpdo_rl_stats', array() ); $rl_stats = get_option( 'wpdo_rl_stats', array() );
$rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + 1; $rl_stats[ (string) $post_id ] = ( (int) ( $rl_stats[ (string) $post_id ] ?? 0 ) ) + self::RL_STATS_SAMPLE;
if ( count( $rl_stats ) > 100 ) { if ( count( $rl_stats ) > 100 ) {
// Keep only the top-100 posts by hit count to bound option size. // Keep only the top-100 posts by hit count to bound option size.
arsort( $rl_stats ); arsort( $rl_stats );
$rl_stats = array_slice( $rl_stats, 0, 100, true ); $rl_stats = array_slice( $rl_stats, 0, 100, true );
} }
update_option( 'wpdo_rl_stats', $rl_stats, false ); update_option( 'wpdo_rl_stats', $rl_stats, false );
}
return new WP_REST_Response( return new WP_REST_Response(
array( array(
+5 -2
View File
@@ -282,7 +282,8 @@ class TMDO_Schema_Registry {
* Whether a field may appear in the public listings endpoints: declared * Whether a field may appear in the public listings endpoints: declared
* show_in_rest, not a protected meta key (leading underscore — WordPress * show_in_rest, not a protected meta key (leading underscore — WordPress
* hides those from public output, e.g. _purchase_note), and not named like a * hides those from public output, e.g. _purchase_note), and not named like a
* credential (tokens, secrets, passwords, API keys), whatever it declares. * credential (tokens, secrets, passwords, API keys) or a contact / location
* detail (email, phone, address, IP), whatever it declares.
* *
* @param array<string, mixed> $field Registered field. * @param array<string, mixed> $field Registered field.
* @return bool * @return bool
@@ -291,7 +292,9 @@ class TMDO_Schema_Registry {
$key = (string) ( $field['meta_key'] ?? '' ); $key = (string) ( $field['meta_key'] ?? '' );
return ! empty( $field['show_in_rest'] ) return ! empty( $field['show_in_rest'] )
&& 0 !== strpos( $key, '_' ) && 0 !== strpos( $key, '_' )
&& 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) ); && 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) )
// Contact and location details (2.3 PR-096): private even when declared public.
&& 1 !== preg_match( '/e_?mail|phone|address|(^|_)ip(_|$|\s)/i', $key . ' ' . ( $field['column'] ?? '' ) );
} }
/** /**
+104 -1
View File
@@ -23,6 +23,9 @@ final class TMDO_Schema_Manager {
/** Option: "entity/group" → md5 of the composite index definition already applied. */ /** Option: "entity/group" → md5 of the composite index definition already applied. */
public const INDEXES_OPTION = 'tmdo_entity_indexes'; public const INDEXES_OPTION = 'tmdo_entity_indexes';
/** Single cron event that adds declared indexes a visitor request had to leave out. */
public const INDEXES_HOOK = 'tmdo_ensure_group_indexes';
/** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */ /** Request-scoped cache for table_exists() — prevents repeated SHOW TABLES per request. */
private static array $table_exists_cache = array(); private static array $table_exists_cache = array();
@@ -144,6 +147,11 @@ final class TMDO_Schema_Manager {
} }
} }
// A new table gets its declared composite indexes while it is empty.
foreach ( TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name ) as $name => $keys ) {
$sql_indexes[] = 'KEY `cidx_' . self::sanitize_column_name( (string) $name ) . '` (' . implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) ) . ')';
}
$columns_sql = implode( ",\n ", $sql_columns ); $columns_sql = implode( ",\n ", $sql_columns );
$indexes_sql = implode( ",\n ", $sql_indexes ); $indexes_sql = implode( ",\n ", $sql_indexes );
@@ -176,6 +184,12 @@ final class TMDO_Schema_Manager {
* applied definition is remembered per group (one autoloaded option), so * applied definition is remembered per group (one autoloaded option), so
* requests only run SHOW INDEX / ALTER when the declaration changes. * requests only run SHOW INDEX / ALTER when the declaration changes.
* *
* On a large table the ALTER takes seconds, so it runs only from WP-CLI or
* WP-Cron (ddl_allowed(); other requests schedule INDEXES_HOOK) and in one
* process at a time (MySQL GET_LOCK on index_lock_name()). Until every
* ALTER succeeds the group stays in pending_indexes() and the table works
* without the index.
*
* @param string $entity_type Entity type. * @param string $entity_type Entity type.
* @param string $group_name Group name. * @param string $group_name Group name.
* @return void * @return void
@@ -193,24 +207,113 @@ final class TMDO_Schema_Manager {
if ( ( $applied[ $id ] ?? '' ) === $signature ) { if ( ( $applied[ $id ] ?? '' ) === $signature ) {
return; return;
} }
if ( ! self::ddl_allowed() ) {
if ( ! wp_next_scheduled( self::INDEXES_HOOK ) ) {
wp_schedule_single_event( time(), self::INDEXES_HOOK );
}
return;
}
$table = self::get_table_name( $entity_type, $group_name ); $table = self::get_table_name( $entity_type, $group_name );
if ( ! self::table_exists( $table ) ) { if ( ! self::table_exists( $table ) ) {
return; return;
} }
// add_option() is INSERT ... ON DUPLICATE KEY UPDATE and would let two
// processes both "acquire" an option lock; GET_LOCK is atomic and is
// released by MySQL if this process dies.
$lock = self::index_lock_name();
if ( '1' !== (string) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(MD5(CONCAT(DATABASE(), %s)), 0)', $lock ) ) ) {
return; // Another process is adding indexes.
}
try {
// SHOW INDEX (not the option, cached for this request) tells what another process already added.
$existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) ); $existing = array_unique( array_column( (array) $wpdb->get_results( "SHOW INDEX FROM `{$table}`", ARRAY_A ), 'Key_name' ) );
$failed = false;
foreach ( $declared as $name => $keys ) { foreach ( $declared as $name => $keys ) {
$index = 'cidx_' . self::sanitize_column_name( $name ); $index = 'cidx_' . self::sanitize_column_name( $name );
if ( in_array( $index, $existing, true ) ) { if ( in_array( $index, $existing, true ) ) {
continue; continue;
} }
$columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) ); $columns = implode( ', ', array_map( static fn( $key ) => '`' . self::sanitize_column_name( $key ) . '`', $keys ) );
$wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" ); if ( false === $wpdb->query( "ALTER TABLE `{$table}` ADD INDEX `{$index}` ({$columns})" ) ) {
$failed = true;
TMDO_Logger::warning(
'schema.index_failed',
array(
'table' => $table,
'index' => $index,
'error' => $wpdb->last_error,
)
);
}
} }
if ( ! $failed ) {
$applied[ $id ] = $signature; $applied[ $id ] = $signature;
update_option( self::INDEXES_OPTION, $applied, true ); update_option( self::INDEXES_OPTION, $applied, true );
} }
} finally {
$wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), %s)))', $lock ) );
}
}
/**
* INDEXES_HOOK handler: add the indexes a visitor request could not.
*
* @return void
*/
public static function ensure_pending_indexes(): void {
foreach ( self::pending_indexes() as $id ) {
list( $entity_type, $group_name ) = explode( '/', $id, 2 );
self::ensure_group_indexes( $entity_type, $group_name );
}
}
/**
* Key of the MySQL lock held while indexes are added. Lock names are
* server-wide, so SQL hashes it with DATABASE() to keep sites apart.
*
* @return string
*/
public static function index_lock_name(): string {
global $wpdb;
return $wpdb->prefix . self::INDEXES_OPTION;
}
/**
* Whether this request may alter flat tables: WP-CLI or WP-Cron only.
* Front-end, REST, admin-ajax, admin-post and even /wp-admin/ requests
* (init runs before auth_redirect()) can be a visitor's, who would wait
* for the ALTER. Filter: `tmdo_schema_ddl_allowed`.
*
* @return bool
*/
public static function ddl_allowed(): bool {
$allowed = ( defined( 'WP_CLI' ) && WP_CLI )
|| ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() );
return (bool) apply_filters( 'tmdo_schema_ddl_allowed', $allowed );
}
/**
* Registered groups whose declared indexes are not on their table yet.
*
* @return string[] "entity/group"
*/
public static function pending_indexes(): array {
$applied = get_option( self::INDEXES_OPTION, array() );
$applied = is_array( $applied ) ? $applied : array();
$pending = array();
foreach ( TMDO_Entity_Registry::VALID_ENTITY_TYPES as $entity_type ) {
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group_name ) {
$declared = TMDO_Entity_Registry::get_group_indexes( $entity_type, $group_name );
if ( $declared && ( $applied[ "{$entity_type}/{$group_name}" ] ?? '' ) !== md5( (string) wp_json_encode( $declared ) )
&& self::table_exists( self::get_table_name( $entity_type, $group_name ) ) ) {
$pending[] = "{$entity_type}/{$group_name}";
}
}
}
return $pending;
}
/** /**
* 計算 Schema Hash(用於偵測欄位變動) * 計算 Schema Hash(用於偵測欄位變動)
@@ -372,7 +372,23 @@ class TMDO_Post_Query_Router {
if ( ! $counted ) { if ( ! $counted ) {
return $request; return $request;
} }
$matches = preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request ); global $wpdb;
$matches = (string) preg_replace( '/\s+LIMIT\s+\d+(\s*,\s*\d+)?\s*$/i', '', $request );
$not_in = " AND {$wpdb->posts}.ID NOT IN (";
// post__not_in (HivePress sends a random set per page) would give every
// search its own cache key: count without it (cached) minus the excluded
// posts that match (primary-key lookups, so no forced flat-first order).
if ( 1 === substr_count( $matches, $not_in ) && preg_match( '/' . preg_quote( $not_in, '/' ) . '([\d,]+)\)/', $matches, $m ) ) {
$excluded = str_replace( $m[0], " AND {$wpdb->posts}.ID IN ({$m[1]})", (string) preg_replace( '/^SELECT STRAIGHT_JOIN /', 'SELECT ', $matches ) );
$query->set(
self::COUNT_VAR,
array(
'SELECT COUNT(*) FROM ( ' . str_replace( $m[0], '', $matches ) . ' ) AS tmdo_found',
"SELECT COUNT(*) FROM ( {$excluded} ) AS tmdo_found",
)
);
return $request;
}
$query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" ); $query->set( self::COUNT_VAR, "SELECT COUNT(*) FROM ( {$matches} ) AS tmdo_found" );
return $request; return $request;
} }
@@ -414,13 +430,18 @@ class TMDO_Post_Query_Router {
if ( empty( $count_sql ) ) { if ( empty( $count_sql ) ) {
return (string) ( $sql ?? '' ); return (string) ( $sql ?? '' );
} }
global $wpdb;
list( $count_sql, $excluded_sql ) = array_pad( (array) $count_sql, 2, '' );
$key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' ); $key = md5( $count_sql ) . ':' . wp_cache_get_last_changed( 'posts' );
$total = wp_cache_get( $key, self::COUNT_CACHE_GROUP ); $total = wp_cache_get( $key, self::COUNT_CACHE_GROUP );
if ( false === $total ) { if ( false === $total ) {
global $wpdb;
$total = (int) $wpdb->get_var( $count_sql ); $total = (int) $wpdb->get_var( $count_sql );
wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS ); wp_cache_set( $key, $total, self::COUNT_CACHE_GROUP, HOUR_IN_SECONDS );
} }
if ( '' !== $excluded_sql ) {
$total = max( 0, (int) $total - (int) $wpdb->get_var( $excluded_sql ) );
}
return 'SELECT ' . (int) $total; return 'SELECT ' . (int) $total;
} }
+9 -1
View File
@@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress
Requires at least: 6.0 Requires at least: 6.0
Tested up to: 6.9 Tested up to: 6.9
Requires PHP: 8.1 Requires PHP: 8.1
Stable tag: 1.2.0 Stable tag: 1.3.0
License: GPLv2 or later License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -107,6 +107,14 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
== Changelog == == Changelog ==
= 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 (HivePress keyword + price search no longer slower than native)
* Rate-limited view requests sample their statistics (1 in 10) instead of writing an option each time
* Logger info / notice lines only with WP_DEBUG_LOG (filter `tmdo_log_info`)
* The public listings REST output withholds email, phone, address and IP fields even when declared public
* Admin form posts are handled before the page renders; uninstall.php globals are prefixed
= 1.2.0 = = 1.2.0 =
* `wp tmdo drift-check <entity> [--group] [--sample] [--fix]`: reports flat-vs-native drift per key (miss / diff / extra) and rebuilds drifted flat rows from native; native meta is never written, and aeav_only sites are refused * `wp tmdo drift-check <entity> [--group] [--sample] [--fix]`: reports flat-vs-native drift per key (miss / diff / extra) and rebuilds drifted flat rows from native; native meta is never written, and aeav_only sites are refused
* The post query router keeps a query native while one of its groups is recorded as drifting; a daily sample (500 entities per group) on the existing health-check event keeps the record current * The post query router keeps a query native while one of its groups is recorded as drifting; a daily sample (500 entities per group) on the existing health-check event keeps the record current
@@ -31,6 +31,10 @@ class GroupCompositeIndexTest extends TestCase {
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, ci_flag BIGINT(20) DEFAULT NULL, ci_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' ); $wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, ci_flag BIGINT(20) DEFAULT NULL, ci_price DECIMAL(18,6) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
$wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, ci_flag, ci_price) VALUES (1, 1, 10), (2, 0, 20)' ); $wpdb->query( 'INSERT INTO `' . self::TABLE . '` (post_id, ci_flag, ci_price) VALUES (1, 1, 10), (2, 0, 20)' );
$GLOBALS['_wp_options'] = array(); $GLOBALS['_wp_options'] = array();
// Index DDL runs from WP-CLI / WP-Cron only (2.3 PR-090a); these tests act as one.
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true );
$GLOBALS['_wp_scheduled_single'] = array();
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' ); $ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true ); $ref->setAccessible( true );
@@ -53,6 +57,81 @@ class GroupCompositeIndexTest extends TestCase {
global $wpdb; global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' ); $wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
WPDO_Entity_Registry::init(); WPDO_Entity_Registry::init();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
unset( $GLOBALS['_wp_is_admin'], $GLOBALS['_wp_scheduled_single'] );
}
public function test_a_web_request_adds_no_index_and_leaves_it_to_wp_cron(): void {
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
foreach ( array( false, true ) as $is_admin ) { // /wp-admin/ runs init before auth_redirect().
$GLOBALS['_wp_is_admin'] = $is_admin;
$this->assertFalse( TMDO_Schema_Manager::ddl_allowed() );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'no ALTER in a web request' );
}
$this->assertSame( array( 'post/ci_core' ), TMDO_Schema_Manager::pending_indexes() );
$this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] );
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // The cron run.
TMDO_Schema_Manager::ensure_pending_indexes();
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
$this->assertSame( array(), TMDO_Schema_Manager::pending_indexes() );
}
public function test_the_ddl_decision_can_be_overridden(): void {
add_filter( 'tmdo_schema_ddl_allowed', static fn() => false );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes() );
}
public function test_a_failed_alter_is_not_recorded_as_applied(): void {
global $wpdb;
$wpdb->query( 'ALTER TABLE `' . self::TABLE . '` ADD COLUMN ci_note TEXT DEFAULT NULL' );
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
WPDO_Entity_Registry::register_group(
'post',
'ci_core',
array(
array( 'key' => 'ci_flag', 'type' => 'integer' ),
array( 'key' => 'ci_note', 'type' => 'textarea' ),
),
array( 'indexes' => array( 'flag_note' => array( 'ci_flag', 'ci_note' ) ) ) // TEXT without a prefix length: MySQL 1170.
);
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_note', $this->indexes() );
$this->assertFalse( get_option( TMDO_Schema_Manager::INDEXES_OPTION ), 'not recorded, so it is retried' );
$this->assertSame( array( 'post/ci_core' ), TMDO_Schema_Manager::pending_indexes() );
}
public function test_a_lock_held_by_another_process_skips_the_ddl_until_it_is_released(): void {
$other = new mysqli(
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
(string) getenv( 'TMDO_TEST_DB_PASS' ),
getenv( 'TMDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test'
);
$name = $other->real_escape_string( TMDO_Schema_Manager::index_lock_name() );
$this->assertSame( '1', (string) $other->query( "SELECT GET_LOCK(MD5(CONCAT(DATABASE(), '{$name}')), 0)" )->fetch_row()[0] );
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayNotHasKey( 'cidx_flag_price', $this->indexes(), 'another process holds the lock' );
$other->query( "SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), '{$name}')))" );
$other->close();
TMDO_Schema_Manager::ensure_group_indexes( 'post', 'ci_core' );
$this->assertArrayHasKey( 'cidx_flag_price', $this->indexes() );
$this->assertSame( array(), TMDO_Schema_Manager::pending_indexes() );
global $wpdb;
$this->assertSame( '1', (string) $wpdb->get_var( $wpdb->prepare( 'SELECT IS_FREE_LOCK(MD5(CONCAT(DATABASE(), %s)))', TMDO_Schema_Manager::index_lock_name() ) ), 'lock released' );
} }
/** @return array<string, string[]> index name → ordered columns */ /** @return array<string, string[]> index name → ordered columns */
@@ -121,6 +121,31 @@ class PostQueryRouterFoundRowsTest extends TestCase {
$this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' ); $this->assertSame( '1', $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) ), 'recounted' );
} }
/**
* HivePress excludes a different random set of posts on each search page;
* the total is the cached count without the exclusion minus a live count
* of the excluded posts that match (by primary key).
*/
public function test_excluded_posts_do_not_split_the_cached_total(): void {
global $wpdb;
$count = function ( string $not_in ) use ( $wpdb ): string {
list( $router, $query, $request ) = $this->routed();
$request = str_replace( ' GROUP BY', " AND {$wpdb->posts}.ID NOT IN ({$not_in}) GROUP BY", $request );
$router->posts_request( $request, $query );
return (string) $wpdb->get_var( $router->found_posts_query( 'SELECT FOUND_ROWS()', $query ) );
};
$this->assertSame( '1', $count( '3' ), 'prices 20, 30 match; 3 excluded' );
$this->assertSame( '0', $count( '2,3' ) );
$wpdb->query( 'UPDATE `' . self::TABLE . '` SET fr_price = 5 WHERE post_id = 3' ); // Not seen until posts change.
$this->assertSame( '2', $count( '1' ), 'one cached count serves every exclusion list; 1 does not match' );
$this->assertSame( '1', $count( '2' ) );
wp_cache_set_posts_last_changed();
$this->assertSame( '0', $count( '2' ), 'recounted' );
}
public function test_flat_writes_and_deletes_move_posts_last_changed(): void { public function test_flat_writes_and_deletes_move_posts_last_changed(): void {
$GLOBALS['_wp_current_filter'] = 'update_post_metadata'; $GLOBALS['_wp_current_filter'] = 'update_post_metadata';
$before = wp_cache_get_last_changed( 'posts' ); $before = wp_cache_get_last_changed( 'posts' );
+11 -1
View File
@@ -168,7 +168,12 @@ $wpdb = new class( $_mysqli ) {
fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'", fn( $v ) => $v === null ? 'NULL' : "'" . $this->db->real_escape_string( (string) $v ) . "'",
array_values( $data ) array_values( $data )
) ); ) );
try {
$ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" ); $ok = $this->db->query( "INSERT INTO `{$table}` ({$cols}) VALUES ({$vals})" );
} catch ( mysqli_sql_exception $e ) { // Like wpdb::insert(): an SQL error returns false.
$this->last_error = $e->getMessage();
return false;
}
if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->insert_id; } if ( $ok ) { $this->insert_id = (int) $this->db->insert_id; return $this->insert_id; }
$this->last_error = (string) $this->db->error; $this->last_error = (string) $this->db->error;
return false; return false;
@@ -200,7 +205,12 @@ $wpdb = new class( $_mysqli ) {
public function flush(): void {} public function flush(): void {}
public function query( string $sql ): int|bool { public function query( string $sql ): int|bool {
try {
$result = $this->db->query( $sql ); $result = $this->db->query( $sql );
} catch ( mysqli_sql_exception $e ) { // Like wpdb::query(): an SQL error returns false.
$this->last_error = $e->getMessage();
return false;
}
if ( $result instanceof mysqli_result ) { $result->free(); return true; } if ( $result instanceof mysqli_result ) { $result->free(); return true; }
if ( false === $result ) { return false; } if ( false === $result ) { return false; }
return $this->db->affected_rows; return $this->db->affected_rows;
@@ -291,7 +301,7 @@ if ( ! function_exists( 'is_singular' ) ) { function is_singular( $t = '' ):
if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } } if ( ! function_exists( 'wp_doing_ajax' ) ) { function wp_doing_ajax(): bool { return false; } }
if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } } if ( ! function_exists( 'wp_next_scheduled' ) ) { function wp_next_scheduled( string $hook ): int|false { return false; } }
if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } } if ( ! function_exists( 'wp_schedule_event' ) ) { function wp_schedule_event( int $t, string $r, string $h ): bool { return true; } }
if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { return true; } } if ( ! function_exists( 'wp_schedule_single_event' ) ) { function wp_schedule_single_event( int $ts, string $hook ): bool { $GLOBALS['_wp_scheduled_single'][] = $hook; return true; } }
if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } } if ( ! function_exists( 'wp_clear_scheduled_hook' ) ) { function wp_clear_scheduled_hook( string $hook ): int|false { return 0; } }
if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } } if ( ! function_exists( 'sanitize_text_field' ) ) { function sanitize_text_field( string $s ): string { return trim( strip_tags( $s ) ); } }
if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } } if ( ! function_exists( '__' ) ) { function __( string $text, string $domain = 'default' ): string { return $text; } }
+34
View File
@@ -130,4 +130,38 @@ class LoggerTest extends TestCase {
$deleted = WPDO_Logger::purge( 30 ); $deleted = WPDO_Logger::purge( 30 );
$this->assertSame( 5, $deleted ); $this->assertSame( 5, $deleted );
} }
/** error_log() output of $fn. */
private function logged( callable $fn ): string {
$file = tempnam( sys_get_temp_dir(), 'tmdo-log' );
$old = ini_set( 'error_log', $file );
try {
$fn();
} finally {
ini_set( 'error_log', (string) $old );
}
$out = (string) file_get_contents( $file );
unlink( $file );
return $out;
}
public function test_info_and_notice_stay_out_of_the_log_unless_debug_logging_is_on(): void {
$GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array();
$this->assertSame( '', $this->logged( static function () {
WPDO_Logger::info( 'cache_flushed' );
WPDO_Logger::notice( 'mode_changed' );
} ) );
add_filter( 'tmdo_log_info', static fn() => true );
try {
$this->assertStringContainsString( '[WPDO][INFO][cache_flushed]', $this->logged( static fn() => WPDO_Logger::info( 'cache_flushed' ) ) );
} finally {
$GLOBALS['_wp_filter_callbacks']['tmdo_log_info'] = array();
}
}
public function test_warnings_are_always_logged(): void {
$this->assertStringContainsString( '[WPDO][WARN][odd_return]', $this->logged( static fn() => WPDO_Logger::warning( 'odd_return' ) ) );
}
} }
+30 -4
View File
@@ -164,6 +164,21 @@ class RestApiTest extends TestCase {
$this->assertSame( array( 'id', 'post_type', 'hp_price' ), array_keys( $data ) ); $this->assertSame( array( 'id', 'post_type', 'hp_price' ), array_keys( $data ) );
} }
public function test_contact_and_address_keys_stay_private_even_when_declared_public(): void {
$private = array( 'hp_email', 'hp_contact_e_mail', 'hp_phone_number', 'hp_address', 'billing_address_1', 'hp_ip', 'hp_client_ip_addr' );
$public = array( 'hp_price', 'hp_description', 'hp_tip_amount', 'hp_shipping_days' );
$fields = array();
$values = array();
foreach ( array_merge( $private, $public ) as $key ) {
$fields[] = array( 'meta_key' => $key, 'zone' => 'cold', 'show_in_rest' => true );
$values[ $key ] = 'v';
}
$data = $this->listing_with( $fields, $values );
$this->assertSame( array_merge( array( 'id', 'post_type' ), $public ), array_keys( $data ) );
}
public function test_rest_visible_columns_exclude_protected_and_credential_keys(): void { public function test_rest_visible_columns_exclude_protected_and_credential_keys(): void {
$registry = WPDO_Schema_Registry::instance(); $registry = WPDO_Schema_Registry::instance();
$registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) ); $registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) );
@@ -377,7 +392,11 @@ class RestApiTest extends TestCase {
$this->assertSame( 'too_many_requests', $response->get_data()['code'] ); $this->assertSame( 'too_many_requests', $response->get_data()['code'] );
} }
public function test_post_view_429_increments_rate_limit_stats(): void { /**
* Blocked requests are counted 1 in 10 (each sample adds 10), so anonymous
* traffic cannot turn every blocked request into an option write.
*/
public function test_post_view_429_samples_rate_limit_stats(): void {
$GLOBALS['_wp_post_types'][40] = 'hp_listing'; $GLOBALS['_wp_post_types'][40] = 'hp_listing';
$_COOKIE['wpdo_view_40'] = '1'; // Trigger cookie block. $_COOKIE['wpdo_view_40'] = '1'; // Trigger cookie block.
$nonce = wp_create_nonce( 'wp_rest' ); $nonce = wp_create_nonce( 'wp_rest' );
@@ -385,10 +404,17 @@ class RestApiTest extends TestCase {
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/40/view' ); $req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/40/view' );
$req->set_param( 'id', 40 ); $req->set_param( 'id', 40 );
$req->set_header( 'X-WP-Nonce', $nonce ); $req->set_header( 'X-WP-Nonce', $nonce );
$this->api->post_view( $req ); $writes = 0;
for ( $i = 0; $i < 200; $i++ ) {
$before = get_option( 'wpdo_rl_stats', [] );
$this->assertSame( 429, $this->api->post_view( $req )->get_status() );
$writes += get_option( 'wpdo_rl_stats', [] ) === $before ? 0 : 1;
}
$stats = get_option( 'wpdo_rl_stats', [] ); $count = (int) ( get_option( 'wpdo_rl_stats', [] )['40'] ?? 0 );
$this->assertSame( 1, (int) ( $stats['40'] ?? 0 ) ); $this->assertSame( 10 * $writes, $count, 'each sample adds 10' );
$this->assertGreaterThan( 0, $writes );
$this->assertLessThan( 60, $writes, 'about 20 writes for 200 blocked requests, not 200' );
} }
// ── get_status: rate_limit_stats ───────────────────────────────────────── // ── get_status: rate_limit_stats ─────────────────────────────────────────
+15 -15
View File
@@ -30,7 +30,7 @@ if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
global $wpdb; global $wpdb;
// ── Load shared cleanup logic + snapshot classes ───────────────────────── // ── Load shared cleanup logic + snapshot classes ─────────────────────────
$bootstrap_files = array( $wpdo_bootstrap_files = array(
__DIR__ . '/includes/class-tmdo-logger.php', __DIR__ . '/includes/class-tmdo-logger.php',
__DIR__ . '/includes/class-tmdo-feature-flags.php', __DIR__ . '/includes/class-tmdo-feature-flags.php',
__DIR__ . '/includes/class-tmdo-installer.php', __DIR__ . '/includes/class-tmdo-installer.php',
@@ -39,13 +39,13 @@ $bootstrap_files = array(
__DIR__ . '/includes/snapshots/class-tmdo-snapshot-reader.php', __DIR__ . '/includes/snapshots/class-tmdo-snapshot-reader.php',
__DIR__ . '/includes/snapshots/class-tmdo-snapshot-pruner.php', __DIR__ . '/includes/snapshots/class-tmdo-snapshot-pruner.php',
); );
$bootstrap_ok = true; $wpdo_bootstrap_ok = true;
foreach ( $bootstrap_files as $f ) { foreach ( $wpdo_bootstrap_files as $wpdo_bootstrap_file ) {
if ( ! is_file( $f ) ) { if ( ! is_file( $wpdo_bootstrap_file ) ) {
$bootstrap_ok = false; $wpdo_bootstrap_ok = false;
continue; continue;
} }
require_once $f; require_once $wpdo_bootstrap_file;
} }
// ── Sites to check and clean (0 = single site, no blog switching) ──────── // ── Sites to check and clean (0 = single site, no blog switching) ────────
@@ -77,7 +77,7 @@ $wpdo_for_each_site = static function ( callable $callback ) use ( $wpdo_site_id
}; };
// ── Refuse while any site holds data that exists only in wpdo_* tables ─── // ── Refuse while any site holds data that exists only in wpdo_* tables ───
if ( ! $bootstrap_ok ) { if ( ! $wpdo_bootstrap_ok ) {
wp_die( esc_html( '2meet Data Optimizer:無法載入資料安全檢查,為避免資料遺失,已停止解除安裝。' ) ); wp_die( esc_html( '2meet Data Optimizer:無法載入資料安全檢查,為避免資料遺失,已停止解除安裝。' ) );
} }
@@ -103,7 +103,7 @@ if ( $wpdo_blocked ) {
// wpdo_snapshots rows (which is per-site so per-site snapshots survive the // wpdo_snapshots rows (which is per-site so per-site snapshots survive the
// site-level table DROP that happens later). Best-effort. // site-level table DROP that happens later). Best-effort.
if ( class_exists( 'TMDO_Snapshot_Manager' ) ) { if ( class_exists( 'TMDO_Snapshot_Manager' ) ) {
$result = TMDO_Snapshot_Manager::create( $wpdo_snapshot = TMDO_Snapshot_Manager::create(
'pre_uninstall', 'pre_uninstall',
array(), array(),
array( array(
@@ -112,18 +112,18 @@ if ( class_exists( 'TMDO_Snapshot_Manager' ) ) {
'inline_threshold_bytes' => 0, 'inline_threshold_bytes' => 0,
) )
); );
if ( ! empty( $result['ok'] ) ) { if ( ! empty( $wpdo_snapshot['ok'] ) ) {
$snapshot_id = $result['snapshot_id']; $wpdo_snapshot_id = $wpdo_snapshot['snapshot_id'];
$dir = TMDO_Snapshot_Manager::backup_dir(); $wpdo_backup_dir = TMDO_Snapshot_Manager::backup_dir();
if ( is_dir( $dir ) ) { if ( is_dir( $wpdo_backup_dir ) ) {
file_put_contents( file_put_contents(
trailingslashit( $dir ) . 'README-pre-uninstall.txt', trailingslashit( $wpdo_backup_dir ) . 'README-pre-uninstall.txt',
"WPDO pre-uninstall snapshot: {$snapshot_id}\n" "WPDO pre-uninstall snapshot: {$wpdo_snapshot_id}\n"
. 'Created: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n\n" . 'Created: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n\n"
. "To restore after re-installing wp-data-optimizer:\n" . "To restore after re-installing wp-data-optimizer:\n"
. " 1. Re-install the plugin (it will re-create wp_wpdo_snapshots).\n" . " 1. Re-install the plugin (it will re-create wp_wpdo_snapshots).\n"
. " 2. Manually re-insert this snapshot's catalog row, or use wp wpdo snapshot list to confirm.\n" . " 2. Manually re-insert this snapshot's catalog row, or use wp wpdo snapshot list to confirm.\n"
. " 3. Run: wp wpdo snapshot restore {$snapshot_id} --apply\n" . " 3. Run: wp wpdo snapshot restore {$wpdo_snapshot_id} --apply\n"
); );
} }
} }