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 ); } }