From 13ea341ef8511f32c5571b49b27733daced6258c Mon Sep 17 00:00:00 2001 From: wpdev Date: Sat, 26 Sep 2026 22:46:01 +0800 Subject: [PATCH] fix(2meet-data-optimizer): do not invent default rows when migrating an entity group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../class-tmdo-entity-migration-engine.php | 12 ++ .../EntityMigrationEngineDefaultsTest.php | 139 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 tests/integration/EntityMigrationEngineDefaultsTest.php diff --git a/includes/engine/class-tmdo-entity-migration-engine.php b/includes/engine/class-tmdo-entity-migration-engine.php index 8d262be..d31231d 100644 --- a/includes/engine/class-tmdo-entity-migration-engine.php +++ b/includes/engine/class-tmdo-entity-migration-engine.php @@ -243,6 +243,18 @@ final class TMDO_Entity_Migration_Engine { 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 ) { return true; } diff --git a/tests/integration/EntityMigrationEngineDefaultsTest.php b/tests/integration/EntityMigrationEngineDefaultsTest.php new file mode 100644 index 0000000..a606828 --- /dev/null +++ b/tests/integration/EntityMigrationEngineDefaultsTest.php @@ -0,0 +1,139 @@ +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 */ + 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'] ); + } +}