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
334 lines
9.4 KiB
PHP
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 ) . "'";
|
|
}
|
|
}
|