Compare commits

7 Commits

Author SHA1 Message Date
wpdev 781f171770 fix(2meet-data-optimizer): skip zone deletes on tables that were never created
- TMDO_Zone_Router::delete_post() deleted from wp_wpdo_hot_/cold_{post_type}
  whenever fields were registered for that zone, so every post delete on a
  HivePress site (cold fields registered, module idle, table never created)
  logged a "table doesn't exist" error. Hot and cold deletes now also need
  the table to exist; module state is not checked, so a table left by a
  rolled-back module still loses the rows of deleted posts.
- Live suite zero_side_effect: saving / updating / deleting an hp_listing
  issues no query on missing zone tables (covers PR-054 and this PR).
- Verify: ZoneRouterTest 22 OK; live zero_side_effect + shadow_write +
  serialized 9/9 PASS (old code: zero_side_effect FAIL); unit 687 OK,
  integration 484 OK, phpcs (phpcs.xml) 0, stan OK
- PR: PR-054b
2026-09-26 19:02:10 +08:00
wpdev b84252d787 fix(2meet-data-optimizer): serve native meta in shadow_read and compare flat values
- In shadow_read, TMDO_Hook_Bus::intercept_get() returned the flat value.
  update_metadata() reads the old value through that filter after the Hook
  Bus has upserted the new one, so WP saw old === new and never wrote native
  meta: every update in shadow_read was lost on demotion (dev36 2026-09-26).
  shadow_read now returns $check (native) and compares flat on a sample of
  reads (filter tmdo_shadow_compare_sample_rate, default 0.1). aeav_only is
  unchanged; the post query router still routes in shadow_read.
- Live suite shadow_write: xfail markers removed, HivePress listing case
  added (new int key hp_rating_count); fixtures gain post() and a
  post_type-scoped field()/flat().
- Verify: live shadow_write + serialized 8/8 PASS (old code: 4 FAIL);
  unit 685 OK, integration 484 OK, phpcs (phpcs.xml) 0, stan OK
- PR: PR-056
2026-09-26 18:56:45 +08:00
wpdev 08f9e313e0 fix(2meet-data-optimizer): serialize arrays in text fields instead of storing "Array"
- TMDO_Type_Caster: text / textarea values that are arrays or objects are
  stored with maybe_serialize() (was (string) → the literal "Array", which
  bridge-restore-native then wrote back over native meta); from_db()
  unserializes them with TMDO_Safe_Unserialize. text columns are
  VARCHAR(255): longer values are stored NULL (native keeps them) and
  logged as text_overflow.
- Add `wp tmdo repair-array-literals [--entity] [--confirm]`
  (TMDO_Array_Literal_Repair): rewrites flat "Array" values from native
  meta and lists native rows that are "Array" themselves (backup needed).
