PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 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 All 508 releases
← All changes | vendor/wp-php-toolkit/reprint-server/src/class-database-rows-reader.php +772 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,772 @@
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 +}