08f9e313e0
- 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
200 lines
5.6 KiB
PHP
200 lines
5.6 KiB
PHP
<?php
|
||
/**
|
||
* TMDO_Type_Caster - 型別轉換器
|
||
*
|
||
* 負責:
|
||
* - PHP → DB 序列化(寫入)
|
||
* - DB → PHP 反序列化(讀取)
|
||
* - wpdb format string 生成
|
||
* - 型別驗證與清理
|
||
*
|
||
* @package WP_Data_Optimizer
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
// phpcs:disable Squiz.Commenting,Generic.Commenting,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.CodeAnalysis.EmptyStatement,Squiz.PHP.DisallowMultipleAssignments,Squiz.PHP.DisallowSizeFunctionsInLoops,WordPress.WP.I18n.MissingTranslatorsComment,WordPress.PHP.NoSilencedErrors,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,Squiz.PHP.CommentedOutCode,Universal.NamingConventions.NoReservedKeywordParameterNames,WordPress.PHP.YodaConditions,Squiz.Commenting.InlineComment.InvalidEndChar -- PR-1 ported from UAE; cleanup PR scheduled.
|
||
|
||
|
||
defined( 'ABSPATH' ) || exit;
|
||
|
||
final class TMDO_Type_Caster {
|
||
|
||
/**
|
||
* PHP → DB 轉換(寫入)
|
||
*/
|
||
public static function to_db( $value, array $field_def ) {
|
||
$type = $field_def['type'] ?? 'text';
|
||
|
||
// null 值處理
|
||
if ( $value === null ) {
|
||
if ( ! empty( $field_def['required'] ) ) {
|
||
return $field_def['default'] ?? '';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
switch ( $type ) {
|
||
case 'integer':
|
||
return (int) $value;
|
||
|
||
case 'decimal':
|
||
return (float) $value;
|
||
|
||
case 'boolean':
|
||
return self::to_bool( $value ) ? 1 : 0;
|
||
|
||
case 'date':
|
||
return self::format_date( $value, 'Y-m-d' );
|
||
|
||
case 'datetime':
|
||
case 'timestamp':
|
||
return self::format_date( $value, 'Y-m-d H:i:s' );
|
||
|
||
case 'json':
|
||
if ( is_string( $value ) ) {
|
||
// 若已是 JSON 字串則直接儲存(驗證後)
|
||
$decoded = json_decode( $value, true );
|
||
if ( json_last_error() === JSON_ERROR_NONE ) {
|
||
return $value;
|
||
}
|
||
}
|
||
return wp_json_encode( $value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES );
|
||
|
||
case 'enum':
|
||
$options = $field_def['options'] ?? array();
|
||
$str = (string) $value;
|
||
return ( ! empty( $options ) && in_array( $str, $options, true ) ) ? $str : ( $field_def['default'] ?? '' );
|
||
|
||
case 'binary':
|
||
return $value;
|
||
|
||
case 'text':
|
||
return self::fit_text( is_scalar( $value ) ? (string) $value : maybe_serialize( $value ), $field_def );
|
||
|
||
case 'textarea':
|
||
// 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 行為)
|
||
return maybe_serialize( $value );
|
||
}
|
||
}
|
||
|
||
/** '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 轉換(讀取)
|
||
*/
|
||
public static function from_db( $value, array $field_def ) {
|
||
$type = $field_def['type'] ?? 'text';
|
||
|
||
if ( $value === null ) {
|
||
return null;
|
||
}
|
||
|
||
switch ( $type ) {
|
||
case 'integer':
|
||
return (int) $value;
|
||
|
||
case 'decimal':
|
||
return (float) $value;
|
||
|
||
case 'boolean':
|
||
return (bool) (int) $value;
|
||
|
||
case 'date':
|
||
case 'datetime':
|
||
case 'timestamp':
|
||
return (string) $value;
|
||
|
||
case 'json':
|
||
$decoded = json_decode( (string) $value, true );
|
||
return ( json_last_error() === JSON_ERROR_NONE ) ? $decoded : $value;
|
||
|
||
case 'enum':
|
||
return (string) $value;
|
||
|
||
case 'text':
|
||
case 'textarea':
|
||
// 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).
|
||
return TMDO_Safe_Unserialize::run( $value );
|
||
}
|
||
}
|
||
|
||
/**
|
||
* wpdb format 字串(%s / %d / %f)
|
||
*/
|
||
public static function get_wpdb_format( string $type ): string {
|
||
return match ( $type ) {
|
||
'integer', 'boolean' => '%d',
|
||
'decimal' => '%f',
|
||
default => '%s',
|
||
};
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 工具方法
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
private static function to_bool( $value ): bool {
|
||
if ( is_bool( $value ) ) {
|
||
return $value;
|
||
}
|
||
if ( is_numeric( $value ) ) {
|
||
return (int) $value !== 0;
|
||
}
|
||
$str = strtolower( trim( (string) $value ) );
|
||
return in_array( $str, array( 'true', 'yes', 'y', '1', 'on' ), true );
|
||
}
|
||
|
||
private static function format_date( $value, string $format ): ?string {
|
||
if ( $value === '' || $value === null ) {
|
||
return null;
|
||
}
|
||
|
||
// 接受 Unix timestamp
|
||
if ( is_numeric( $value ) ) {
|
||
return gmdate( $format, (int) $value );
|
||
}
|
||
|
||
$ts = strtotime( (string) $value );
|
||
if ( $ts === false ) {
|
||
return null;
|
||
}
|
||
|
||
return gmdate( $format, $ts );
|
||
}
|
||
}
|