"}; a genuine string * that merely looks like that object stays a JSON string, so the two cannot be confused. * * Batches are bounded by bytes, not only rows: each table's batch size comes from its average row length (the larger * of the server's AVG_ROW_LENGTH and a 10-row sample), aiming at about 32 MB of rows (filter * mench_migrate_db_batch_bytes), clamped to 10..1,000 rows, and kept in the cursor. While a batch is read and written a * marker file .batch exists; finding it on entry means the last request died inside a batch (out of memory), so * the batch is halved before trying again. * * @package Mench_Migrate */ defined( 'ABSPATH' ) || exit; // phpcs:disable WordPress.WP.AlternativeFunctions -- Streams backups, uploads and job files (often several GB) chunk by chunk inside the plugin's own storage folder and the site being restored; WP_Filesystem has no streaming or locking API. // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are internal machine codes; screens and REST answers show only the escaped sentence from Mench_Migrate_Rest::message(). class Mench_Migrate_Db_Exporter { /** Most and fewest rows per batch; also the granularity of the deadline check. */ private const BATCH = 1000; private const MIN_BATCH = 10; /** Bytes of rows one batch aims at (filter mench_migrate_db_batch_bytes). */ private const BATCH_BYTES = 33554432; private wpdb $db; private string $prefix; /** @var array{0:string[],1:string[]}|null Base tables and views, cached. */ private ?array $listing = null; public function __construct( wpdb $db, string $prefix ) { $this->db = $db; $this->prefix = $prefix; } /** @return string[] Base tables whose name starts with the prefix. */ public function tables(): array { return $this->list_objects()[0]; } /** @return string[] Views with the prefix; they are not exported. */ public function skipped(): array { return $this->list_objects()[1]; } /** SHOW CREATE TABLE text for one prefixed base table. */ public function schema( string $table ): string { $q = $this->quote_table( $table ); $row = $this->db->get_row( "SHOW CREATE TABLE $q", ARRAY_N ); $this->fail_on_error( 'schema' ); if ( ! is_array( $row ) || ! isset( $row[1] ) ) { throw new Mench_Migrate_Db_Exception( "Could not read the schema of $table.", 'export-db' ); } return (string) $row[1]; } /** * Appends rows of one table to $out_file (truncated to the cursor's byte size first), batch by batch, until done or the deadline passes (at least one batch per call). * * @param array{last_key?:string,offset?:int,rows:int,bytes:int,limit?:int}|null $cursor Null starts the table (the file is truncated); pass the returned cursor to resume. * @param callable|null $keep fn( array $row ): bool; rows it rejects are left out of the file and of the count. * @param callable|null $on_batch fn( int $rows ): void after each batch, with the table's rows exported so far. * @return array{done:bool, cursor:?array, rows:int} rows is the total exported so far for this table. */ public function export_rows( string $table, string $out_file, ?array $cursor, float $deadline, ?callable $keep = null, ?callable $on_batch = null ): array { $q = $this->quote_table( $table ); $pk = $this->primary_key( $q ); $rows = (int) ( $cursor['rows'] ?? 0 ); $keyset = 1 === count( $pk ); $last = isset( $cursor['last_key'] ) ? (string) $cursor['last_key'] : null; $offset = (int) ( $cursor['offset'] ?? 0 ); $order = $pk ? ' ORDER BY ' . implode( ', ', array_map( [ $this, 'quote' ], $pk ) ) : ''; // Rewind the file to the last saved cursor, so rows written after it (a crash before the cursor was stored) are not doubled. $bytes = isset( $cursor['bytes'] ) ? (int) $cursor['bytes'] : 0; clearstatcache( true, $out_file ); if ( $bytes > 0 && ( ! is_file( $out_file ) || filesize( $out_file ) < $bytes ) ) { throw new Mench_Migrate_Db_Exception( 'export_file_short', 'export-db' ); } $fh = fopen( $out_file, 'cb' ); if ( ! $fh ) { throw new Mench_Migrate_Db_Exception( "Could not open $out_file for writing.", 'export-db' ); } if ( ! ftruncate( $fh, $bytes ) || 0 !== fseek( $fh, $bytes ) ) { fclose( $fh ); throw new Mench_Migrate_Db_Exception( "Could not reset $out_file.", 'export-db' ); } $marker = $out_file . '.batch'; $limit = isset( $cursor['limit'] ) ? (int) $cursor['limit'] : 0; if ( is_file( $marker ) ) { // The last request died inside a batch: try again at half its size. $died = (int) file_get_contents( $marker ); $limit = max( self::MIN_BATCH, intdiv( $died > 0 ? $died : ( $limit > 0 ? $limit : self::BATCH ), 2 ) ); } elseif ( $limit <= 0 ) { $limit = $this->batch_size( $q ); } $limit = max( self::MIN_BATCH, min( self::BATCH, $limit ) ); try { do { if ( false === @file_put_contents( $marker, (string) $limit ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors throw new Mench_Migrate_Db_Exception( "Could not write to $out_file.", 'export-db' ); } if ( $keyset ) { $key = $this->quote( $pk[0] ); $sql = null === $last ? "SELECT * FROM $q$order LIMIT $limit" : $this->db->prepare( "SELECT * FROM $q WHERE $key > %s$order LIMIT $limit", $last ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare } else { $sql = "SELECT * FROM $q$order LIMIT $limit OFFSET $offset"; } $batch = $this->db->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $this->fail_on_error( 'read rows' ); $batch = is_array( $batch ) ? $batch : []; $this->db->flush(); // wpdb keeps its own copy of the last result set; drop it. $count = count( $batch ); $written = 0; $length = 0; if ( $keyset && $count ) { $last = (string) $batch[ $count - 1 ][ $pk[0] ]; } foreach ( $batch as $i => $row ) { // Each row is written and dropped at once, so the batch never doubles in memory. unset( $batch[ $i ] ); if ( null === $keep || $keep( $row ) ) { $line = $this->encode_row( $row ) . "\n"; if ( false === fwrite( $fh, $line ) ) { throw new Mench_Migrate_Db_Exception( "Could not write to $out_file.", 'export-db' ); } $length += strlen( $line ); ++$written; } } fflush( $fh ); $bytes += $length; @unlink( $marker ); // phpcs:ignore WordPress.PHP.NoSilencedErrors -- the batch is written. $rows += $written; $offset += $count; $done = $count < $limit; if ( null !== $on_batch ) { $on_batch( $rows ); } } while ( ! $done && microtime( true ) < $deadline ); } finally { fclose( $fh ); } if ( $done ) { return [ 'done' => true, 'cursor' => null, 'rows' => $rows ]; } $next = $keyset ? [ 'last_key' => $last, 'rows' => $rows, 'bytes' => $bytes, 'limit' => $limit ] : [ 'offset' => $offset, 'rows' => $rows, 'bytes' => $bytes, 'limit' => $limit ]; return [ 'done' => false, 'cursor' => $next, 'rows' => $rows ]; } /** * Rows per batch for a table: about BATCH_BYTES of rows by the larger of AVG_ROW_LENGTH (which can be stale) and the * encoded size of a 10-row sample, clamped to MIN_BATCH..BATCH. */ private function batch_size( string $quoted_table ): int { $budget = max( 1, (int) apply_filters( 'mench_migrate_db_batch_bytes', self::BATCH_BYTES ) ); $avg = (int) $this->db->get_var( $this->db->prepare( 'SELECT AVG_ROW_LENGTH FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s', trim( $quoted_table, '`' ) ) ); $sample = $this->db->get_results( "SELECT * FROM $quoted_table LIMIT " . self::MIN_BATCH, ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $this->db->flush(); if ( is_array( $sample ) && $sample ) { $len = 0; foreach ( $sample as $row ) { $len += strlen( $this->encode_row( $row ) ); } $avg = max( $avg, intdiv( $len, count( $sample ) ) ); } $this->db->last_error = ''; return $avg > 0 ? max( self::MIN_BATCH, min( self::BATCH, intdiv( $budget, $avg ) ) ) : self::BATCH; } /** @param array $row */ private function encode_row( array $row ): string { foreach ( $row as $col => $value ) { if ( is_string( $value ) && 1 !== preg_match( '//u', $value ) ) { // Not valid UTF-8 (no mbstring needed). $row[ $col ] = [ '__b64' => base64_encode( $value ) ]; } } return wp_json_encode( [ 'c' => $row ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR ); } /** @return string[] Primary key columns in key order (empty when the table has none). */ private function primary_key( string $quoted_table ): array { $rows = $this->db->get_results( "SHOW KEYS FROM $quoted_table WHERE Key_name = 'PRIMARY'", ARRAY_A ); $this->fail_on_error( 'read keys' ); usort( $rows, static fn( $a, $b ) => (int) $a['Seq_in_index'] <=> (int) $b['Seq_in_index'] ); return array_map( static fn( $r ) => (string) $r['Column_name'], $rows ); } /** @return array{0:string[],1:string[]} */ private function list_objects(): array { if ( null === $this->listing ) { $rows = $this->db->get_results( $this->db->prepare( 'SHOW FULL TABLES LIKE %s', $this->db->esc_like( $this->prefix ) . '%' ), ARRAY_N ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared $this->fail_on_error( 'list tables' ); $tables = []; $views = []; foreach ( (array) $rows as $r ) { if ( 0 !== strpos( (string) $r[0], $this->prefix ) ) { continue; // LIKE can be case-insensitive. } if ( 'BASE TABLE' === $r[1] ) { $tables[] = (string) $r[0]; } elseif ( 'VIEW' === $r[1] ) { $views[] = (string) $r[0]; } } sort( $tables ); sort( $views ); $this->listing = [ $tables, $views ]; } return $this->listing; } private function quote_table( string $table ): string { if ( '' === $this->prefix || 0 !== strpos( $table, $this->prefix ) ) { throw new Mench_Migrate_Db_Exception( "Table $table is outside the prefix.", 'export-db' ); } return $this->quote( $table ); } private function quote( string $name ): string { if ( '' === $name || false !== strpos( $name, '`' ) || false !== strpos( $name, "\0" ) ) { throw new Mench_Migrate_Db_Exception( 'Invalid database identifier.', 'export-db' ); } return '`' . $name . '`'; } private function fail_on_error( string $what ): void { if ( '' !== $this->db->last_error ) { throw new Mench_Migrate_Db_Exception( "Database error during $what: " . $this->db->last_error, 'export-db' ); } } }