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_Indexes.php

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

306 lines 13.1 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 * Index discovery, parsing, verification, and add-index DDL helpers for
9 * ABJ_404_Solution_DatabaseUpgradesEtc, plus the small ensureLogs* helpers
10 * that gate online DDL on the logsv2 table.
11 *
12 * Extracted from DatabaseUpgradesEtc.php in 4.1.12 to keep the host class
13 * under the FileSizeLimitsTest line budget. No behavior change.
14 */
15 trait ABJ_404_Solution_DatabaseUpgradesEtc_IndexesTrait {
16
17 /** @return void */
18 function createIndexes() {
19 foreach ($this->discoverPermanentDDLFiles() as $ddlEntry) {
20 $tableName = $this->dbCore->doTableNameReplacements($ddlEntry['placeholder']);
21 $query = $this->dbCore->doTableNameReplacements($ddlEntry['ddlContent']);
22 $this->verifyIndexes($tableName, $query);
23 }
24 }
25
26 /**
27 * @param string $tableName
28 * @param string $createTableStatementGoal
29 * @return void
30 */
31 function verifyIndexes($tableName, $createTableStatementGoal) {
32
33 // get the indexes.
34 // Pattern matches lines starting with "KEY" / "UNIQUE KEY" - handles composite indexes with commas inside parens
35 // Indexes: treat the CREATE TABLE SQL as source of truth, and treat the database as truth
36 // for what exists (SHOW INDEX). Avoid parsing SHOW CREATE TABLE output, which is vendor/format dependent.
37 $goalSpecsByName = $this->parseIndexSpecsFromCreateTableSql($createTableStatementGoal);
38
39 $missingIndexNames = [];
40 foreach (array_keys($goalSpecsByName) as $indexName) {
41 if (!$this->indexExists($tableName, $indexName)) {
42 $missingIndexNames[] = $indexName;
43 }
44 }
45
46 if (count($missingIndexNames) > 0) {
47 $this->logger->infoMessage(self::$uniqID . ": On {$tableName} I'm adding missing indexes: " . implode(', ', $missingIndexNames));
48 }
49
50 // Get actual columns in the table so we can skip indexes that reference missing columns.
51 $existingColumns = [];
52 $showColResult = $this->dbCore->queryAndGetResults("SHOW COLUMNS FROM " . $tableName);
53 $showColRows = is_array($showColResult['rows'] ?? null) ? $showColResult['rows'] : [];
54 foreach ($showColRows as $colRow) {
55 if (!is_array($colRow)) { continue; }
56 foreach ($colRow as $key => $value) {
57 if (strtolower((string)$key) === 'field') {
58 $existingColumns[] = strtolower((string)$value);
59 break;
60 }
61 }
62 }
63
64 foreach ($missingIndexNames as $indexName) {
65 $spec = $goalSpecsByName[$indexName] ?? null;
66 if (empty($spec)) {
67 continue;
68 }
69
70 // Verify all columns referenced by this index actually exist in the table.
71 if (!empty($existingColumns)) {
72 $indexColNames = [];
73 preg_match_all('/`([^`]+)`/', $spec['columns'], $colMatches);
74 if (!empty($colMatches[1])) {
75 $indexColNames = array_map('strtolower', $colMatches[1]);
76 }
77 $missingCols = array_diff($indexColNames, $existingColumns);
78 if (!empty($missingCols)) {
79 $this->logger->warn("Skipping index {$indexName} on {$tableName}: " .
80 "column(s) " . implode(', ', $missingCols) . " do not exist in the table.");
81 continue;
82 }
83 }
84
85 $spellingCacheTableName = $this->dbCore->doTableNameReplacements('{wp_abj404_spelling_cache}');
86 $tableNameLower = strtolower($tableName);
87 if ($tableNameLower == $spellingCacheTableName && !empty($spec['unique'])) {
88 $this->contentRepo->deleteSpellingCache();
89 }
90
91 $addStatement = $this->buildAddIndexStatementFromParts($tableName, $spec['name'], $spec['columns'], $spec['unique']);
92 $this->dbCore->queryAndGetResults($addStatement);
93 $this->logger->infoMessage("I added an index: " . $addStatement);
94 }
95 }
96
97 /**
98 * @param string $tableName
99 * @param string $indexName
100 * @return bool
101 */
102 private function indexExists($tableName, $indexName) {
103 global $wpdb;
104 $sql = $wpdb->prepare("SHOW INDEX FROM {$tableName} WHERE Key_name = %s", $indexName);
105 // DAO-bypass-approved: indexExists() schema-introspection helper (already prepared); DDL pre-check before ALTER TABLE
106 $results = $wpdb->get_results($sql, ARRAY_A);
107 return !empty($results);
108 }
109
110 /**
111 * Parse an index DDL line from our CREATE TABLE SQL into a structured spec.
112 *
113 * Accepts forms like:
114 * - KEY `name` (`col`(190), `other`)
115 * - UNIQUE KEY `name` (`col`)
116 * - KEY `name` (`col`) USING BTREE
117 *
118 * Returns null if the line doesn't look like a KEY/UNIQUE KEY definition.
119 *
120 * @param string $indexDDL
121 * @return array{name: string, columns: string, unique: bool}|null
122 */
123 private function parseIndexDDLToSpec($indexDDL) {
124 $indexDDL = trim($indexDDL);
125 // Tolerate a trailing comma — the line-extracting regex pulls each
126 // KEY definition out as-is from the surrounding CREATE TABLE list,
127 // and any KEY that isn't the LAST one will end with a comma. Same
128 // canonical form either way.
129 $indexDDL = rtrim($indexDDL, ',');
130 $matches = [];
131 if (!preg_match('/^(unique\\s+)?key\\s+`?([^`\\s]+)`?\\s*(\\(.+\\))\\s*(?:using\\s+\\w+)?\\s*$/i', $indexDDL, $matches)) {
132 return null;
133 }
134
135 return [
136 'name' => $matches[2],
137 'columns' => $matches[3],
138 'unique' => !empty($matches[1]),
139 ];
140 }
141
142 /**
143 * Extract index specs from a CREATE TABLE statement (plugin SQL templates).
144 *
145 * @param string $createTableSql
146 * @return array<string, array{name:string, columns:string, unique:bool}> keyed by index name
147 */
148 private function parseIndexSpecsFromCreateTableSql($createTableSql) {
149 if (!is_string($createTableSql) || $createTableSql === '') {
150 return [];
151 }
152
153 $matches = [];
154 preg_match_all('/^\\s*(?:unique\\s+)?key\\s+.+?\\s*$/im', $createTableSql, $matches);
155 $lines = $matches[0];
156
157 $specsByName = [];
158 foreach ($lines as $line) {
159 $spec = $this->parseIndexDDLToSpec($line);
160 if (empty($spec) || empty($spec['name'])) {
161 continue;
162 }
163 $specsByName[$spec['name']] = $spec;
164 }
165
166 return $specsByName;
167 }
168
169 /**
170 * Build a valid ALTER TABLE ... ADD INDEX statement from structured parts.
171 *
172 * @param string $tableName
173 * @param string $indexName
174 * @param string $columnsSql Must include surrounding parentheses, e.g. "(`a`, `b`(190))"
175 * @param bool $unique
176 * @return string
177 */
178 private function buildAddIndexStatementFromParts($tableName, $indexName, $columnsSql, $unique) {
179 global $wpdb;
180 /** @var \wpdb $wpdb */
181 $serverVersion = method_exists($wpdb, 'db_version') ? ($wpdb->db_version() ?: '') : '';
182 $serverInfo = property_exists($wpdb, 'db_server_info') ? ($wpdb->db_server_info ?? '') : '';
183
184 $isMaria = stripos($serverInfo, 'mariadb') !== false || stripos($serverVersion, 'maria') !== false;
185 $cleanedVersion = preg_replace('/[^\d\.]/', '', $serverVersion) ?? '';
186 $supportsIfNotExists = $isMaria && version_compare($cleanedVersion, '10.5', '>=');
187
188 $indexType = $unique ? 'unique index' : 'index';
189 $ifNotExists = $supportsIfNotExists ? ' if not exists' : '';
190
191 return "alter table " . $tableName . " add " . $indexType . $ifNotExists . " `" . $indexName . "` " . trim($columnsSql);
192 }
193
194 /**
195 * @param string $logsTable
196 * @param string|null $createSqlOverride
197 * @return void
198 */
199 private function ensureLogsCompositeIndex($logsTable, $createSqlOverride = null) {
200 $indexName = 'idx_requested_url_timestamp';
201 $createSql = is_string($createSqlOverride) ? $createSqlOverride : ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/createLogTable.sql");
202 $specsByName = $this->parseIndexSpecsFromCreateTableSql($createSql);
203 $spec = $specsByName[$indexName] ?? null;
204 if (empty($spec)) {
205 $this->logger->errorMessage("Failed to add {$indexName} to {$logsTable}: index definition not found in createLogTable.sql");
206 return;
207 }
208
209 if ($this->indexExists($logsTable, $indexName)) {
210 return;
211 }
212 $query = $this->buildAddIndexStatementFromParts($logsTable, $spec['name'], $spec['columns'], $spec['unique']);
213 $results = $this->dbCore->queryAndGetResults($query);
214 if (!empty($results['last_error'])) {
215 $this->logger->errorMessage("Failed to add {$indexName} to {$logsTable}: " . $results['last_error'] . " (query: {$query})");
216 } else {
217 $this->logger->infoMessage("Added {$indexName} to {$logsTable} using query: {$query}");
218 }
219 }
220
221 /**
222 * Add the canonical_url column to logsv2 with online DDL when supported.
223 *
224 * Mirrors ensureLogsCompositeIndex(): a small idempotent helper that runs
225 * ahead of the generic verifyColumns() flow so the column add can use
226 * ALGORITHM=INPLACE, LOCK=NONE on InnoDB ≥ 5.6 (no table lock during the
227 * rewrite). On engines that don't support online DDL for ADD COLUMN the
228 * explicit clause causes the statement to fail with
229 * ER_ALTER_OPERATION_NOT_SUPPORTED; we then fall back to a bare ALTER —
230 * which is what verifyColumns() also runs as the safety net.
231 *
232 * The matching idx_canonical_url is added by the standard verifyIndexes()
233 * flow — index adds use online DDL by default on InnoDB ≥ 5.6 so a
234 * separate ensure helper isn't required for the index.
235 *
236 * @param string $logsTable
237 * @return void
238 */
239 private function ensureLogsv2CanonicalUrlColumn(string $logsTable): void {
240 if ($this->columnExists($logsTable, 'canonical_url')) {
241 return;
242 }
243 $inplaceQuery = "ALTER TABLE " . $logsTable .
244 " ADD COLUMN `canonical_url` VARCHAR(2048) DEFAULT NULL," .
245 " ALGORITHM=INPLACE, LOCK=NONE";
246 $result = $this->dbCore->queryAndGetResults($inplaceQuery,
247 array('log_too_slow' => false, 'log_errors' => false));
248 if (empty($result['last_error'])) {
249 $this->logger->infoMessage("Added canonical_url to {$logsTable} (ALGORITHM=INPLACE, LOCK=NONE).");
250 return;
251 }
252 // Engine didn't support online DDL for ADD COLUMN — bare ALTER falls
253 // back to whatever algorithm the engine picks (COPY on MyISAM / very
254 // old InnoDB). On modern InnoDB the bare ALTER is itself implicitly
255 // INPLACE for ADD COLUMN ... DEFAULT NULL, so this branch only runs
256 // on legacy engines where some lock is unavoidable.
257 $bareQuery = "ALTER TABLE " . $logsTable .
258 " ADD COLUMN `canonical_url` VARCHAR(2048) DEFAULT NULL";
259 $bare = $this->dbCore->queryAndGetResults($bareQuery,
260 array('log_too_slow' => false));
261 if (empty($bare['last_error'])) {
262 $this->logger->infoMessage("Added canonical_url to {$logsTable} (bare ALTER fallback).");
263 }
264 }
265
266 /**
267 * Add the canonical_url column to the redirects table with online DDL
268 * when supported.
269 *
270 * Sibling of ensureLogsv2CanonicalUrlColumn() applied to the redirects
271 * side. The column shipped in 4.1.11 and is normally added by dbDelta
272 * on plugin update. On hosts where dbDelta silently fails to ALTER ADD
273 * it, every captured-404 INSERT errors out with "Unknown column
274 * 'canonical_url' in 'field list'" until verifyColumns eventually
275 * retries the column add. One site in the May 10 debug zip emitted
276 * 1671 such errors over 10 days on 4.1.12. Calling this helper eagerly
277 * from runInitialCreateTables() shortens that window: every cron tick
278 * that runs the bootstrap loop retries the ALTER on its own,
279 * independent of the verifyColumns DDL diff path.
280 *
281 * @param string $redirectsTable
282 * @return void
283 */
284 private function ensureRedirectsCanonicalUrlColumn(string $redirectsTable): void {
285 if ($this->columnExists($redirectsTable, 'canonical_url')) {
286 return;
287 }
288 $inplaceQuery = "ALTER TABLE " . $redirectsTable .
289 " ADD COLUMN `canonical_url` VARCHAR(2048) DEFAULT NULL," .
290 " ALGORITHM=INPLACE, LOCK=NONE";
291 $result = $this->dbCore->queryAndGetResults($inplaceQuery,
292 array('log_too_slow' => false, 'log_errors' => false));
293 if (empty($result['last_error'])) {
294 $this->logger->infoMessage("Added canonical_url to {$redirectsTable} (ALGORITHM=INPLACE, LOCK=NONE).");
295 return;
296 }
297 $bareQuery = "ALTER TABLE " . $redirectsTable .
298 " ADD COLUMN `canonical_url` VARCHAR(2048) DEFAULT NULL";
299 $bare = $this->dbCore->queryAndGetResults($bareQuery,
300 array('log_too_slow' => false));
301 if (empty($bare['last_error'])) {
302 $this->logger->infoMessage("Added canonical_url to {$redirectsTable} (bare ALTER fallback).");
303 }
304 }
305 }
306