PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.0
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 4.3.0, at includes/database/upgrades/DatabaseUpgradeSchemaDiff.php

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