- Verify: TypeCasterSerializeTest 13 OK, ArrayLiteralRepairTest 3 OK;
  unit 685 OK, integration 483 OK, phpcs (phpcs.xml) 0, stan OK;
  live `serialized` 3 PASS; dev36 repair dry-run lists the value corrupted
  by the 2026-09-26 round trip (user #1 community-events-location)
- PR: PR-055
2026-09-26 18:45:59 +08:00
wpdev 20bd59ddae fix(2meet-data-optimizer): skip cold-zone cache warming unless the cold module is active
- TMDO_Cache_Layer::prefetch() / warm_post() (and so on_save_post /
  on_loop_start) queried wp_wpdo_cold_{post_type} whenever cold fields
  were registered, even with the cold module idle and the table never
  created: every HivePress / WC save logged "table doesn't exist" errors.
  They now return unless the module is write-active and the table exists.
- CacheLayerTest activates the module in setUp; new cases assert zero cold
  queries while idle or when the table is missing.
- Verify: CacheLayerTest 12 OK; unit 671 OK, integration 480 OK,
  phpcs (phpcs.xml) 0, stan OK; dev36 live: seeding listings/products/
  vendors with cold_hp_listing idle → 0 wpdo_cold errors (was hundreds)
- PR: PR-054
2026-09-26 18:34:09 +08:00
wpdev 79a6c4d724 fix(2meet-data-optimizer): refuse aeav_only and cleanup on HivePress / WooCommerce sites
- Add TMDO_Promotion_Guard: HivePress models read every meta key with an
  empty-key get_*_meta() and WooCommerce does the same for products (and
  writes stock/sales with direct SQL), so on those sites native meta must
  stay authoritative. Promotion to aeav_only is refused in
  TMDO_Mode_Manager::set() (covers REST, CLI, phases, auto-promoter) for
  post/user/term/comment with HivePress and post/user with WooCommerce;
  TMDO_Post_Migration::cleanup() and the cleanup phase refuse too.
  Demotion stays open; filter tmdo_promotion_guard_allow overrides.
- Admin notice lists guarded entities already in aeav_only with the
  restore-native command (no forced demotion on read).
- Verify: PromotionGuardTest 8 OK; unit 668 OK, integration 480 OK,
  phpcs (phpcs.xml) 0, stan OK; dev36 live: 4 promotions + post-cleanup
  refused, wpdo_bridge_modes unchanged
- PR: PR-053
2026-09-26 18:29:04 +08:00
wpdev f72a315986 test(2meet-data-optimizer): add live test runner and failing shadow_read write suite
- Add tests/live: a `wp eval-file` runner (PASS/FAIL/XFAIL/XPASS/SKIP) with
  fixtures that override entity modes in process (pre_option) and read
  native / flat values with plain SQL, so WP core meta flows run for real
- shadow_write suite reproduces the dev36 finding: in shadow_read,
  update_*_meta returns false and native meta is never written
  (3 XFAIL marked for PR-056); the dual_write control passes
- Move the dev36 integration harness into tests/live/tools; outputs go to
  cwd / $TMDO_LIVE_OUT, site URL and path are env-configurable
- Verify: wp --path=<dev36> eval-file tests/live/run.php shadow_write
  → 1 passed, 3 xfail; unit 658 OK, integration 480 OK,
  phpcs (phpcs.xml) 0, stan OK
- PR: PR-052
2026-09-26 18:21:31 +08:00
wpdev 2540d7c0e2 Merge pull request 'hardening 2.0: 2meet-data-optimizer (staged rollout, dev22 first)' (#3) from hardening/2.0 into main
Tests / Unit Tests (push) Successful in 49s
Anti-EAV Lint + Quality Gate / anti-eav-lint (push) Successful in 22s
Tests / PHP Lint (push) Successful in 52s
Tests / Integration Tests (push) Successful in 1m15s
Tests / PHPStan (push) Failing after 38s
Tests / PHPCS (push) Successful in 30s
2026-09-26 10:20:23 +08:00
36 changed files with 2066 additions and 39 deletions
+4
View File
@@ -234,6 +234,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
// ── v2.0.0 Engine + Adapters + Modules ─────────────────────────────────── // ── v2.0.0 Engine + Adapters + Modules ───────────────────────────────────
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
@@ -260,6 +261,7 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php';
add_action( TMDO_Migration_Orchestrator::CRON_HOOK, array( 'TMDO_Migration_Orchestrator', 'cron_tick' ) ); add_action( TMDO_Migration_Orchestrator::CRON_HOOK, array( 'TMDO_Migration_Orchestrator', 'cron_tick' ) );
add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) ); add_action( 'wpdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) ); add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) );
@@ -292,6 +294,8 @@ if ( is_admin() ) {
TMDO_Deprecated_Addons_Notice::register(); TMDO_Deprecated_Addons_Notice::register();
require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php'; require_once TMDO_PATH . 'admin/class-tmdo-missing-addons-notice.php';
TMDO_Missing_Addons_Notice::register(); TMDO_Missing_Addons_Notice::register();
require_once TMDO_PATH . 'admin/class-tmdo-promotion-guard-notice.php';
TMDO_Promotion_Guard_Notice::register();
} }
// ── WP-CLI ───────────────────────────────────────────────────────────────── // ── WP-CLI ─────────────────────────────────────────────────────────────────
@@ -0,0 +1,63 @@
<?php
/**
* Admin notice when an entity is in aeav_only on a HivePress / WooCommerce site.
*
* Those plugins read native meta directly, so on an aeav_only entity they read
* stale (or, after cleanup, missing) values. The Promotion Guard refuses new
* promotions; sites promoted before it existed are only warned — demoting on
* read would leave them reading whatever native meta is left.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Warns administrators about guarded entities that are already in aeav_only.
*/
final class TMDO_Promotion_Guard_Notice {
/**
* Hook the notice.
*
* @return void
*/
public static function register(): void {
add_action( 'admin_notices', array( __CLASS__, 'render' ) );
}
/**
* Print the notice for administrators when a guarded entity is in aeav_only.
*
* @return void
*/
public static function render(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$violations = TMDO_Promotion_Guard::violations();
if ( empty( $violations ) ) {
return;
}
$lines = array();
foreach ( $violations as $entity_type => $integrations ) {
$lines[] = sprintf( '%s(%s):wp tmdo bridge-restore-native %s --demote', $entity_type, implode( ' / ', $integrations ), $entity_type );
}
printf(
'<div class="notice notice-error"><p><strong>%s</strong> %s</p></div>',
esc_html__( '2meet Data Optimizer:', '2meet-data-optimizer' ),
esc_html(
sprintf(
/* translators: %s: "entity (integrations): command" list */
__( '以下實體處於 aeav_only,但這些外掛會直接讀取原生 meta,可能讀到過期或遺失的資料。請先備份,再把扁平資料寫回原生並降級:%s', '2meet-data-optimizer' ),
implode( ';', $lines )
)
)
);
}
}
+46
View File
@@ -217,6 +217,50 @@ class TMDO_CLI_V2 {
WP_CLI::success( $report['demoted'] ? "Restored; {$entity} is now dual_write." : 'Restored. Demote the entity to dual_write (admin Entity Bridge tab) before its next write, or re-run with --demote.' ); WP_CLI::success( $report['demoted'] ? "Restored; {$entity} is now dual_write." : 'Restored. Demote the entity to dual_write (admin Entity Bridge tab) before its next write, or re-run with --demote.' );
} }
/**
* Find flat text / textarea values stored as the literal "Array" (pre-1.1.0
* type caster) and rewrite them from native meta.
*
* Native rows that are "Array" too cannot be repaired here; they are listed
* so they can be restored from a backup.
*
* ## OPTIONS
*
* [--entity=<entity>]
* : post | user | term | comment. Default: all four.
*
* [--confirm]
* : Write the repairs. Without it the command only reports.
*
* ## EXAMPLES
*
* wp tmdo repair-array-literals
* wp tmdo repair-array-literals --entity=user --confirm
*
* @param array<int, string> $args Positional arguments (unused).
* @param array<string, string> $assoc_args Named arguments.
*/
public function repair_array_literals( $args, $assoc_args ): void {
$entities = isset( $assoc_args['entity'] ) ? array( (string) $assoc_args['entity'] ) : TMDO_Entity_Registry::VALID_ENTITY_TYPES;
$apply = isset( $assoc_args['confirm'] );
foreach ( $entities as $entity ) {
if ( ! in_array( $entity, TMDO_Entity_Registry::VALID_ENTITY_TYPES, true ) ) {
WP_CLI::error( "Unknown entity: {$entity}" );
}
$report = TMDO_Array_Literal_Repair::run( $entity, $apply );
WP_CLI::log( sprintf( '%s: %d "Array" value(s) in flat tables, %d repairable from native, %d repaired.', $entity, $report['found'], $report['repairable'], $report['repaired'] ) );
foreach ( $report['unrepairable'] as $row ) {
WP_CLI::log( sprintf( ' unrepairable %s/%s #%d (%s)', $row['group'], $row['key'], $row['id'], $row['reason'] ) );
}
foreach ( $report['native_polluted'] as $row ) {
WP_CLI::warning( sprintf( 'native meta is "Array": %s %s #%d — restore it from a backup', $entity, $row['key'], $row['id'] ) );
}
}
WP_CLI::success( $apply ? 'Repair finished.' : '[dry-run] Nothing written. Re-run with --confirm to repair.' );
}
/** /**
* Audit per-module state + shadow_read flags. * Audit per-module state + shadow_read flags.
* *
@@ -851,6 +895,8 @@ WP_CLI::add_command( 'wpdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) ); WP_CLI::add_command( 'tmdo bridge-set', array( 'TMDO_CLI_V2', 'bridge_set' ) );
WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) ); WP_CLI::add_command( 'wpdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) ); WP_CLI::add_command( 'tmdo bridge-restore-native', array( new TMDO_CLI_V2(), 'bridge_restore_native' ) );
WP_CLI::add_command( 'wpdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
WP_CLI::add_command( 'tmdo repair-array-literals', array( new TMDO_CLI_V2(), 'repair_array_literals' ) );
WP_CLI::add_command( 'wpdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) ); WP_CLI::add_command( 'wpdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
WP_CLI::add_command( 'tmdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) ); WP_CLI::add_command( 'tmdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) );
WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) ); WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) );
+15 -2
View File
@@ -40,7 +40,7 @@ class TMDO_Cache_Layer {
} }
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type ); $cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
if ( empty( $cold_keys ) ) { if ( empty( $cold_keys ) || ! self::cold_active( $post_type ) ) {
return; return;
} }
@@ -108,7 +108,7 @@ class TMDO_Cache_Layer {
*/ */
public static function warm_post( int $post_id, string $post_type ): void { public static function warm_post( int $post_id, string $post_type ): void {
$cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type ); $cold_keys = TMDO_Schema_Registry::instance()->get_cold_meta_keys( $post_type );
if ( empty( $cold_keys ) ) { if ( empty( $cold_keys ) || ! self::cold_active( $post_type ) ) {
return; return;
} }
@@ -120,6 +120,19 @@ class TMDO_Cache_Layer {
TMDO_Zone_Cold::get_blob( $post_id, $post_type ); TMDO_Zone_Cold::get_blob( $post_id, $post_type );
} }
/**
* Whether the cold zone holds data for this post type: its module writes
* to the cold table and the table exists. Registered cold fields alone do
* not mean either (zero side effect while the module is idle).
*
* @param string $post_type Post type.
* @return bool
*/
private static function cold_active( string $post_type ): bool {
return TMDO_Feature_Flags::is_write_active( TMDO_Zone_Router::module_name( 'cold', $post_type ) )
&& TMDO_Schema_Manager::table_exists( TMDO_Zone_Cold::table( $post_type ) );
}
/** /**
* Flush all cold cache for a post type. * Flush all cold cache for a post type.
* *
+39 -17
View File
@@ -388,12 +388,20 @@ final class TMDO_Hook_Bus {
// ── Mode-aware dispatch ────────────────────────────── // ── Mode-aware dispatch ──────────────────────────────
// disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV // disabled : 完全不攔截,回傳 $check 讓 WP 走原生 EAV
// dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check // dual_write : 讀取仍走 EAV(flat 可能還沒有資料),回傳 $check
// shadow_read : 讀取走 UAE flat,同時與 EAV 比對記錄 diff // shadow_read : 讀取仍走 EAV,抽樣與 flat 比對記錄 diff
// aeav_only : 讀取走 UAE flat,不讀 EAV // aeav_only : 讀取走 UAE flat,不讀 EAV
if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) { if ( ! TMDO_Mode_Manager::reads_from_flat( $type ) ) {
return $check; // disabled / dual_write return $check; // disabled / dual_write
} }
// shadow_read serves native: update_metadata() reads the old value
// through this filter, and serving the just-upserted flat value made
// WP see old === new and skip the native write (dev36, 2026-09-26).
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
self::maybe_shadow_compare( $type, (int) $object_id, $meta_key, $field_def );
return $check;
}
$group = $field_def['group']; $group = $field_def['group'];
$row = self::get_or_load_row( $type, (int) $object_id, $group ); $row = self::get_or_load_row( $type, (int) $object_id, $group );
@@ -401,22 +409,6 @@ final class TMDO_Hook_Bus {
$has_value = is_array( $row ) && array_key_exists( $col, $row ); $has_value = is_array( $row ) && array_key_exists( $col, $row );
$value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null; $value = $has_value ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
// Shadow-read:與 EAV 比對,記錄差異
if ( TMDO_Mode_Manager::does_shadow_compare( $type ) ) {
try {
TMDO_Shadow_Diff_Logger::compare_and_log(
$type,
(int) $object_id,
$meta_key,
$value,
$field_def
);
} catch ( \Throwable $e ) {
// 比對失敗不該影響讀取
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
}
}
// 值為空 → 回 WP 原生慣例 // 值為空 → 回 WP 原生慣例
if ( empty( $row ) || $value === null || $value === '' ) { if ( empty( $row ) || $value === null || $value === '' ) {
return $single ? '' : array(); return $single ? '' : array();
@@ -426,6 +418,36 @@ final class TMDO_Hook_Bus {
return $single ? array( $value ) : array( $value ); return $single ? array( $value ) : array( $value );
} }
/**
* shadow_read: compare the flat value with native meta for a sample of reads.
*
* Each compare costs a flat row read and a native SQL read, so only a share
* of reads is checked (filter `tmdo_shadow_compare_sample_rate`, 0..1,
* default 0.1). The logger rate-limits repeated diffs itself.
*
* @param string $type Entity type.
* @param int $object_id Entity ID.
* @param string $meta_key Meta key.
* @param array<string, mixed> $field_def Field definition.
*/
private static function maybe_shadow_compare( string $type, int $object_id, string $meta_key, array $field_def ): void {
$rate = (float) apply_filters( 'tmdo_shadow_compare_sample_rate', 0.1, $type, $meta_key );
if ( $rate <= 0 || ( $rate < 1 && wp_rand( 1, 1000 ) > $rate * 1000 ) ) {
return;
}
$row = self::get_or_load_row( $type, $object_id, $field_def['group'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $meta_key );
$value = array_key_exists( $col, $row ) ? TMDO_Type_Caster::from_db( $row[ $col ], $field_def ) : null;
try {
TMDO_Shadow_Diff_Logger::compare_and_log( $type, $object_id, $meta_key, $value, $field_def );
} catch ( \Throwable $e ) {
// 比對失敗不該影響讀取
TMDO_Logger::error( 'entity_bridge', 'shadow_compare_exception', $e->getMessage() );
}
}
/** /**
* 取得(或載入)完整列資料,並快取 * 取得(或載入)完整列資料,並快取
*/ */
@@ -140,6 +140,11 @@ final class TMDO_Mode_Manager {
); );
} }
$guarded = TMDO_Promotion_Guard::check_promotion( $entity_type, $new_mode );
if ( $guarded ) {
return $guarded;
}
$all = self::all(); $all = self::all();
$all[ $entity_type ] = $new_mode; $all[ $entity_type ] = $new_mode;
@@ -0,0 +1,140 @@
<?php
/**
* TMDO_Promotion_Guard — keeps native meta authoritative on HivePress /
* WooCommerce sites (ADR-004 "search-index mode").
*
* Both plugins read native meta without going through a keyed
* get_*_meta() call: HivePress models load every key with get_*_meta( $id ),
* WooCommerce does the same for products and writes stock / sales with direct
* SQL. The Hook Bus cannot serve those reads from flat tables, so on these
* sites an entity must never reach aeav_only and native rows must never be
* cleaned up. Demotion always stays open.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Refuses aeav_only promotion and native-meta cleanup where an integration
* reads native meta directly.
*/
final class TMDO_Promotion_Guard {
/**
* Entity types each integration reads natively.
*
* HivePress models (Post, User, Term, Comment) all load meta with an empty
* key; WooCommerce does so for products and customers.
*/
private const GUARDED = array(
'HivePress' => array( 'post', 'user', 'term', 'comment' ),
'WooCommerce' => array( 'post', 'user' ),
);
/**
* Check a mode change. Only promotion to aeav_only is guarded.
*
* @param string $entity_type Entity type.
* @param string $new_mode Target mode.
* @return WP_Error|null Error when refused.
*/
public static function check_promotion( string $entity_type, string $new_mode ): ?WP_Error {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $new_mode ) {
return null;
}
return self::refuse( $entity_type, TMDO_Mode_Manager::MODE_AEAV_ONLY );
}
/**
* Check a cleanup of the entity's native managed meta rows.
*
* @param string $entity_type Entity type.
* @return WP_Error|null Error when refused.
*/
public static function check_cleanup( string $entity_type ): ?WP_Error {
return self::refuse( $entity_type, 'cleanup' );
}
/**
* Guarded entity types that are already in aeav_only (promoted before the
* guard existed). Native meta may be stale there.
*
* @return array<string, string[]> Entity type → integration names.
*/
public static function violations(): array {
$out = array();
foreach ( TMDO_Mode_Manager::all() as $type => $mode ) {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
continue;
}
$integrations = self::integrations_for( $type );
if ( ! empty( $integrations ) ) {
$out[ $type ] = $integrations;
}
}
return $out;
}
/**
* Active integrations that read this entity type natively.
*
* @param string $entity_type Entity type.
* @return string[]
*/
private static function integrations_for( string $entity_type ): array {
$active = array(
'HivePress' => TMDO_Compatibility::is_hivepress_active(),
'WooCommerce' => TMDO_Compatibility::is_woocommerce_active(),
);
$names = array();
foreach ( self::GUARDED as $name => $types ) {
if ( $active[ $name ] && in_array( $entity_type, $types, true ) ) {
$names[] = $name;
}
}
return $names;
}
/**
* Build the refusal, unless no integration applies or a filter allows it.
*
* @param string $entity_type Entity type.
* @param string $action 'aeav_only' or 'cleanup'.
* @return WP_Error|null
*/
private static function refuse( string $entity_type, string $action ): ?WP_Error {
$integrations = self::integrations_for( $entity_type );
if ( empty( $integrations ) ) {
return null;
}
/**
* Allow a guarded aeav_only promotion or cleanup anyway.
*
* @param bool $allow Default false.
* @param string $entity_type Entity type.
* @param string $action 'aeav_only' or 'cleanup'.
* @param string[] $integrations Integrations that read this entity natively.
*/
if ( apply_filters( 'tmdo_promotion_guard_allow', false, $entity_type, $action, $integrations ) ) {
return null;
}
return new WP_Error(
'promotion_guarded',
sprintf(
/* translators: 1: integration names, 2: entity type, 3: aeav_only or cleanup */
__( '%1$s 直接讀取原生 meta,%2$s 不能執行 %3$s:這類站台以原生 meta 為唯一資料來源(搜尋索引模式)。', '2meet-data-optimizer' ),
implode( ' / ', $integrations ),
$entity_type,
$action
)
);
}
}
+35 -3
View File
@@ -70,10 +70,12 @@ final class TMDO_Type_Caster {
return $value; return $value;
case 'text': case 'text':
return (string) $value; return self::fit_text( is_scalar( $value ) ? (string) $value : maybe_serialize( $value ), $field_def );
case 'textarea': case 'textarea':
return (string) $value; // Arrays / objects are serialized like WP stores them in native meta;
// (string) would store the literal "Array".
return is_scalar( $value ) ? (string) $value : maybe_serialize( $value );
default: default:
// 未知型別:嘗試序列化(向後相容 WordPress get_metadata 行為) // 未知型別:嘗試序列化(向後相容 WordPress get_metadata 行為)
@@ -81,6 +83,33 @@ final class TMDO_Type_Caster {
} }
} }
/** 'text' columns are VARCHAR(255) (TMDO_Schema_Manager type map). */
private const TEXT_MAX_CHARS = 255;
/**
* Keep a text value only if it fits its VARCHAR(255) column; otherwise
* store NULL (native meta keeps the full value) and log the overflow.
*
* @param string $value Value to store.
* @param array<string, mixed> $field_def Field definition.
* @return string|null
*/
private static function fit_text( string $value, array $field_def ): ?string {
$length = mb_strlen( $value );
if ( $length <= self::TEXT_MAX_CHARS ) {
return $value;
}
TMDO_Logger::warning(
'text_overflow',
array(
'group' => $field_def['group'] ?? '',
'key' => $field_def['key'] ?? '',
'length' => $length,
)
);
return null;
}
/** /**
* DB → PHP 轉換(讀取) * DB → PHP 轉換(讀取)
*/ */
@@ -111,9 +140,12 @@ final class TMDO_Type_Caster {
return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value; return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value;
case 'enum': case 'enum':
return (string) $value;
case 'text': case 'text':
case 'textarea': case 'textarea':
return (string) $value; // Arrays were stored serialized (to_db); plain strings pass through.
return TMDO_Safe_Unserialize::run( (string) $value );
default: default:
// v2.13.3: object-injection-safe unserialize (fixes L-DESER-1). // v2.13.3: object-injection-safe unserialize (fixes L-DESER-1).
@@ -0,0 +1,111 @@
<?php
/**
* TMDO_Array_Literal_Repair — finds flat text / textarea values stored as the
* literal "Array" (pre-1.1.0 type caster cast arrays with (string)) and
* rewrites them from native meta.
*
* Native rows that are themselves "Array" (written back by an earlier
* bridge-restore-native) cannot be repaired here: they are only listed, to be
* restored from a backup.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery -- table / column names come from the registry and adapter; values are prepared.
/**
* Repairs "Array" literals in flat text / textarea columns.
*/
final class TMDO_Array_Literal_Repair {
/** What (string) array produced. */
private const LITERAL = 'Array';
/**
* Scan (and optionally repair) one entity type.
*
* @param string $entity_type Entity type.
* @param bool $apply Write repairs; false = report only.
* @return array{found:int, repairable:int, repaired:int, unrepairable:array<int, array{group:string, key:string, id:int, reason:string}>, native_polluted:array<int, array{group:string, key:string, id:int}>}
*/
public static function run( string $entity_type, bool $apply ): array {
global $wpdb;
$report = array(
'found' => 0,
'repairable' => 0,
'repaired' => 0,
'unrepairable' => array(),
'native_polluted' => array(),
);
$adapter = TMDO_Entity_Registry::get_adapter( $entity_type );
if ( ! $adapter ) {
return $report;
}
$meta_table = $adapter->get_native_meta_table();
$id_col = $adapter->get_entity_id_column();
$meta_pk = 'user' === $entity_type ? 'umeta_id' : 'meta_id';
foreach ( TMDO_Entity_Registry::get_groups_for_type( $entity_type ) as $group ) {
$table = TMDO_Schema_Manager::get_table_name( $entity_type, $group );
if ( ! TMDO_Schema_Manager::table_exists( $table ) ) {
continue;
}
foreach ( TMDO_Entity_Registry::get_group_fields( $entity_type, $group ) as $field ) {
if ( ! in_array( $field['type'] ?? 'text', array( 'text', 'textarea' ), true ) ) {
continue;
}
$key = (string) $field['key'];
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
$ids = $wpdb->get_col( $wpdb->prepare( "SELECT `{$id_col}` FROM `{$table}` WHERE `{$col}` = %s", self::LITERAL ) );
foreach ( $ids as $id ) {
$id = (int) $id;
++$report['found'];
$native = $wpdb->get_var( $wpdb->prepare( "SELECT meta_value FROM `{$meta_table}` WHERE `{$id_col}` = %d AND meta_key = %s ORDER BY `{$meta_pk}` LIMIT 1", $id, $key ) );
$reason = null === $native ? 'no_native_row' : ( self::LITERAL === $native ? 'native_is_array_literal' : '' );
if ( '' !== $reason ) {
$report['unrepairable'][] = array(
'group' => $group,
'key' => $key,
'id' => $id,
'reason' => $reason,
);
continue;
}
++$report['repairable'];
if ( $apply ) {
$value = TMDO_Type_Caster::to_db( TMDO_Safe_Unserialize::run( $native ), $field + array( 'group' => $group ) );
$wpdb->update( $table, array( $col => $value ), array( $id_col => $id ) );
++$report['repaired'];
}
}
$polluted = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT `{$id_col}` FROM `{$meta_table}` WHERE meta_key = %s AND meta_value = %s", $key, self::LITERAL ) );
foreach ( $polluted as $id ) {
$report['native_polluted'][] = array(
'group' => $group,
'key' => $key,
'id' => (int) $id,
);
}
}
}
if ( $report['repaired'] > 0 ) {
TMDO_Cache_Orchestrator::flush_entity( $entity_type );
}
return $report;
}
}
@@ -228,6 +228,10 @@ final class TMDO_Post_Migration {
'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only' 'Refusing post cleanup — mode is ' . esc_html( $mode ) . ', must be aeav_only'
); );
} }
$guarded = TMDO_Promotion_Guard::check_cleanup( self::ENTITY_TYPE );
if ( $guarded ) {
throw new RuntimeException( esc_html( $guarded->get_error_message() ) );
}
global $wpdb; global $wpdb;
$keys = self::get_managed_keys(); $keys = self::get_managed_keys();
@@ -46,6 +46,10 @@ class TMDO_Phase_Cleanup extends TMDO_Migration_Phase_Base {
if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) { if ( TMDO_Mode_Manager::MODE_AEAV_ONLY !== $mode ) {
throw new \RuntimeException( "Refusing cleanup — mode is {$mode}, must be aeav_only" ); throw new \RuntimeException( "Refusing cleanup — mode is {$mode}, must be aeav_only" );
} }
$guarded = TMDO_Promotion_Guard::check_cleanup( $this->entity_type );
if ( $guarded ) {
throw new \RuntimeException( $guarded->get_error_message() );
}
if ( ! empty( $job['options']['auto_backup'] ) && empty( $job['backup_path'] ) ) { if ( ! empty( $job['options']['auto_backup'] ) && empty( $job['backup_path'] ) ) {
throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' ); throw new \RuntimeException( 'Refusing cleanup — auto_backup requested but no backup_path on record' );
} }
+6 -3
View File
@@ -104,7 +104,10 @@ class TMDO_Zone_Router {
* *
* Callers only need post_id and post_type; zone enumeration and per-zone * Callers only need post_id and post_type; zone enumeration and per-zone
* error isolation are handled here. Hot and Cold are skipped when the * error isolation are handled here. Hot and Cold are skipped when the
* post_type has no registered fields in that zone (table may not exist). * post_type has no registered fields in that zone, or when the zone table
* was never created (fields are registered long before a module runs).
* Module state is not checked: a table left by a rolled-back module still
* gets its rows for deleted posts removed.
* *
* @param int $post_id Post ID being deleted. * @param int $post_id Post ID being deleted.
* @param string $post_type Post type slug. * @param string $post_type Post type slug.
@@ -113,7 +116,7 @@ class TMDO_Zone_Router {
public static function delete_post( int $post_id, string $post_type ): void { public static function delete_post( int $post_id, string $post_type ): void {
$registry = TMDO_Schema_Registry::instance(); $registry = TMDO_Schema_Registry::instance();
if ( ! empty( $registry->get_hot_columns( $post_type ) ) ) { if ( ! empty( $registry->get_hot_columns( $post_type ) ) && TMDO_Schema_Manager::table_exists( TMDO_Zone_Hot::table( $post_type ) ) ) {
try { try {
TMDO_Zone_Hot::delete( $post_id, $post_type ); TMDO_Zone_Hot::delete( $post_id, $post_type );
} catch ( \Throwable $e ) { } catch ( \Throwable $e ) {
@@ -121,7 +124,7 @@ class TMDO_Zone_Router {
} }
} }
if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) ) { if ( ! empty( $registry->get_cold_meta_keys( $post_type ) ) && TMDO_Schema_Manager::table_exists( TMDO_Zone_Cold::table( $post_type ) ) ) {
try { try {
TMDO_Zone_Cold::delete( $post_id, $post_type ); TMDO_Zone_Cold::delete( $post_id, $post_type );
} catch ( \Throwable $e ) { } catch ( \Throwable $e ) {
+2
View File
@@ -613,6 +613,7 @@ require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
// Engine (v2.0.0). // Engine (v2.0.0).
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
@@ -646,6 +647,7 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-post-status-sync.php';
// Options Manager. // Options Manager.
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Repair of text / textarea flat columns holding the literal "Array" (stored
* by the pre-1.1.0 type caster when the meta value was an array).
*
* Fixture (user entity, group al_prefs):
* user 1 prefs 'Array' native prefs serialized array → repairable
* user 2 loc 'Array' native loc 'Array' too → unrepairable, native polluted
* user 3 name 'Array' no native row → unrepairable
* user 4 prefs serialized array, loc 'Taipei' → untouched
*/
class ArrayLiteralRepairTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_al_prefs';
public static function setUpBeforeClass(): void {
foreach ( array(
'includes/adapters/interface-entity-adapter.php',
'includes/engine/class-tmdo-type-caster.php',
'includes/engine/class-tmdo-schema-manager.php',
'includes/engine/class-tmdo-entity-registry.php',
'includes/engine/class-tmdo-cache-orchestrator.php',
'includes/adapters/class-tmdo-adapter-user.php',
'includes/migration/class-tmdo-array-literal-repair.php',
) as $file ) {
require_once WPDO_PLUGIN_DIR . $file;
}
}
protected function setUp(): void {
global $wpdb;
self::drop();
$wpdb->query( "CREATE TABLE `{$wpdb->usermeta}` ( umeta_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, meta_key VARCHAR(255) DEFAULT NULL, meta_value LONGTEXT, PRIMARY KEY (umeta_id), KEY user_id (user_id) ) ENGINE=InnoDB" );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, prefs TEXT DEFAULT NULL, loc VARCHAR(255) DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$prefs = serialize( array( 'core' => array( 'welcomeGuide' => false ) ) );
$wpdb->insert( self::TABLE, array( 'user_id' => 1, 'prefs' => 'Array' ) );
$wpdb->insert( self::TABLE, array( 'user_id' => 2, 'loc' => 'Array' ) );
$wpdb->insert( self::TABLE, array( 'user_id' => 3, 'name' => 'Array' ) );
$wpdb->insert( self::TABLE, array( 'user_id' => 4, 'prefs' => $prefs, 'loc' => 'Taipei' ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 1, 'meta_key' => 'prefs', 'meta_value' => $prefs ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 2, 'meta_key' => 'loc', 'meta_value' => 'Array' ) );
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => 4, 'meta_key' => 'prefs', 'meta_value' => $prefs ) );
$GLOBALS['_wp_cache'] = array();
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group(
'user',
'al_prefs',
array(
array( 'key' => 'prefs', 'type' => 'textarea' ),
array( 'key' => 'loc', 'type' => 'text' ),
array( 'key' => 'name', 'type' => 'text' ),
)
);
WPDO_Schema_Manager::flush_table_exists_cache();
}
protected function tearDown(): void {
self::drop();
WPDO_Entity_Registry::init();
}
private static function drop(): void {
global $wpdb;
foreach ( array( $wpdb->usermeta, self::TABLE ) as $table ) {
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
}
}
private function flat( int $user_id, string $col ): ?string {
global $wpdb;
return $wpdb->get_var( $wpdb->prepare( "SELECT `{$col}` FROM `" . self::TABLE . '` WHERE user_id = %d', $user_id ) );
}
public function test_dry_run_reports_without_writing(): void {
$report = TMDO_Array_Literal_Repair::run( 'user', false );
$this->assertSame( 3, $report['found'] );
$this->assertSame( 1, $report['repairable'] );
$this->assertSame( 0, $report['repaired'] );
$this->assertSame( 'Array', $this->flat( 1, 'prefs' ) );
}
public function test_apply_repairs_from_native_and_lists_the_rest(): void {
$report = TMDO_Array_Literal_Repair::run( 'user', true );
$this->assertSame( 1, $report['repaired'] );
$this->assertSame( serialize( array( 'core' => array( 'welcomeGuide' => false ) ) ), $this->flat( 1, 'prefs' ) );
$this->assertSame( 'Array', $this->flat( 2, 'loc' ), 'native is "Array" too — nothing to repair from' );
$this->assertSame( 'Array', $this->flat( 3, 'name' ), 'no native row' );
$this->assertSame( 'Taipei', $this->flat( 4, 'loc' ) );
$unrepairable = array_map(
static function ( $r ) {
return $r['id'] . ':' . $r['key'] . ':' . $r['reason'];
},
$report['unrepairable']
);
sort( $unrepairable );
$this->assertSame( array( '2:loc:native_is_array_literal', '3:name:no_native_row' ), $unrepairable );
$this->assertSame( array( array( 'group' => 'al_prefs', 'key' => 'loc', 'id' => 2 ) ), $report['native_polluted'] );
}
public function test_second_run_finds_only_the_unrepairable_rows(): void {
TMDO_Array_Literal_Repair::run( 'user', true );
$report = TMDO_Array_Literal_Repair::run( 'user', true );
$this->assertSame( 2, $report['found'] );
$this->assertSame( 0, $report['repaired'] );
}
}
@@ -268,6 +268,30 @@ class HookBusIntegrationTest extends TestCase {
$this->assertNull( $result, 'dual_write does not read from flat — must return $check' ); $this->assertNull( $result, 'dual_write does not read from flat — must return $check' );
} }
/**
* shadow_read serves native meta: update_metadata() reads the old value
* through this filter, and serving the just-written flat value made WP
* skip the native write (dev36, 2026-09-26). Flat is only compared.
*/
public function test_get_serves_native_in_shadow_read_mode_even_with_a_flat_value(): void {
global $wpdb;
$wpdb->insert( self::FLAT_TABLE, array( 'user_id' => 12, 'first_name' => 'Flat' ) );
self::set_user_mode( WPDO_Mode_Manager::MODE_SHADOW_READ );
foreach ( array( 0.0, 1.0 ) as $rate ) {
$GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] = array(
static function () use ( $rate ) {
return $rate;
},
);
$result = WPDO_Hook_Bus::intercept_get( null, 12, 'first_name', true, 'user' );
$this->assertNull( $result, "shadow_read must return \$check (sample rate {$rate})" );
}
unset( $GLOBALS['_wp_filter_callbacks']['tmdo_shadow_compare_sample_rate'] );
}
public function test_get_returns_empty_string_when_column_is_null(): void { public function test_get_returns_empty_string_when_column_is_null(): void {
global $wpdb; global $wpdb;
$wpdb->query( $wpdb->query(
+2
View File
@@ -576,6 +576,7 @@ require_once TMDO_PATH . 'includes/safety/class-tmdo-fsm-guard.php';
require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php'; require_once TMDO_PATH . 'includes/class-tmdo-rest-api.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-type-caster.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-mode-manager.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-promotion-guard.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-audit-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-shadow-diff-logger.php';
require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php'; require_once TMDO_PATH . 'includes/engine/class-tmdo-conflict-detector.php';
@@ -610,6 +611,7 @@ require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.p
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php'; require_once TMDO_PATH . 'includes/migration/class-tmdo-native-restorer.php';
require_once TMDO_PATH . 'includes/migration/class-tmdo-array-literal-repair.php';
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php'; require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php'; require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php';
require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php'; require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php';
+57
View File
@@ -0,0 +1,57 @@
# Live test suite
Tests that must run inside a real WordPress site, because the stubbed
`tests/integration` bootstrap cannot reproduce:
- WP core meta flows (`update_metadata()` reads the old value through
`get_metadata_raw()` before writing — the shadow_read write-loss bug lives there)
- the SQL that `WP_Query` actually builds, and hook priorities
- HivePress / WooCommerce behaviour (`pre_get_posts` sort clauses, empty-key
meta reads, direct-SQL stock updates)
**Never run against a production site.** Suites create and delete fixtures
(`tmdo_live_*` users, `tmdo-live-*` terms). Entity modes are overridden in
process through `pre_option_wpdo_bridge_modes`; nothing is written to
`wpdo_bridge_modes` or `wpdo_hook_bus_enabled`.
The reference site is dev36 (HivePress + Marketplace + WooCommerce), with this
plugin symlinked from the `plugins-dev` worktree while developing.
## Run
```bash
wp --path=/var/www/Studio/wp-local-dev36 eval-file tests/live/run.php <suite> [<suite> ...]
```
Exit code 0 when every test passed, 1 otherwise. Output per test:
| Status | Meaning |
|---|---|
| PASS / FAIL | normal |
| XFAIL | known bug, test marked `xfail` with the PR that fixes it; not counted as a failure |
| XPASS | an `xfail` test passed — counted as a failure so the fixing PR removes the marker |
| SKIP | the site does not register the key / taxonomy the test needs |
## Suites
| Suite | Covers |
|---|---|
| `shadow_write` | writes in shadow_read reach native meta (user, term, HivePress listing) |
| `serialized` | array meta values are stored serialized in flat tables, never "Array" |
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
## Tools (`tools/`)
Benchmark and data-flow helpers used for the 2026-09-26 dev36 integration test.
Output files go to the current directory (`qbench`) or `$TMDO_LIVE_OUT`
(default `/tmp`, `run_batch.sh`), never into the repo.
| Tool | Use |
|---|---|
| `seed.php` | `wp eval-file seed.php <vendors\|listings\|requests\|reviews\|products\|orders\|terms> <n> [tag]` — seed through real HivePress / WC APIs |
| `run_batch.sh` | `run_batch.sh <tag> [divisor]` — parallel seeding batch (`TMDO_LIVE_WP_PATH`, `TMDO_LIVE_OUT`) |
| `clone.sql` | bench only: clone hp_listing rows to reach large scale (`sed "s/__K__/<n>/" clone.sql \| wp db query`) |
| `checker.php` | `wp eval-file checker.php [entity]` — native vs flat consistency per registered key (SQL only) |
| `qbench.php` / `qdiff.php` | run 16 WP_Query shapes, then diff two runs (native vs routed): result sets, order, latency, SQL errors |
| `mut.php` | data-flow mutations through HivePress models, WC CRUD and core APIs; prints native / flat / API values |
| `fe.sh` + `zz-s36-probe.php` | front-end search probe (`TMDO_LIVE_URL`). Copy the probe into `wp-content/mu-plugins/` only for the run and remove it afterwards; it only answers requests carrying `X-S36-Probe: 1` |
+130
View File
@@ -0,0 +1,130 @@
<?php
/**
* Minimal assertion + test registry for the live suite.
*
* Why not PHPUnit: the live suite runs inside a real WordPress request
* (`wp eval-file`) so WP core's update_metadata() flow, WP_Query SQL and
* third-party hooks (HivePress, WooCommerce) are exercised for real — the
* stubbed integration bootstrap cannot reproduce those.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
/**
* Thrown by the assert helpers.
*/
class TMDO_Live_Failure extends RuntimeException {}
/**
* Thrown to skip a test whose preconditions the site does not meet.
*/
class TMDO_Live_Skip extends RuntimeException {}
/**
* Test registry and runner.
*/
final class TMDO_Live {
/**
* Registered tests.
*
* @var array<int, array{suite:string, name:string, fn:callable, xfail:string}>
*/
private static array $tests = array();
/**
* Suite currently being loaded.
*
* @var string
*/
public static string $suite = '';
/**
* Register a test.
*
* `xfail` marks a known bug: a failure is reported as XFAIL (not counted),
* a pass is reported as XPASS and counted as a failure so the marker gets
* removed by the PR that fixes the bug.
*
* @param string $name Test name.
* @param callable $fn Test body.
* @param array $opts Options: xfail => reason.
*/
public static function test( string $name, callable $fn, array $opts = array() ): void {
self::$tests[] = array(
'suite' => self::$suite,
'name' => $name,
'fn' => $fn,
'xfail' => (string) ( $opts['xfail'] ?? '' ),
);
}
/**
* Run every registered test; return true when nothing failed.
*/
public static function run(): bool {
$counts = array( 'PASS' => 0, 'FAIL' => 0, 'XFAIL' => 0, 'XPASS' => 0, 'SKIP' => 0 );
foreach ( self::$tests as $t ) {
$detail = '';
try {
( $t['fn'] )();
$status = '' !== $t['xfail'] ? 'XPASS' : 'PASS';
} catch ( TMDO_Live_Skip $e ) {
$status = 'SKIP';
$detail = $e->getMessage();
} catch ( Throwable $e ) {
$status = '' !== $t['xfail'] ? 'XFAIL' : 'FAIL';
$detail = ( $e instanceof TMDO_Live_Failure ? '' : get_class( $e ) . ': ' ) . $e->getMessage();
} finally {
TMDO_Live_Fixtures::teardown();
}
++$counts[ $status ];
if ( '' !== $t['xfail'] ) {
$detail = trim( $detail . ' [xfail: ' . $t['xfail'] . ']' );
}
printf( "%-5s %s :: %s%s\n", $status, $t['suite'], $t['name'], '' !== $detail ? "\n " . $detail : '' );
}
printf(
"\n%d passed, %d failed, %d xfail, %d xpass, %d skipped\n",
$counts['PASS'],
$counts['FAIL'],
$counts['XFAIL'],
$counts['XPASS'],
$counts['SKIP']
);
return 0 === $counts['FAIL'] && 0 === $counts['XPASS'];
}
}
/**
* Fail unless $actual is identical to $expected.
*
* @param mixed $expected Expected value.
* @param mixed $actual Actual value.
* @param string $message Context.
*/
function tmdo_live_assert_same( $expected, $actual, string $message ): void {
if ( $expected !== $actual ) {
throw new TMDO_Live_Failure(
sprintf( '%s — expected %s, got %s', $message, var_export( $expected, true ), var_export( $actual, true ) ) // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
);
}
}
/**
* Fail unless $condition is true.
*
* @param bool $condition Condition.
* @param string $message Context.
*/
function tmdo_live_assert_true( bool $condition, string $message ): void {
if ( ! $condition ) {
throw new TMDO_Live_Failure( $message );
}
}
+218
View File
@@ -0,0 +1,218 @@
<?php
/**
* Live-suite fixtures: in-process mode overrides, throwaway entities, and
* direct reads of native meta / flat tables (bypassing every meta filter).
*
* Nothing here persists a mode change: modes are overridden through the
* `pre_option_wpdo_bridge_modes` filter for the current process only.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
/**
* Fixture helpers, reset after every test by TMDO_Live::run().
*/
final class TMDO_Live_Fixtures {
/**
* Entities created by the current test, deleted on teardown.
*
* @var array<int, array{0:string, 1:int, 2?:string}>
*/
private static array $created = array();
/**
* Active pre_option callback, if any.
*
* @var callable|null
*/
private static $mode_filter = null;
/**
* Override entity modes for this process and make sure the Hook Bus is hooked.
*
* @param array<string, string> $modes e.g. array( 'user' => 'shadow_read' ).
*/
public static function use_modes( array $modes ): void {
self::clear_mode_filter();
$stored = get_option( 'wpdo_bridge_modes', array() );
$merged = array_merge( is_array( $stored ) ? $stored : array(), $modes );
self::$mode_filter = static function () use ( $merged ) {
return $merged;
};
add_filter( 'pre_option_wpdo_bridge_modes', self::$mode_filter );
self::reset_caches();
// The Hook Bus only boots when wpdo_hook_bus_enabled is on; the suite
// must not depend on (or change) that site option.
if ( false === has_filter( 'update_user_metadata', array( 'TMDO_Hook_Bus', 'intercept_update' ) ) ) {
TMDO_Hook_Bus::init();
}
}
/**
* Create a throwaway user.
*/
public static function user(): int {
$login = 'tmdo_live_' . wp_generate_password( 6, false, false );
$id = wp_insert_user(
array(
'user_login' => $login,
'user_email' => $login . '@example.test',
'user_pass' => wp_generate_password(),
)
);
if ( is_wp_error( $id ) ) {
throw new TMDO_Live_Failure( 'fixture user: ' . $id->get_error_message() );
}
self::$created[] = array( 'user', (int) $id );
return (int) $id;
}
/**
* Create a throwaway term.
*
* @param string $taxonomy Taxonomy.
*/
public static function term( string $taxonomy ): int {
if ( ! taxonomy_exists( $taxonomy ) ) {
throw new TMDO_Live_Skip( "taxonomy {$taxonomy} not registered on this site" );
}
$t = wp_insert_term( 'tmdo-live-' . wp_generate_password( 6, false, false ), $taxonomy );
if ( is_wp_error( $t ) ) {
throw new TMDO_Live_Failure( 'fixture term: ' . $t->get_error_message() );
}
self::$created[] = array( 'term', (int) $t['term_id'], $taxonomy );
return (int) $t['term_id'];
}
/**
* Create a throwaway post.
*
* @param string $post_type Post type.
*/
public static function post( string $post_type ): int {
if ( ! post_type_exists( $post_type ) ) {
throw new TMDO_Live_Skip( "post type {$post_type} not registered on this site" );
}
$id = wp_insert_post(
array(
'post_type' => $post_type,
'post_title' => 'tmdo-live-' . wp_generate_password( 6, false, false ),
'post_status' => 'draft',
),
true
);
if ( is_wp_error( $id ) ) {
throw new TMDO_Live_Failure( 'fixture post: ' . $id->get_error_message() );
}
self::$created[] = array( 'post', (int) $id );
return (int) $id;
}
/**
* Native meta values for a key, read with SQL (no filters, no cache).
*
* @param string $type Entity type (post|user|term|comment).
* @param int $id Entity ID.
* @param string $key Meta key.
* @return string[]
*/
public static function native( string $type, int $id, string $key ): array {
global $wpdb;
$table = _get_meta_table( $type );
$id_col = $type . '_id';
$pk = 'user' === $type ? 'umeta_id' : 'meta_id';
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column names are WP internals.
return $wpdb->get_col( $wpdb->prepare( "SELECT meta_value FROM {$table} WHERE {$id_col} = %d AND meta_key = %s ORDER BY {$pk}", $id, $key ) );
}
/**
* Raw flat-table value for a key; 'NO-ROW' when the entity has no row.
*
* @param string $type Entity type.
* @param int $id Entity ID.
* @param string $key Meta key.
* @param string $post_type For posts: the post type whose group owns the key.
* @return string|null
*/
public static function flat( string $type, int $id, string $key, string $post_type = '' ) {
global $wpdb;
$field = self::field( $type, $key, $post_type );
$table = TMDO_Schema_Manager::get_table_name( $type, $field['group'] );
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table/column come from the registry.
$row = $wpdb->get_row( $wpdb->prepare( "SELECT `{$col}` AS v FROM `{$table}` WHERE {$type}_id = %d", $id ), ARRAY_A );
return null === $row ? 'NO-ROW' : $row['v'];
}
/**
* Registry field for a key, or skip the test when the site does not register it.
*
* A key can live in several post groups (hp_rating in listing and vendor
* groups); pass the post type to pick the group scoped to it.
*
* @param string $type Entity type.
* @param string $key Meta key.
* @param string $post_type For posts: the post type whose group owns the key.
*/
public static function field( string $type, string $key, string $post_type = '' ): array {
$field = TMDO_Entity_Registry::get_field( $type, $key );
if ( '' !== $post_type ) {
foreach ( TMDO_Entity_Registry::get_groups_for_type( $type ) as $group ) {
if ( in_array( $post_type, TMDO_Entity_Registry::get_group_post_types( $type, $group ), true )
&& in_array( $key, TMDO_Entity_Registry::get_group_keys( $type, $group ), true ) ) {
$field = array( 'group' => $group ) + (array) $field;
break;
}
}
}
if ( ! $field ) {
throw new TMDO_Live_Skip( "{$type} key {$key} is not registered on this site" );
}
if ( ! TMDO_Schema_Manager::table_exists( TMDO_Schema_Manager::get_table_name( $type, $field['group'] ) ) ) {
throw new TMDO_Live_Skip( "flat table for {$type}/{$field['group']} does not exist" );
}
return $field;
}
/**
* Delete fixtures and drop the mode override.
*/
public static function teardown(): void {
require_once ABSPATH . 'wp-admin/includes/user.php';
foreach ( array_reverse( self::$created ) as $c ) {
if ( 'user' === $c[0] ) {
wp_delete_user( $c[1] );
} elseif ( 'term' === $c[0] ) {
wp_delete_term( $c[1], $c[2] );
} elseif ( 'post' === $c[0] ) {
wp_delete_post( $c[1], true );
}
}
self::$created = array();
self::clear_mode_filter();
self::reset_caches();
}
/**
* Remove the pre_option override, if any.
*/
private static function clear_mode_filter(): void {
if ( null !== self::$mode_filter ) {
remove_filter( 'pre_option_wpdo_bridge_modes', self::$mode_filter );
self::$mode_filter = null;
}
}
/**
* Reset request-level caches that memoise modes or routing decisions.
*/
private static function reset_caches(): void {
TMDO_Mode_Manager::reset_cache();
TMDO_Routing_Predicate::flush_cache();
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
/**
* Live test runner — runs suites inside a real WordPress site (HivePress /
* WooCommerce active) so WP core meta flows and query SQL are exercised for real.
*
* Usage:
* wp --path=<site> eval-file tests/live/run.php <suite> [<suite> ...]
*
* Exit code 0 when every test passed (XFAIL allowed), 1 otherwise.
* Never run against a production site: suites create and delete fixtures.
*
* No strict_types here: wp eval-file evals this file, and a declare must be
* the first statement of a real file. The required files declare it.
*
* @package WP_Data_Optimizer
*/
require __DIR__ . '/lib/assert.php';
require __DIR__ . '/lib/fixtures.php';
$tmdo_live_suites = isset( $args ) ? (array) $args : array();
if ( ! class_exists( 'TMDO_Hook_Bus' ) ) {
fwrite( STDERR, "2meet-data-optimizer is not active on this site.\n" );
exit( 1 );
}
if ( empty( $tmdo_live_suites ) ) {
$tmdo_live_available = array_map(
static function ( $f ) {
return basename( $f, '.php' );
},
glob( __DIR__ . '/suites/*.php' )
);
fwrite( STDERR, 'Usage: wp eval-file tests/live/run.php <suite> ... Suites: ' . implode( ', ', $tmdo_live_available ) . "\n" );
exit( 1 );
}
foreach ( $tmdo_live_suites as $tmdo_live_suite ) {
$tmdo_live_file = __DIR__ . '/suites/' . basename( (string) $tmdo_live_suite ) . '.php';
if ( ! is_file( $tmdo_live_file ) ) {
fwrite( STDERR, "Unknown suite: {$tmdo_live_suite}\n" );
exit( 1 );
}
TMDO_Live::$suite = (string) $tmdo_live_suite;
require $tmdo_live_file;
}
exit( TMDO_Live::run() ? 0 : 1 );
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* Suite: array meta values survive the flat tables (no "Array" literal).
*
* Bug (dev36, 2026-09-26): admin_prefs `community-events-location` (text) and
* `wp_persisted_preferences` (textarea) hold arrays; the type caster stored
* (string) $array = "Array", and bridge-restore-native wrote it back to native.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
TMDO_Live::test(
'dual_write: array written with update_user_meta is stored serialized',
static function () {
TMDO_Live_Fixtures::field( 'user', 'wp_persisted_preferences' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'dual_write' ) );
$uid = TMDO_Live_Fixtures::user();
$prefs = array( 'core' => array( 'welcomeGuide' => false ) );
$loc = array( 'ip' => '192.0.2.0' );
update_user_meta( $uid, 'wp_persisted_preferences', $prefs );
update_user_meta( $uid, 'community-events-location', $loc );
tmdo_live_assert_same( serialize( $prefs ), TMDO_Live_Fixtures::flat( 'user', $uid, 'wp_persisted_preferences' ), 'flat wp_persisted_preferences' );
tmdo_live_assert_same( serialize( $loc ), TMDO_Live_Fixtures::flat( 'user', $uid, 'community-events-location' ), 'flat community-events-location' );
}
);
TMDO_Live::test(
'backfill: native array meta is flattened serialized, not "Array"',
static function () {
TMDO_Live_Fixtures::field( 'user', 'community-events-location' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'disabled' ) );
$uid = TMDO_Live_Fixtures::user();
$loc = array( 'ip' => '198.51.100.0' );
update_user_meta( $uid, 'community-events-location', $loc );
tmdo_live_assert_same( array( serialize( $loc ) ), TMDO_Live_Fixtures::native( 'user', $uid, 'community-events-location' ), 'native precondition' );
$field = TMDO_Entity_Registry::get_field( 'user', 'community-events-location' );
TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $field['group'] );
TMDO_Entity_Migration_Engine::migrate_group( 'user', $field['group'], array( 'sleep_ms' => 0, 'resume' => false ) );
tmdo_live_assert_same( serialize( $loc ), TMDO_Live_Fixtures::flat( 'user', $uid, 'community-events-location' ), 'flat after backfill' );
}
);
TMDO_Live::test(
'read path: from_db returns the array, not a serialized string',
static function () {
$field = TMDO_Live_Fixtures::field( 'user', 'wp_persisted_preferences' );
$prefs = array( 'core/edit-post' => array( 'panels' => array( 'a', 'b' ) ) );
$stored = TMDO_Type_Caster::to_db( $prefs, $field );
tmdo_live_assert_same( $prefs, TMDO_Type_Caster::from_db( $stored, $field ), 'round trip' );
}
);
+96
View File
@@ -0,0 +1,96 @@
<?php
/**
* Suite: writes made in shadow_read must reach native meta.
*
* Bug (dev36, 2026-09-26): in shadow_read, update_metadata() fetches the old
* value through get_metadata_raw(); the Hook Bus has already upserted the new
* value and serves it from the flat table, so WP sees old === new and returns
* false without writing native meta. Demoting afterwards exposes stale data.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
TMDO_Live::test(
'dual_write: user first_name update reaches native and flat (control)',
static function () {
TMDO_Live_Fixtures::field( 'user', 'first_name' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'dual_write' ) );
$uid = TMDO_Live_Fixtures::user();
update_user_meta( $uid, 'first_name', 'Before' );
$ret = update_user_meta( $uid, 'first_name', 'After' );
tmdo_live_assert_true( false !== $ret, 'update_user_meta returned false' );
tmdo_live_assert_same( array( 'After' ), TMDO_Live_Fixtures::native( 'user', $uid, 'first_name' ), 'native first_name' );
tmdo_live_assert_same( 'After', TMDO_Live_Fixtures::flat( 'user', $uid, 'first_name' ), 'flat first_name' );
}
);
TMDO_Live::test(
'shadow_read: user first_name update reaches native',
static function () {
TMDO_Live_Fixtures::field( 'user', 'first_name' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'dual_write' ) );
$uid = TMDO_Live_Fixtures::user();
update_user_meta( $uid, 'first_name', 'Before' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'shadow_read' ) );
$ret = update_user_meta( $uid, 'first_name', 'After' );
tmdo_live_assert_true( false !== $ret, 'update_user_meta returned false' );
tmdo_live_assert_same( array( 'After' ), TMDO_Live_Fixtures::native( 'user', $uid, 'first_name' ), 'native first_name' );
}
);
TMDO_Live::test(
'shadow_read → dual_write: value written in shadow_read survives demotion',
static function () {
TMDO_Live_Fixtures::field( 'user', 'last_name' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'dual_write' ) );
$uid = TMDO_Live_Fixtures::user();
update_user_meta( $uid, 'last_name', 'Before' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'shadow_read' ) );
update_user_meta( $uid, 'last_name', 'After' );
TMDO_Live_Fixtures::use_modes( array( 'user' => 'dual_write' ) );
wp_cache_delete( $uid, 'user_meta' );
tmdo_live_assert_same( 'After', get_user_meta( $uid, 'last_name', true ), 'last_name after demotion' );
}
);
TMDO_Live::test(
'shadow_read: HivePress term hp_icon update reaches native',
static function () {
TMDO_Live_Fixtures::field( 'term', 'hp_icon' );
TMDO_Live_Fixtures::use_modes( array( 'term' => 'dual_write' ) );
$tid = TMDO_Live_Fixtures::term( 'hp_listing_category' );
update_term_meta( $tid, 'hp_icon', 'star' );
TMDO_Live_Fixtures::use_modes( array( 'term' => 'shadow_read' ) );
$ret = update_term_meta( $tid, 'hp_icon', 'heart' );
tmdo_live_assert_true( false !== $ret, 'update_term_meta returned false' );
tmdo_live_assert_same( array( 'heart' ), TMDO_Live_Fixtures::native( 'term', $tid, 'hp_icon' ), 'native hp_icon' );
}
);
TMDO_Live::test(
'shadow_read: HivePress listing hp_rating_count (new int key) reaches native',
static function () {
TMDO_Live_Fixtures::field( 'post', 'hp_rating_count', 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'dual_write' ) );
$lid = TMDO_Live_Fixtures::post( 'hp_listing' );
TMDO_Live_Fixtures::use_modes( array( 'post' => 'shadow_read' ) );
// HivePress writes the recomputed count as an int; the flat read cast it
// to the same int, so the old code skipped the native write.
$ret = update_post_meta( $lid, 'hp_rating_count', 1 );
tmdo_live_assert_true( false !== $ret, 'update_post_meta returned false' );
tmdo_live_assert_same( array( '1' ), TMDO_Live_Fixtures::native( 'post', $lid, 'hp_rating_count' ), 'native hp_rating_count' );
tmdo_live_assert_same( '1', TMDO_Live_Fixtures::flat( 'post', $lid, 'hp_rating_count', 'hp_listing' ), 'flat hp_rating_count' );
}
);
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Suite: zone tables that were never created are never queried.
*
* Bug (dev36, 2026-09-26): the HivePress AddOn registers cold fields, so every
* save_post / loop_start (PR-054) and every post delete (PR-054b) queried
* wp_wpdo_cold_{post_type} although the cold module was idle and the table
* did not exist ("table doesn't exist" DB errors on each write).
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
TMDO_Live::test(
'saving and deleting an hp_listing does not touch missing zone tables',
static function () {
global $wpdb;
$tables = array( TMDO_Zone_Cold::table( 'hp_listing' ), TMDO_Zone_Hot::table( 'hp_listing' ) );
foreach ( $tables as $table ) {
if ( TMDO_Schema_Manager::table_exists( $table ) ) {
throw new TMDO_Live_Skip( "{$table} exists on this site" );
}
}
$touched = array();
$spy = static function ( $sql ) use ( $tables, &$touched ) {
foreach ( $tables as $table ) {
if ( false !== strpos( $sql, $table ) && 0 !== stripos( ltrim( $sql ), 'SHOW TABLES' ) ) {
$touched[] = $sql;
}
}
return $sql;
};
add_filter( 'query', $spy );
try {
$id = TMDO_Live_Fixtures::post( 'hp_listing' );
wp_update_post( array( 'ID' => $id, 'post_title' => 'tmdo-live-updated' ) );
wp_delete_post( $id, true );
} finally {
remove_filter( 'query', $spy );
}
tmdo_live_assert_same( array(), $touched, 'queries on missing zone tables' );
}
);
+46
View File
@@ -0,0 +1,46 @@
<?php
/**
* Independent native-meta vs flat-table consistency checker (does not use TMDO read paths).
* Usage: wp eval-file checker.php [entity]
* Per registered group/key: native rows, flat non-null, missing-in-flat, value mismatches, extra-in-flat.
*/
global $wpdb;
$only = $args[0] ?? '';
$ids = array( 'post' => 'post_id', 'user' => 'user_id', 'term' => 'term_id', 'comment' => 'comment_id' );
$metas = array( 'post' => $wpdb->postmeta, 'user' => $wpdb->usermeta, 'term' => $wpdb->termmeta, 'comment' => $wpdb->commentmeta );
$tot = array( 'miss' => 0, 'diff' => 0, 'extra' => 0 );
printf( "%-8s %-24s %-28s %8s %8s %6s %6s %6s\n", 'entity', 'group', 'key', 'native', 'flat', 'miss', 'diff', 'extra' );
foreach ( $ids as $e => $idcol ) {
if ( $only && $only !== $e ) { continue; }
$mt = $metas[ $e ];
$mid = ( 'user' === $e ) ? 'umeta_id' : 'meta_id';
foreach ( TMDO_Entity_Registry::get_groups_for_type( $e ) as $g ) {
$table = $wpdb->prefix . 'wpdo_' . $e . '_' . $g;
if ( ! $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) ) { continue; }
$scope = ( 'post' === $e ) ? TMDO_Entity_Registry::get_group_post_types( $e, $g ) : array();
$scope_sql = $scope ? " AND m.post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_type IN ('" . implode( "','", array_map( 'esc_sql', $scope ) ) . "'))" : '';
foreach ( TMDO_Entity_Registry::get_group_keys( $e, $g ) as $key ) {
$col = preg_replace( '/[^a-zA-Z0-9_]/', '', $key );
// Latest native value per entity.
$nat = $wpdb->prepare( "SELECT m.$idcol AS eid, m.meta_value AS v FROM $mt m JOIN (SELECT $idcol, MAX($mid) mid FROM $mt WHERE meta_key=%s GROUP BY $idcol) x ON x.mid=m.$mid WHERE 1=1 $scope_sql", $key );
$n_native = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ($nat) n" );
$n_flat = (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table WHERE `$col` IS NOT NULL" );
$miss = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ($nat) n LEFT JOIN $table f ON f.$idcol=n.eid WHERE f.$idcol IS NULL OR (f.`$col` IS NULL AND n.v <> '')" );
// Value diff: numeric-aware loose compare.
$diff = (int) $wpdb->get_var( "SELECT COUNT(*) FROM ($nat) n JOIN $table f ON f.$idcol=n.eid WHERE f.`$col` IS NOT NULL AND NOT (
CAST(f.`$col` AS CHAR) = n.v
OR (n.v REGEXP '^-?[0-9]+(\\\\.[0-9]+)?$' AND CAST(f.`$col` AS DECIMAL(30,6)) = CAST(n.v AS DECIMAL(30,6)))
OR (n.v='' AND CAST(f.`$col` AS CHAR) IN ('','0'))
)" );
$extra = (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table f WHERE f.`$col` IS NOT NULL AND NOT EXISTS (SELECT 1 FROM $mt m WHERE m.$idcol=f.$idcol AND m.meta_key='" . esc_sql( $key ) . "')" );
if ( $n_native || $n_flat ) {
printf( "%-8s %-24s %-28s %8d %8d %6d %6d %6d\n", $e, $g, $key, $n_native, $n_flat, $miss, $diff, $extra );
}
$tot['miss'] += $miss;
$tot['diff'] += $diff;
$tot['extra'] += $extra;
}
}
}
printf( "TOTAL miss=%d diff=%d extra=%d\n", $tot['miss'], $tot['diff'], $tot['extra'] );
+12
View File
@@ -0,0 +1,12 @@
-- Clone original hp_listing rows (ID < 20000) into copy k with ID offset k*20000.
-- Bench-only: clones hp_listing rows into offset IDs. Never run on a production site.
-- Usage: sed "s/__K__/<n>/" clone.sql | wp db query
SET @k := __K__; SET @off := @k * 20000;
INSERT INTO wp_posts (ID, post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt, post_status, comment_status, ping_status, post_password, post_name, to_ping, pinged, post_modified, post_modified_gmt, post_content_filtered, post_parent, guid, menu_order, post_type, post_mime_type, comment_count)
SELECT ID+@off, post_author, d, d, post_content, CONCAT(post_title,' c',@k), post_excerpt, post_status, comment_status, ping_status, post_password, CONCAT(post_name,'-c',@k), to_ping, pinged, d, d, post_content_filtered, post_parent, CONCAT(guid,'&c=',@k), menu_order, post_type, post_mime_type, comment_count
FROM (SELECT *, DATE_SUB('2026-09-26 12:00:00', INTERVAL FLOOR(RAND()*63072000) SECOND) d FROM wp_posts WHERE post_type='hp_listing' AND ID < 20000) s;
INSERT INTO wp_postmeta (post_id, meta_key, meta_value)
SELECT m.post_id+@off, m.meta_key, CASE m.meta_key WHEN 'hp_price' THEN FLOOR(100+RAND()*49900) WHEN 'hp_hourly_rate' THEN FLOOR(10+RAND()*290) ELSE m.meta_value END
FROM wp_postmeta m JOIN wp_posts p ON p.ID=m.post_id WHERE p.post_type='hp_listing' AND p.ID < 20000 AND m.meta_key NOT LIKE '\_transient%';
INSERT INTO wp_term_relationships (object_id, term_taxonomy_id, term_order)
SELECT r.object_id+@off, r.term_taxonomy_id, r.term_order FROM wp_term_relationships r JOIN wp_posts p ON p.ID=r.object_id WHERE p.post_type='hp_listing' AND p.ID < 20000;
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# usage: fe.sh <label> — front-end HivePress/WC search probe, 5 runs each, median ms
B="${TMDO_LIVE_URL:-https://dev36.2meet.pro}"
URLS=(
"/?post_type=hp_listing&s=&price[]=10000&price[]=20000"
"/?post_type=hp_listing&s=&price[]=10000&price[]=20000&_sort=price__asc"
"/?post_type=hp_listing&s=&hourly_rate[]=10&hourly_rate[]=50&_sort=hourly_rate__desc"
"/?post_type=hp_listing&s=&_sort=rating__desc"
"/?post_type=hp_listing&s=Taipei&price[]=1000&price[]=30000"
"/?post_type=hp_vendor&s=&hourly_rate[]=100&hourly_rate[]=200"
"/?post_type=hp_request&s="
"/shop/?min_price=1000&max_price=5000"
"/shop/?orderby=price-desc"
)
for u in "${URLS[@]}"; do
ms=(); for i in 1 2 3 4 5; do h=$(curl -s -o /dev/null -D - -H "X-S36-Probe: 1" "$B$u"); ms+=($(echo "$h" | grep -i x-s36-ms | awk "{print \$2}" | tr -dc 0-9)); done
med=$(printf "%s\n" "${ms[@]}" | sort -n | sed -n 3p)
echo "$u|$(echo "$h" | grep -i x-s36-found | awk "{print \$2}" | tr -dc 0-9)|$(echo "$h" | grep -i x-s36-ids | cut -d' ' -f2 | tr -d '\r')|$(echo "$h" | grep -i x-s36-queries | awk "{print \$2}" | tr -dc 0-9)|$med"
done
+61
View File
@@ -0,0 +1,61 @@
<?php
// Data-flow mutation test. Usage: wp eval-file mut.php <label>
use HivePress\Models;
add_filter( 'pre_http_request', function ( $r, $a, $u ) { return false !== strpos( $u, 'pixel.wp.com' ) ? array( 'headers' => array(), 'body' => '', 'response' => array( 'code' => 200, 'message' => 'OK' ), 'cookies' => array() ) : $r; }, 1, 3 );
global $wpdb;
$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 ); }
echo "== mode post/user = $mode\n";
// T1: HivePress model update of a listing price.
$lid = (int) $wpdb->get_var( "SELECT ID FROM {$wpdb->posts} WHERE post_type='hp_listing' AND post_status='publish' ORDER BY ID DESC LIMIT 1" );
$new = mt_rand( 60000, 69999 );
update_post_meta( $lid, 'hp_price', $new ); // HP stores attributes via meta API
clear_caches( $lid );
$m = Models\Listing::query()->get_by_id( $lid );
row( "T1 listing $lid hp_price:=$new", array( 'native' => nat( 'post', $lid, 'hp_price' ), 'flat' => flat( 'post', 'hp_listing_core', $lid, 'hp_price' ), 'get_meta' => get_post_meta( $lid, 'hp_price', true ), 'all_meta' => get_post_meta( $lid )['hp_price'][0] ?? '∅' ) );
// T2: HivePress model field (verified) via model save.
$m->set_verified( ! $m->is_verified() )->save( array( 'verified' ) );
clear_caches( $lid );
$m2 = Models\Listing::query()->get_by_id( $lid );
row( "T2 listing verified via HP model", array( 'native' => nat( 'post', $lid, 'hp_verified' ), 'flat' => flat( 'post', 'hp_listing_core', $lid, 'hp_verified' ), 'hp_model' => $m2->is_verified(), 'get_meta' => get_post_meta( $lid, 'hp_verified', true ) ) );
// T3: WC product price via CRUD.
$pid = (int) $wpdb->get_var( "SELECT ID FROM {$wpdb->posts} WHERE post_type='product' AND post_title LIKE 'Product B1%' ORDER BY ID DESC LIMIT 1" );
$p = wc_get_product( $pid ); $np = (string) mt_rand( 7000, 7999 ); $p->set_regular_price( $np ); $p->set_sale_price( '' ); $p->save();
clear_caches( $pid );
row( "T3 product $pid _regular_price:=$np", array( 'native' => nat( 'post', $pid, '_regular_price' ), 'flat' => flat( 'post', 'wc_product', $pid, '_regular_price' ), 'wc_read' => wc_get_product( $pid )->get_regular_price(), 'lookup' => $wpdb->get_var( "SELECT min_price FROM {$wpdb->prefix}wc_product_meta_lookup WHERE product_id=$pid" ) ) );
// T4: order reduces stock (WC direct SQL path).
$before = wc_get_product( $pid )->get_stock_quantity();
$o = wc_create_order(); $o->add_product( wc_get_product( $pid ), 2 ); $o->calculate_totals( false ); $o->set_status( 'processing' ); $o->save(); wc_reduce_stock_levels( $o->get_id() );
clear_caches( $pid );
row( "T4 order x2 on product $pid (stock $before)", array( 'native' => nat( 'post', $pid, '_stock' ), 'flat' => flat( 'post', 'wc_product', $pid, '_stock' ), 'get_meta' => get_post_meta( $pid, '_stock', true ), 'wc_read' => wc_get_product( $pid )->get_stock_quantity(), 'sales_native' => nat( 'post', $pid, 'total_sales' ), 'sales_flat' => flat( 'post', 'wc_product', $pid, 'total_sales' ) ) );
// T5: delete a meta key.
update_post_meta( $lid, 'hp_featured', 1 ); delete_post_meta( $lid, 'hp_featured' ); clear_caches( $lid );
row( "T5 delete_post_meta hp_featured", array( 'native' => nat( 'post', $lid, 'hp_featured' ), 'flat' => flat( 'post', 'hp_listing_core', $lid, 'hp_featured' ), 'get_meta' => var_export( get_post_meta( $lid, 'hp_featured', true ), true ), 'exists' => metadata_exists( 'post', $lid, 'hp_featured' ) ) );
// 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();
$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();
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?
$d = (int) $wpdb->get_var( "SELECT ID FROM {$wpdb->posts} WHERE post_type='hp_listing' AND post_status='draft' ORDER BY ID DESC LIMIT 1" );
wp_delete_post( $d, true );
row( "T8 wp_delete_post $d", array( 'posts' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE ID=$d" ), 'native_meta' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE post_id=$d" ), 'flat_row' => flat( 'post', 'hp_listing_core', $d, 'post_id' ) ) );
// 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();
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() ) );
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* Query correctness + latency harness. Runs identical WP_Query shapes and dumps
* ids / found_posts / timings to JSON so two runs (native vs routed mode) can be diffed.
* Usage: wp eval-file qbench.php <label> [iterations]
*/
global $wpdb;
$label = $args[0] ?? 'run';
$iters = (int) ( $args[1] ?? 5 );
$out = getcwd() . "/qbench_{$label}.json";
// Deterministic parameters derived from data (same in every run).
$vendor_ids = array_map( 'intval', $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type='hp_vendor' AND post_status='publish' ORDER BY ID LIMIT 3" ) );
$L = array( 'post_type' => 'hp_listing', 'post_status' => 'publish', 'posts_per_page' => 20 );
$cases = array(
'Q01 price BETWEEN (NUMERIC)' => $L + array( 'meta_query' => array( array( 'key' => 'hp_price', 'value' => array( 10000, 20000 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ) ) ),
'Q02 price >= AND verified=1' => $L + array( 'meta_query' => array( 'relation' => 'AND', array( 'key' => 'hp_price', 'value' => 25000, 'compare' => '>=', 'type' => 'NUMERIC' ), array( 'key' => 'hp_verified', 'value' => '1' ) ) ),
'Q03 rating >= 4.5 (DECIMAL)' => $L + array( 'meta_query' => array( array( 'key' => 'hp_rating', 'value' => '4.5', 'compare' => '>=', 'type' => 'DECIMAL(10,2)' ) ) ),
'Q04 featured=1 OR verified=1' => $L + array( 'meta_query' => array( 'relation' => 'OR', array( 'key' => 'hp_featured', 'value' => '1' ), array( 'key' => 'hp_verified', 'value' => '1' ) ) ),
'Q05 rate>=100 AND _thumbnail EXISTS'=> $L + array( 'meta_query' => array( array( 'key' => 'hp_hourly_rate', 'value' => 100, 'compare' => '>=', 'type' => 'NUMERIC' ), array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' ) ) ),
'Q06 product _sku LIKE' => array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => 20, 'meta_query' => array( array( 'key' => '_sku', 'value' => 'S36-B1-1', 'compare' => 'LIKE' ) ) ),
'Q07 orderby meta_value_num price' => $L + array( 'meta_key' => 'hp_price', 'orderby' => 'meta_value_num', 'order' => 'DESC' ),
'Q08 multi post_type + hp_budget' => array( 'post_type' => array( 'hp_listing', 'hp_request' ), 'post_status' => 'publish', 'posts_per_page' => 20, 'meta_query' => array( array( 'key' => 'hp_budget', 'value' => 80000, 'compare' => '>=', 'type' => 'NUMERIC' ) ) ),
'Q09 featured NOT EXISTS' => $L + array( 'meta_query' => array( array( 'key' => 'hp_featured', 'compare' => 'NOT EXISTS' ) ) ),
'Q10 rating_count != 1' => $L + array( 'meta_query' => array( array( 'key' => 'hp_rating_count', 'value' => '1', 'compare' => '!=' ) ) ),
'Q11 product _price BETWEEN+instock' => array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => 20, 'meta_query' => array( array( 'key' => '_price', 'value' => array( 1000, 5000 ), 'compare' => 'BETWEEN', 'type' => 'NUMERIC' ), array( 'key' => '_stock_status', 'value' => 'instock' ) ) ),
'Q12 request budget > (NUMERIC)' => array( 'post_type' => 'hp_request', 'post_status' => 'publish', 'posts_per_page' => 20, 'meta_query' => array( array( 'key' => 'hp_budget', 'value' => 50000, 'compare' => '>', 'type' => 'NUMERIC' ) ) ),
'Q13 rating_count IN (1,2)' => $L + array( 'meta_query' => array( array( 'key' => 'hp_rating_count', 'value' => array( 1, 2 ), 'compare' => 'IN' ) ) ),
'Q16 price<1000 OR warranty=0 (mix)' => $L + array( 'meta_query' => array( 'relation' => 'OR', array( 'key' => 'hp_price', 'value' => 1000, 'compare' => '<', 'type' => 'NUMERIC' ), array( 'key' => 'hp_warranty', 'value' => '0' ) ) ),
'Q14 price > (CHAR, no type)' => $L + array( 'meta_query' => array( array( 'key' => 'hp_price', 'value' => '9000', 'compare' => '>' ) ) ),
'Q15 hourly_rate < 50 orderby price' => $L + array( 'meta_query' => array( 'rate' => array( 'key' => 'hp_hourly_rate', 'value' => 50, 'compare' => '<', 'type' => 'NUMERIC' ), 'price' => array( 'key' => 'hp_price', 'type' => 'NUMERIC', 'compare' => 'EXISTS' ) ), 'orderby' => 'price', 'order' => 'ASC' ),
);
$res = array();
foreach ( $cases as $name => $qa ) {
$times = array();
$ids = null;
$found = null;
$err = '';
$sql = '';
for ( $i = 0; $i < $iters; $i++ ) {
wp_cache_flush_group( 'tmdo_found_rows' );
$wpdb->last_error = '';
$t = microtime( true );
$q = new WP_Query( $qa + array( 'fields' => 'ids', 'cache_results' => false, 'suppress_filters' => false ) );
$times[] = ( microtime( true ) - $t ) * 1000;
$ids = array_map( 'intval', $q->posts );
$found = (int) $q->found_posts;
$sql = $q->request;
if ( $wpdb->last_error ) { $err = $wpdb->last_error; }
}
sort( $times );
$res[ $name ] = array(
'found' => $found,
'ids' => $ids,
'p50' => round( $times[ (int) floor( count( $times ) / 2 ) ], 2 ),
'max' => round( max( $times ), 2 ),
'err' => $err,
'sql' => preg_replace( '/\s+/', ' ', $sql ),
);
printf( "%-36s found=%-6d p50=%8.2fms %s\n", $name, $found, $res[ $name ]['p50'], $err ? "ERR: $err" : '' );
}
file_put_contents( $out, wp_json_encode( array( 'mode' => TMDO_Mode_Manager::get( 'post' ), 'res' => $res ), JSON_PRETTY_PRINT ) );
echo "saved $out (post mode=" . TMDO_Mode_Manager::get( 'post' ) . ")\n";
+11
View File
@@ -0,0 +1,11 @@
<?php
// php qdiff.php a.json b.json — compare two qbench runs (a = native reference).
$a = json_decode( file_get_contents( $argv[1] ), true ); $b = json_decode( file_get_contents( $argv[2] ), true );
printf( "A=%s (%s) B=%s (%s)\n", basename( $argv[1] ), $a['mode'], basename( $argv[2] ), $b['mode'] );
printf( "%-36s %8s %8s %6s %6s %9s %9s %7s\n", 'case', 'foundA', 'foundB', 'set', 'order', 'p50A', 'p50B', 'speedup' );
foreach ( $a['res'] as $k => $ra ) {
$rb = $b['res'][ $k ];
$set = ( array_diff( $ra['ids'], $rb['ids'] ) || array_diff( $rb['ids'], $ra['ids'] ) ) ? 'DIFF' : 'same';
$ord = $ra['ids'] === $rb['ids'] ? 'same' : 'DIFF';
printf( "%-36s %8d %8d %6s %6s %9.2f %9.2f %6.1fx %s\n", $k, $ra['found'], $rb['found'], $set, $ord, $ra['p50'], $rb['p50'], $rb['p50'] > 0 ? $ra['p50'] / $rb['p50'] : 0, $rb['err'] ? 'ERR:' . substr( $rb['err'], 0, 60 ) : '' );
}
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# usage: run_batch.sh <batch-tag> <scale-divisor>
S="$(cd "$(dirname "$0")" && pwd)/seed.php"
cd "${TMDO_LIVE_WP_PATH:-/var/www/Studio/wp-local-dev36}"
B=$1; D=${2:-1}; OUT=${TMDO_LIVE_OUT:-/tmp}; LOG=$OUT/batch_$B.log
run(){ wp eval-file $S "$@" 2>>$OUT/batch_${B}_err.log | grep SEED >> $LOG; }
echo "== start $(date +%T)" >> $LOG
run terms $((100/D)) ${B}
for p in 1 2 3 4; do run vendors $((250/D)) ${B}p$p & done; wait
for p in 1 2 3 4; do run listings $((1000/D)) ${B}p$p & done
run requests $((500/D)) ${B} & run products $((1000/D)) ${B} & wait
for p in 1 2; do run reviews $((2000/D)) ${B}p$p & done
for p in 1 2; do run orders $((750/D)) ${B}p$p & done; wait
echo "== end $(date +%T)" >> $LOG
+158
View File
@@ -0,0 +1,158 @@
<?php
/**
* dev36 integration seeder — goes through the real APIs (HivePress models, WC CRUD,
* wp_insert_user / update_*_meta) so every write passes the Hook Bus exactly like prod.
*
* Usage: wp eval-file seed.php <kind> <count> [tag]
* kind: vendors | listings | requests | reviews | products | orders | terms
*/
use HivePress\Models;
add_filter( 'pre_wp_mail', '__return_true' );
// WC Tracks pings pixel.wp.com ~3x per product save (~180 ms each); irrelevant to the test.
add_filter( 'pre_http_request', function ( $r, $a, $url ) { return false !== strpos( $url, 'pixel.wp.com' ) ? array( 'headers' => array(), 'body' => '', 'response' => array( 'code' => 200, 'message' => 'OK' ), 'cookies' => array() ) : $r; }, 1, 3 );
wp_defer_term_counting( true );
wp_defer_comment_counting( true );
$kind = $args[0] ?? '';
$count = (int) ( $args[1] ?? 0 );
$tag = $args[2] ?? 'b1';
mt_srand( crc32( $kind . $tag ) );
$cities = array( 'Taipei', 'Taichung', 'Kaohsiung', 'Tainan', 'Hsinchu', 'Keelung' );
function s36_pick_ids( $post_type, $limit = 5000 ) {
global $wpdb;
return array_map( 'intval', $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type=%s AND post_status='publish' ORDER BY RAND() LIMIT %d", $post_type, $limit ) ) );
}
$t0 = microtime( true );
$q0 = get_num_queries();
$ok = 0;
switch ( $kind ) {
case 'vendors':
for ( $i = 0; $i < $count; $i++ ) {
$login = "s36v_{$tag}_{$i}";
$uid = wp_insert_user( array( 'user_login' => $login, 'user_email' => "$login@example.test", 'user_pass' => wp_generate_password(), 'role' => 'contributor', 'first_name' => "F$i", 'last_name' => "L$i", 'description' => "Vendor $i bio" ) );
if ( is_wp_error( $uid ) ) { continue; }
update_user_meta( $uid, 'billing_city', $cities[ $i % 6 ] );
update_user_meta( $uid, 'billing_phone', '09' . mt_rand( 10000000, 99999999 ) );
update_user_meta( $uid, 'facebook', "https://facebook.com/$login" );
update_user_meta( $uid, 'login_count', mt_rand( 1, 300 ) );
$v = ( new Models\Vendor() )->fill( array( 'name' => "Vendor $tag $i", 'description' => 'Seeded vendor', 'status' => 'publish', 'user' => $uid, 'verified' => (bool) mt_rand( 0, 1 ) ) );
if ( $v->save() ) {
update_post_meta( $v->get_id(), 'hp_hourly_rate', mt_rand( 10, 300 ) );
update_post_meta( $v->get_id(), 'hp_rating', mt_rand( 10, 50 ) / 10 );
$ok++;
}
}
break;
case 'listings':
global $wpdb;
$vendors = $wpdb->get_results( "SELECT ID, post_author FROM {$wpdb->posts} WHERE post_type='hp_vendor' AND post_status='publish'" );
$cats = get_terms( array( 'taxonomy' => 'hp_listing_category', 'hide_empty' => false, 'fields' => 'ids' ) );
for ( $i = 0; $i < $count; $i++ ) {
$v = $vendors[ array_rand( $vendors ) ];
$l = ( new Models\Listing() )->fill( array(
'title' => "Listing $tag $i " . $cities[ $i % 6 ],
'description' => str_repeat( 'Seeded listing body. ', 8 ),
'status' => ( mt_rand( 1, 10 ) <= 9 ) ? 'publish' : 'draft',
'user' => (int) $v->post_author,
'vendor' => (int) $v->ID,
'featured' => mt_rand( 1, 20 ) === 1,
'verified' => mt_rand( 1, 3 ) === 1,
'categories' => $cats ? array( $cats[ array_rand( $cats ) ] ) : array(),
) );
if ( $l->save() ) {
$id = $l->get_id();
update_post_meta( $id, 'hp_price', mt_rand( 100, 50000 ) );
update_post_meta( $id, 'hp_hourly_rate', mt_rand( 10, 300 ) );
update_post_meta( $id, 'hp_warranty', mt_rand( 0, 24 ) );
$ok++;
}
}
break;
case 'requests':
$users = get_users( array( 'fields' => 'ID', 'number' => 3000, 'orderby' => 'rand' ) );
for ( $i = 0; $i < $count; $i++ ) {
$r = ( new Models\Request() )->fill( array( 'title' => "Request $tag $i", 'description' => 'Need help', 'status' => 'publish', 'user' => (int) $users[ array_rand( $users ) ] ) );
if ( $r->save() ) {
update_post_meta( $r->get_id(), 'hp_budget', mt_rand( 500, 90000 ) );
update_post_meta( $r->get_id(), 'hp_due_date', gmdate( 'Y-m-d', time() + mt_rand( 1, 90 ) * DAY_IN_SECONDS ) );
$ok++;
}
}
break;
case 'reviews':
$listings = s36_pick_ids( 'hp_listing' );
$users = get_users( array( 'fields' => array( 'ID', 'display_name', 'user_email' ), 'number' => 3000, 'orderby' => 'rand' ) );
for ( $i = 0; $i < $count; $i++ ) {
$u = $users[ array_rand( $users ) ];
$rv = ( new Models\Review() )->fill( array( 'text' => "Review $tag $i", 'rating' => mt_rand( 1, 5 ), 'approved' => 1, 'author' => (int) $u->ID, 'author__display_name' => $u->display_name, 'author__email' => $u->user_email, 'listing' => $listings[ array_rand( $listings ) ] ) );
if ( $rv->save() ) { $ok++; }
}
break;
case 'terms':
for ( $i = 0; $i < $count; $i++ ) {
$t = wp_insert_term( "Cat $tag $i", 'hp_listing_category' );
if ( ! is_wp_error( $t ) ) {
update_term_meta( $t['term_id'], 'hp_sort_order', $i );
update_term_meta( $t['term_id'], 'hp_icon', 'star' );
$ok++;
}
}
break;
case 'products':
$pcats = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => false, 'fields' => 'ids' ) );
for ( $i = 0; $i < $count; $i++ ) {
$p = new WC_Product_Simple();
$p->set_name( "Product $tag $i" );
$p->set_status( 'publish' );
$reg = mt_rand( 100, 20000 );
$p->set_regular_price( (string) $reg );
if ( mt_rand( 1, 4 ) === 1 ) { $p->set_sale_price( (string) (int) ( $reg * 0.8 ) ); }
$p->set_sku( "S36-$tag-$i" );
$p->set_manage_stock( true );
$p->set_stock_quantity( mt_rand( 0, 500 ) );
if ( $pcats ) { $p->set_category_ids( array( $pcats[ array_rand( $pcats ) ] ) ); }
if ( $p->save() ) { $ok++; }
}
break;
case 'orders':
$products = wc_get_products( array( 'limit' => 2000, 'status' => 'publish', 'orderby' => 'rand', 'return' => 'ids' ) );
$users = get_users( array( 'fields' => 'ID', 'number' => 3000, 'orderby' => 'rand' ) );
$statuses = array( 'completed', 'completed', 'processing', 'on-hold', 'cancelled', 'refunded' );
for ( $i = 0; $i < $count; $i++ ) {
$o = wc_create_order( array( 'customer_id' => (int) $users[ array_rand( $users ) ] ) );
if ( is_wp_error( $o ) ) { continue; }
$n = mt_rand( 1, 3 );
for ( $j = 0; $j < $n; $j++ ) { $o->add_product( wc_get_product( $products[ array_rand( $products ) ] ), mt_rand( 1, 3 ) ); }
$o->set_billing_city( $cities[ $i % 6 ] );
$o->set_billing_email( "buyer$i@example.test" );
$o->calculate_totals( false );
$o->set_status( $statuses[ array_rand( $statuses ) ] );
$o->save();
$ok++;
}
break;
default:
WP_CLI::error( "unknown kind $kind" );
}
wp_defer_term_counting( false );
wp_defer_comment_counting( false );
$el = microtime( true ) - $t0;
$q = get_num_queries() - $q0;
WP_CLI::log( sprintf( 'SEED %s x%d ok=%d %.1fs %.2f ms/item %.1f queries/item mode(post=%s,user=%s,term=%s,comment=%s)',
$kind, $count, $ok, $el, $ok ? $el * 1000 / $ok : 0, $ok ? $q / $ok : 0,
...( class_exists( 'TMDO_Mode_Manager' ) ? array( TMDO_Mode_Manager::get( 'post' ), TMDO_Mode_Manager::get( 'user' ), TMDO_Mode_Manager::get( 'term' ), TMDO_Mode_Manager::get( 'comment' ) ) : array( 'NO-TMDO', '-', '-', '-' ) ) ) );
+22
View File
@@ -0,0 +1,22 @@
<?php
/**
* TEMPORARY test probe for the 2meet-DO integration test (2026-09-26). Delete after testing.
* Only active when the request carries header "X-S36-Probe: 1".
*/
if ( empty( $_SERVER['HTTP_X_S36_PROBE'] ) ) {
return;
}
add_action(
'template_redirect',
function () {
global $wp_query, $wpdb;
$ids = wp_list_pluck( (array) $wp_query->posts, 'ID' );
header( 'X-S36-Found: ' . (int) $wp_query->found_posts );
header( 'X-S36-Ids: ' . implode( ',', array_map( 'intval', $ids ) ) );
header( 'X-S36-Queries: ' . (int) $wpdb->num_queries );
header( 'X-S36-Routed: ' . ( false !== strpos( (string) $wp_query->request, 'wpdo' ) ? 1 : 0 ) );
header( 'X-S36-Sql: ' . substr( preg_replace( '/\s+/', ' ', (string) $wp_query->request ), 0, 1500 ) );
header( 'X-S36-Ms: ' . round( ( microtime( true ) - $_SERVER['REQUEST_TIME_FLOAT'] ) * 1000 ) );
},
0
);
+61 -1
View File
@@ -11,17 +11,35 @@ class CacheLayerTest extends TestCase {
/** Rows returned by get_results() for the bulk-fetch query. */ /** Rows returned by get_results() for the bulk-fetch query. */
public static array $db_rows = []; public static array $db_rows = [];
/** Whether SHOW TABLES reports the cold table. */
public static bool $table_exists = true;
/** Queries that read the cold table (get_var / get_results on it). */
public static int $cold_queries = 0;
protected function setUp(): void { protected function setUp(): void {
self::$db_rows = []; self::$db_rows = [];
self::$table_exists = true;
self::$cold_queries = 0;
$GLOBALS['_wp_cache'] = []; $GLOBALS['_wp_cache'] = [];
// Reset Schema Registry singleton. // Reset Schema Registry singleton.
$ref = new ReflectionClass( WPDO_Schema_Registry::class ); $ref = new ReflectionClass( WPDO_Schema_Registry::class );
$ref->getProperty( 'instance' )->setValue( null, null ); $ref->getProperty( 'instance' )->setValue( null, null );
// The cold module is write-active unless a test says otherwise.
$this->set_cold_module( 'dual_write' );
WPDO_Schema_Manager::flush_table_exists_cache();
$this->setup_wpdb_mock(); $this->setup_wpdb_mock();
} }
private function set_cold_module( string $state ): void {
$GLOBALS['_wp_options']['wpdo_features'] = array( 'cold_hp_listing' => $state );
$prop = new ReflectionProperty( WPDO_Feature_Flags::class, 'cache' );
$prop->setValue( null, null );
}
private function setup_wpdb_mock(): void { private function setup_wpdb_mock(): void {
global $wpdb; global $wpdb;
@@ -36,7 +54,15 @@ class CacheLayerTest extends TestCase {
}, $sql ); }, $sql );
} }
public function get_var( string $sql ): ?string { return null; } public function get_var( string $sql ): ?string {
if ( preg_match( "/^SHOW TABLES LIKE '(.+)'$/", $sql, $m ) ) {
return CacheLayerTest::$table_exists ? stripslashes( $m[1] ) : null;
}
if ( str_contains( $sql, 'wpdo_cold_' ) ) {
++CacheLayerTest::$cold_queries;
}
return null;
}
public function get_row( string $sql, $output = OBJECT ) { return null; } public function get_row( string $sql, $output = OBJECT ) { return null; }
public function insert( string $table, array $data, $format = null ): int|false { return 1; } public function insert( string $table, array $data, $format = null ): int|false { return 1; }
public function update( string $table, array $data, array $where, $f = null, $wf = null ): int|false { return 1; } public function update( string $table, array $data, array $where, $f = null, $wf = null ): int|false { return 1; }
@@ -44,11 +70,45 @@ class CacheLayerTest extends TestCase {
public function query( string $sql ): int|bool { return 1; } public function query( string $sql ): int|bool { return 1; }
public function get_results( string $sql, $output = OBJECT ): array { public function get_results( string $sql, $output = OBJECT ): array {
if ( str_contains( $sql, 'wpdo_cold_' ) ) {
++CacheLayerTest::$cold_queries;
}
return CacheLayerTest::$db_rows; return CacheLayerTest::$db_rows;
} }
}; };
} }
// ── Zero side effect while the cold module is not active ─────────────────
public function test_prefetch_does_not_query_while_module_idle(): void {
$this->register_cold_field();
$this->set_cold_module( 'idle' );
WPDO_Cache_Layer::prefetch( [ 1, 2 ], 'hp_listing' );
$this->assertSame( 0, self::$cold_queries );
$this->assertEmpty( $GLOBALS['_wp_cache'] );
}
public function test_warm_post_does_not_query_while_module_idle(): void {
$this->register_cold_field();
$this->set_cold_module( 'idle' );
WPDO_Cache_Layer::warm_post( 20, 'hp_listing' );
$this->assertSame( 0, self::$cold_queries );
}
public function test_no_query_when_cold_table_is_missing(): void {
$this->register_cold_field();
self::$table_exists = false;
WPDO_Cache_Layer::prefetch( [ 1 ], 'hp_listing' );
WPDO_Cache_Layer::warm_post( 1, 'hp_listing' );
$this->assertSame( 0, self::$cold_queries );
}
// ── Helper: register cold field ────────────────────────────────────────── // ── Helper: register cold field ──────────────────────────────────────────
private function register_cold_field( string $post_type = 'hp_listing', string $meta_key = 'hp_description' ): void { private function register_cold_field( string $post_type = 'hp_listing', string $meta_key = 'hp_description' ): void {
+163
View File
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* Promotion Guard: HivePress / WooCommerce read native meta directly
* (empty-key get_*_meta(), direct SQL), so on those sites native meta must
* stay authoritative — no aeav_only, no cleanup of native rows.
*
* Tests that declare HivePress / WooCommerce stand-in classes run in their own
* process so those classes do not leak into other tests.
*/
class PromotionGuardTest extends TestCase {
protected function setUp(): void {
if ( ! class_exists( 'TMDO_Promotion_Guard_Notice' ) ) {
require_once TMDO_PATH . 'admin/class-tmdo-promotion-guard-notice.php';
}
// Mode changes log through error_log(); in a separate process PHPUnit
// treats that stderr output as an error.
ini_set( 'error_log', '/dev/null' );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_filter_callbacks'] = array();
$GLOBALS['_wp_current_user_can'] = array( 'manage_options' => true );
TMDO_Mode_Manager::reset_cache();
}
private function modes( array $modes ): void {
$GLOBALS['_wp_options']['wpdo_bridge_modes'] = $modes;
TMDO_Mode_Manager::reset_cache();
}
public function test_without_integrations_aeav_only_is_allowed(): void {
$this->modes( array( 'post' => 'shadow_read' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'post', 'aeav_only' ) );
$this->assertNull( TMDO_Promotion_Guard::check_cleanup( 'post' ) );
$this->assertSame( array(), TMDO_Promotion_Guard::violations() );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_hivepress_refuses_aeav_only_for_every_entity(): void {
eval( 'namespace HivePress; class Core {}' );
foreach ( array( 'post', 'user', 'term', 'comment' ) as $type ) {
$this->modes( array( $type => 'shadow_read' ) );
$result = TMDO_Mode_Manager::set( $type, 'aeav_only' );
$this->assertInstanceOf( WP_Error::class, $result, $type );
$this->assertSame( 'promotion_guarded', $result->get_error_code(), $type );
$this->assertStringContainsString( 'HivePress', $result->get_error_message(), $type );
$this->assertSame( 'shadow_read', TMDO_Mode_Manager::get( $type ), "{$type} mode must not change" );
$this->assertSame( array( $type => 'shadow_read' ), $GLOBALS['_wp_options']['wpdo_bridge_modes'], "{$type} option must not change" );
}
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_woocommerce_guards_post_and_user_only(): void {
eval( 'class WooCommerce {}' );
$this->modes( array( 'post' => 'shadow_read', 'user' => 'shadow_read', 'term' => 'shadow_read' ) );
$this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'post', 'aeav_only' ) );
$this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'user', 'aeav_only' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'term', 'aeav_only' ) );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_guard_allows_lower_modes_and_demotion(): void {
eval( 'namespace HivePress; class Core {}' );
$this->modes( array( 'post' => 'disabled' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'post', 'dual_write' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'post', 'shadow_read' ) );
$this->modes( array( 'post' => 'aeav_only' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'post', 'dual_write' ), 'the way out of aeav_only stays open' );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_filter_can_allow_a_guarded_promotion(): void {
eval( 'namespace HivePress; class Core {}' );
add_filter(
'tmdo_promotion_guard_allow',
static function ( $allow, $entity_type, $action, $integrations ) {
return 'user' === $entity_type && 'aeav_only' === $action && array( 'HivePress' ) === $integrations;
},
10,
4
);
$this->modes( array( 'user' => 'shadow_read', 'post' => 'shadow_read' ) );
$this->assertTrue( TMDO_Mode_Manager::set( 'user', 'aeav_only' ) );
$this->assertInstanceOf( WP_Error::class, TMDO_Mode_Manager::set( 'post', 'aeav_only' ) );
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_cleanup_is_refused_even_in_aeav_only(): void {
eval( 'namespace HivePress; class Core {}' );
$this->modes( array( 'post' => 'aeav_only', 'user' => 'aeav_only' ) );
$error = TMDO_Promotion_Guard::check_cleanup( 'post' );
$this->assertInstanceOf( WP_Error::class, $error );
try {
TMDO_Post_Migration::cleanup();
$this->fail( 'post cleanup must be refused' );
} catch ( RuntimeException $e ) {
$this->assertStringContainsString( 'HivePress', $e->getMessage() );
}
$job = array( 'log' => array(), 'options' => array() );
try {
( new TMDO_Phase_Cleanup( 'user' ) )->execute( $job );
$this->fail( 'user cleanup phase must be refused' );
} catch ( RuntimeException $e ) {
$this->assertStringContainsString( 'HivePress', $e->getMessage() );
}
}
/**
* @runInSeparateProcess
* @preserveGlobalState disabled
*/
public function test_sites_already_in_aeav_only_are_reported(): void {
eval( 'class WooCommerce {}' );
$this->modes( array( 'post' => 'aeav_only', 'term' => 'aeav_only' ) );
$this->assertSame( array( 'post' => array( 'WooCommerce' ) ), TMDO_Promotion_Guard::violations() );
ob_start();
TMDO_Promotion_Guard_Notice::render();
$html = (string) ob_get_clean();
$this->assertStringContainsString( 'post', $html );
$this->assertStringContainsString( 'bridge-restore-native post', $html );
}
public function test_notice_is_silent_without_violations(): void {
$this->modes( array( 'post' => 'aeav_only' ) );
ob_start();
TMDO_Promotion_Guard_Notice::render();
$this->assertSame( '', ob_get_clean() );
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* text / textarea fields holding arrays (e.g. usermeta wp_persisted_preferences,
* community-events-location) were cast with (string) and stored as "Array";
* restoring native meta then wrote "Array" back over the real data.
*/
class TypeCasterSerializeTest extends TestCase {
/** Rows passed to $wpdb->insert() (wpdo_errors via TMDO_Logger::warning). */
public static array $inserts = array();
private const TEXT = array( 'type' => 'text', 'group' => 'admin_prefs', 'key' => 'community-events-location' );
private const TEXTAREA = array( 'type' => 'textarea', 'group' => 'admin_prefs', 'key' => 'wp_persisted_preferences' );
protected function setUp(): void {
self::$inserts = array();
ini_set( 'error_log', '/dev/null' );
global $wpdb;
$wpdb = new class {
public string $prefix = 'wp_';
public function insert( string $table, array $data, $format = null ) {
TypeCasterSerializeTest::$inserts[] = array( $table, $data );
return 1;
}
};
}
public static function arrays(): array {
return array(
'flat' => array( array( 'ip' => '118.163.181.0' ) ),
'nested' => array( array( 'core/edit-post' => array( 'welcomeGuide' => false, 'panels' => array( 'a', 'b' ) ) ) ),
'empty' => array( array() ),
'list' => array( array( 1, 2, 3 ) ),
);
}
/**
* @dataProvider arrays
*/
public function test_text_array_round_trips( array $value ): void {
$stored = TMDO_Type_Caster::to_db( $value, self::TEXT );
$this->assertNotSame( 'Array', $stored );
$this->assertSame( serialize( $value ), $stored );
$this->assertSame( $value, TMDO_Type_Caster::from_db( $stored, self::TEXT ) );
}
/**
* @dataProvider arrays
*/
public function test_textarea_array_round_trips( array $value ): void {
$stored = TMDO_Type_Caster::to_db( $value, self::TEXTAREA );
$this->assertSame( serialize( $value ), $stored );
$this->assertSame( $value, TMDO_Type_Caster::from_db( $stored, self::TEXTAREA ) );
}
public function test_object_is_serialized_not_cast(): void {
$value = (object) array( 'a' => 1 );
$this->assertSame( serialize( $value ), TMDO_Type_Caster::to_db( $value, self::TEXTAREA ) );
}
public function test_plain_strings_are_unchanged(): void {
foreach ( array( 'fresh', '', 'a:b', '0' ) as $s ) {
$this->assertSame( $s, TMDO_Type_Caster::to_db( $s, self::TEXT ) );
$this->assertSame( $s, TMDO_Type_Caster::from_db( $s, self::TEXT ) );
}
$this->assertSame( '42', TMDO_Type_Caster::to_db( 42, self::TEXT ) );
}
public function test_text_over_255_chars_is_stored_as_null_and_logged(): void {
$long = str_repeat( 'é', 256 );
$this->assertNull( TMDO_Type_Caster::to_db( $long, self::TEXT ) );
$this->assertCount( 1, self::$inserts );
$this->assertStringContainsString( 'text_overflow', self::$inserts[0][1]['hook'] );
$this->assertStringContainsString( 'community-events-location', (string) self::$inserts[0][1]['context'] );
}
public function test_text_of_exactly_255_chars_is_kept(): void {
$max = str_repeat( 'é', 255 );
$this->assertSame( $max, TMDO_Type_Caster::to_db( $max, self::TEXT ) );
$this->assertCount( 0, self::$inserts );
}
public function test_serialized_array_over_255_chars_overflows_too(): void {
$value = array( 'k' => str_repeat( 'x', 300 ) );
$this->assertNull( TMDO_Type_Caster::to_db( $value, self::TEXT ) );
$this->assertSame( serialize( $value ), TMDO_Type_Caster::to_db( $value, self::TEXTAREA ), 'textarea is TEXT, no 255 limit' );
}
}
+61 -13
View File
@@ -21,9 +21,69 @@ class ZoneRouterTest extends TestCase {
self::$last_query = ''; self::$last_query = '';
self::$last_delete = []; self::$last_delete = [];
self::$get_var_return = null; self::$get_var_return = null;
WPDO_Schema_Manager::flush_table_exists_cache();
$this->setup_wpdb_mock(); $this->setup_wpdb_mock();
} }
/**
* $wpdb mock recording delete() tables; SHOW TABLES reports $existing.
*
* @param string[] $existing Tables that exist.
*/
private function delete_logger( array &$captured, array $existing ): void {
global $wpdb;
$wpdb = new class( $captured, $existing ) {
public string $prefix = 'wp_';
public array $log;
public function __construct( array &$ref, private array $existing ) { $this->log = &$ref; }
public function prepare( string $s, mixed ...$a ): string { return str_replace( '%s', (string) ( $a[0] ?? '' ), $s ); }
public function query( string $s ): int { return 1; }
public function get_var( string $s ): mixed {
foreach ( $this->existing as $t ) {
if ( "SHOW TABLES LIKE {$t}" === $s ) {
return $t;
}
}
return null;
}
public function insert( string $t, array $d, mixed $f = null ): int { return 1; }
public function delete( string $table, array $where, array $formats ): void {
$this->log[] = $table;
}
};
}
private function register_hot_and_cold(): void {
$sr = new ReflectionClass( WPDO_Schema_Registry::class );
$sr->getProperty( 'instance' )->setValue( null, null );
WPDO_Schema_Registry::instance()->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'type' => 'decimal' ) );
WPDO_Schema_Registry::instance()->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_images', 'zone' => 'cold' ) );
}
/** Registered fields do not mean the zone tables exist (zero side effect). */
public function test_delete_post_skips_hot_and_cold_tables_that_do_not_exist(): void {
$this->register_hot_and_cold();
$captured = array();
$this->delete_logger( $captured, array() );
WPDO_Zone_Router::delete_post( 3, 'hp_listing' );
$this->assertNotContains( 'wp_wpdo_hot_hp_listing', $captured );
$this->assertNotContains( 'wp_wpdo_cold_hp_listing', $captured );
$this->assertContains( 'wp_wpdo_warm', $captured );
}
public function test_delete_post_deletes_cold_rows_when_the_table_exists(): void {
$this->register_hot_and_cold();
$captured = array();
$this->delete_logger( $captured, array( 'wp_wpdo_cold_hp_listing' ) );
WPDO_Zone_Router::delete_post( 4, 'hp_listing' );
$this->assertContains( 'wp_wpdo_cold_hp_listing', $captured );
$this->assertNotContains( 'wp_wpdo_hot_hp_listing', $captured );
}
private function setup_wpdb_mock(): void { private function setup_wpdb_mock(): void {
global $wpdb; global $wpdb;
$wpdb = new class { $wpdb = new class {
@@ -204,19 +264,7 @@ class ZoneRouterTest extends TestCase {
) ); ) );
$captured = array(); $captured = array();
global $wpdb; $this->delete_logger( $captured, array( 'wp_wpdo_hot_hp_listing' ) );
$wpdb = new class( $captured ) {
public string $prefix = 'wp_';
public array $log;
public function __construct( array &$ref ) { $this->log = &$ref; }
public function prepare( string $s, mixed ...$a ): string { return $s; }
public function query( string $s ): int { return 1; }
public function get_var( string $s ): mixed { return null; }
public function insert( string $t, array $d, mixed $f = null ): int { return 1; }
public function delete( string $table, array $where, array $formats ): void {
$this->log[] = $table;
}
};
WPDO_Zone_Router::delete_post( 10, 'hp_listing' ); WPDO_Zone_Router::delete_post( 10, 'hp_listing' );