PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.1
16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 All 503 releases
jetpack / vendor / wp-php-toolkit / reprint-server / src / class-database-rows-reader.php

class-database-rows-reader.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.1, at vendor/wp-php-toolkit/reprint-server/src/class-database-rows-reader.php

773 lines 29.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WordPress\Reprint\Server;
4
5 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Database cursor errors are never HTML.
6
7 /**
8 * Reads database rows through bounded, resumable, primary-key-ordered queries.
9 */
10 class DatabaseRowsReader {
11
12 /** Prefix shared by every schema version of Reprint's internal MySQL progress table. */
13 private const MYSQL_IMPORT_PROGRESS_TABLE_PREFIX = "__reprint_db_pull_progress_";
14
15
16 /** @var mixed PDO or a PDO-compatible adapter. */
17 private $db;
18
19 /** @var array|null */
20 private $current_pk_columns = null;
21
22 /**
23 * Cursor bookmark containing the primary key of the last returned record.
24 * The next SELECT starts strictly after these values.
25 *
26 * @var array|null
27 */
28 private $last_pk_values = null;
29
30 /**
31 * Fallback cursor for tables without a primary key. OFFSET pagination
32 * re-scans earlier rows and can drift when records are inserted or deleted.
33 * Consumers which require stable resume must reject non-empty unkeyed tables.
34 *
35 * @var int
36 */
37 private $current_offset = 0;
38
39 /** @var string|null */
40 private $current_table = null;
41
42 /** @var mixed */
43 private $current_result_set = null;
44
45 /**
46 * Distinguishes an exhausted LIMIT batch from an empty fresh query. The
47 * latter means the current table is complete.
48 *
49 * @var int
50 */
51 private $rows_fetched_from_current_query = 0;
52
53 /** @var array */
54 private $tables_to_process;
55
56 /**
57 * Column metadata cached by table and column name. Each column contains
58 * data_type (for example, varchar), column_type (for example,
59 * varchar(255)), and its nullable collation name.
60 *
61 * @var array<string,array<string,array{data_type:string,column_type:string,collation:?string}>>
62 */
63 private $column_type_cache = [];
64
65 /** @var array<string,int> Maximum character bytes cached by collation. */
66 private $maximum_character_bytes_by_collation = [];
67
68
69 /** @var array|null */
70 private $current_row = null;
71
72 /** @var bool */
73 private $current_row_ends_query_batch = false;
74
75 /** @var array|null */
76 private $current_column_types = null;
77
78 /** @var array|null */
79 private $current_column_names = null;
80
81 /** @var int */
82 private $batch_size;
83
84 /** @var int|null */
85 private $query_time_limit_ms = null;
86
87 /** @var array<string,list<array{column:string,value:string}>> Row exclusions keyed by table. */
88 private $exclude_rows_by_table = [];
89
90 /** @var string[] Table names omitted from automatic discovery. */
91 private $exclude_tables = [];
92
93
94 /**
95 * Initializes the bounded database row reader.
96 *
97 * @param mixed $db PDO or a PDO-compatible adapter.
98 * @param array $options {
99 * Reader options.
100 *
101 * @type array|null $tables_to_process Tables to read, or null to discover them.
102 * @type int $batch_size Maximum records per query.
103 * @type int|null $query_time_limit_ms Maximum query duration in milliseconds.
104 * @type array $exclude_rows Table, column, and value exclusion rules.
105 * @type string[] $exclude_tables Table names to omit from automatic discovery.
106 * }
107 */
108 public function __construct($db, $options = [])
109 {
110 $this->db = $db;
111 $this->tables_to_process = $options["tables_to_process"] ?? null;
112 $this->batch_size = max(1, (int) ( $options["batch_size"] ?? 250 ));
113 $this->exclude_tables = array_values(array_filter(
114 $options["exclude_tables"] ?? [],
115 "is_string"
116 ));
117
118 if (isset($options["query_time_limit_ms"])) {
119 $limit = (int) $options["query_time_limit_ms"];
120 $this->query_time_limit_ms = $limit > 0 ? $limit : null;
121 }
122
123 if (isset($options["exclude_rows"]) && is_array($options["exclude_rows"])) {
124 foreach ($options["exclude_rows"] as $rule) {
125 if (
126 !is_array($rule) ||
127 !isset($rule["table"], $rule["column"], $rule["value"]) ||
128 !is_string($rule["table"]) ||
129 !is_string($rule["column"]) ||
130 !is_string($rule["value"])
131 ) {
132 continue;
133 }
134 $this->exclude_rows_by_table[$rule["table"]][] = [
135 "column" => $rule["column"],
136 "value" => $rule["value"],
137 ];
138 }
139 }
140 }
141
142 /**
143 * Fetches the next row and advances the resume position.
144 *
145 * An exhausted batch opens another bounded query after the last primary
146 * key. A fresh query returning no rows means the table is complete.
147 */
148 public function next_record()
149 {
150 $this->current_row_ends_query_batch = false;
151 if (!$this->current_result_set) {
152 $query = $this->build_select_query();
153 try {
154 $this->current_result_set = $this->db->query($query);
155 } catch (\Exception $e) {
156 throw new \RuntimeException(
157 "Database query `{$query}` failed for table " . $this->quote_identifier($this->current_table) . ": " . $e->getMessage()
158 );
159 }
160 $this->rows_fetched_from_current_query = 0;
161 }
162
163 $record = $this->current_result_set->fetch(PdoConstants::fetch_assoc());
164 if (!$record) {
165 $this->current_result_set = null;
166 if ($this->rows_fetched_from_current_query === 0) {
167 return false;
168 }
169 if ($this->last_pk_values !== null || $this->current_offset > 0) {
170 return $this->next_record();
171 }
172 return false;
173 }
174
175 ++$this->rows_fetched_from_current_query;
176 if ($this->current_column_names === null) {
177 $this->current_column_names = array_keys($record);
178 }
179
180 if ($this->current_pk_columns && count($this->current_pk_columns) > 0) {
181 $this->last_pk_values = [];
182 foreach ($this->current_pk_columns as $column) {
183 if (!array_key_exists($column, $record)) {
184 throw new \RuntimeException(
185 "Primary key column '{$column}' missing from SELECT result for table " .
186 $this->quote_identifier($this->current_table)
187 );
188 }
189 $this->last_pk_values[$column] = $record[$column];
190 }
191 } else {
192 ++$this->current_offset;
193 }
194
195 $this->current_row = $record;
196 if ($this->rows_fetched_from_current_query >= $this->batch_size) {
197 $this->current_row_ends_query_batch = true;
198 $this->release_current_result_set();
199 }
200 return true;
201 }
202
203 /** Drains unconsumed records and releases the active LIMIT-sized result set. */
204 private function release_current_result_set()
205 {
206 if ($this->current_result_set === null) {
207 return;
208 }
209 $record = $this->current_result_set->fetch(PdoConstants::fetch_assoc());
210 while ($record !== false) {
211 $record = $this->current_result_set->fetch(PdoConstants::fetch_assoc());
212 }
213 $this->current_result_set = null;
214 }
215
216 /** Returns whether the table list has been initialized. */
217 public function has_initialized_tables()
218 {
219 return $this->tables_to_process !== null;
220 }
221
222 /** Returns the table currently being read. */
223 public function get_current_table()
224 {
225 return $this->current_table;
226 }
227
228 /** Returns the fetched record retained until its consumer clears it. */
229 public function get_current_record()
230 {
231 return $this->current_row;
232 }
233
234 /** Clears the retained record after its consumer has processed it. */
235 public function clear_current_record()
236 {
237 $this->current_row = null;
238 $this->current_row_ends_query_batch = false;
239 }
240
241 /** Returns whether the retained record is the final row of its bounded query. */
242 public function is_current_record_at_query_batch_boundary()
243 {
244 return $this->current_row_ends_query_batch;
245 }
246
247 /** Returns column names in table order. */
248 public function get_current_column_names()
249 {
250 return $this->current_column_names;
251 }
252
253 /** Returns primary key column names in ordinal order. */
254 public function get_current_primary_key_columns()
255 {
256 return $this->current_pk_columns;
257 }
258
259 /** Returns the maximum number of rows read by one query. */
260 public function get_batch_size()
261 {
262 return $this->batch_size;
263 }
264
265 /**
266 * Returns the row reader fields needed to resume at the current position.
267 *
268 * @return array {
269 * @type string|null $current_table Current table name.
270 * @type array|null $current_pk_columns Current primary key columns.
271 * @type array|null $last_pk_values Encoded primary key values.
272 * @type int $current_offset Offset for a table without a primary key.
273 * @type array|null $current_row Encoded retained record.
274 * @type bool $current_row_ends_query_batch Whether the retained record ends its query batch.
275 * @type array|null $current_column_names Current column names.
276 * }
277 */
278 public function get_cursor_state()
279 {
280 return [
281 "current_table" => $this->current_table,
282 "current_pk_columns" => $this->current_pk_columns,
283 "last_pk_values" => $this->encode_database_values_for_cursor($this->last_pk_values),
284 "current_offset" => $this->current_offset,
285 "current_row" => $this->encode_database_values_for_cursor($this->current_row),
286 "current_row_ends_query_batch" => $this->current_row_ends_query_batch,
287 "current_column_names" => $this->current_column_names,
288 ];
289 }
290
291 /**
292 * Restores row reader fields from reader cursor data.
293 *
294 * @param array $cursor_data Reader cursor fields returned by get_cursor_state().
295 * @return bool Whether the cursor's current table still exists.
296 */
297 public function restore_cursor_state($cursor_data)
298 {
299 $this->current_table = $cursor_data["current_table"] ?? null;
300 if ($this->current_table !== null && !is_string($this->current_table)) {
301 throw new \InvalidArgumentException(
302 "Invalid cursor: current_table must be string or null, got " . gettype($this->current_table)
303 );
304 }
305 $this->current_pk_columns = $cursor_data["current_pk_columns"] ?? null;
306 $this->last_pk_values = $this->decode_database_values_from_cursor(
307 $cursor_data["last_pk_values"] ?? null
308 );
309 $this->current_offset = $cursor_data["current_offset"] ?? 0;
310 if (!is_int($this->current_offset) && !is_float($this->current_offset)) {
311 throw new \InvalidArgumentException(
312 "Invalid cursor: current_offset must be numeric, got " . gettype($this->current_offset)
313 );
314 }
315 $this->current_offset = (int) $this->current_offset;
316 $this->current_row = $this->decode_database_values_from_cursor(
317 $cursor_data["current_row"] ?? null
318 );
319 $this->current_row_ends_query_batch = $cursor_data["current_row_ends_query_batch"] ?? false;
320 if (!is_bool($this->current_row_ends_query_batch)) {
321 throw new \InvalidArgumentException(
322 "Invalid cursor: current_row_ends_query_batch must be boolean, got " .
323 gettype($this->current_row_ends_query_batch)
324 );
325 }
326 $this->current_column_names = $cursor_data["current_column_names"] ?? null;
327
328 if ($this->tables_to_process === null) {
329 $this->initialize_tables_to_process();
330 }
331 if ($this->current_table) {
332 $position = array_search($this->current_table, $this->tables_to_process, true);
333 if ($position === false) {
334 $this->current_table = null;
335 return false;
336 }
337 reset($this->tables_to_process);
338 while (key($this->tables_to_process) !== $position) {
339 next($this->tables_to_process);
340 }
341 if ($this->get_primary_key_columns($this->current_table) !== $this->current_pk_columns) {
342 throw new \RuntimeException(
343 "Cannot restore the database row cursor because the primary key for table " .
344 $this->quote_identifier($this->current_table) . " changed."
345 );
346 }
347 $this->current_column_types = $this->get_column_types($this->current_table);
348 if (empty($this->current_column_types)) {
349 throw new \RuntimeException(
350 "Table " . $this->quote_identifier($this->current_table) . " was dropped between export requests " .
351 "(no columns found in SHOW FULL COLUMNS)"
352 );
353 }
354 if ($this->current_column_names === null) {
355 $this->current_column_names = array_keys($this->current_column_types);
356 }
357 }
358 return true;
359 }
360
361 /**
362 * Builds the next bounded, byte-preserving SELECT.
363 *
364 * Non-numeric, non-binary columns are cast to BINARY so MySQL returns raw
365 * bytes instead of transcoding them through the connection character set.
366 * A latin1 column read through utf8mb4 must retain its original bytes.
367 */
368 private function build_select_query()
369 {
370 $select = "SELECT";
371 if ($this->query_time_limit_ms !== null) {
372 // Prevent one slow table query from consuming the PHP time budget.
373 $select .= " /*+ MAX_EXECUTION_TIME(" . $this->query_time_limit_ms . ") */";
374 }
375
376 if ($this->current_column_types) {
377 $select_parts = [];
378 foreach ($this->current_column_types as $column => $column_info) {
379 $quoted_column = $this->quote_identifier($column);
380 if (
381 $this->is_numeric_type($column_info["data_type"]) ||
382 $this->is_binary_type($column_info["data_type"])
383 ) {
384 $select_parts[] = $quoted_column;
385 } else {
386 $select_parts[] = "CAST({$quoted_column} AS BINARY) AS {$quoted_column}";
387 }
388 }
389 $query = $select . " " . implode(", ", $select_parts) .
390 " FROM " . $this->quote_identifier($this->current_table);
391 } else {
392 $query = $select . " * FROM " . $this->quote_identifier($this->current_table);
393 }
394
395 $where_conditions = $this->build_row_exclusion_where_conditions();
396 if ($this->current_pk_columns && count($this->current_pk_columns) > 0) {
397 if ($this->last_pk_values) {
398 $where_conditions[] = $this->build_pk_where_clause();
399 }
400 if ($where_conditions) {
401 $query .= " WHERE " . implode(" AND ", array_map(function ($condition) {
402 return "({$condition})";
403 }, $where_conditions));
404 }
405 $order_columns = array_map(function ($column) {
406 return $this->build_primary_key_column_expression($column) . " ASC";
407 }, $this->current_pk_columns);
408 $query .= " ORDER BY " . implode(", ", $order_columns);
409 $query .= " LIMIT {$this->batch_size}";
410 } else {
411 if ($where_conditions) {
412 $query .= " WHERE " . implode(" AND ", array_map(function ($condition) {
413 return "({$condition})";
414 }, $where_conditions));
415 }
416 $query .= " LIMIT {$this->batch_size}";
417 if ($this->current_offset > 0) {
418 // Best-effort pagination for tables without a primary key.
419 $query .= " OFFSET {$this->current_offset}";
420 }
421 }
422 return $query;
423 }
424
425 private function build_row_exclusion_where_conditions()
426 {
427 if (!$this->current_table || empty($this->exclude_rows_by_table[$this->current_table])) {
428 return [];
429 }
430 $conditions = [];
431 foreach ($this->exclude_rows_by_table[$this->current_table] as $rule) {
432 $column = $rule["column"];
433 if (!isset($this->current_column_types[$column])) {
434 continue;
435 }
436 $quoted_column = $this->quote_identifier($column);
437 $encoded_value = base64_encode($rule["value"]);
438 // NULL <> value is UNKNOWN, so preserve NULL explicitly.
439 $conditions[] = "{$quoted_column} IS NULL OR {$quoted_column} <> FROM_BASE64('{$encoded_value}')";
440 }
441 return $conditions;
442 }
443
444 /**
445 * Builds the lexicographic condition after a composite primary key.
446 *
447 * For (a, b, c), this expands to:
448 * (a > A) OR (a = A AND b > B) OR (a = A AND b = B AND c > C).
449 * The expanded form works on MySQL versions which do not optimize row-value
450 * comparisons well.
451 */
452 private function build_pk_where_clause()
453 {
454 if (!$this->last_pk_values || count($this->current_pk_columns) === 0) {
455 return "1=1";
456 }
457 if (count($this->current_pk_columns) === 1) {
458 $column = $this->current_pk_columns[0];
459 return $this->build_comparison($column, $this->last_pk_values[$column], ">");
460 }
461 $conditions = [];
462 $prefix_conditions = [];
463 foreach ($this->current_pk_columns as $column) {
464 $value = $this->last_pk_values[$column];
465 $parts = $prefix_conditions;
466 $parts[] = $this->build_comparison($column, $value, ">");
467 $conditions[] = "(" . implode(" AND ", $parts) . ")";
468 $prefix_conditions[] = $this->build_comparison($column, $value, "=");
469 }
470 return "(" . implode(" OR ", $conditions) . ")";
471 }
472
473 public function build_comparison($column, $value, $operator)
474 {
475 $column_expression = $this->build_primary_key_column_expression($column);
476 if ($value === null) {
477 return $operator === "="
478 ? "{$column_expression} IS NULL"
479 : "{$column_expression} IS NOT NULL";
480 }
481 if ($this->is_numeric_type($this->get_data_type($column))) {
482 return "{$column_expression} {$operator} {$value}";
483 }
484 return "{$column_expression} {$operator} FROM_BASE64('" . base64_encode($value) . "')";
485 }
486
487 /**
488 * Builds the column expression shared by primary-key comparison and order.
489 *
490 * Character columns retain their declared collation and remain bare so the
491 * database can use a primary-key range scan. FROM_BASE64() has higher
492 * coercibility than the column, so MySQL applies the column's character set
493 * and collation without reading cursor bytes through the connection
494 * character set. ENUM and SET use a binary cast because their index
495 * positions and fetched string values differ.
496 */
497 private function build_primary_key_column_expression($column)
498 {
499 $qualified_column = $this->quote_identifier($this->current_table) . "." .
500 $this->quote_identifier($column);
501 $data_type = strtoupper($this->get_data_type($column));
502 if ($this->is_numeric_type($data_type) || $this->is_binary_type($data_type)) {
503 return $qualified_column;
504 }
505 if ($this->is_character_string_type($data_type)) {
506 return $qualified_column;
507 }
508 return "CAST({$qualified_column} AS BINARY)";
509 }
510
511 /** Returns primary key column names in ordinal order, or an empty array. */
512 private function get_primary_key_columns($table)
513 {
514 $primary_key_columns = [];
515 $columns_by_position = [];
516 $has_usable_positions = true;
517 $query = "SHOW INDEX FROM " . $this->quote_identifier($table);
518 try {
519 $statement = $this->db->query($query);
520 } catch (\Exception $e) {
521 throw new \RuntimeException(
522 "Failed to get primary key columns for " . $this->quote_identifier($table) . ": " . $e->getMessage() . " Query: {$query}"
523 );
524 }
525 $row = $statement->fetch(PdoConstants::fetch_assoc());
526 while ($row !== false) {
527 if (!isset($row["Key_name"]) || strcasecmp($row["Key_name"], "PRIMARY") !== 0) {
528 $row = $statement->fetch(PdoConstants::fetch_assoc());
529 continue;
530 }
531
532 $column = $row["Column_name"];
533 $primary_key_columns[] = $column;
534 $position = $row["Seq_in_index"] ?? null;
535 if (is_string($position) && ctype_digit($position)) {
536 $position = intval($position);
537 }
538 if (
539 !is_int($position) ||
540 $position < 1 ||
541 isset($columns_by_position[$position])
542 ) {
543 $has_usable_positions = false;
544 } else {
545 $columns_by_position[$position] = $column;
546 }
547 $row = $statement->fetch(PdoConstants::fetch_assoc());
548 }
549
550 if (!$has_usable_positions) {
551 return $primary_key_columns;
552 }
553 ksort($columns_by_position, SORT_NUMERIC);
554 return array_values($columns_by_position);
555 }
556
557 public function move_to_next_table()
558 {
559 if ($this->tables_to_process === null) {
560 return false;
561 }
562 if (!$this->current_table) {
563 $this->current_table = reset($this->tables_to_process) ?: null;
564 } else {
565 $this->current_table = next($this->tables_to_process) ?: null;
566 }
567 if ($this->current_table) {
568 $this->current_pk_columns = $this->get_primary_key_columns($this->current_table);
569 $this->last_pk_values = null;
570 $this->current_offset = 0;
571 $this->current_column_types = $this->get_column_types($this->current_table);
572 $this->current_column_names = array_keys($this->current_column_types);
573 $this->current_row = null;
574 $this->current_row_ends_query_batch = false;
575 }
576 return (bool) $this->current_table;
577 }
578
579 /**
580 * Discovers BASE TABLEs and excludes views and Reprint progress tables.
581 *
582 * @TODO: Paginate databases with millions of tables.
583 */
584 public function initialize_tables_to_process()
585 {
586 $this->tables_to_process = [];
587 $statement = $this->db->query("SHOW FULL TABLES");
588 $row = $statement->fetch(PdoConstants::fetch_assoc());
589 while ($row !== false) {
590 $values = array_values($row);
591 $excluded = isset($values[0]) && stripos(
592 $values[0],
593 self::MYSQL_IMPORT_PROGRESS_TABLE_PREFIX
594 ) === 0;
595 foreach ($this->exclude_tables as $excluded_table) {
596 if (isset($values[0]) && strcasecmp($values[0], $excluded_table) === 0) {
597 $excluded = true;
598 break;
599 }
600 }
601 if (
602 isset($values[0], $values[1])
603 && strcasecmp($values[1], "BASE TABLE") === 0
604 && !$excluded
605 ) {
606 $this->tables_to_process[] = $values[0];
607 }
608 $row = $statement->fetch(PdoConstants::fetch_assoc());
609 }
610 }
611
612 /** Returns cached column metadata for a table. */
613 private function get_column_types($table_name)
614 {
615 if (isset($this->column_type_cache[$table_name])) {
616 return $this->column_type_cache[$table_name];
617 }
618 try {
619 $statement = $this->db->query(
620 "SHOW FULL COLUMNS FROM " . $this->quote_identifier($table_name)
621 );
622 } catch (\Exception $e) {
623 throw new \RuntimeException(
624 "Failed to get column types for " . $this->quote_identifier($table_name) . ": " . $e->getMessage()
625 );
626 }
627 $columns = [];
628 $row = $statement->fetch(PdoConstants::fetch_assoc());
629 while ($row !== false) {
630 $column_type = $row["Type"];
631 $columns[$row["Field"]] = [
632 "data_type" => preg_replace('/[\s(].*$/', '', $column_type),
633 "column_type" => $column_type,
634 "collation" => $row["Collation"] ?? null,
635 ];
636 $row = $statement->fetch(PdoConstants::fetch_assoc());
637 }
638 $this->column_type_cache[$table_name] = $columns;
639 return $columns;
640 }
641
642 /** Identifies numeric types which the dump emits as bare literals. */
643 public function is_numeric_type($data_type)
644 {
645 $data_type = strtoupper($data_type);
646 foreach (["TINYINT", "SMALLINT", "MEDIUMINT", "INTEGER", "INT", "BIGINT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "REAL", "BIT", "YEAR"] as $type) {
647 if (strpos($data_type, $type) === 0) {
648 return true;
649 }
650 }
651 return false;
652 }
653
654 /** Identifies binary columns which do not need a binary SELECT cast. */
655 public function is_binary_type($data_type)
656 {
657 $data_type = strtoupper($data_type);
658 foreach (["BINARY", "VARBINARY", "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB"] as $type) {
659 if (strpos($data_type, $type) === 0) {
660 return true;
661 }
662 }
663 return false;
664 }
665
666 /** Identifies character strings whose SQL substring ranges count characters. */
667 public function is_character_string_type($data_type)
668 {
669 $data_type = strtoupper($data_type);
670 foreach (["CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"] as $type) {
671 if (strpos($data_type, $type) === 0) {
672 return true;
673 }
674 }
675 return false;
676 }
677
678 /** Returns the DATA_TYPE string for a column, or throws if unknown. */
679 public function get_data_type(string $column): string
680 {
681 if (!isset($this->current_column_types[$column]["data_type"])) {
682 throw new \RuntimeException(
683 "No column type info for '{$column}' in table " .
684 $this->quote_identifier($this->current_table) .
685 ". This is a bug — SHOW FULL COLUMNS should have returned it."
686 );
687 }
688 return $this->current_column_types[$column]["data_type"];
689 }
690
691 /** Returns the declared character set's maximum bytes per character. */
692 public function get_maximum_character_bytes(string $column): int
693 {
694 if (!isset($this->current_column_types[$column])) {
695 throw new \RuntimeException(
696 "No column type info for '{$column}' in table " .
697 $this->quote_identifier($this->current_table) . "."
698 );
699 }
700
701 $collation = $this->current_column_types[$column]["collation"];
702 if ($collation === null) {
703 return 1;
704 }
705 if (isset($this->maximum_character_bytes_by_collation[$collation])) {
706 return $this->maximum_character_bytes_by_collation[$collation];
707 }
708
709 $statement = $this->db->prepare(
710 "SELECT character_sets.MAXLEN " .
711 "FROM information_schema.COLLATIONS AS collations " .
712 "JOIN information_schema.CHARACTER_SETS AS character_sets " .
713 "ON character_sets.CHARACTER_SET_NAME = collations.CHARACTER_SET_NAME " .
714 "WHERE collations.COLLATION_NAME = ?"
715 );
716 $statement->execute([$collation]);
717 $maximum_character_bytes = (int) $statement->fetchColumn();
718 if ($maximum_character_bytes < 1) {
719 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Database metadata errors are never HTML.
720 throw new \RuntimeException(
721 "Cannot determine the maximum character byte length for column " .
722 $this->quote_identifier($this->current_table) . "." .
723 $this->quote_identifier($column) . " with collation {$collation}."
724 );
725 // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
726 }
727
728 $this->maximum_character_bytes_by_collation[$collation] = $maximum_character_bytes;
729 return $maximum_character_bytes;
730 }
731
732 /** Escapes backticks by doubling them: tricky`table becomes `tricky``table`. */
733 public function quote_identifier($identifier)
734 {
735 return '`' . str_replace('`', '``', $identifier) . '`';
736 }
737
738 /**
739 * Encodes database strings for JSON cursor storage.
740 *
741 * JSON cannot represent arbitrary database bytes. The __binary__ marker
742 * distinguishes strings which must be decoded when restoring the cursor.
743 */
744 public function encode_database_values_for_cursor($values)
745 {
746 if ($values === null) {
747 return null;
748 }
749 $encoded = [];
750 foreach ($values as $column => $value) {
751 $encoded[$column] = $value !== null && is_string($value)
752 ? ["__binary__" => base64_encode($value)]
753 : $value;
754 }
755 return $encoded;
756 }
757
758 /** Restores database strings encoded by encode_database_values_for_cursor(). */
759 public function decode_database_values_from_cursor($values)
760 {
761 if ($values === null) {
762 return null;
763 }
764 $decoded = [];
765 foreach ($values as $column => $value) {
766 $decoded[$column] = is_array($value) && isset($value["__binary__"])
767 ? base64_decode($value["__binary__"])
768 : $value;
769 }
770 return $decoded;
771 }
772 }
773