PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / DatabaseUpgradesEtcTrait_SchemaDiff.php

DatabaseUpgradesEtcTrait_SchemaDiff.php in 404 Solution 4.2.0, at includes/DatabaseUpgradesEtcTrait_SchemaDiff.php

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