.part" and renamed only once * the last line is down, so a half-written dump never looks like a backup. */ final class Dumper { public const CHUNK_ROWS = 500; public const CHUNK_BYTES = 4194304; public const STATEMENT_BYTES = 1048576; private const ESCAPES = [ '\\' => '\\\\', "\0" => '\\0', "\n" => '\\n', "\r" => '\\r', "'" => "\\'", '"' => '\\"', "\x1a" => '\\Z', ]; public function __construct( private readonly Database $db, private readonly string $prefix, private readonly bool $allTables, private readonly int $chunkRows = self::CHUNK_ROWS, private readonly int $statementBytes = self::STATEMENT_BYTES, private readonly int $chunkBytes = self::CHUNK_BYTES, ) { } /** @return list the tables a dump includes: the site's prefix unless all tables were asked for */ public function tables(): array { $tables = $this->db->tables(); if ($this->allTables || $this->prefix === '') { return $tables; } return array_values(array_filter($tables, fn (string $t): bool => str_starts_with($t, $this->prefix))); } /** * @param array $about header lines (label => value) * @return array{tables: int, rows: int, per_table: array, bytes: int} */ public function dump(string $path, array $about = []): array { $part = $path . '.part'; $gz = gzopen($part, 'wb6'); if ($gz === false) { throw new DumpError(sprintf( /* translators: %s: file path */ __('Could not create the backup file %s. Check that the backup folder is writable, then try again.', 'freshet-backup'), $part )); } $perTable = []; $completed = false; $this->db->beginSnapshot(); try { $tables = $this->tables(); $this->write($gz, $this->header($tables, $about)); foreach ($tables as $table) { $perTable[$table] = $this->table($gz, $table); } $this->write($gz, "\nSET FOREIGN_KEY_CHECKS=1;\nSET UNIQUE_CHECKS=1;\n-- Dump completed " . gmdate('Y-m-d H:i:s') . " UTC\n"); $completed = true; } finally { $this->db->endSnapshot(); gzclose($gz); if (!$completed && is_file($part)) { wp_delete_file($part); } } if (!rename($part, $path)) { wp_delete_file($part); throw new DumpError(__('The backup file was written but could not be renamed into place. Check the backup folder\'s permissions, then try again.', 'freshet-backup')); } return [ 'tables' => count($perTable), 'rows' => array_sum($perTable), 'per_table' => $perTable, 'bytes' => (int) filesize($path), ]; } /** One value as a SQL literal. Binary columns go out as hex so no byte is reinterpreted on import. */ public static function literal(string|int|float|null $value, bool $binary = false): string { if ($value === null) { return 'NULL'; } $value = (string) $value; if ($binary) { return $value === '' ? "''" : '0x' . bin2hex($value); } return "'" . strtr($value, self::ESCAPES) . "'"; } public static function identifier(string $name): string { return '`' . str_replace('`', '``', $name) . '`'; } /** @param list $tables */ private function header(array $tables, array $about): string { $lines = ['-- Freshet Backup database dump']; foreach ($about as $label => $value) { $lines[] = '-- ' . $label . ': ' . str_replace(["\r", "\n"], ' ', $value); } $lines[] = '-- Generated: ' . gmdate('Y-m-d H:i:s') . ' UTC'; $lines[] = '-- Tables: ' . count($tables); $lines[] = ''; $lines[] = 'SET NAMES ' . preg_replace('/[^a-z0-9_]/i', '', $this->db->charset()) . ';'; $lines[] = "SET time_zone = '+00:00';"; $lines[] = 'SET FOREIGN_KEY_CHECKS=0;'; $lines[] = 'SET UNIQUE_CHECKS=0;'; $lines[] = "SET SQL_MODE='NO_AUTO_VALUE_ON_ZERO';"; return implode("\n", $lines) . "\n"; } /** @param resource $gz */ private function table($gz, string $table): int { $create = rtrim($this->db->createStatement($table), "; \n"); $id = self::identifier($table); $this->write($gz, "\n--\n-- Table " . $id . "\n--\n\nDROP TABLE IF EXISTS " . $id . ";\n" . $create . ";\n"); $columns = array_values(array_filter($this->db->columns($table), fn (array $c): bool => !$c['generated'])); if ($columns === []) { return 0; } $names = array_column($columns, 'name'); $binary = array_map(fn (array $c): bool => (bool) preg_match('/^(tiny|medium|long)?blob|^(var)?binary|^bit/', $c['type']), $columns); $insert = 'INSERT INTO ' . $id . ' (' . implode(', ', array_map([self::class, 'identifier'], $names)) . ') VALUES '; $pk = $this->db->primaryKey($table); $key = count($pk) === 1 && in_array($pk[0], $names, true) ? $pk[0] : null; $keyIndex = $key === null ? null : array_search($key, $names, true); $numericKey = $key !== null && (bool) preg_match('/^(tiny|small|medium|big)?int\b/', $columns[$keyIndex]['type']); $count = 0; $after = null; $offset = 0; $limit = $this->limitFor($this->db->averageRowLength($table)); while (true) { $asked = $limit; $rows = $this->db->rows($table, $names, $key, $after, $numericKey, $offset, $asked); if ($rows === []) { break; } $statement = ''; $bytes = 0; foreach ($rows as $row) { $values = []; foreach ($row as $i => $value) { $values[] = self::literal($value, $binary[$i] ?? false); } $tuple = '(' . implode(',', $values) . ')'; $bytes += strlen($tuple); if ($statement !== '' && strlen($statement) + strlen($tuple) > $this->statementBytes) { $this->write($gz, $insert . $statement . ";\n"); $statement = ''; } $statement .= ($statement === '' ? '' : ',') . $tuple; } $this->write($gz, $insert . $statement . ";\n"); $count += count($rows); if ($key !== null) { $last = end($rows)[$keyIndex]; if ($after !== null && (string) $last === (string) $after) { throw new DumpError(sprintf( /* translators: %s: database table name */ __('Reading table %s stopped moving forward, so the backup was stopped rather than copy the same rows twice. Try again; if it happens again, repair that table with your host\'s database tool.', 'freshet-backup'), $table )); } $after = $last; } else { $offset += count($rows); } if (count($rows) < $asked) { break; } $limit = $this->limitFor(intdiv($bytes, count($rows))); } return $count; } /** Rows per chunk for rows of about $rowBytes each. */ private function limitFor(int $rowBytes): int { return $rowBytes <= 0 ? $this->chunkRows : max(1, min($this->chunkRows, intdiv($this->chunkBytes, $rowBytes))); } /** @param resource $gz */ private function write($gz, string $data): void { if ($data !== '' && gzwrite($gz, $data) !== strlen($data)) { throw new DumpError(__('Writing the backup file failed part-way, most likely because the server\'s disk is full. Nothing was saved from this run; free up space and try again.', 'freshet-backup')); } } }