| 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 |
// Trimmed HERE rather than in the shared reader: SHOW INDEX metadata |
| 180 |
// arrives through drivers that pad, which is a fact about this source |
| 181 |
// and not about whole numbers in general. A JSON journal this plugin |
| 182 |
// wrote itself has no such excuse, and padding there means something |
| 183 |
// else rewrote the field. |
| 184 |
return ABJ_404_Solution_ExactInteger::read(trim((string)$value), $minimum); |
| 185 |
} |
| 186 |
} |
| 187 |
|