fix(2meet-data-optimizer): serialize arrays in text fields instead of storing "Array"
- TMDO_Type_Caster: text / textarea values that are arrays or objects are stored with maybe_serialize() (was (string) → the literal "Array", which bridge-restore-native then wrote back over native meta); from_db() unserializes them with TMDO_Safe_Unserialize. text columns are VARCHAR(255): longer values are stored NULL (native keeps them) and logged as text_overflow. - Add `wp tmdo repair-array-literals [--entity] [--confirm]` (TMDO_Array_Literal_Repair): rewrites flat "Array" values from native meta and lists native rows that are "Array" themselves (backup needed). - Verify: TypeCasterSerializeTest 13 OK, ArrayLiteralRepairTest 3 OK; unit 685 OK, integration 483 OK, phpcs (phpcs.xml) 0, stan OK; live `serialized` 3 PASS; dev36 repair dry-run lists the value corrupted by the 2026-09-26 round trip (user #1 community-events-location) - PR: PR-055
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Repair of text / textarea flat columns holding the literal "Array" (stored
|
||||
* by the pre-1.1.0 type caster when the meta value was an array).
|
||||
*
|
||||
* Fixture (user entity, group al_prefs):
|
||||
* user 1 prefs 'Array' native prefs serialized array → repairable
|
||||
* user 2 loc 'Array' native loc 'Array' too → unrepairable, native polluted
|
||||
* user 3 name 'Array' no native row → unrepairable
|
||||
* user 4 prefs serialized array, loc 'Taipei' → untouched
|
||||
*/
|
||||
class ArrayLiteralRepairTest extends TestCase {
|
||||
|
||||
private const TABLE = 'wp_itest_wpdo_user_al_prefs';
|
||||
|
||||
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-cache-orchestrator.php',
|
||||
'includes/adapters/class-tmdo-adapter-user.php',
|
||||
'includes/migration/class-tmdo-array-literal-repair.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, 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'] );
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
/**
|
||||
* Suite: array meta values survive the flat tables (no "Array" literal).
|
||||
*
|
||||
* Bug (dev36, 2026-09-26): admin_prefs `community-events-location` (text) and
|
||||
* `wp_persisted_preferences` (textarea) hold arrays; the type caster stored
|
||||
* (string) $array = "Array", and bridge-restore-native wrote it back to native.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
TMDO_Live::test(
|
||||
'dual_write: array written with update_user_meta is stored serialized',
|
||||
static function () {
|
||||
TMDO_Live_Fixtures::field( 'user', 'wp_persisted_preferences' );
|
||||
TMDO_Live_Fixtures::use_modes( array( 'user' => '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' );
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* text / textarea fields holding arrays (e.g. usermeta wp_persisted_preferences,
|
||||
* community-events-location) were cast with (string) and stored as "Array";
|
||||
* restoring native meta then wrote "Array" back over the real data.
|
||||
*/
|
||||
class TypeCasterSerializeTest extends TestCase {
|
||||
|
||||
/** Rows passed to $wpdb->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' );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user