PluginProbe
404 Solution / 4.3.3
404 Solution v4.3.3
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / database / ShowIndexRowReader.php

ShowIndexRowReader.php in 404 Solution 4.3.3, at includes/database/ShowIndexRowReader.php

210 lines 9.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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