Files
2meet-data-optimizer/includes/snapshots/class-tmdo-snapshot-writer.php
T
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 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
2026-07-31 06:13:33 +08:00

334 lines
9.4 KiB
PHP

<?php
/**
* TMDO_Snapshot_Writer — produces a SQL dump of WPDO tables (v2.2.0 M1).
*
* Strategy:
* 1. Resolve which tables to dump from the scope (default = all WPDO-owned tables).
* 2. Iterate each table in 5000-row chunks; emit `INSERT INTO ... VALUES (...)`.
* 3. Stream output to a temp file with gzip; compute sha256 and final size.
* 4. If final size ≤ INLINE_THRESHOLD_BYTES, slurp into memory + delete file
* (so small snapshots travel with `wp db export`).
* 5. Otherwise leave the file under wp-content/uploads/wpdo-backups/.
*
* @package WP_Data_Optimizer
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Streamed dump writer with chunking + gzip + sha256.
*/
class TMDO_Snapshot_Writer {
/** Rows per SELECT chunk. */
private const CHUNK_SIZE = 5000;
/**
* Snapshot ULID assigned by manager.
*
* @var string
*/
private string $snapshot_id;
/**
* Resolved dump scope.
*
* @var array{entities:array,modules:array,tables:array}
*/
private array $scope;
/**
* Writer options (gzip, inline_threshold_bytes, max_tables).
*
* @var array<string,mixed>
*/
private array $opts;
/**
* Constructor.
*
* @param string $snapshot_id Snapshot ULID.
* @param array $scope Resolved scope.
* @param array $opts {gzip:bool, inline_threshold_bytes:int, max_tables:int}.
*/
public function __construct( string $snapshot_id, array $scope, array $opts = array() ) {
$this->snapshot_id = $snapshot_id;
$this->scope = $scope;
$this->opts = $opts;
}
/**
* Run the dump.
*
* @return array {storage,file_path,sha256,size_bytes,row_count,inline_blob}
* @throws RuntimeException When no tables resolved or filesystem unwritable.
*/
public function write(): array {
$tables = $this->resolve_tables();
if ( empty( $tables ) ) {
throw new RuntimeException( 'snapshot scope produced 0 tables' );
}
$gzip = (bool) ( $this->opts['gzip'] ?? true );
$inline_th = (int) ( $this->opts['inline_threshold_bytes'] ?? TMDO_Snapshot_Manager::INLINE_THRESHOLD_BYTES );
$dir = TMDO_Snapshot_Manager::backup_dir();
$ext = $gzip ? '.sql.gz' : '.sql';
$path = trailingslashit( $dir ) . $this->snapshot_id . $ext;
$handle = $gzip ? gzopen( $path, 'wb6' ) : fopen( $path, 'wb' );
if ( false === $handle ) {
throw new RuntimeException( 'cannot open ' . $path . ' for write' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- exception message, not HTML output
}
$write = static function ( string $chunk ) use ( $handle, $gzip ): void {
if ( $gzip ) {
gzwrite( $handle, $chunk );
} else {
fwrite( $handle, $chunk );
}
};
$row_count = 0;
$header = $this->dump_header( $tables );
$write( $header );
global $wpdb;
foreach ( $tables as $table ) {
$row_count += $this->dump_table( $table, $write );
}
$write( "\n-- end of dump\n" );
if ( $gzip ) {
gzclose( $handle );
} else {
fclose( $handle );
}
$size = (int) filesize( $path );
$sha = hash_file( 'sha256', $path );
// Decide inline vs file storage.
if ( $size <= $inline_th ) {
$blob = file_get_contents( $path );
@unlink( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
return array(
'storage' => 'inline',
'file_path' => null,
'sha256' => $sha,
'size_bytes' => $size,
'row_count' => $row_count,
'inline_blob' => $blob,
);
}
return array(
'storage' => 'file',
'file_path' => $path,
'sha256' => $sha,
'size_bytes' => $size,
'row_count' => $row_count,
'inline_blob' => null,
);
}
/**
* Returns canonical scope (after manager resolution).
*
* @return array
*/
public function get_scope(): array {
return $this->scope;
}
// ─── private ──────────────────────────────────────────────────────────
/**
* Resolve which tables to dump.
*
* Priority:
* 1. Explicit `scope.tables` if provided.
* 2. `scope.entities` → wp_postmeta / wp_usermeta / etc.
* 3. Empty scope → all WPDO-owned tables (zone + system) + wp_postmeta if classifier active.
*
* @return array<int,string> Fully-prefixed table names.
*/
private function resolve_tables(): array {
global $wpdb;
if ( ! empty( $this->scope['tables'] ) ) {
return array_values( array_filter( array_map( 'strval', $this->scope['tables'] ), array( $this, 'is_safe_name' ) ) );
}
$max = (int) ( $this->opts['max_tables'] ?? 200 );
$out = array();
// Always include WPDO system tables.
$wpdo_likes = array(
$wpdb->prefix . 'wpdo_%',
);
foreach ( $wpdo_likes as $like ) {
if ( class_exists( 'WP_SQLite_Driver' ) ) {
$rows = $wpdb->get_col(
$wpdb->prepare( // phpcs:ignore WordPress.DB
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE %s",
$like
)
);
} else {
$rows = $wpdb->get_col(
$wpdb->prepare( // phpcs:ignore WordPress.DB
'SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE %s',
$like
)
);
}
if ( is_array( $rows ) ) {
foreach ( $rows as $t ) {
if ( $this->is_safe_name( (string) $t ) ) {
$out[ $t ] = true;
}
}
}
}
// Skip wp_wpdo_snapshots itself — we don't want a dump of a dump.
unset( $out[ $wpdb->prefix . 'wpdo_snapshots' ] );
// If `entities` includes 'post', also dump wp_postmeta (mainly used for
// pre_v2_upgrade trigger so we have the pre-migration meta).
if ( ! empty( $this->scope['entities'] ) ) {
$entity_to_table = array(
'post' => $wpdb->postmeta,
'user' => $wpdb->usermeta,
'term' => $wpdb->termmeta ?? ( $wpdb->prefix . 'termmeta' ),
'comment' => $wpdb->commentmeta,
);
foreach ( $this->scope['entities'] as $entity ) {
if ( isset( $entity_to_table[ $entity ] ) ) {
$t = $entity_to_table[ $entity ];
if ( $this->is_safe_name( $t ) ) {
$out[ $t ] = true;
}
}
}
}
$tables = array_keys( $out );
if ( count( $tables ) > $max ) {
$tables = array_slice( $tables, 0, $max );
}
sort( $tables );
return $tables;
}
/**
* Validate a table name (alphanumeric + underscore only, must start with $wpdb->prefix).
*
* @param string $name Table name.
* @return bool
*/
private function is_safe_name( string $name ): bool {
global $wpdb;
return (bool) preg_match( '/^[a-zA-Z0-9_]+$/', $name )
&& strpos( $name, $wpdb->prefix ) === 0;
}
/**
* Dump header (timestamp + table list).
*
* @param array $tables Tables that will be dumped.
* @return string
*/
private function dump_header( array $tables ): string {
$out = "-- WPDO snapshot {$this->snapshot_id}\n";
$out .= '-- Generated: ' . gmdate( 'Y-m-d H:i:s' ) . " UTC\n";
$out .= '-- Tables: ' . count( $tables ) . "\n";
$out .= "-- Format: SQL INSERT statements (one per row, batched by table)\n\n";
$out .= "SET NAMES utf8mb4;\n";
$out .= "SET FOREIGN_KEY_CHECKS=0;\n\n";
return $out;
}
/**
* Stream-dump one table.
*
* @param string $table Fully-prefixed table name.
* @param callable $write Stream writer.
* @return int rows dumped.
*/
private function dump_table( string $table, callable $write ): int {
global $wpdb;
$write( "-- Table {$table}\n" );
$count_total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
if ( 0 === $count_total ) {
$write( "-- (empty)\n\n" );
return 0;
}
$columns = $wpdb->get_col( "DESC `{$table}`" ); // phpcs:ignore WordPress.DB
if ( ! is_array( $columns ) || empty( $columns ) ) {
return 0;
}
$col_list = '`' . implode( '`,`', $columns ) . '`';
$offset = 0;
$rows_dumped = 0;
while ( $offset < $count_total ) {
$chunk = $wpdb->get_results(
$wpdb->prepare( // phpcs:ignore WordPress.DB
"SELECT * FROM `{$table}` LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is a trusted table name via TMDO_DB::table()
self::CHUNK_SIZE,
$offset
),
ARRAY_A
);
if ( ! is_array( $chunk ) || empty( $chunk ) ) {
break;
}
$values = array();
foreach ( $chunk as $row ) {
$row_vals = array();
foreach ( $columns as $col ) {
$row_vals[] = $this->escape_sql_value( $row[ $col ] ?? null );
}
$values[] = '(' . implode( ',', $row_vals ) . ')';
}
$write( "INSERT INTO `{$table}` ({$col_list}) VALUES " . implode( ",\n ", $values ) . ";\n" );
$rows_dumped += count( $chunk );
$offset += self::CHUNK_SIZE;
}
$write( "\n" );
return $rows_dumped;
}
/**
* Escape a single value for SQL INSERT.
*
* @param mixed $v Value.
* @return string SQL-quoted literal.
*/
private function escape_sql_value( $v ): string {
global $wpdb;
if ( null === $v ) {
return 'NULL';
}
if ( is_bool( $v ) ) {
return $v ? '1' : '0';
}
if ( is_int( $v ) || is_float( $v ) ) {
return (string) $v;
}
// String / longtext / blob — wrap with hex notation if non-utf8 bytes.
$s = (string) $v;
// Use mb_check_encoding when available; fall back to a heuristic.
$is_utf8 = function_exists( 'mb_check_encoding' ) ? mb_check_encoding( $s, 'UTF-8' ) : ( @iconv( 'UTF-8', 'UTF-8//IGNORE', $s ) === $s ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
if ( ! $is_utf8 ) {
return '0x' . bin2hex( $s );
}
return "'" . esc_sql( $s ) . "'";
}
}