Files
2meet-data-optimizer/tests/integration/SchemaUpgradeDeferralTest.php
T
wpdev 1d70d13f45 fix(2meet-data-optimizer): close the gaps in deferred table upgrades found in the 2.3 review
- a deferred group whose stored definition matches again (reverted, or
  upgraded by another process) goes through end_deferral() instead of staying
  native for good (review F2)
- the tmdo_ensure_group_indexes handler upgrades deferred tables itself: with
  ALTERNATE_WP_CRON the event runs after init in a visitor's request (F3)
- a new group table created while the entity writes to flat stays native
  until the drift fix has filled it from native meta; an opted-in HivePress
  attribute no longer routes searches to an empty table (F6)
- direct_read() returns native values for a deferred group (F10); the Hook
  Bus ignores deferral in aeav_only, as the router does
- dbDelta failures: the definition is recorded only when every column exists
  (schema.upgrade_failed otherwise), so a failed ALTER is retried and the
  group is not released onto missing columns
- test bootstrap: get_metadata() reads the real meta table
- Verify: SchemaUpgradeDeferralTest (6), Hook Bus direct read, integration
  587, unit 703, phpcs, stan
- PR: PR-114 (2.3 acceptance correctness review)
2026-09-27 17:30:36 +08:00

178 lines
8.2 KiB
PHP

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
/**
* An existing flat table whose definition changed is upgraded from WP-CLI /
* WP-Cron only (2.3 PR-107); web requests record the group as deferred.
*/
class SchemaUpgradeDeferralTest extends TestCase {
private const TABLE = 'wp_itest_wpdo_user_sd_core';
public static function setUpBeforeClass(): void {
$base = WPDO_PLUGIN_DIR;
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-mode-manager.php',
'includes/adapters/class-tmdo-adapter-user.php',
) as $file ) {
require_once $base . $file;
}
WPDO_Installer::install_v2_tables();
}
protected function setUp(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->query( 'CREATE TABLE `' . self::TABLE . '` ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, sd_a VARCHAR(255) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
$wpdb->insert( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core', 'schema_hash' => 'old', 'field_definitions' => '[]' ) );
$GLOBALS['_wp_options'] = array();
$GLOBALS['_wp_scheduled_single'] = array();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array(); // A web request.
$ref = new ReflectionProperty( WPDO_Schema_Manager::class, 'table_exists_cache' );
$ref->setAccessible( true );
$ref->setValue( null, array() );
self::set_user_mode( 'dual_write' );
WPDO_Entity_Registry::init();
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
WPDO_Entity_Registry::register_group( 'user', 'sd_core', array( array( 'key' => 'sd_a', 'type' => 'text' ), array( 'key' => 'sd_b', 'type' => 'integer' ) ) );
}
protected function tearDown(): void {
global $wpdb;
$wpdb->query( 'DROP TABLE IF EXISTS `' . self::TABLE . '`' );
$wpdb->delete( $wpdb->prefix . 'wpdo_registry_meta', array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
WPDO_Entity_Registry::init();
self::set_user_mode( null );
unset( $GLOBALS['_wp_scheduled_single'] );
}
private static function set_user_mode( ?string $mode ): void {
$ref = new ReflectionProperty( WPDO_Mode_Manager::class, 'cache' );
$ref->setAccessible( true );
$ref->setValue( null, null === $mode ? null : array( 'post' => 'disabled', 'user' => $mode, 'term' => 'disabled', 'comment' => 'disabled' ) );
}
private function upgrade(): bool {
return TMDO_Schema_Manager::create_or_upgrade_table( 'user', 'sd_core', WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
}
public function test_a_web_request_leaves_the_upgrade_to_wp_cron_and_the_group_native(): void {
global $wpdb;
$this->assertFalse( $this->upgrade() );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
$this->assertContains( TMDO_Schema_Manager::INDEXES_HOOK, $GLOBALS['_wp_scheduled_single'] );
$this->assertSame( array( 'id', 'user_id', 'sd_a' ), $wpdb->get_col( 'SHOW COLUMNS FROM `' . self::TABLE . '`' ), 'no ALTER' );
$this->assertSame( 'old', TMDO_Schema_Manager::get_stored_schema_hash( 'user', 'sd_core' ) );
}
public function test_only_existing_tables_outside_aeav_only_and_outside_cli_or_cron_wait(): void {
$this->assertTrue( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', 'wp_itest_wpdo_user_sd_missing' ), 'a new table is created at once' );
self::set_user_mode( 'aeav_only' );
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ), 'aeav_only: the flat table is the only copy' );
self::set_user_mode( 'dual_write' );
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true ); // WP-CLI / WP-Cron.
$this->assertFalse( TMDO_Schema_Manager::upgrade_waits( 'user', self::TABLE ) );
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
}
/**
* An existing table is altered by one process at a time: anonymous
* /wp-cron.php requests pass ddl_allowed() too (security review H1).
*/
public function test_an_upgrade_is_skipped_while_another_process_holds_the_schema_lock(): void {
add_filter( 'tmdo_schema_ddl_allowed', static fn() => true );
$other = new mysqli(
getenv( 'TMDO_TEST_DB_HOST' ) ?: '127.0.0.1',
getenv( 'TMDO_TEST_DB_USER' ) ?: 'dbo',
(string) getenv( 'TMDO_TEST_DB_PASS' ),
getenv( 'TMDO_TEST_DB_NAME' ) ?: 'wp_wpdo_test'
);
$name = $other->real_escape_string( TMDO_Schema_Manager::index_lock_name() );
$other->query( "SELECT GET_LOCK(MD5(CONCAT(DATABASE(), '{$name}')), 0)" );
try {
$this->assertFalse( $this->upgrade(), 'returns before dbDelta (unavailable here)' );
} finally {
$other->query( "SELECT RELEASE_LOCK(MD5(CONCAT(DATABASE(), '{$name}')))" );
$other->close();
$GLOBALS['_wp_filter_callbacks']['tmdo_schema_ddl_allowed'] = array();
}
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
}
/**
* After the upgrade the group stays native until the drift fix has
* rebuilt the rows written meanwhile (security review M1).
*/
public function test_a_drift_fix_releases_only_upgraded_groups_of_its_entity(): void {
global $wpdb;
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
update_option(
TMDO_Schema_Manager::DEFERRED_OPTION,
array(
'user/sd_core' => 1,
'user/sd_later' => 1, // Not upgraded yet (no stored definition).
'post/sd_core' => 1, // Another entity.
)
);
TMDO_Schema_Manager::resynced( 'user' );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'stored hash still old: not upgraded' );
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
TMDO_Schema_Manager::resynced( 'user' );
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ) );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_later' ) );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'post', 'sd_core' ) );
}
/**
* The definition went back to the stored one (or another process upgraded
* the table) while the group was deferred (correctness review F2).
*/
public function test_a_deferred_group_whose_definition_matches_again_is_resynced_then_released(): void {
global $wpdb;
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) );
$this->assertTrue( $this->upgrade() );
$this->assertTrue( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'dual_write: native until the drift fix' );
$this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] );
self::set_user_mode( 'disabled' );
$this->upgrade();
$this->assertFalse( TMDO_Schema_Manager::is_deferred( 'user', 'sd_core' ), 'no flat writes: nothing to rebuild' );
}
/**
* The cron event upgrades deferred groups itself: with ALTERNATE_WP_CRON
* it runs inside a visitor's request, after init (correctness review F3).
*/
public function test_the_cron_event_handles_deferred_groups(): void {
global $wpdb;
$current = TMDO_Schema_Manager::calculate_schema_hash( WPDO_Entity_Registry::get_group_fields( 'user', 'sd_core' ) );
$wpdb->update( $wpdb->prefix . 'wpdo_registry_meta', array( 'schema_hash' => $current ), array( 'entity_type' => 'user', 'group_name' => 'sd_core' ) );
update_option( TMDO_Schema_Manager::DEFERRED_OPTION, array( 'user/sd_core' => 1 ) );
TMDO_Schema_Manager::ensure_pending_indexes();
$this->assertContains( TMDO_Drift_Check::RESYNC_HOOK, $GLOBALS['_wp_scheduled_single'] );
}
}