*/ 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 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 ) . "'"; } }