PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 / upgrades / DatabaseUpgradeSchemaDiff.php

DatabaseUpgradeSchemaDiff.php in 404 Solution trunk, at includes/database/upgrades/DatabaseUpgradeSchemaDiff.php

386 lines 15.9 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 class ABJ_404_Solution_DatabaseUpgradeSchemaDiff extends ABJ_404_Solution_DatabaseUpgradeComponent {
8
9 /**
10 * @param string $tableName
11 * @param string $createTableStatementGoal
12 * @return void
13 */
14 function verifyColumns($tableName, $createTableStatementGoal) {
15 $tableName = is_scalar($tableName) ? (string)$tableName : '';
16 $updatesWereNeeded = false;
17
18 // find the differences
19 $tableDifferences = $this->getTableDifferences($tableName, $createTableStatementGoal);
20 $tableDifferences = is_array($tableDifferences) ? $tableDifferences : [];
21 $updateCols = is_array($tableDifferences['updateTheseColumns']) ? $tableDifferences['updateTheseColumns'] : [];
22 $createCols = is_array($tableDifferences['createTheseColumns']) ? $tableDifferences['createTheseColumns'] : [];
23 if (count($updateCols) > 0 ||
24 count($createCols) > 0) {
25 $updatesWereNeeded = true;
26 }
27 // make the changes
28 $this->updateATableBasedOnDifferences($tableName, $tableDifferences);
29
30 // Data migrations can outlive the DDL request that created their column.
31 // Keep their retry path reachable on every schema verification.
32 if (strpos($tableName, 'abj404_logsv2') !== false &&
33 in_array('min_log_id',
34 ABJ_404_Solution_CreateTableColumnParser::columnNames($createTableStatementGoal),
35 true)) {
36 $this->upgrades()->addedColumnBackfillUpgrade()->runPendingBackfills($tableName);
37 }
38
39 // verify that there are now no changes that need to be made.
40 $tableDifferences = $this->getTableDifferences($tableName, $createTableStatementGoal);
41 $tableDifferences = is_array($tableDifferences) ? $tableDifferences : [];
42 $updateCols = is_array($tableDifferences['updateTheseColumns']) ? $tableDifferences['updateTheseColumns'] : [];
43 $createCols = is_array($tableDifferences['createTheseColumns']) ? $tableDifferences['createTheseColumns'] : [];
44
45 if (count($updateCols) > 0 ||
46 count($createCols) > 0) {
47
48 // Persistent post-update diff is usually a benign DDL-normalizer mismatch
49 // (parser misreads a comment, column landed in a slightly-different form).
50 // Plugin keeps functioning, so log at warn (stays in debug log without
51 // crossing the email-threshold reporter). Defensive coding philosophy #8.
52 $this->logger->warn("There are still differences after updating the " .
53 $tableName . " table. " . print_r($tableDifferences, true));
54
55 } else if ($updatesWereNeeded) {
56 $this->logger->infoMessage("No more differences found after updating the " .
57 $tableName . " table columns. All is well.");
58 }
59 }
60
61 /**
62 * @param string $tableName
63 * @param string $createTableStatementGoal
64 * @return array<string, mixed>
65 */
66 function getTableDifferences($tableName, $createTableStatementGoal) {
67
68 // get the current create table statement
69 $existingTableSQL = $this->dbCore->tableNameResolver()->getCreateTableDDL($tableName);
70
71 $existingTableSQL = strtolower($this->removeCommentsFromColumns($existingTableSQL));
72 $createTableStatementGoal = strtolower(
73 $this->removeCommentsFromColumns($createTableStatementGoal));
74
75 // remove the "COLLATE xxx" from the columns.
76 $removeCollatePattern = '/collate[= ]\w+ ?/';
77 $existingTableSQL = preg_replace($removeCollatePattern, "", $existingTableSQL) ?? '';
78 $createTableStatementGoal = preg_replace($removeCollatePattern, "", $createTableStatementGoal) ?? '';
79
80 // remove the int size format from columns because it doesn't matter.
81 $removeIntSizePattern = '/( \w*?int)(\(\d+\))/m';
82 $existingTableSQL = preg_replace($removeIntSizePattern, "$1", $existingTableSQL) ?? '';
83 $createTableStatementGoal = preg_replace($removeIntSizePattern, "$1", $createTableStatementGoal) ?? '';
84
85 // MySQL's SHOW CREATE TABLE omits "DEFAULT NULL" for TEXT/BLOB columns
86 // (it's implicit). Normalize both sides so this doesn't flag as a mismatch.
87 $removeTextDefaultNull = '/(text|blob|mediumtext|longtext|tinytext|mediumblob|longblob|tinyblob)\s+default\s+null/';
88 $existingTableSQL = preg_replace($removeTextDefaultNull, "$1", $existingTableSQL) ?? $existingTableSQL;
89 $createTableStatementGoal = preg_replace($removeTextDefaultNull, "$1", $createTableStatementGoal) ?? $createTableStatementGoal;
90
91 // Split each statement into its column definitions. The parser rejects
92 // index and constraint declarations by the keyword they start with, so an
93 // index's trailing USING BTREE can never be read as a column named `using`
94 // (report 286: that misread had the upgrade issue
95 // `alter table wp_abj404_redirects add using btree` on every run).
96 $existingTableMatches = $this->columnMatchGroups($existingTableSQL);
97 $goalTableMatches = $this->columnMatchGroups($createTableStatementGoal);
98
99 // get the matches.
100 $goalTableMatchesColumnNames = $goalTableMatches[2];
101 $existingTableMatchesColumnNames = $existingTableMatches[2];
102
103 // Safety guard: if the goal DDL produced zero column names the parser could
104 // not read it (e.g. malformed or unparseable DDL). In that case never drop
105 // any existing columns — an empty goal list would otherwise flag every real
106 // column as "extra" and wipe the table.
107 if (empty($goalTableMatchesColumnNames) && !empty($existingTableMatchesColumnNames)) {
108 $this->logger->errorMessage("Goal DDL for " . $tableName .
109 " produced no column matches -- the DDL may be malformed or unparseable. " .
110 "Skipping column comparison to prevent data loss.");
111 $dropTheseColumns = [];
112 $createTheseColumns = [];
113 return array("updateTheseColumns" => [],
114 "dropTheseColumns" => [],
115 "createTheseColumns" => [],
116 "goalTableMatchesColumnDDL" => [],
117 "existingTableMatchesColumnDDL" => [],
118 "goalTableMatches" => $goalTableMatches,
119 "goalTableMatchesColumnNames" => []
120 );
121 }
122
123 // see if some columns need to be created.
124 $dropTheseColumns = array_diff($existingTableMatchesColumnNames,
125 $goalTableMatchesColumnNames);
126 $createTheseColumns = array_diff($goalTableMatchesColumnNames,
127 $existingTableMatchesColumnNames);
128
129 // get the ddl for each column
130 $goalTableMatchesColumnDDL = $goalTableMatches[1];
131 $existingTableMatchesColumnDDL = $existingTableMatches[1];
132
133 // normalize minor differences between mysql versions (strip backticks so DDL
134 // files using either quoting style compare equal to SHOW CREATE TABLE output)
135 $goalTableMatchesColumnDDL = array_map([$this, 'normalizeColumnDDL'], $goalTableMatchesColumnDDL);
136 $existingTableMatchesColumnDDL = array_map([$this, 'normalizeColumnDDL'], $existingTableMatchesColumnDDL);
137
138 // see if anything needs to be updated or created.
139 $updateTheseColumns = array_diff($goalTableMatchesColumnDDL,
140 $existingTableMatchesColumnDDL);
141
142 // wrap the results
143 $results = array("updateTheseColumns" => $updateTheseColumns,
144 "dropTheseColumns" => $dropTheseColumns,
145 "createTheseColumns" => $createTheseColumns,
146 "goalTableMatchesColumnDDL" => $goalTableMatchesColumnDDL,
147 "existingTableMatchesColumnDDL" => $existingTableMatchesColumnDDL,
148 "goalTableMatches" => $goalTableMatches,
149 "goalTableMatchesColumnNames" => $goalTableMatchesColumnNames
150 );
151 return $results;
152 }
153
154 /**
155 * The column definitions of one CREATE TABLE statement, in the positional
156 * layout the rest of this class and its tests read:
157 *
158 * [0] the whole entry, [1] the same entry (name + type), [2] the column
159 * name on its own, [3] the type on its own.
160 *
161 * Kept because updateATableBasedOnDifferences() locates a column by index
162 * across [1] and [2], so the two lists have to stay positionally aligned;
163 * the parser guarantees that by construction.
164 *
165 * @param string $createTableSql
166 * @return array<int, array<int, string>>
167 */
168 private function columnMatchGroups($createTableSql) {
169 $groups = array(array(), array(), array(), array());
170 foreach (ABJ_404_Solution_CreateTableColumnParser::fromCreateTableSql($createTableSql)
171 as $column) {
172 $groups[0][] = $column['definition'];
173 $groups[1][] = $column['definition'];
174 $groups[2][] = $column['name'];
175 $groups[3][] = $column['type'];
176 }
177 return $groups;
178 }
179
180 /**
181 * @param string $tableName
182 * @param array<string, mixed> $tableDifferences
183 * @return void
184 */
185 function updateATableBasedOnDifferences($tableName, $tableDifferences) {
186 $tableName = is_scalar($tableName) ? (string)$tableName : '';
187 $tableDifferences = is_array($tableDifferences) ? $tableDifferences : [];
188
189 /** @var array<int, string> $dropTheseColumns */
190 $dropTheseColumns = is_array($tableDifferences['dropTheseColumns'])
191 ? $this->stringValues($tableDifferences['dropTheseColumns'])
192 : [];
193 /** @var array<int, string> $updateTheseColumns */
194 $updateTheseColumns = is_array($tableDifferences['updateTheseColumns'])
195 ? $this->stringValues($tableDifferences['updateTheseColumns'])
196 : [];
197 /** @var array<int, string> $createTheseColumns */
198 $createTheseColumns = is_array($tableDifferences['createTheseColumns'])
199 ? $this->stringValues($tableDifferences['createTheseColumns'])
200 : [];
201 $goalTableMatchesColumnDDL = is_array($tableDifferences['goalTableMatchesColumnDDL'])
202 ? $this->stringValues($tableDifferences['goalTableMatchesColumnDDL'])
203 : [];
204 $existingTableMatchesColumnDDL = is_array($tableDifferences['existingTableMatchesColumnDDL'])
205 ? $this->stringValues($tableDifferences['existingTableMatchesColumnDDL'])
206 : [];
207 /** @var array<int, array<int, mixed>> $goalTableMatches */
208 $goalTableMatches = is_array($tableDifferences['goalTableMatches']) ? $tableDifferences['goalTableMatches'] : [];
209 /** @var array<int, string> $goalTableMatchesColumnNames */
210 $goalTableMatchesColumnNames = is_array($tableDifferences['goalTableMatchesColumnNames'])
211 ? $this->stringValues($tableDifferences['goalTableMatchesColumnNames'])
212 : [];
213
214 // drop unnecessary columns — but never drop ALL columns (MySQL error:
215 // "You can't delete all columns with ALTER TABLE; use DROP TABLE instead").
216 // This happens when a table is completely restructured and every existing
217 // column name differs from the goal schema.
218 $existingColumnCount = count($existingTableMatchesColumnDDL);
219 if (count($dropTheseColumns) > 0 && count($dropTheseColumns) >= $existingColumnCount) {
220 $this->logger->warn("Skipping column drops on " . $tableName .
221 " because it would remove all " . $existingColumnCount .
222 " existing columns. Drops requested: " . implode(', ', $dropTheseColumns));
223 } else {
224 foreach ($dropTheseColumns as $colName) {
225 $colName = (string)$colName;
226 $query = "alter table " . $tableName . " drop " . $colName;
227 $this->dbCore->queryAndGetResults($query);
228 $this->logger->infoMessage("I dropped a column (1): " . $query);
229 }
230 }
231
232 // say why we're doing what we're doing.
233 if (count($updateTheseColumns) > 0) {
234 $this->logger->infoMessage($this->getUpgradeRuntimeId() . ": On " . $tableName .
235 " I'm updating various columns because we want: \n`" .
236 print_r($goalTableMatchesColumnDDL, true) . "\n but we have: \n" .
237 print_r($existingTableMatchesColumnDDL, true));
238 }
239
240 // create missing columns
241 // Normalize $goalMatchesSub using the same normalizeColumnDDL() that
242 // getTableDifferences() uses, so array_search() can find the right index.
243 $goalMatchesSub = is_array($goalTableMatches[1] ?? null) ? $this->stringValues($goalTableMatches[1]) : [];
244 $goalMatchesSub = array_map([$this, 'normalizeColumnDDL'], $goalMatchesSub);
245 foreach ($updateTheseColumns as $colDDL) {
246 $colDDL = (string)$colDDL;
247 // find the colum name.
248 $matchIndex = array_search($colDDL, $goalMatchesSub);
249 if ($matchIndex === false) {
250 $this->logger->warn("Could not match column DDL to goal schema, skipping: " . $colDDL);
251 continue;
252 }
253 $colName = is_scalar($goalTableMatchesColumnNames[$matchIndex] ?? null)
254 ? (string)$goalTableMatchesColumnNames[$matchIndex]
255 : '';
256
257 // if the column exists then update it. otherwise create it.
258 if (!in_array($colName, $createTheseColumns)) {
259 // update the existing column.
260 // ALTER TABLE `mywp_abj404_redirects` CHANGE `status` `status` BIGINT(19) NOT NULL;
261 $updateColStatement = "alter table " . $tableName . " change " . $colName .
262 " " . $colDDL;
263 $this->dbCore->queryAndGetResults($updateColStatement);
264 $this->logger->infoMessage("I updated a column: " . $updateColStatement);
265
266 } else {
267 // create the column.
268 $createColStatement = "alter table " . $tableName . " add " . $colDDL;
269 $this->dbCore->queryAndGetResults($createColStatement);
270 $this->logger->infoMessage("I added a column: " . $createColStatement);
271 }
272
273 $this->runAddedColumnBackfill(array(
274 'tableName' => $tableName,
275 'colName' => $colName,
276 ));
277 }
278 }
279
280 /**
281 * @param array{tableName: string, colName: string} $context
282 * @return void
283 */
284 private function runAddedColumnBackfill(array $context) {
285 // min_log_id is drained once per verification by runPendingBackfills(),
286 // including on later requests after the column already exists.
287 if ($context['colName'] === 'min_log_id') {
288 return;
289 }
290 $this->upgrades()->addedColumnBackfillUpgrade()->runBackfillsForAddedColumn($context);
291 }
292
293 /** Create table DDL is returned without SQL comments of any kind.
294 * Strips block comments (slash-star ... star-slash), line comments (-- ...),
295 * and inline COMMENT 'text' column clauses so the column-name regex in
296 * getTableDifferences() cannot mistake comment text for column definitions.
297 * @param string|null $createTableDDL
298 * @return string
299 */
300 function removeCommentsFromColumns($createTableDDL) {
301 if ($createTableDDL === null) {
302 return '';
303 }
304 $ddl = (string) $createTableDDL;
305 // Strip block comments (slash-star ... star-slash), including multi-line.
306 $ddl = preg_replace('/\/\*.*?\*\//s', '', $ddl) ?? $ddl;
307 // Strip line comments (-- ...).
308 $ddl = preg_replace('/--[^\r\n]*/', '', $ddl) ?? $ddl;
309 // Strip inline COMMENT 'text', clauses from column definitions.
310 return preg_replace('/ (?:COMMENT.+?,[\r\n])/', ",\n", $ddl) ?? $ddl;
311 }
312 /**
313 * Normalize a single column DDL fragment for comparison.
314 *
315 * Strips backticks and unquotes integer defaults so that DDL from
316 * SHOW CREATE TABLE (e.g. default '1') matches the goal DDL file
317 * (e.g. default 1). Used by both getTableDifferences() and
318 * updateATableBasedOnDifferences() — a single source of truth
319 * prevents the two normalization sites from drifting out of sync.
320 *
321 * @param mixed $ddl A column DDL string (or non-string from regex match)
322 * @return string
323 */
324 function normalizeColumnDDL($ddl): string {
325 $ddlStr = is_string($ddl) ? $ddl : '';
326 $normalized = strtolower(str_replace('`', '', trim($ddlStr)));
327 $normalized = preg_replace("/default '(\d+)'/", 'default $1', $normalized) ?? $normalized;
328 // MySQL omits DEFAULT NULL for nullable columns — strip it so DDL file
329 // and SHOW CREATE TABLE produce identical normalized strings.
330 $normalized = preg_replace('/\s+default\s+null\b/', '', $normalized) ?? $normalized;
331 return $normalized;
332 }
333
334 /**
335 * @param array<int|string, mixed> $values
336 * @return array<int, string>
337 */
338 private function stringValues(array $values): array {
339 $result = [];
340 foreach ($values as $value) {
341 if (is_scalar($value)) {
342 $result[] = (string)$value;
343 }
344 }
345 return $result;
346 }
347
348 /**
349 * @param string $tableName
350 * @return void
351 */
352 function deleteIndexes($tableName) {
353
354 // get the indexes list.
355 $results = $this->dbCore->queryAndGetResults("show index from " . $tableName .
356 " where key_name != 'PRIMARY'");
357 /** @var array<int, array<string, mixed>> $rows */
358 $rows = isset($results['rows']) && is_array($results['rows']) ? $results['rows'] : [];
359
360 if (empty($rows)) {
361 return;
362 }
363
364 // find the key_name column because the case can be different on different systems.
365 $keyNameColumn = 'key_name';
366 $aRow = $rows[0];
367 foreach (array_keys($aRow) as $someKey) {
368 if ($this->f->strtolower((string)$someKey) == 'key_name') {
369 $keyNameColumn = (string)$someKey;
370 break;
371 }
372 }
373
374 foreach ($rows as $row) {
375 // delete them
376 $indexName = $row[$keyNameColumn] ?? '';
377 if (!is_string($indexName) || $indexName === '') {
378 continue;
379 }
380 $query = "alter table " . $tableName . " drop index " . $indexName;
381 $this->dbCore->queryAndGetResults($query);
382 }
383 }
384
385 }
386