feat(2meet-data-optimizer): reconcile post rows written to the wrong group table
- TMDO_Post_Group_Reconciler copies non-NULL values that legacy routing stored in another group's table into the post type's own group (NULL/missing legacy values keep the scoped value), resumable via a cursor, with a catch-up pass for rows updated during the run, then switches on tmdo_post_scoped_routing - CLI: wp tmdo post-reconcile-groups [--dry-run] [--batch=<n>]; registry exposes get_group_post_types() - Verify: PostGroupReconcilerPlanTest (4), PostGroupReconcilerTest (6); core unit 635 / integration 449, HP 145/49, WC 10/22 OK; read-only dry-run: dev20 320 values / 0 overwrites, dev21/22 none - PR: PR-034
This commit is contained in:
@@ -642,6 +642,7 @@ require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-cleanup.php
|
||||
require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php';
|
||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.php';
|
||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-migration.php';
|
||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-post-group-reconciler.php';
|
||||
|
||||
// Options Manager.
|
||||
require_once TMDO_PATH . 'modules/options/class-tmdo-options-manager.php';
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Moving values written under legacy routing into their post type's group.
|
||||
*
|
||||
* Fixture (legacy owner of `shared_key` = rc_vendor, the last group registered):
|
||||
* listing 101: vendor table 'live' | listing table 'backfill' (+ price 5)
|
||||
* listing 102: vendor table 'only-in-vendor' | no listing row
|
||||
* listing 103: no vendor row | listing table 'keep'
|
||||
* listing 104: vendor row with NULL | listing table 'backfill-104'
|
||||
* vendor 202: vendor table 'vendor own value'
|
||||
*
|
||||
* Reads before the switch returned the vendor-table value for 101/102, so
|
||||
* those must win; 103/104 were never written after the backfill, so the
|
||||
* listing value stays.
|
||||
*/
|
||||
class PostGroupReconcilerTest extends TestCase {
|
||||
|
||||
private const LISTING = 'wp_itest_wpdo_post_rc_listing';
|
||||
private const VENDOR = 'wp_itest_wpdo_post_rc_vendor';
|
||||
|
||||
public static function setUpBeforeClass(): void {
|
||||
$base = WPDO_PLUGIN_DIR;
|
||||
foreach ( array(
|
||||
'includes/adapters/interface-entity-adapter.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-post.php',
|
||||
'includes/migration/class-tmdo-post-group-reconciler.php',
|
||||
) as $file ) {
|
||||
require_once $base . $file;
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void {
|
||||
global $wpdb;
|
||||
self::drop();
|
||||
$wpdb->query( "CREATE TABLE `{$wpdb->posts}` ( ID BIGINT UNSIGNED NOT NULL, post_type VARCHAR(20) NOT NULL DEFAULT 'post', post_status VARCHAR(20) NOT NULL DEFAULT 'publish', PRIMARY KEY (ID) ) ENGINE=InnoDB" );
|
||||
foreach ( array( 101 => 'hp_listing', 102 => 'hp_listing', 103 => 'hp_listing', 104 => 'hp_listing', 202 => 'hp_vendor' ) as $id => $type ) {
|
||||
$wpdb->insert( $wpdb->posts, array( 'ID' => $id, 'post_type' => $type ) );
|
||||
}
|
||||
$wpdb->query( 'CREATE TABLE `' . self::LISTING . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, shared_key VARCHAR(255) DEFAULT NULL, price DECIMAL(18,6) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->query( 'CREATE TABLE `' . self::VENDOR . '` ( id BIGINT NOT NULL AUTO_INCREMENT, post_id BIGINT NOT NULL, shared_key VARCHAR(255) DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_entity (post_id) ) ENGINE=InnoDB' );
|
||||
$wpdb->insert( self::VENDOR, array( 'post_id' => 101, 'shared_key' => 'live' ) );
|
||||
$wpdb->insert( self::VENDOR, array( 'post_id' => 102, 'shared_key' => 'only-in-vendor' ) );
|
||||
$wpdb->query( 'INSERT INTO `' . self::VENDOR . "` (post_id, shared_key) VALUES (104, NULL)" );
|
||||
$wpdb->insert( self::VENDOR, array( 'post_id' => 202, 'shared_key' => 'vendor own value' ) );
|
||||
$wpdb->insert( self::LISTING, array( 'post_id' => 101, 'shared_key' => 'backfill', 'price' => 5 ) );
|
||||
$wpdb->insert( self::LISTING, array( 'post_id' => 103, 'shared_key' => 'keep' ) );
|
||||
$wpdb->insert( self::LISTING, array( 'post_id' => 104, 'shared_key' => 'backfill-104' ) );
|
||||
// Historical rows: older than any run, so the catch-up pass leaves them alone.
|
||||
$wpdb->query( 'UPDATE `' . self::VENDOR . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||
$wpdb->query( 'UPDATE `' . self::LISTING . "` SET updated_at = '2020-01-01 00:00:00'" );
|
||||
|
||||
$GLOBALS['_wp_options'] = array( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION => '0' );
|
||||
$GLOBALS['_wp_cache'] = array();
|
||||
|
||||
WPDO_Entity_Registry::init();
|
||||
WPDO_Entity_Registry::register_adapter( 'post', new WPDO_Adapter_Post() );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'rc_listing', array( array( 'key' => 'shared_key', 'type' => 'text' ), array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||
WPDO_Entity_Registry::register_group( 'post', 'rc_vendor', array( array( 'key' => 'shared_key', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) );
|
||||
}
|
||||
|
||||
protected function tearDown(): void {
|
||||
self::drop();
|
||||
WPDO_Entity_Registry::init();
|
||||
}
|
||||
|
||||
private static function drop(): void {
|
||||
global $wpdb;
|
||||
foreach ( array( $wpdb->posts, self::LISTING, self::VENDOR ) as $table ) {
|
||||
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||
}
|
||||
}
|
||||
|
||||
private function listing_value( int $post_id ): ?string {
|
||||
global $wpdb;
|
||||
return $wpdb->get_var( $wpdb->prepare( 'SELECT shared_key FROM `' . self::LISTING . '` WHERE post_id = %d', $post_id ) );
|
||||
}
|
||||
|
||||
public function test_dry_run_reports_without_writing(): void {
|
||||
$report = TMDO_Post_Group_Reconciler::run( true );
|
||||
|
||||
$this->assertSame( 2, $report['moves'][0]['copied'], '101 and 102 hold non-NULL legacy values' );
|
||||
$this->assertSame( 1, $report['moves'][0]['overwritten'], '101 has a different listing value' );
|
||||
$this->assertFalse( $report['enabled'] );
|
||||
$this->assertSame( 'backfill', $this->listing_value( 101 ) );
|
||||
$this->assertNull( $this->listing_value( 102 ) );
|
||||
$this->assertSame( '0', get_option( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION ) );
|
||||
}
|
||||
|
||||
public function test_run_moves_the_values_reads_returned_before_the_switch(): void {
|
||||
$report = TMDO_Post_Group_Reconciler::run( false, 1 );
|
||||
|
||||
$this->assertSame( 'live', $this->listing_value( 101 ) );
|
||||
$this->assertSame( 'only-in-vendor', $this->listing_value( 102 ) );
|
||||
$this->assertSame( 'keep', $this->listing_value( 103 ) );
|
||||
$this->assertSame( 'backfill-104', $this->listing_value( 104 ), 'NULL legacy values do not erase the listing value' );
|
||||
$this->assertNull( $this->listing_value( 202 ), 'vendor posts are not copied into the listing table' );
|
||||
$this->assertSame( 2, $report['moves'][0]['copied'] );
|
||||
}
|
||||
|
||||
public function test_other_columns_of_the_target_row_are_untouched(): void {
|
||||
global $wpdb;
|
||||
TMDO_Post_Group_Reconciler::run( false );
|
||||
|
||||
$this->assertSame( 5.0, (float) $wpdb->get_var( 'SELECT price FROM `' . self::LISTING . '` WHERE post_id = 101' ) );
|
||||
}
|
||||
|
||||
public function test_completion_turns_scoped_routing_on_and_clears_the_cursor(): void {
|
||||
$report = TMDO_Post_Group_Reconciler::run( false );
|
||||
|
||||
$this->assertTrue( $report['enabled'] );
|
||||
$this->assertSame( '1', get_option( WPDO_Entity_Registry::SCOPED_ROUTING_OPTION ) );
|
||||
$this->assertFalse( get_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION ) );
|
||||
}
|
||||
|
||||
public function test_catch_up_copies_rows_written_during_the_run(): void {
|
||||
global $wpdb;
|
||||
// Simulates a legacy-routed write that lands after the batch passed post 101.
|
||||
$wpdb->query( 'UPDATE `' . self::VENDOR . "` SET shared_key = 'written during run', updated_at = NOW() + INTERVAL 1 HOUR WHERE post_id = 101" );
|
||||
update_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION, array( 'rc_vendor>rc_listing:shared_key' => 101 ) );
|
||||
|
||||
TMDO_Post_Group_Reconciler::run( false );
|
||||
|
||||
$this->assertSame( 'written during run', $this->listing_value( 101 ) );
|
||||
}
|
||||
|
||||
public function test_resumes_after_the_stored_cursor(): void {
|
||||
update_option( TMDO_Post_Group_Reconciler::CURSOR_OPTION, array( 'rc_vendor>rc_listing:shared_key' => 101 ) );
|
||||
|
||||
TMDO_Post_Group_Reconciler::run( false );
|
||||
|
||||
$this->assertSame( 'backfill', $this->listing_value( 101 ), 'rows up to the cursor were already done' );
|
||||
$this->assertSame( 'only-in-vendor', $this->listing_value( 102 ) );
|
||||
}
|
||||
}
|
||||
@@ -602,6 +602,7 @@ require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-cleanup.php
|
||||
require_once TMDO_PATH . 'includes/migration/phases/class-tmdo-phase-completed.php';
|
||||
require_once TMDO_PATH . 'includes/migration/class-tmdo-migration-orchestrator.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 . 'modules/options/class-tmdo-options-manager.php';
|
||||
require_once TMDO_PATH . 'includes/class-tmdo-postmeta-cleaner.php';
|
||||
require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php';
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Which values the reconciler has to move.
|
||||
*
|
||||
* Under legacy routing a key shared by several post groups was read and
|
||||
* written through the last group registered. Once lookups follow the post
|
||||
* type, each scoped group must first receive the values its posts stored in
|
||||
* that legacy owner's table.
|
||||
*/
|
||||
class PostGroupReconcilerPlanTest extends TestCase {
|
||||
|
||||
protected function setUp(): void {
|
||||
TMDO_Entity_Registry::init();
|
||||
TMDO_Entity_Registry::register_adapter( 'post', $this->createMock( TMDO_Entity_Adapter_Interface::class ) );
|
||||
}
|
||||
|
||||
public function test_plan_lists_shared_keys_whose_legacy_owner_is_another_group(): void {
|
||||
TMDO_Entity_Registry::register_group( 'post', 'listing', array( array( 'key' => 'shared', 'type' => 'text' ), array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||
TMDO_Entity_Registry::register_group( 'post', 'vendor', array( array( 'key' => 'shared', 'type' => 'text' ) ), array( 'post_types' => array( 'hp_vendor' ) ) );
|
||||
|
||||
$this->assertSame(
|
||||
array(
|
||||
array(
|
||||
'target' => 'listing',
|
||||
'source' => 'vendor',
|
||||
'key' => 'shared',
|
||||
'post_types' => array( 'hp_listing' ),
|
||||
),
|
||||
),
|
||||
TMDO_Post_Group_Reconciler::plan()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_unscoped_groups_and_unshared_keys_need_no_move(): void {
|
||||
TMDO_Entity_Registry::register_group( 'post', 'site', array( array( 'key' => '_edit_lock', 'type' => 'text' ) ) );
|
||||
TMDO_Entity_Registry::register_group( 'post', 'listing', array( array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||
|
||||
$this->assertSame( array(), TMDO_Post_Group_Reconciler::plan() );
|
||||
}
|
||||
|
||||
public function test_registry_exposes_group_post_types(): void {
|
||||
TMDO_Entity_Registry::register_group( 'post', 'listing', array( array( 'key' => 'price', 'type' => 'decimal' ) ), array( 'post_types' => array( 'hp_listing' ) ) );
|
||||
TMDO_Entity_Registry::register_group( 'post', 'site', array( array( 'key' => '_edit_lock', 'type' => 'text' ) ) );
|
||||
|
||||
$this->assertSame( array( 'hp_listing' ), TMDO_Entity_Registry::get_group_post_types( 'post', 'listing' ) );
|
||||
$this->assertSame( array(), TMDO_Entity_Registry::get_group_post_types( 'post', 'site' ) );
|
||||
|
||||
TMDO_Entity_Registry::init();
|
||||
$this->assertSame( array(), TMDO_Entity_Registry::get_group_post_types( 'post', 'listing' ) );
|
||||
}
|
||||
|
||||
public function test_core_hivepress_groups_plan_the_eight_misrouted_keys(): void {
|
||||
TMDO_Post_Fields::register_entity_fields();
|
||||
|
||||
$moves = array();
|
||||
foreach ( TMDO_Post_Group_Reconciler::plan() as $m ) {
|
||||
$moves[] = "{$m['source']}>{$m['target']}:{$m['key']}";
|
||||
}
|
||||
sort( $moves );
|
||||
|
||||
$this->assertSame(
|
||||
array(
|
||||
'hp_request_core>hp_listing_core:hp_expired_time',
|
||||
'hp_request_core>hp_listing_core:hp_status',
|
||||
'hp_request_core>hp_listing_core:hp_view_count',
|
||||
'hp_vendor_core>hp_listing_core:hp_hourly_rate',
|
||||
'hp_vendor_core>hp_listing_core:hp_rating',
|
||||
'hp_vendor_core>hp_listing_core:hp_rating_count',
|
||||
'hp_vendor_core>hp_listing_core:hp_verified',
|
||||
'hp_vendor_core>hp_request_core:hp_user',
|
||||
),
|
||||
$moves
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user