In-memory PK column cache (keyed by table name). */ private array $pk_cache = []; /** @var array In-memory table size cache (keyed by full table name). */ private array $table_sizes_cache = []; /** * Binary column names per table, read once per export. * * @var array> */ private array $binary_columns = []; /** * Generated column names per table, read once per export. The destination computes * them, so inserting into them is a MySQL error and they are left out. * * @var array> */ private array $generated_columns = []; /** @var array> SHOW COLUMNS rows per table, read once per export. */ private array $column_rows = []; /** @var array> ORDER BY columns for keyless tables, read once per export. */ private array $keyless_order_cache = []; public function __construct( private readonly \wpdb $wpdb ) {} /** * Map of table name => row count. * * @param string[] $tables Full table names (with prefix). * * @return array */ public function row_counts( array $tables ): array { if ( empty( $tables ) ) { return []; } $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- server metadata, nothing to cache $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT TABLE_NAME AS t, TABLE_ROWS AS c FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (' . implode( ', ', array_fill( 0, count( $tables ), '%s' ) ) . ')', array_values( $tables ) ) ); $map = []; foreach ( $rows as $row ) { $map[ $row->t ] = max( 0, (int) $row->c ); } $counts = []; foreach ( $tables as $table ) { $counts[ $table ] = $map[ $table ] ?? 0; } return $counts; } /** * Map of table name => estimated on-disk bytes, from MySQL table metadata. * * @param string[] $tables Full table names (with prefix). * * @return array */ public function table_sizes( array $tables ): array { $sizes = []; $missing = array_diff( $tables, array_keys( $this->table_sizes_cache ) ); if ( $missing ) { $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- server metadata, nothing to cache $rows = $wpdb->get_results( $wpdb->prepare( 'SHOW TABLE STATUS WHERE `Name` IN (' . implode( ', ', array_fill( 0, count( $missing ), '%s' ) ) . ')', array_values( $missing ) ), ARRAY_A ); foreach ( $rows ?? [] as $row ) { $name = $row['Name'] ?? ''; if ( '' !== $name ) { $this->table_sizes_cache[ $name ] = max( 0, (int) round( (float) ( $row['Data_length'] ?? 0 ) * self::SQL_EXPANSION_FACTOR ) ); } } foreach ( $missing as $t ) { if ( ! isset( $this->table_sizes_cache[ $t ] ) ) { $this->table_sizes_cache[ $t ] = 0; } } } foreach ( $tables as $t ) { $sizes[ $t ] = $this->table_sizes_cache[ $t ] ?? 0; } return $sizes; } /** * Export a single chunk of rows from a table. * * With a primary key and a non-null $last_pk, rows are selected by keyset cursor * (`WHERE pk > $last_pk ORDER BY pk LIMIT N`), which stays cheap where * LIMIT/OFFSET slows with depth. Pass null for the first chunk; it also sends the * CREATE TABLE schema. Keyless tables fall back to LIMIT/OFFSET, which is less * stable under concurrent writes but keeps memory bounded. * * @param string $table Full table name (with prefix). * @param int $offset Row offset, used only on the no-key path. * @param string|null $last_pk Last primary-key value received (enables keyset path). * * @return array{ * table: string, * prefix: string, * columns: string[], * rows: array>, * schema: string|null, * has_more: bool, * next_offset: int, * last_pk: string|null, * chunk_hash: string, * chunk_bytes: int, * table_size_bytes: int, * } * * @throws RuntimeException On query failure. */ public function export_chunk( string $table, int $offset = 0, ?int $chunk_size = null, ?string $last_pk = null ): array { $table_size_bytes = (int) ( $this->table_sizes( [ $table ] )[ $table ] ?? 0 ); // An explicit $chunk_size is the caller's own decision and is clamped // as given. Falling back to the option goes through effective_chunk_size() // so a pre-upgrade or post-reset stored value is read the same way the // Settings screen displays it, not as a literal row count. $raw_chunk_size = $chunk_size ?? self::effective_chunk_size( (int) get_option( 'shiftora_chunk_size', self::DEFAULT_CHUNK_SIZE ) ); $chunk_size = min( max( 1, $raw_chunk_size ), self::MAX_CHUNK_SIZE ); // Deterministic pagination needs an ORDER BY on the key. Without it, // LIMIT/OFFSET can overlap or skip rows if the table changes between // chunk queries. $pk_cols = $this->get_primary_key( $table ); $wpdb = $this->wpdb; if ( [] === $pk_cols ) { // Tables without a key still have to be chunked to avoid memory // exhaustion, and LIMIT/OFFSET is the only option left. ServerLog::write( "[Shiftora] Table `{$table}` has no primary key, using LIMIT/OFFSET fallback." ); $rows = $this->rows_or_fail( KeyCursor::fetch_page( $wpdb, $table, [], null, $chunk_size, $offset ), $table ); $fetched_count = count( $rows ); $bare = $this->strip_prefix( $table ); $rows = $this->filter_protected_option_rows( $rows, $bare ); $columns = ! empty( $rows ) ? array_keys( $rows[0] ) : []; $data = array_map( 'array_values', $rows ); $schema = null; if ( 0 === $offset ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- server metadata, nothing to cache $create = $wpdb->get_row( $wpdb->prepare( 'SHOW CREATE TABLE %i', $table ), ARRAY_N ); $schema = $create[1] ?? null; } $chunk_payload = [ 'table' => $bare, 'prefix' => $this->wpdb->prefix, 'columns' => $columns, 'rows' => $data, 'schema' => $schema, ]; $next_offset = $offset + $fetched_count; return [ 'table' => $bare, 'prefix' => $this->wpdb->prefix, 'columns' => $columns, 'rows' => $data, 'schema' => $schema, 'has_more' => $fetched_count === $chunk_size, 'next_offset' => $next_offset, 'last_pk' => null, 'chunk_hash' => self::chunk_identity_hash( $bare, $next_offset, null, $data ), 'chunk_bytes' => strlen( (string) ( wp_json_encode( $chunk_payload ) ?: '' ) ), 'table_size_bytes' => $table_size_bytes, ]; } // Keyset cursor path (key tuple exists). // Tuple comparison is lexicographic. The first chunk has no cursor, so // every row is a candidate. $cursor = KeyCursor::decode( $last_pk ); // A cursor shaped for a different key (single-column vs composite) // cannot resume lexicographically, so restart from the numeric offset. if ( null !== $cursor && count( $cursor ) !== count( $pk_cols ) ) { $cursor = null; } // Without a cursor this is the first chunk, still ordered by the key tuple; // a non-zero $offset is a migration begun with offset paging. $rows = $this->rows_or_fail( KeyCursor::fetch_page( $wpdb, $table, $pk_cols, $cursor, $chunk_size, $offset ), $table ); // Strip the DB prefix so the importer can apply the destination prefix. $bare_table = $this->strip_prefix( $table ); $fetched_rows = $rows; $rows = $this->filter_protected_option_rows( $rows, $bare_table ); $columns = []; $data = []; $new_last_pk = null; if ( ! empty( $fetched_rows ) ) { $columns = array_keys( $rows[0] ?? $fetched_rows[0] ); foreach ( $rows as $row ) { $data[] = array_values( $row ); } // Take the full ordered key tuple of the last FETCHED row (not the // filtered set) so the next keyset query resumes where this chunk // ended. Otherwise protected rows would be re-read forever. $last_row = end( $fetched_rows ); $tuple = []; foreach ( $pk_cols as $key_col ) { $tuple[] = isset( $last_row[ $key_col ] ) ? (string) $last_row[ $key_col ] : ''; } $new_last_pk = KeyCursor::encode( $tuple ); } $schema = null; $has_more = count( $fetched_rows ) === $chunk_size; // Send the schema only on the first chunk (no cursor, zero offset). if ( null === $last_pk && 0 === $offset ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- server metadata, nothing to cache $create = $wpdb->get_row( $wpdb->prepare( 'SHOW CREATE TABLE %i', $table ), ARRAY_N ); $schema = $create[1] ?? null; } $chunk_payload = [ 'table' => $bare_table, 'prefix' => $this->wpdb->prefix, 'columns' => $columns, 'rows' => $data, 'schema' => $schema, ]; return [ 'table' => $bare_table, 'prefix' => $this->wpdb->prefix, 'columns' => $columns, 'rows' => $data, 'schema' => $schema, 'has_more' => $has_more, 'next_offset' => $offset + $chunk_size, // retained for compat 'last_pk' => $new_last_pk, 'chunk_hash' => self::chunk_identity_hash( $bare_table, $offset + $chunk_size, $new_last_pk, $data ), 'chunk_bytes' => strlen( (string) ( wp_json_encode( $chunk_payload ) ?: '' ) ), 'table_size_bytes' => $table_size_bytes, ]; } /** * All WordPress tables (core, plus multisite if applicable). * * Shiftora and Action Scheduler tables are excluded: they hold install-specific * keys, tokens and schedule state that must not move between sites. So is any * second WordPress install nested under the prefix. The result * passes through the `shiftora_export_tables` filter. * * @return string[] Full table names. */ public function get_wp_tables(): array { // Base tables only. A view's schema is "CREATE VIEW", which dbDelta can't read, so // importing one would fail the whole migration; its own plugin recreates it. $all = $this->wpdb->get_col( "SHOW FULL TABLES WHERE Table_type = 'BASE TABLE'" ); $tables = TableEligibility::partition( (array) $all, $this->wpdb->prefix )['kept']; $tables = (array) apply_filters( 'shiftora_export_tables', $tables ); return array_values( array_map( 'strval', $tables ) ); } /** * Strip the database prefix from a table name. */ public function strip_prefix( string $table ): string { $prefix = $this->wpdb->prefix; if ( str_starts_with( $table, $prefix ) ) { return substr( $table, strlen( $prefix ) ); } return $table; } /** * Export a single chunk as gzipped raw SQL instead of JSON. * * The fast path: no JSON encoding of large row arrays, and the importer just runs * the INSERTs. Find/replace is applied here, so the destination gets ready SQL. * * @param string|null $last_pk Last primary-key value received (enables keyset path). * * @return array{table: string, prefix: string, sql_gz: string, schema: string|null, has_more: bool, next_offset: int, last_pk: string|null, rows_count: int, chunk_bytes: int, page_identity: string} * * @throws RuntimeException On query failure, or when a single row exceeds the budget. */ public function export_sql_chunk( string $table, RuleSet $rule_set, int $offset = 0, ?int $chunk_size = null, ?string $last_pk = null ): array { // The peer reads a backslash as an escape, so values must be escaped by a // session that does too, whatever sql_mode this connection runs with. $sql_mode_before = SessionSqlMode::relax( $this->wpdb ); try { return $this->build_sql_chunk( $table, $rule_set, $offset, $chunk_size, $last_pk ); } finally { SessionSqlMode::restore( $this->wpdb, $sql_mode_before ); } } /** * @return array{table: string, prefix: string, sql_gz: string, schema: string|null, has_more: bool, next_offset: int, last_pk: string|null, rows_count: int, chunk_bytes: int, page_identity: string} * * @throws RuntimeException On query failure, or when a single row exceeds the budget. */ private function build_sql_chunk( string $table, RuleSet $rule_set, int $offset = 0, ?int $chunk_size = null, ?string $last_pk = null ): array { $raw_chunk_size = $chunk_size ?? self::effective_chunk_size( (int) get_option( 'shiftora_chunk_size', self::DEFAULT_CHUNK_SIZE ) ); $wpdb = $this->wpdb; $pk_cols = $this->get_primary_key( $table ); $bare_table = $this->strip_prefix( $table ); // The ceiling this page's encoded form must stay under. $budget = max( 1, (int) get_option( 'shiftora_sql_encoded_budget', self::SQL_ENCODED_BUDGET ) ); $schema = null; $new_last_pk = null; // First chunk: include the CREATE TABLE schema. It is fetched once per // chunk because only the INSERT body changes with the row limit, so the // halving loop below never re-reads it. if ( null === $last_pk && 0 === $offset ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- server metadata, nothing to cache $create = $wpdb->get_row( $wpdb->prepare( 'SHOW CREATE TABLE %i', $table ), ARRAY_N ); // Replace the source table name with the __TARGET__ placeholder so // the importer can redirect to staging tables. if ( isset( $create[1] ) ) { $schema = preg_replace( '/CREATE TABLE `[^`]+`/', 'CREATE TABLE `__TARGET__`', $create[1] ); } } $has_more = false; $columns = []; $row_count = 0; $assoc_rows = []; $sql = ''; $sql_gz = ''; $fetched_rows = []; $limit = min( max( 1, $raw_chunk_size ), self::MAX_CHUNK_SIZE ); do { // Fetch rows with the same pagination logic as export_chunk(). $limit // is 25000 at most and shrinks when a page fails the encoded budget, // so the re-fetch below stays bounded. if ( [] === $pk_cols ) { // No usable unique key: LIMIT/OFFSET fallback. Order by every // sortable column so paging stays stable on a live table; // without it OFFSET can skip or repeat rows between chunks. // Identical rows sort together, so duplicates stay correct. $sortable = $this->keyless_order_columns( (string) $table ); $fetched_rows = $this->rows_or_fail( KeyCursor::fetch_page( $wpdb, $table, $sortable, null, $limit, $offset ), $table ); } else { $cursor = KeyCursor::decode( $last_pk ); // A cursor shaped for a different key (single-column vs composite) // cannot resume lexicographically, so restart from the numeric offset. if ( null !== $cursor && count( $cursor ) !== count( $pk_cols ) ) { $cursor = null; } // Keyset cursor with lexicographic tuple comparison; with no cursor, // the first chunk, or an offset run when $offset is set. $fetched_rows = $this->rows_or_fail( KeyCursor::fetch_page( $wpdb, $table, $pk_cols, $cursor, $limit, $offset ), $table ); } $sql = ''; // Emit the schema, then the rows. This page may carry fewer rows than // the requested chunk size. if ( null !== $schema ) { $sql .= $schema . ";\n"; } $has_more = count( $fetched_rows ) === $limit; $columns = []; $row_count = 0; // A reconnect since the fetch can have changed the session, so its mode is read again for this page. SqlLiteral::session_mode_changed( $wpdb ); // Find/replace on serialized values is where a page spends its time. Rows are // transformed a slice at a time so the page can stop at the request budget; // the fetched set is cut at the same row so the cursor resumes right after it. $engine = new FindReplaceEngine( $rule_set ); $assoc_rows = []; $shipped = 0; foreach ( self::time_slices( $fetched_rows ) as $slice ) { if ( $shipped > 0 && RequestBudget::exhausted() ) { $fetched_rows = array_slice( $fetched_rows, 0, $shipped ); $has_more = true; break; } // Drop install-bound options from *_options tables before building the // INSERT. The cursor was already fetched against the full set, so // filtered rows are not re-read on the next chunk. $rows = $this->filter_protected_option_rows( $slice, $bare_table ); // The prefixed name plus the site prefix, so a scope written either // way round resolves. A bare rule must keep meaning the same table // on a site with a different prefix. $assoc_rows = array_merge( $assoc_rows, $engine->process_rows( $rows, (string) $table, $this->wpdb->prefix ) ); $shipped += count( $slice ); } if ( ! empty( $assoc_rows ) ) { // Generated columns are recomputed by the destination from their // own expression, so an INSERT that names them fails. Keep them in // SHOW CREATE TABLE but out of the column list and values. $generated = $this->generated_columns( (string) $table ); $columns = array_values( array_filter( array_keys( $assoc_rows[0] ), static fn( $col ): bool => ! isset( $generated[ $col ] ) ) ); // INSERTs use the __TARGET__ placeholder so the importer can redirect to staging. // Wide rows could push one statement past max_allowed_packet, so a new INSERT // starts once the byte estimate crosses the budget. Rows are never split. $col_list = SqlLiteral::identifiers( $columns ); $head = "INSERT INTO `__TARGET__` ({$col_list}) VALUES\n"; $byte_budget = max( 1, (int) get_option( 'shiftora_sql_byte_budget', self::SQL_BYTE_BUDGET ) ); $binary = $this->binary_columns( (string) $table ); $sql .= $head; $value_groups = []; // Running byte estimate of the current statement: head bytes plus // each group and the ",\n" separator before it. $statement_bytes = strlen( $head ); foreach ( $assoc_rows as $row ) { $values = []; foreach ( $columns as $col ) { $values[] = SqlLiteral::value( $row[ $col ] ?? null, isset( $binary[ $col ] ) ); } $group_bytes = strlen( $group = '(' . implode( ', ', $values ) . ')' ); // Close the current statement and open a fresh one before this // group would push it past the budget. The first group of a // statement always stays so an oversized row stands alone. if ( ! empty( $value_groups ) && $statement_bytes + $group_bytes + 2 > $byte_budget ) { $sql .= implode( ",\n", $value_groups ) . ";\n" . $head; $value_groups = []; $statement_bytes = strlen( $head ); } $value_groups[] = $group; $statement_bytes += $group_bytes + 2; ++$row_count; } $sql .= implode( ",\n", $value_groups ) . ';'; } // Gzip the SQL to reduce payload size. $gzipped = gzcompress( $sql, 6 ); if ( false === $gzipped ) { throw new RuntimeException( "gzip compression failed for table {$table}" ); } $sql_gz = base64_encode( $gzipped ); if ( strlen( $sql_gz ) <= $budget ) { break; } // Over budget. Halve the page and re-fetch from the same cursor: the chunk size // isn't the problem when a table has a few wide rows. The ledger never sees this // page because it never shipped. if ( $limit > 1 ) { $limit = max( 1, (int) floor( $limit / 2 ) ); ServerLog::write( "[Shiftora] Table `{$table}` SQL page trimmed to {$limit} rows to fit the {$budget}-byte encoded budget." ); continue; } // A single row still tops the cap and no page can carry it. Name it // so it can be found in the source table. $this->assert_encoded_budget( $table, $columns, $assoc_rows, $budget ); } while ( true ); // Take the key tuple from the source rows, not the transformed or filtered ones, // so the next query resumes at real key values. Outside the emit block because a // chunk filtered down to zero rows must still advance the cursor. if ( [] !== $pk_cols && ! empty( $fetched_rows ) ) { $last_row = end( $fetched_rows ); $tuple = []; foreach ( $pk_cols as $key_col ) { $tuple[] = isset( $last_row[ $key_col ] ) ? (string) $last_row[ $key_col ] : ''; } $new_last_pk = KeyCursor::encode( $tuple ); } $chunk_bytes = strlen( $sql ); return [ 'table' => $bare_table, 'prefix' => $this->wpdb->prefix, 'sql_gz' => $sql_gz, 'schema' => $schema, 'has_more' => $has_more, 'next_offset' => $offset + count( $fetched_rows ), 'last_pk' => $new_last_pk, 'rows_count' => $row_count, 'chunk_bytes' => $chunk_bytes, 'page_identity' => SqlImportChunkIds::page_identity( $bare_table, $offset + count( $fetched_rows ), $new_last_pk, $row_count ), ]; } /** * Fail when an encoded chunk exceeds its budget. * * Only reached once the adaptive loop has halved the page to one row that still * tops the cap. Names the row so it can be found, or the table when the schema * riding along is what pushes it over. * * @param array $columns Column order of the emitted INSERTs. * @param array> $assoc_rows The emitted rows. * * @throws RuntimeException When the encoded payload exceeds the budget. */ private function assert_encoded_budget( string $table, array $columns, array $assoc_rows, int $budget ): void { $col_list = SqlLiteral::identifiers( $columns ); $binary = $this->binary_columns( $table ); foreach ( $assoc_rows as $row ) { $values = []; foreach ( $columns as $col ) { $values[] = SqlLiteral::value( $row[ $col ] ?? null, isset( $binary[ $col ] ) ); } $single = "INSERT INTO `__TARGET__` ({$col_list}) VALUES\n(" . implode( ', ', $values ) . ');'; $encoded = strlen( base64_encode( gzcompress( $single ) ?: $single ) ); if ( $encoded > $budget ) { throw new RuntimeException( "Row {$this->row_label( $row )} in table {$table} exceeds the encoded SQL budget of {$budget} bytes ({$encoded} bytes encoded). Reduce the row size or split the export." ); } } throw new RuntimeException( "SQL chunk for table {$table} exceeds the encoded budget of {$budget} bytes even at a single row. The row's INSERT fits only if the stored data or the request-size limit changes." ); } /** * Short row identifier for budget-failure messages: the first non-empty value. * * @param array $row */ private function row_label( array $row ): string { foreach ( $row as $col => $val ) { if ( null !== $val && '' !== (string) $val ) { return "{$col}={$val}"; } } return 'row with all-NULL values'; } /** * SHOW COLUMNS for a table, read once: three classifiers below share it. * * @return array */ private function column_rows( string $table ): array { if ( ! isset( $this->column_rows[ $table ] ) ) { $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- server metadata, nothing to cache $this->column_rows[ $table ] = (array) $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ) ); } return $this->column_rows[ $table ]; } /** * Columns whose values are bytes rather than text. * * @return array Column names as keys. */ private function binary_columns( string $table ): array { if ( isset( $this->binary_columns[ $table ] ) ) { return $this->binary_columns[ $table ]; } $binary = []; foreach ( $this->column_rows( $table ) as $column ) { $type = strtolower( (string) ( $column->Type ?? '' ) ); if ( 1 === preg_match( '/^(?:(?:tiny|medium|long)?blob|(?:var)?binary|bit|geometry|point|linestring|polygon|multipoint|multilinestring|multipolygon|geometrycollection)\b/', $type ) ) { $binary[ (string) $column->Field ] = true; } } return $this->binary_columns[ $table ] = $binary; } /** * Generated column names of a table, from the "VIRTUAL/STORED GENERATED" Extra * value (MySQL and MariaDB use the same wording). * * @return array Column names as keys. */ private function generated_columns( string $table ): array { if ( isset( $this->generated_columns[ $table ] ) ) { return $this->generated_columns[ $table ]; } $generated = []; foreach ( $this->column_rows( $table ) as $column ) { if ( false !== stripos( (string) ( $column->Extra ?? '' ), 'GENERATED' ) ) { $generated[ (string) $column->Field ] = true; } } return $this->generated_columns[ $table ] = $generated; } /** * Columns a keyless table is ordered by, for deterministic paging. * * BLOB/TEXT/JSON/geometry columns are skipped: sorting them needs a prefix length * and is costly. Empty when nothing is sortable. * * @return list */ private function keyless_order_columns( string $table ): array { if ( isset( $this->keyless_order_cache[ $table ] ) ) { return $this->keyless_order_cache[ $table ]; } $sortable = []; foreach ( $this->column_rows( $table ) as $column ) { $type = strtolower( (string) ( $column->Type ?? '' ) ); if ( 1 === preg_match( '/^(?:(?:tiny|medium|long)?(?:blob|text)|json|geometry|point|linestring|polygon|multipoint|multilinestring|multipolygon|geometrycollection)\\b/', $type ) ) { continue; } $sortable[] = (string) $column->Field; } return $this->keyless_order_cache[ $table ] = $sortable; } /** * Drop protected option rows from a *_options table chunk. * * The importer applies the same policy on the JSON path, but raw SQL INSERTs can't * be filtered reliably at the destination, so both encodings are filtered here. * * @param array> $rows Associative rows from one chunk. * * @return array> */ /** * Groups of consecutive rows, each closed at TIME_SLICE_ROWS or once its string * data passes TIME_SLICE_BYTES, so a slice of large blobs stays short enough for the * budget check between slices to matter. * * @param array> $rows * * @return array>> */ private static function time_slices( array $rows ): array { $slices = []; $slice = []; $bytes = 0; foreach ( $rows as $row ) { $slice[] = $row; foreach ( $row as $value ) { if ( is_string( $value ) ) { $bytes += strlen( $value ); } } if ( count( $slice ) >= self::TIME_SLICE_ROWS || $bytes >= self::TIME_SLICE_BYTES ) { $slices[] = $slice; $slice = []; $bytes = 0; } } if ( [] !== $slice ) { $slices[] = $slice; } return $slices; } private function filter_protected_option_rows( array $rows, string $bare_table ): array { if ( ! str_ends_with( $bare_table, 'options' ) ) { return $rows; } return array_values( array_filter( $rows, static fn( array $row ): bool => ! ProtectedOptions::is_protected( (string) ( $row['option_name'] ?? '' ) ) ) ); } /** * The ordered keyset column tuple for a table. * * Prefers the full PRIMARY KEY, then a unique index whose columns are all NOT * NULL. Composite keys return the whole tuple so cursors resume lexicographically * without dropping rows. Tables with neither fall back to LIMIT/OFFSET. * * @return list Key column names, empty when none usable. */ private function get_primary_key( string $table ): array { if ( ! array_key_exists( $table, $this->pk_cache ) ) { // No ORDER BY here: MariaDB rejects it in SHOW INDEX (error 1064). // KeyCursor sorts the metadata rows in PHP instead. $wpdb = $this->wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- server metadata, nothing to cache $indexes = $wpdb->get_results( $wpdb->prepare( 'SHOW INDEX FROM %i', $table ), ARRAY_A ); $cols = []; if ( is_array( $indexes ) ) { $cols = KeyCursor::pick_keyset_columns( $indexes ); } $this->pk_cache[ $table ] = $cols; } return $this->pk_cache[ $table ]; } /** * Row fetch that fails loudly. * * get_results() can return [] on a failed query, so a null check alone would read * a broken SELECT as a clean end of data. The wpdb error is cleared by the next * query, so it is checked immediately. * * @param mixed $rows Raw get_results() value. * * @return array> * * @throws RuntimeException When the query failed. */ private function rows_or_fail( mixed $rows, string $table ): array { if ( null === $rows || ( [] === $rows && '' !== $this->wpdb->last_error ) ) { throw new RuntimeException( "Export query failed for table {$table}: {$this->wpdb->last_error}" ); } return $rows ?? []; } /** * SHA-256 over the page's logical identity plus its content. * * A content-only hash can't tell apart identical pages of a keyless table. Binding * the table and page sequence keeps them distinct while still catching transport * corruption. The importer recomputes it to verify a received chunk. * * @param int $next_offset Page sequence (offset + fetched). * @param string|null $last_pk Cursor of the page's first row, or null. * @param list> $rows Export row data. * * @return string Hex hash. */ public static function chunk_identity_hash( string $table, int $next_offset, ?string $last_pk, array $rows ): string { $page = [ $table, $next_offset, (string) $last_pk, $rows ]; return hash( 'sha256', wp_json_encode( $page ) ?: '' ); } // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped }