fix(2meet-data-optimizer): do not invent default rows when migrating an entity group
- migrate_group() REPLACEs the whole flat row; columns without a native row fell back to their DDL default (session_count / account_flags DEFAULT 0), so every backfilled user got invented zeros. Keys with no native row are now written as NULL; required NOT NULL columns keep their default. - Covers the cron batch path (migrate_group_batch) and clears stale flat values on re-run. - Verify: EntityMigrationEngineDefaultsTest (3 RED on main); dev36 user/activity backfill: checker extra 123 → 0 for session_count and account_flags, miss/diff unchanged. - PR: PR-066
This commit is contained in:
@@ -243,6 +243,18 @@ final class TMDO_Entity_Migration_Engine {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// REPLACE rewrites the whole row: a column left out falls back to its DDL
|
||||||
|
// default (e.g. session_count DEFAULT 0), inventing a value the object never
|
||||||
|
// had. Keys without a native row are written as NULL; required columns are
|
||||||
|
// NOT NULL, so they keep their default (treated as "absent" by the diff logger).
|
||||||
|
foreach ( $field_map as $key => $field_def ) {
|
||||||
|
$col = TMDO_Schema_Manager::sanitize_column_name( $key );
|
||||||
|
if ( ! array_key_exists( $col, $data ) && empty( $field_def['required'] ) ) {
|
||||||
|
$data[ $col ] = null;
|
||||||
|
$formats[] = '%s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ( $dry_run ) {
|
if ( $dry_run ) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TMDO_Entity_Migration_Engine::migrate_group() must not invent values for keys
|
||||||
|
* the object has no native row for.
|
||||||
|
*
|
||||||
|
* Bug (dev36 R-2.1, 2026-09-26): the engine REPLACEs only the columns it found
|
||||||
|
* native rows for, so every other column fell back to its DDL default —
|
||||||
|
* user activity.session_count / account_flags (nullable, DEFAULT 0) became 0
|
||||||
|
* for every backfilled user, and the checker's "extra" count went 60 → 123.
|
||||||
|
*
|
||||||
|
* Fixture (user entity, group md_act):
|
||||||
|
* login_count integer, default 0, nullable
|
||||||
|
* session_count integer, default 0, nullable
|
||||||
|
* note text
|
||||||
|
* req_flag integer, required (NOT NULL DEFAULT 0)
|
||||||
|
*/
|
||||||
|
class EntityMigrationEngineDefaultsTest extends TestCase {
|
||||||
|
|
||||||
|
private const TABLE = 'wp_itest_wpdo_user_md_act';
|
||||||
|
private const STATUS = 'wp_itest_wpdo_migration_status';
|
||||||
|
|
||||||
|
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-entity-migration-engine.php',
|
||||||
|
'includes/adapters/class-tmdo-adapter-user.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, login_count BIGINT(20) DEFAULT 0, session_count BIGINT(20) DEFAULT 0, note VARCHAR(255) DEFAULT NULL, req_flag BIGINT(20) NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_entity (user_id) ) ENGINE=InnoDB' );
|
||||||
|
$wpdb->query( 'CREATE TABLE `' . self::STATUS . "` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, entity_type VARCHAR(50) NOT NULL, group_name VARCHAR(50) NOT NULL, last_id BIGINT UNSIGNED NOT NULL DEFAULT 0, total_migrated BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'pending', started_at DATETIME DEFAULT NULL, completed_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY entity_group (entity_type, group_name) ) ENGINE=InnoDB" );
|
||||||
|
|
||||||
|
$GLOBALS['_wp_cache'] = array();
|
||||||
|
WPDO_Entity_Registry::init();
|
||||||
|
WPDO_Entity_Registry::register_adapter( 'user', new WPDO_Adapter_User() );
|
||||||
|
WPDO_Entity_Registry::register_group(
|
||||||
|
'user',
|
||||||
|
'md_act',
|
||||||
|
array(
|
||||||
|
array( 'key' => 'login_count', 'type' => 'integer', 'default' => 0 ),
|
||||||
|
array( 'key' => 'session_count', 'type' => 'integer', 'default' => 0 ),
|
||||||
|
array( 'key' => 'note', 'type' => 'text' ),
|
||||||
|
array( 'key' => 'req_flag', 'type' => 'integer', 'default' => 0, 'required' => true ),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
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, self::STATUS ) as $table ) {
|
||||||
|
$wpdb->query( "DROP TABLE IF EXISTS `{$table}`" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function native( int $user_id, string $key, string $value ): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->insert( $wpdb->usermeta, array( 'user_id' => $user_id, 'meta_key' => $key, 'meta_value' => $value ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string|null> */
|
||||||
|
private function flat_row( int $user_id ): array {
|
||||||
|
global $wpdb;
|
||||||
|
return (array) $wpdb->get_row( $wpdb->prepare( 'SELECT login_count, session_count, note, req_flag FROM `' . self::TABLE . '` WHERE user_id = %d', $user_id ), ARRAY_A );
|
||||||
|
}
|
||||||
|
|
||||||
|
private function migrate(): array {
|
||||||
|
return TMDO_Entity_Migration_Engine::migrate_group( 'user', 'md_act', array( 'resume' => false, 'sleep_ms' => 0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_keys_without_a_native_row_stay_null_instead_of_the_column_default(): void {
|
||||||
|
$this->native( 1, 'login_count', '7' );
|
||||||
|
|
||||||
|
$report = $this->migrate();
|
||||||
|
|
||||||
|
$this->assertSame( 1, $report['migrated'] );
|
||||||
|
$row = $this->flat_row( 1 );
|
||||||
|
$this->assertSame( '7', $row['login_count'] );
|
||||||
|
$this->assertNull( $row['session_count'], 'no native session_count — must not become the DEFAULT 0' );
|
||||||
|
$this->assertNull( $row['note'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_native_value_equal_to_the_default_is_still_written(): void {
|
||||||
|
$this->native( 1, 'session_count', '0' );
|
||||||
|
|
||||||
|
$this->migrate();
|
||||||
|
|
||||||
|
$this->assertSame( '0', $this->flat_row( 1 )['session_count'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_stale_flat_value_is_cleared_when_the_native_row_is_gone(): void {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->insert( self::TABLE, array( 'user_id' => 1, 'login_count' => 3, 'session_count' => 5, 'note' => 'old' ) );
|
||||||
|
$this->native( 1, 'login_count', '4' );
|
||||||
|
|
||||||
|
$this->migrate();
|
||||||
|
|
||||||
|
$row = $this->flat_row( 1 );
|
||||||
|
$this->assertSame( '4', $row['login_count'] );
|
||||||
|
$this->assertNull( $row['session_count'] );
|
||||||
|
$this->assertNull( $row['note'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_a_required_column_without_a_native_row_keeps_its_default(): void {
|
||||||
|
$this->native( 1, 'login_count', '1' );
|
||||||
|
|
||||||
|
$report = $this->migrate();
|
||||||
|
|
||||||
|
$this->assertSame( 0, $report['errors'] );
|
||||||
|
$this->assertSame( '0', $this->flat_row( 1 )['req_flag'], 'NOT NULL column cannot hold NULL — its default is the documented stand-in' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_the_cron_batch_path_does_not_invent_defaults_either(): void {
|
||||||
|
$this->native( 1, 'login_count', '2' );
|
||||||
|
|
||||||
|
$result = TMDO_Entity_Migration_Engine::migrate_group_batch( 'user', 'md_act' );
|
||||||
|
|
||||||
|
$this->assertSame( 1, $result['migrated'] );
|
||||||
|
$this->assertNull( $this->flat_row( 1 )['session_count'] );
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user