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:
@@ -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<string, mixed> $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).
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Array_Literal_Repair — finds flat text / textarea values stored as the
|
||||
* literal "Array" (pre-1.1.0 type caster cast arrays with (string)) and
|
||||
* rewrites them from native meta.
|
||||
*
|
||||
* Native rows that are themselves "Array" (written back by an earlier
|
||||
* bridge-restore-native) cannot be repaired here: they are only listed, to be
|
||||
* restored from a backup.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery -- table / column names come from the registry and adapter; values are prepared.
|
||||
|
||||
/**
|
||||
* Repairs "Array" literals in flat text / textarea columns.
|
||||
*/
|
||||
final class TMDO_Array_Literal_Repair {
|
||||
|
||||
/** What (string) array produced. */
|
||||
private const LITERAL = 'Array';
|
||||
|
||||
/**
|
||||
* Scan (and optionally repair) one entity type.
|
||||
*
|
||||
* @param string $entity_type Entity type.
|
||||
* @param bool $apply Write repairs; false = report only.
|
||||
* @return array{found:int, repairable:int, repaired:int, unrepairable:array<int, array{group:string, key:string, id:int, reason:string}>, native_polluted:array<int, array{group:string, key:string, id:int}>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user