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
274 lines
7.4 KiB
PHP
274 lines
7.4 KiB
PHP
<?php
|
|
/**
|
|
* Zone D (Archive) handler for historical data archival.
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Zone D (Archive) handler — historical data archival with optional compression.
|
|
*
|
|
* Single table: wpdo_archive
|
|
* Stores old/infrequently accessed postmeta entries with optional gzip compression.
|
|
*
|
|
* Archive flow:
|
|
* 1. Identify cold/stale postmeta entries (by age, post_status, or manual selection)
|
|
* 2. Copy to wpdo_archive with original_meta_id reference
|
|
* 3. Optionally compress meta_value with gzencode()
|
|
* 4. Delete from wp_postmeta (only during cleanup phase)
|
|
*
|
|
* Retrieval: decompress on read, restore to postmeta if needed.
|
|
*/
|
|
class TMDO_Zone_Archive {
|
|
|
|
/**
|
|
* Get the archive table name.
|
|
*/
|
|
public static function table(): string {
|
|
return TMDO_DB::table( 'wpdo_archive' );
|
|
}
|
|
|
|
/**
|
|
* Archive a single postmeta entry.
|
|
*
|
|
* @param int $post_id Post ID.
|
|
* @param string $post_type Post type.
|
|
* @param string $meta_key Meta key.
|
|
* @param string $meta_value Meta value.
|
|
* @param int $meta_id Original meta_id from wp_postmeta.
|
|
* @param bool $compress Whether to gzip-compress the value.
|
|
*/
|
|
public static function archive( int $post_id, string $post_type, string $meta_key, string $meta_value, int $meta_id = 0, bool $compress = false ): void {
|
|
global $wpdb;
|
|
$table = self::table();
|
|
|
|
$compressed = 0;
|
|
if ( $compress && function_exists( 'gzencode' ) ) {
|
|
$meta_value = base64_encode( gzencode( $meta_value, 6 ) );
|
|
$compressed = 1;
|
|
}
|
|
|
|
$wpdb->insert(
|
|
$table,
|
|
array(
|
|
'post_id' => $post_id,
|
|
'post_type' => $post_type,
|
|
'meta_key' => $meta_key,
|
|
'meta_value' => $meta_value,
|
|
'compressed' => $compressed,
|
|
'archived_at' => TMDO_DB::now(),
|
|
'original_meta_id' => $meta_id,
|
|
),
|
|
array( '%d', '%s', '%s', '%s', '%d', '%s', '%d' )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Archive multiple postmeta entries in batch.
|
|
*
|
|
* @param array $entries Array of [post_id, post_type, meta_key, meta_value, meta_id].
|
|
* @param bool $compress Whether to compress values.
|
|
* @return void
|
|
* @throws \Throwable When a batch insert fails and the transaction is rolled back.
|
|
*/
|
|
public static function archive_batch( array $entries, bool $compress = false ): void {
|
|
TMDO_DB::begin();
|
|
try {
|
|
foreach ( $entries as $entry ) {
|
|
self::archive(
|
|
(int) $entry['post_id'],
|
|
$entry['post_type'],
|
|
$entry['meta_key'],
|
|
$entry['meta_value'],
|
|
(int) ( $entry['meta_id'] ?? 0 ),
|
|
$compress
|
|
);
|
|
}
|
|
TMDO_DB::commit();
|
|
} catch ( \Throwable $e ) {
|
|
TMDO_DB::rollback();
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retrieve archived values for a post.
|
|
*
|
|
* @param int $post_id Post ID.
|
|
* @param string|null $meta_key Optional specific meta_key filter.
|
|
* @return array Array of [meta_key, meta_value, archived_at, compressed].
|
|
*/
|
|
public static function get( int $post_id, ?string $meta_key = null ): array {
|
|
global $wpdb;
|
|
$table = self::table();
|
|
|
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from TMDO_Zone_Archive::table() via TMDO_DB::table().
|
|
if ( $meta_key ) {
|
|
$rows = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT meta_key, meta_value, compressed, archived_at FROM `{$table}` WHERE post_id = %d AND meta_key = %s ORDER BY archived_at DESC",
|
|
$post_id,
|
|
$meta_key
|
|
),
|
|
ARRAY_A
|
|
);
|
|
} else {
|
|
$rows = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT meta_key, meta_value, compressed, archived_at FROM `{$table}` WHERE post_id = %d ORDER BY archived_at DESC",
|
|
$post_id
|
|
),
|
|
ARRAY_A
|
|
);
|
|
}
|
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
|
|
// Decompress where needed.
|
|
// Note: must iterate $rows directly (not $rows ?: []) to allow &$row to modify the original array.
|
|
foreach ( $rows as &$row ) {
|
|
if ( (int) $row['compressed'] && function_exists( 'gzdecode' ) ) {
|
|
$decoded = base64_decode( $row['meta_value'] );
|
|
if ( false !== $decoded ) {
|
|
$decompressed = gzdecode( $decoded );
|
|
if ( false !== $decompressed ) {
|
|
$row['meta_value'] = $decompressed;
|
|
}
|
|
}
|
|
}
|
|
unset( $row['compressed'] );
|
|
}
|
|
|
|
return $rows ?: array();
|
|
}
|
|
|
|
/**
|
|
* Restore archived entries back to wp_postmeta.
|
|
*
|
|
* @param int $post_id Post ID.
|
|
* @param string|null $meta_key Optional meta_key filter (null = restore all).
|
|
* @return int Number of entries restored.
|
|
*/
|
|
public static function restore( int $post_id, ?string $meta_key = null ): int {
|
|
$entries = self::get( $post_id, $meta_key );
|
|
$count = 0;
|
|
|
|
foreach ( $entries as $entry ) {
|
|
update_post_meta( $post_id, $entry['meta_key'], $entry['meta_value'] );
|
|
++$count;
|
|
}
|
|
|
|
// Delete restored entries from archive.
|
|
if ( $count > 0 ) {
|
|
global $wpdb;
|
|
$table = self::table();
|
|
|
|
if ( $meta_key ) {
|
|
$wpdb->delete(
|
|
$table,
|
|
array(
|
|
'post_id' => $post_id,
|
|
'meta_key' => $meta_key,
|
|
),
|
|
array( '%d', '%s' )
|
|
);
|
|
} else {
|
|
$wpdb->delete( $table, array( 'post_id' => $post_id ), array( '%d' ) );
|
|
}
|
|
}
|
|
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Delete all archived entries for a post.
|
|
*
|
|
* @param int $post_id Post ID.
|
|
* @return void
|
|
*/
|
|
public static function delete( int $post_id ): void {
|
|
global $wpdb;
|
|
$wpdb->delete( self::table(), array( 'post_id' => $post_id ), array( '%d' ) );
|
|
}
|
|
|
|
/**
|
|
* Sweep: archive stale postmeta entries by age.
|
|
*
|
|
* Finds postmeta for trashed/deleted posts older than $days and archives them.
|
|
*
|
|
* @param int $days Minimum age in days.
|
|
* @param bool $compress Whether to compress.
|
|
* @param int $limit Maximum rows per sweep.
|
|
* @return int Number of entries archived.
|
|
*/
|
|
public static function sweep( int $days = 90, bool $compress = true, int $limit = 500 ): int {
|
|
global $wpdb;
|
|
|
|
$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
|
|
|
|
$rows = $wpdb->get_results(
|
|
$wpdb->prepare(
|
|
"SELECT pm.meta_id, pm.post_id, pm.meta_key, pm.meta_value, p.post_type
|
|
FROM {$wpdb->postmeta} pm
|
|
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
|
WHERE p.post_status = 'trash'
|
|
AND p.post_modified_gmt < %s
|
|
LIMIT %d",
|
|
$cutoff,
|
|
$limit
|
|
),
|
|
ARRAY_A
|
|
);
|
|
|
|
if ( ! $rows ) {
|
|
return 0;
|
|
}
|
|
|
|
$entries = array();
|
|
foreach ( $rows as $row ) {
|
|
$entries[] = array(
|
|
'post_id' => $row['post_id'],
|
|
'post_type' => $row['post_type'],
|
|
'meta_key' => $row['meta_key'],
|
|
'meta_value' => $row['meta_value'],
|
|
'meta_id' => $row['meta_id'],
|
|
);
|
|
}
|
|
|
|
self::archive_batch( $entries, $compress );
|
|
|
|
return count( $entries );
|
|
}
|
|
|
|
/**
|
|
* Get archive statistics.
|
|
*
|
|
* @return array{total_rows: int, compressed_rows: int, post_types: array}
|
|
*/
|
|
public static function stats(): array {
|
|
global $wpdb;
|
|
$table = self::table();
|
|
|
|
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from internal self::table()
|
|
$total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
|
|
$compressed = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE compressed = 1" );
|
|
|
|
$types = $wpdb->get_results(
|
|
"SELECT post_type, COUNT(*) as cnt FROM `{$table}` GROUP BY post_type ORDER BY cnt DESC",
|
|
ARRAY_A
|
|
);
|
|
// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
|
|
|
|
return array(
|
|
'total_rows' => $total,
|
|
'compressed_rows' => $compressed,
|
|
'post_types' => $types ?: array(),
|
|
);
|
|
}
|
|
}
|