| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* What one SHOW INDEX row actually said, field by field. |
| 9 |
* |
| 10 |
* This is the boundary between an engine's answer and the plugin's idea of an |
| 11 |
* index. The plugin runs on MySQL 5.6 to 8.x and MariaDB 10.3 to 11.x across |
| 12 |
* several drivers, and they do not agree on how to spell the same fact: the |
| 13 |
* row's keys arrive in varying case, Sub_part arrives as null or '' or 0 or |
| 14 |
* '190' for the same physical index, and every number may arrive as a string. |
| 15 |
* Reading those differences is a separate job from deciding what an index IS, |
| 16 |
* and it is the job this class does. |
| 17 |
* |
| 18 |
* ONE RULE RUNS THROUGH ALL OF IT: a value this version cannot represent |
| 19 |
* exactly is reported as unreadable, never as a default. The definitions these |
| 20 |
* readers feed are compared against the plugin's own DDL, and the repair path |
| 21 |
* answers a difference by rewriting the index -- so a field quietly read as 0, |
| 22 |
* or truncated toward one, spends a table rewrite on metadata nobody actually |
| 23 |
* read. Refusing costs a skipped comparison; guessing costs the table. |
| 24 |
* |
| 25 |
* Split out of {@see ABJ_404_Solution_TableIndexDefinitions}, which owns the |
| 26 |
* other half: what an index IS, once its rows can be read. |
| 27 |
*/ |
| 28 |
class ABJ_404_Solution_ShowIndexRowReader { |
| 29 |
|
| 30 |
/** |
| 31 |
* A metadata row under the keys this reader looks fields up by. |
| 32 |
* |
| 33 |
* Drivers return SHOW INDEX and information_schema column names in varying |
| 34 |
* cases (defensive philosophy #5), so every lookup here is against |
| 35 |
* lowercased keys and every row passes through this first. |
| 36 |
* |
| 37 |
* @param array<string|int, mixed> $row |
| 38 |
* @return array<string, mixed> |
| 39 |
*/ |
| 40 |
public static function normalizedFields(array $row): array { |
| 41 |
$lowered = array(); |
| 42 |
foreach ($row as $key => $value) { |
| 43 |
$lowered[strtolower((string)$key)] = $value; |
| 44 |
} |
| 45 |
return $lowered; |
| 46 |
} |
| 47 |
|
| 48 |
/** |
| 49 |
* The uniqueness the engine reported for one index row, or null when it did |
| 50 |
* not report it in a form this version can read. |
| 51 |
* |
| 52 |
* Non_unique is 0 for a unique index and 1 otherwise. Anything else -- the |
| 53 |
* field absent, an array or object where a flag was expected, a word, or a |
| 54 |
* number outside that two-value domain -- is metadata this version does not |
| 55 |
* understand. Reading it as a number is not enough on its own: a non-numeric |
| 56 |
* string casts to integer 0, and so does 0.5, and 0 is the value that means |
| 57 |
* UNIQUE, so "no" or "0.5" would otherwise read as a confident "this index |
| 58 |
* is unique" and invite a UNIQUE rebuild on a table that has duplicate rows. |
| 59 |
* |
| 60 |
* @param array<string, mixed> $fields Lowercased-key SHOW INDEX row. |
| 61 |
* @return bool|null |
| 62 |
*/ |
| 63 |
public static function readUniqueFlag(array $fields): ?bool { |
| 64 |
if (!isset($fields['non_unique'])) { |
| 65 |
return null; |
| 66 |
} |
| 67 |
$flag = self::readExactInteger($fields['non_unique'], 0); |
| 68 |
if ($flag === null || $flag > 1) { |
| 69 |
return null; |
| 70 |
} |
| 71 |
return $flag === 0; |
| 72 |
} |
| 73 |
|
| 74 |
/** |
| 75 |
* Where one SHOW INDEX row places its column, or NULL when this version |
| 76 |
* cannot read the placement. |
| 77 |
* |
| 78 |
* The row-level counterpart to {@see readUniqueFlag()}: that one answers |
| 79 |
* what a row says about its index's uniqueness, this one answers what it |
| 80 |
* says about its columns. Both return NULL for "the engine did not tell us |
| 81 |
* in a form we understand", and both leave the caller to record the index |
| 82 |
* as present but undescribable. |
| 83 |
* |
| 84 |
* Seq_in_index IS the column order, and the order is what the signature |
| 85 |
* comparison is FOR. Inventing one from arrival order when the engine did |
| 86 |
* not report it produces a definition that looks authoritative and compares |
| 87 |
* as drift against a DDL whose real order differs -- a needless rewrite of a |
| 88 |
* healthy index on a large table. |
| 89 |
* |
| 90 |
* A Sub_part we cannot read is likewise not "no prefix". Treating it as one |
| 91 |
* compares unequal to a DDL that DOES carry a prefix, which reports drift |
| 92 |
* and rebuilds a healthy index. |
| 93 |
* |
| 94 |
* @param array<string, mixed> $fields Lowercased-key SHOW INDEX row. |
| 95 |
* @param string $column Non-empty column name already read from the row. |
| 96 |
* @return array{position: int, entry: array{column: string, prefix: int|null}}|null |
| 97 |
*/ |
| 98 |
public static function readColumnPlacement(array $fields, string $column): ?array { |
| 99 |
$position = isset($fields['seq_in_index']) |
| 100 |
? self::readExactInteger($fields['seq_in_index'], 1) : null; |
| 101 |
if ($position === null) { |
| 102 |
return null; |
| 103 |
} |
| 104 |
$prefix = self::readPrefix($fields['sub_part'] ?? null); |
| 105 |
if (!$prefix['readable']) { |
| 106 |
return null; |
| 107 |
} |
| 108 |
return array( |
| 109 |
'position' => $position, |
| 110 |
'entry' => array( |
| 111 |
'column' => strtolower($column), |
| 112 |
'prefix' => $prefix['prefix'], |
| 113 |
), |
| 114 |
); |
| 115 |
} |
| 116 |
|
| 117 |
/** |
| 118 |
* Read a reported Sub_part as either "indexes the whole column" or a prefix |
| 119 |
* length, and say whether it could be read at all. |
| 120 |
* |
| 121 |
* Deciding readability and producing the value used to be two methods, and |
| 122 |
* they disagreed: the gate accepted every numeric value, then the normalizer |
| 123 |
* turned a negative one into "no prefix" and truncated a fractional one. A |
| 124 |
* value the gate calls readable and the normalizer silently changes is the |
| 125 |
* whole defect, so there is now one reader and the two answers come out of |
| 126 |
* it together. |
| 127 |
* |
| 128 |
* NULL, an empty string and 0 all legitimately mean "indexes the whole |
| 129 |
* column". A prefix length is a whole number of characters, so a fractional |
| 130 |
* or negative one is metadata this version does not understand, and must |
| 131 |
* not be flattened into "no prefix" or truncated toward one. |
| 132 |
* |
| 133 |
* @param mixed $subPart |
| 134 |
* @return array{readable: bool, prefix: int|null} |
| 135 |
*/ |
| 136 |
private static function readPrefix($subPart): array { |
| 137 |
if ($subPart === null) { |
| 138 |
return array('readable' => true, 'prefix' => null); |
| 139 |
} |
| 140 |
if (!is_scalar($subPart) || is_bool($subPart)) { |
| 141 |
return array('readable' => false, 'prefix' => null); |
| 142 |
} |
| 143 |
if (trim((string)$subPart) === '') { |
| 144 |
return array('readable' => true, 'prefix' => null); |
| 145 |
} |
| 146 |
$length = self::readExactInteger($subPart, 0); |
| 147 |
if ($length === null) { |
| 148 |
return array('readable' => false, 'prefix' => null); |
| 149 |
} |
| 150 |
return array('readable' => true, 'prefix' => $length === 0 ? null : $length); |
| 151 |
} |
| 152 |
|
| 153 |
/** |
| 154 |
* The whole number a metadata field reports, or NULL when the value is not |
| 155 |
* an exact integer at or above the smallest one its domain allows. |
| 156 |
* |
| 157 |
* Every SHOW INDEX field this class reads is a whole number over a known |
| 158 |
* range -- a column position from 1, a prefix length from 0, a uniqueness |
| 159 |
* flag of 0 or 1 -- and every one of them was previously admitted by |
| 160 |
* is_numeric() and then cast with (int). That pair accepts values it cannot |
| 161 |
* represent and answers with a confident wrong one: '1.5' becomes 1, '-1' |
| 162 |
* becomes a position ahead of the first column, '0.5' becomes the 0 that |
| 163 |
* means UNIQUE. The comparison those values feed answers a difference with |
| 164 |
* destructive DDL, so a value that does not survive the round trip is not a |
| 165 |
* value this version can read. |
| 166 |
* |
| 167 |
* Booleans are refused rather than cast: no engine reports one, and (string) |
| 168 |
* renders true as '1' while rendering false as '', so accepting them would |
| 169 |
* read one of the two as a confident flag and the other as absent. |
| 170 |
* |
| 171 |
* @param mixed $value |
| 172 |
* @param int $minimum Smallest value the field's documented domain allows. |
| 173 |
* @return int|null |
| 174 |
*/ |
| 175 |
private static function readExactInteger($value, int $minimum): ?int { |
| 176 |
if (!is_scalar($value) || is_bool($value)) { |
| 177 |
return null; |
| 178 |
} |
| 179 |
$text = trim((string)$value); |
| 180 |
if ($text === '' || !is_numeric($text)) { |
| 181 |
return null; |
| 182 |
} |
| 183 |
// Integrality is decided from the TEXT, never from the number the text |
| 184 |
// converts to. Past 2^53 a double has no room left for the fractional |
| 185 |
// part it was handed, so '9007199254740992.5' arrives already rounded |
| 186 |
// to a whole number: a floor() check downstream sees nothing wrong and |
| 187 |
// hands back a prefix length the server never reported, which the |
| 188 |
// comparator answers with destructive DDL. Every field this reads is a |
| 189 |
// plain whole number in every engine that reports it, so a value |
| 190 |
// written any other way -- a fraction, an exponent -- is one this |
| 191 |
// version does not read, and unreadable means undescribable rather |
| 192 |
// than a guess. |
| 193 |
if (!preg_match('/\A[+-]?[0-9]+\z/', $text)) { |
| 194 |
return null; |
| 195 |
} |
| 196 |
$number = $text + 0; |
| 197 |
if (is_float($number)) { |
| 198 |
// A digit run too long for an int converts to a float instead: |
| 199 |
// still whole, but possibly infinite and possibly outside the range |
| 200 |
// an int holds. (int) answers both with a different value. |
| 201 |
if (!is_finite($number) |
| 202 |
|| $number < (float)PHP_INT_MIN || $number >= (float)PHP_INT_MAX) { |
| 203 |
return null; |
| 204 |
} |
| 205 |
} |
| 206 |
$integer = (int)$number; |
| 207 |
return $integer < $minimum ? null : $integer; |
| 208 |
} |
| 209 |
} |
| 210 |
|