Files
2meet-data-optimizer/includes/engine/class-tmdo-type-caster.php
T
wpdev 08f9e313e0 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
2026-09-26 18:45:59 +08:00

200 lines
5.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 );
}
}