76c01e44df
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入 並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
399 lines
12 KiB
PHP
399 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* Zone Classifier for wp_postmeta analysis and zone assignment suggestions.
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Zone Classifier — analyzes wp_postmeta and suggests zone assignments.
|
|
*
|
|
* Examines meta_key usage patterns across the database to recommend
|
|
* which zone each key should belong to:
|
|
*
|
|
* Hot (A) — Used in WP_Query meta_query (search/filter); numeric or short values
|
|
* Warm (B) — Transient/computed data; infrequently updated
|
|
* Cold (C) — Display data read often but never queried; long text/JSON blobs
|
|
* Archive (D) — Belongs to trashed/old posts; rarely if ever accessed
|
|
*
|
|
* Signals analyzed:
|
|
* - Value length distribution (short = hot candidate, long = cold candidate)
|
|
* - Post status distribution (trash/draft heavy = archive candidate)
|
|
* - Key prefix patterns (hp_, _hp_, _transient_ etc.)
|
|
* - Whether the key appears in meta_query (via slow query log or heuristics)
|
|
* - Distinct value cardinality (low = likely enum/flag = hot)
|
|
*
|
|
* Used by Admin UI and WP-CLI `wp wpdo analyze` command.
|
|
*/
|
|
class TMDO_Zone_Classifier {
|
|
|
|
/**
|
|
* Analyze postmeta for a specific post type and return zone suggestions.
|
|
*
|
|
* @param string $post_type Post type to analyze.
|
|
* @param int $sample_size Number of rows to sample per meta_key.
|
|
* @return array Array of suggestions, each with: meta_key, suggested_zone, confidence, reasons.
|
|
*/
|
|
public static function analyze( string $post_type, int $sample_size = 100 ): array {
|
|
global $wpdb;
|
|
|
|
// Return cached result if available (TTL: 1 hour).
|
|
$transient_key = 'wpdo_classifier_' . sanitize_key( $post_type );
|
|
$cached = get_transient( $transient_key );
|
|
if ( false !== $cached ) {
|
|
return $cached;
|
|
}
|
|
|
|
// Get all distinct meta_keys for this post type.
|
|
$keys = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT pm.meta_key, COUNT(*) as row_count
|
|
FROM {$wpdb->postmeta} pm
|
|
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
|
WHERE p.post_type = %s
|
|
GROUP BY pm.meta_key
|
|
ORDER BY row_count DESC
|
|
LIMIT 200",
|
|
$post_type
|
|
),
|
|
ARRAY_A
|
|
);
|
|
|
|
if ( empty( $keys ) ) {
|
|
return array();
|
|
}
|
|
|
|
$suggestions = array();
|
|
$registry = TMDO_Schema_Registry::instance();
|
|
|
|
foreach ( $keys as $key_info ) {
|
|
$meta_key = $key_info['meta_key'];
|
|
$row_count = (int) $key_info['row_count'];
|
|
|
|
// Skip WordPress internal keys.
|
|
if ( self::is_wp_internal( $meta_key ) ) {
|
|
continue;
|
|
}
|
|
|
|
// Check if already registered.
|
|
$existing = $registry->get_field( $post_type, $meta_key );
|
|
$already_assigned = $existing ? $existing['zone'] : null;
|
|
|
|
// Collect signals.
|
|
$signals = self::collect_signals( $meta_key, $post_type, $row_count, $sample_size );
|
|
|
|
// Score each zone.
|
|
$scores = self::score_zones( $signals );
|
|
|
|
// Pick the best zone.
|
|
arsort( $scores );
|
|
$best_zone = array_key_first( $scores );
|
|
$confidence = $scores[ $best_zone ];
|
|
|
|
$suggestions[] = array(
|
|
'meta_key' => $meta_key,
|
|
'row_count' => $row_count,
|
|
'suggested_zone' => $best_zone,
|
|
'confidence' => round( $confidence, 2 ),
|
|
'already_assigned' => $already_assigned,
|
|
'scores' => $scores,
|
|
'reasons' => self::build_reasons( $signals, $best_zone ),
|
|
);
|
|
}
|
|
|
|
// Sort by confidence descending.
|
|
usort( $suggestions, fn( $a, $b ) => $b['confidence'] <=> $a['confidence'] );
|
|
|
|
set_transient( $transient_key, $suggestions, HOUR_IN_SECONDS );
|
|
|
|
return $suggestions;
|
|
}
|
|
|
|
/**
|
|
* Quick summary: count of meta_keys per suggested zone.
|
|
*
|
|
* @param string $post_type Post type to analyze.
|
|
* @return array{hot: int, warm: int, cold: int, archive: int, already_assigned: int}
|
|
*/
|
|
public static function summary( string $post_type ): array {
|
|
$suggestions = self::analyze( $post_type );
|
|
$summary = array(
|
|
'hot' => 0,
|
|
'warm' => 0,
|
|
'cold' => 0,
|
|
'archive' => 0,
|
|
'already_assigned' => 0,
|
|
);
|
|
|
|
foreach ( $suggestions as $s ) {
|
|
if ( $s['already_assigned'] ) {
|
|
++$summary['already_assigned'];
|
|
} else {
|
|
++$summary[ $s['suggested_zone'] ];
|
|
}
|
|
}
|
|
|
|
return $summary;
|
|
}
|
|
|
|
// ── Private helpers ───────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Collect analytical signals for a meta_key.
|
|
*
|
|
* @param string $meta_key Meta key to analyze.
|
|
* @param string $post_type Post type context.
|
|
* @param int $row_count Total rows for this meta key.
|
|
* @param int $sample_size Number of rows sampled.
|
|
* @return array Signal data for zone scoring.
|
|
*/
|
|
private static function collect_signals( string $meta_key, string $post_type, int $row_count, int $sample_size ): array {
|
|
global $wpdb;
|
|
|
|
$signals = array(
|
|
'meta_key' => $meta_key,
|
|
'row_count' => $row_count,
|
|
'avg_length' => 0,
|
|
'max_length' => 0,
|
|
'distinct_values' => 0,
|
|
'numeric_ratio' => 0.0,
|
|
'trash_ratio' => 0.0,
|
|
'is_serialized' => false,
|
|
'is_json' => false,
|
|
'prefix' => '',
|
|
);
|
|
|
|
// Prefix detection.
|
|
if ( str_starts_with( $meta_key, 'hp_' ) || str_starts_with( $meta_key, '_hp_' ) ) {
|
|
$signals['prefix'] = 'hivepress';
|
|
} elseif ( str_starts_with( $meta_key, '_transient_' ) || str_starts_with( $meta_key, '_site_transient_' ) ) {
|
|
$signals['prefix'] = 'transient';
|
|
} elseif ( str_starts_with( $meta_key, '_' ) ) {
|
|
$signals['prefix'] = 'internal';
|
|
}
|
|
|
|
// Sample values for analysis.
|
|
$samples = $wpdb->get_col(
|
|
$wpdb->prepare(
|
|
"SELECT pm.meta_value
|
|
FROM {$wpdb->postmeta} pm
|
|
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
|
WHERE p.post_type = %s AND pm.meta_key = %s
|
|
LIMIT %d",
|
|
$post_type,
|
|
$meta_key,
|
|
$sample_size
|
|
)
|
|
);
|
|
|
|
if ( ! empty( $samples ) ) {
|
|
$lengths = array_map( 'strlen', $samples );
|
|
$signals['avg_length'] = (int) ( array_sum( $lengths ) / count( $lengths ) );
|
|
$signals['max_length'] = max( $lengths );
|
|
|
|
$numeric_count = 0;
|
|
foreach ( $samples as $val ) {
|
|
if ( is_numeric( $val ) ) {
|
|
++$numeric_count;
|
|
}
|
|
}
|
|
$signals['numeric_ratio'] = $numeric_count / count( $samples );
|
|
|
|
// Check serialized/JSON.
|
|
$first = $samples[0] ?? '';
|
|
$signals['is_serialized'] = is_serialized( $first );
|
|
$signals['is_json'] = ( str_starts_with( $first, '{' ) || str_starts_with( $first, '[' ) )
|
|
&& null !== json_decode( $first );
|
|
}
|
|
|
|
// Distinct value count.
|
|
$signals['distinct_values'] = (int) $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT COUNT(DISTINCT pm.meta_value)
|
|
FROM {$wpdb->postmeta} pm
|
|
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
|
WHERE p.post_type = %s AND pm.meta_key = %s",
|
|
$post_type,
|
|
$meta_key
|
|
)
|
|
);
|
|
|
|
// Trash ratio.
|
|
if ( $row_count > 0 ) {
|
|
$trash_count = (int) $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT COUNT(*)
|
|
FROM {$wpdb->postmeta} pm
|
|
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
|
WHERE p.post_type = %s AND pm.meta_key = %s AND p.post_status = 'trash'",
|
|
$post_type,
|
|
$meta_key
|
|
)
|
|
);
|
|
$signals['trash_ratio'] = $trash_count / $row_count;
|
|
}
|
|
|
|
return $signals;
|
|
}
|
|
|
|
/**
|
|
* Score each zone based on collected signals.
|
|
*
|
|
* @param array $signals Signal data from collect_signals().
|
|
* @return array{hot: float, warm: float, cold: float, archive: float} Zone scores.
|
|
*/
|
|
private static function score_zones( array $signals ): array {
|
|
$scores = array(
|
|
'hot' => 0.0,
|
|
'warm' => 0.0,
|
|
'cold' => 0.0,
|
|
'archive' => 0.0,
|
|
);
|
|
|
|
// --- Hot signals ---
|
|
// Short, numeric values are great for indexing.
|
|
if ( $signals['avg_length'] < 50 ) {
|
|
$scores['hot'] += 0.3;
|
|
}
|
|
if ( $signals['numeric_ratio'] > 0.8 ) {
|
|
$scores['hot'] += 0.3;
|
|
}
|
|
// Low cardinality = enum/flag = good for filtering.
|
|
if ( $signals['distinct_values'] > 0 && $signals['distinct_values'] <= 20 ) {
|
|
$scores['hot'] += 0.2;
|
|
}
|
|
// HivePress prefix = likely a search field.
|
|
if ( 'hivepress' === $signals['prefix'] && $signals['avg_length'] < 100 ) {
|
|
$scores['hot'] += 0.2;
|
|
}
|
|
|
|
// --- Warm signals ---
|
|
// Transient prefix is a clear warm signal.
|
|
if ( 'transient' === $signals['prefix'] ) {
|
|
$scores['warm'] += 0.8;
|
|
}
|
|
// Internal prefix + short values.
|
|
if ( 'internal' === $signals['prefix'] && $signals['avg_length'] < 100 ) {
|
|
$scores['warm'] += 0.2;
|
|
}
|
|
|
|
// --- Cold signals ---
|
|
// Long text/JSON blobs are cold candidates.
|
|
if ( $signals['avg_length'] > 200 ) {
|
|
$scores['cold'] += 0.4;
|
|
}
|
|
if ( $signals['is_json'] || $signals['is_serialized'] ) {
|
|
$scores['cold'] += 0.3;
|
|
}
|
|
// High cardinality + long values = display/profile data.
|
|
if ( $signals['distinct_values'] > 50 && $signals['avg_length'] > 100 ) {
|
|
$scores['cold'] += 0.2;
|
|
}
|
|
// HivePress prefix + long values = description field.
|
|
if ( 'hivepress' === $signals['prefix'] && $signals['avg_length'] > 100 ) {
|
|
$scores['cold'] += 0.2;
|
|
}
|
|
|
|
// --- Archive signals ---
|
|
// High trash ratio = archive candidate.
|
|
if ( $signals['trash_ratio'] > 0.5 ) {
|
|
$scores['archive'] += 0.6;
|
|
} elseif ( $signals['trash_ratio'] > 0.2 ) {
|
|
$scores['archive'] += 0.3;
|
|
}
|
|
|
|
// Normalize: ensure at least one zone has a score.
|
|
$max = max( $scores );
|
|
if ( 0.0 === $max ) {
|
|
// Default to cold for unknown patterns.
|
|
$scores['cold'] = 0.1;
|
|
}
|
|
|
|
return $scores;
|
|
}
|
|
|
|
/**
|
|
* Build human-readable reasons for the zone suggestion.
|
|
*
|
|
* @param array $signals Signal data from collect_signals().
|
|
* @param string $zone Suggested zone name.
|
|
* @return array Array of human-readable reason strings.
|
|
*/
|
|
private static function build_reasons( array $signals, string $zone ): array {
|
|
$reasons = array();
|
|
|
|
switch ( $zone ) {
|
|
case 'hot':
|
|
if ( $signals['avg_length'] < 50 ) {
|
|
$reasons[] = sprintf( 'Short values (avg %d chars) — efficient for indexing', $signals['avg_length'] );
|
|
}
|
|
if ( $signals['numeric_ratio'] > 0.8 ) {
|
|
$reasons[] = sprintf( '%.0f%% numeric values — ideal for range queries', $signals['numeric_ratio'] * 100 );
|
|
}
|
|
if ( $signals['distinct_values'] <= 20 ) {
|
|
$reasons[] = sprintf( 'Low cardinality (%d distinct values) — good for filtering', $signals['distinct_values'] );
|
|
}
|
|
break;
|
|
|
|
case 'warm':
|
|
if ( 'transient' === $signals['prefix'] ) {
|
|
$reasons[] = 'Transient prefix detected — ephemeral data with natural TTL';
|
|
}
|
|
break;
|
|
|
|
case 'cold':
|
|
if ( $signals['avg_length'] > 200 ) {
|
|
$reasons[] = sprintf( 'Long values (avg %d chars) — display/profile data', $signals['avg_length'] );
|
|
}
|
|
if ( $signals['is_json'] ) {
|
|
$reasons[] = 'JSON structure detected — good for blob storage';
|
|
}
|
|
if ( $signals['is_serialized'] ) {
|
|
$reasons[] = 'Serialized data — good for blob storage';
|
|
}
|
|
break;
|
|
|
|
case 'archive':
|
|
if ( $signals['trash_ratio'] > 0.2 ) {
|
|
$reasons[] = sprintf( '%.0f%% of entries belong to trashed posts', $signals['trash_ratio'] * 100 );
|
|
}
|
|
break;
|
|
}
|
|
|
|
if ( empty( $reasons ) ) {
|
|
$reasons[] = 'Default classification based on overall signal pattern';
|
|
}
|
|
|
|
return $reasons;
|
|
}
|
|
|
|
/**
|
|
* Check if a meta_key is a WordPress internal key that should be skipped.
|
|
*
|
|
* @param string $meta_key Meta key to check.
|
|
* @return bool True if the key is a WordPress internal key.
|
|
*/
|
|
private static function is_wp_internal( string $meta_key ): bool {
|
|
$skip = array(
|
|
'_edit_lock',
|
|
'_edit_last',
|
|
'_wp_page_template',
|
|
'_wp_old_slug',
|
|
'_wp_trash_meta_time',
|
|
'_wp_trash_meta_status',
|
|
'_wp_desired_post_slug',
|
|
'_thumbnail_id',
|
|
'_encloseme',
|
|
'_pingme',
|
|
);
|
|
|
|
return in_array( $meta_key, $skip, true );
|
|
}
|
|
}
|