diff --git a/2meet-data-optimizer.php b/2meet-data-optimizer.php index a7fcf5d..5866a52 100644 --- a/2meet-data-optimizer.php +++ b/2meet-data-optimizer.php @@ -261,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-group-reconciler.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( 'wpdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) ); add_action( 'tmdo_bridge_mode_changed', array( 'TMDO_Migration_Orchestrator', 'bust_attention_cache' ) ); diff --git a/cli/class-tmdo-cli-v2.php b/cli/class-tmdo-cli-v2.php index b3d76f2..bdc728f 100644 --- a/cli/class-tmdo-cli-v2.php +++ b/cli/class-tmdo-cli-v2.php @@ -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.' ); } + /** + * 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=] + * : 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 $args Positional arguments (unused). + * @param array $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. * @@ -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( '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( '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( 'tmdo mode-audit', array( 'TMDO_CLI_V2', 'mode_audit' ) ); WP_CLI::add_command( 'wpdo mode-set', array( 'TMDO_CLI_V2', 'mode_set' ) ); diff --git a/includes/engine/class-tmdo-type-caster.php b/includes/engine/class-tmdo-type-caster.php index f6d8bbe..44c2bff 100644 --- a/includes/engine/class-tmdo-type-caster.php +++ b/includes/engine/class-tmdo-type-caster.php @@ -70,10 +70,12 @@ final class TMDO_Type_Caster { return $value; case 'text': - return (string) $value; + return self::fit_text( is_scalar( $value ) ? (string) $value : maybe_serialize( $value ), $field_def ); 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: // 未知型別:嘗試序列化(向後相容 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 $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 轉換(讀取) */ @@ -111,9 +140,12 @@ final class TMDO_Type_Caster { return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value; case 'enum': + return (string) $value; + case 'text': case 'textarea': - return (string) $value; + // Arrays were stored serialized (to_db); plain strings pass through. + return TMDO_Safe_Unserialize::run( (string) $value ); default: // v2.13.3: object-injection-safe unserialize (fixes L-DESER-1). diff --git a/includes/migration/class-tmdo-array-literal-repair.php b/includes/migration/class-tmdo-array-literal-repair.php new file mode 100644 index 0000000..a074020 --- /dev/null +++ b/includes/migration/class-tmdo-array-literal-repair.php @@ -0,0 +1,111 @@ +, native_polluted:array} + */ + 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; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 2ab62a7..91cf5f2 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -647,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-group-reconciler.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'; // Options Manager. diff --git a/tests/integration/ArrayLiteralRepairTest.php b/tests/integration/ArrayLiteralRepairTest.php new file mode 100644 index 0000000..95f0cb4 --- /dev/null +++ b/tests/integration/ArrayLiteralRepairTest.php @@ -0,0 +1,119 @@ +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'] ); + } +} diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 9578e92..afccaa9 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -611,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-group-reconciler.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 . 'includes/class-tmdo-postmeta-cleaner.php'; require_once TMDO_PATH . 'includes/class-tmdo-termmeta-cleaner.php'; diff --git a/tests/live/suites/serialized.php b/tests/live/suites/serialized.php new file mode 100644 index 0000000..ea98d25 --- /dev/null +++ b/tests/live/suites/serialized.php @@ -0,0 +1,59 @@ + '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' ); + } +); diff --git a/tests/unit/TypeCasterSerializeTest.php b/tests/unit/TypeCasterSerializeTest.php new file mode 100644 index 0000000..38d0a39 --- /dev/null +++ b/tests/unit/TypeCasterSerializeTest.php @@ -0,0 +1,99 @@ +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' ); + } +}