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 / DatabaseUpgradeOrphanAdoption.php

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

467 lines 15.4 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 require_once __DIR__ . '/../DatabaseCollationHelper.php';
8
9 class ABJ_404_Solution_DatabaseUpgradeOrphanAdoption extends ABJ_404_Solution_DatabaseUpgradeComponent {
10
11 /**
12 * Detect orphaned plugin tables under old prefixes and adopt their data
13 * into the current-prefix tables. Uses slug verification against the logs
14 * table to confirm ownership before adopting.
15 *
16 * @return void
17 */
18 public function adoptOrphanedTables(): void {
19 global $wpdb;
20
21 $dbNameRaw = $wpdb->dbname ?? '';
22 if ($dbNameRaw === '') {
23 return;
24 }
25 // @utf8-audit: opt-out — $wpdb->dbname is set by WordPress at
26 // bootstrap from wp-config.php; never user input.
27 $dbNameEscaped = esc_sql($dbNameRaw);
28 $dbName = is_array($dbNameEscaped) ? '' : $dbNameEscaped;
29
30 // Find all abj404 tables in the database, grouped by prefix.
31 $query = "SELECT table_name
32 FROM information_schema.tables
33 WHERE table_schema = '{$dbName}'
34 AND LOWER(table_name) LIKE '%abj404\\_%'";
35 $results = $this->dbCore->queryAndGetResults($query);
36
37 if (!is_array($results['rows']) || empty($results['rows'])) {
38 return;
39 }
40
41 $currentPrefix = $this->dbCore->tableNameResolver()->getLowercasePrefix();
42
43 // Group tables by their prefix (everything before 'abj404_').
44 $tablesByPrefix = $this->groupAbj404TablesByPrefix($results['rows']);
45
46 // Skip prefixes we've already adopted.
47 $adoptedPrefixes = get_option('abj404_adopted_prefixes', array());
48 if (!is_array($adoptedPrefixes)) {
49 $adoptedPrefixes = array();
50 }
51
52 // Authoritative tenant-isolation boundary: any prefix that belongs to an
53 // active multisite blog (a sibling subsite) is NEVER an orphan to adopt,
54 // regardless of how well its content happens to match this site. The
55 // slug-match heuristic below is only a tie-breaker among genuine
56 // migration leftovers, never the authorization boundary.
57 $activeBlogPrefixes = $this->getActiveBlogPrefixesLowercase();
58
59 // Process each OLD prefix (not the current one).
60 foreach ($tablesByPrefix as $oldPrefix => $tables) {
61 $oldPrefix = (string)$oldPrefix;
62 if ($oldPrefix === $currentPrefix) {
63 continue;
64 }
65 if (in_array($oldPrefix, $adoptedPrefixes, true)) {
66 continue;
67 }
68 if (in_array($oldPrefix, $activeBlogPrefixes, true)) {
69 $this->logger->infoMessage(
70 "Orphaned tables under prefix '{$oldPrefix}' belong to an active "
71 . "multisite blog (sibling subsite). Skipping adoption to preserve "
72 . "tenant isolation."
73 );
74 continue;
75 }
76
77 $this->logger->infoMessage(
78 "Found orphaned plugin tables under prefix '{$oldPrefix}' "
79 . "(current prefix is '{$currentPrefix}'): " . implode(', ', $tables)
80 );
81
82 // Check if old tables have any data at all.
83 $totalRows = $this->countOldPrefixRows($oldPrefix, $tables);
84 if ($totalRows === 0) {
85 $this->logger->infoMessage(
86 "Orphaned tables under prefix '{$oldPrefix}' are all empty. Skipping adoption."
87 );
88 continue;
89 }
90
91 // Verify ownership via logs dest_url slug matching.
92 $matchResult = $this->verifyOwnershipViaLogs($oldPrefix);
93
94 if ($matchResult === null) {
95 // Logs verification returned no data — fall back to redirects post-ID check.
96 $matchResult = $this->verifyOwnershipViaRedirects($oldPrefix);
97 }
98
99 if ($matchResult !== true) {
100 // false = data doesn't match this site; null = insufficient data to verify.
101 // Either way, do not adopt — absence of veto is not permission.
102 $reason = ($matchResult === false)
103 ? "Data does not appear to belong to this site."
104 : "Insufficient data in logs and redirects to verify ownership.";
105 $this->logger->infoMessage(
106 "Orphaned tables under prefix '{$oldPrefix}' — skipping adoption. {$reason}"
107 );
108 continue;
109 }
110
111 // Ownership positively verified. Adopt the data.
112 $this->adoptDataFromPrefix($oldPrefix, $currentPrefix, $tables);
113 }
114 }
115
116 /**
117 * Parse information_schema rows into a prefix => [lowercase table name, ...]
118 * map. Each table name is lowercased and grouped by the substring before its
119 * 'abj404_' segment; rows without a recognizable table_name or abj404_
120 * segment are skipped. Extracted from adoptOrphanedTables() so that method's
121 * branching stays within the cyclomatic-complexity budget.
122 *
123 * @param array<array-key, mixed> $rows
124 * @return array<string, array<int, string>>
125 */
126 private function groupAbj404TablesByPrefix(array $rows): array {
127 $tablesByPrefix = [];
128 foreach ($rows as $row) {
129 if (!is_array($row)) {
130 continue;
131 }
132 $tableName = null;
133 foreach ($row as $key => $value) {
134 if (strtolower((string)$key) === 'table_name' && is_scalar($value)) {
135 $tableName = strtolower((string)$value);
136 break;
137 }
138 }
139 if ($tableName === null) {
140 continue;
141 }
142
143 $abj404Pos = strpos($tableName, 'abj404_');
144 if ($abj404Pos === false) {
145 continue;
146 }
147
148 $prefix = substr($tableName, 0, $abj404Pos);
149 if (!is_string($prefix)) {
150 continue;
151 }
152 $tablesByPrefix[$prefix][] = $tableName;
153 }
154 return $tablesByPrefix;
155 }
156
157 /**
158 * Count total rows across all known plugin tables for a given prefix.
159 *
160 * @param string $oldPrefix
161 * @param array<int, string> $knownTables Table names actually found in information_schema.
162 * @return int
163 */
164 private function countOldPrefixRows(string $oldPrefix, array $knownTables): int {
165 $total = 0;
166 foreach ($this->getPluginTableSuffixes() as $suffix) {
167 $tableName = $oldPrefix . $suffix;
168 if (!in_array($tableName, $knownTables, true)) {
169 continue;
170 }
171 $result = $this->dbCore->queryAndGetResults(
172 "SELECT COUNT(*) AS cnt FROM `{$tableName}`",
173 ['ignore_errors' => ["doesn't exist", "not found"]]
174 );
175 if (is_array($result['rows']) && !empty($result['rows'])) {
176 $row = $result['rows'][0];
177 $countValue = is_array($row) ? ($row['cnt'] ?? $row['CNT'] ?? 0) : 0;
178 $cnt = is_numeric($countValue) ? (int)$countValue : 0;
179 $total += $cnt;
180 }
181 }
182 return $total;
183 }
184
185 /**
186 * Verify ownership of orphaned tables by matching logs dest_url against
187 * current site's published post slugs.
188 *
189 * @param string $oldPrefix The old table prefix.
190 * @return bool|null true = verified, false = failed, null = no data to verify.
191 */
192 private function verifyOwnershipViaLogs(string $oldPrefix): ?bool {
193 global $wpdb;
194 $logsTable = $oldPrefix . 'abj404_logsv2';
195 // WordPress core posts table uses the original $wpdb->prefix (possibly mixed-case),
196 // NOT our lowercased prefix. Only plugin tables were renamed to lowercase.
197 $postsTable = ($wpdb->prefix ?? 'wp_') . 'posts';
198
199 // p.post_name belongs to a WordPress CORE table and dest_url to a PLUGIN
200 // table, so each carries whatever collation its own table was created
201 // with. When those differ MySQL refuses the comparison outright
202 // ("Illegal mix of collations ... for operation 'locate'", errno 1267),
203 // the query returns nothing, ownership is never verified, and the
204 // site's orphaned tables stay stranded under the old prefix. Converting
205 // BOTH operands to one charset and one collation is the same treatment
206 // updatePermalinkCache.sql and getPublishedPagesAndPostsIDs.sql already
207 // give their wp_posts / wp_terms comparisons; this query is built
208 // inline, which is how it missed out. The collation comes from the one
209 // derivation that guarantees it is valid for the charset beside it.
210 $collation = ABJ_404_Solution_DatabaseCollationHelper::utf8mb4CollationOrFallback(
211 isset($wpdb->collate) && is_scalar($wpdb->collate) ? (string)$wpdb->collate : ''
212 );
213 $postNameOperand = "CONVERT(p.post_name USING utf8mb4) COLLATE " . $collation;
214 $destUrlOperand = "CONVERT(dest_url USING utf8mb4) COLLATE " . $collation;
215
216 // Check distinct internal dest_urls against published post slugs.
217 $query = "SELECT COUNT(*) AS total,
218 SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) AS matches
219 FROM (
220 SELECT DISTINCT dest_url,
221 EXISTS(SELECT 1 FROM `{$postsTable}` p
222 WHERE p.post_status = 'publish'
223 AND LENGTH(p.post_name) >= 3
224 AND LOCATE({$postNameOperand}, {$destUrlOperand}) > 0) AS matched
225 FROM `{$logsTable}` l
226 WHERE dest_url IS NOT NULL
227 AND dest_url != ''
228 AND dest_url != '404'
229 AND dest_url NOT LIKE 'http://%'
230 AND dest_url NOT LIKE 'https://%'
231 LIMIT 500
232 ) sub";
233
234 $result = $this->dbCore->queryAndGetResults($query,
235 ['ignore_errors' => ["doesn't exist", "not found"]]);
236
237 if (!is_array($result['rows']) || empty($result['rows'])) {
238 return null;
239 }
240
241 $row = is_array($result['rows'][0] ?? null) ? $result['rows'][0] : [];
242 $total = 0;
243 $matches = 0;
244 foreach ($row as $key => $value) {
245 $lk = strtolower((string)$key);
246 if ($lk === 'total' && is_numeric($value)) { $total = (int)$value; }
247 if ($lk === 'matches' && is_numeric($value)) { $matches = (int)$value; }
248 }
249
250 if ($total === 0) {
251 return null; // No internal dest_urls to verify.
252 }
253
254 $matchPct = ($matches / max(1, $total)) * 100;
255 $this->logger->infoMessage(
256 "Logs ownership verification for prefix '{$oldPrefix}': "
257 . "{$matches}/{$total} distinct internal dest_urls match published post slugs "
258 . "({$matchPct}%)"
259 );
260
261 return $matchPct >= 80;
262 }
263
264 /**
265 * Fallback ownership verification using redirects table post-ID existence.
266 * Weaker than slug matching but useful when logs have no internal dest_urls.
267 *
268 * @param string $oldPrefix
269 * @return bool|null true = verified, false = failed, null = no data.
270 */
271 private function verifyOwnershipViaRedirects(string $oldPrefix): ?bool {
272 global $wpdb;
273 $redirectsTable = $oldPrefix . 'abj404_redirects';
274 $postsTable = ($wpdb->prefix ?? 'wp_') . 'posts';
275
276 $query = "SELECT COUNT(*) AS total,
277 SUM(CASE WHEN p.ID IS NOT NULL THEN 1 ELSE 0 END) AS matches
278 FROM `{$redirectsTable}` r
279 LEFT JOIN `{$postsTable}` p
280 ON p.ID = CAST(r.final_dest AS UNSIGNED)
281 AND p.post_status IN ('publish', 'draft', 'private')
282 WHERE r.type IN (1, 2, 3)";
283
284 $result = $this->dbCore->queryAndGetResults($query,
285 ['ignore_errors' => ["doesn't exist", "not found"]]);
286
287 if (!is_array($result['rows']) || empty($result['rows'])) {
288 return null;
289 }
290
291 $row = is_array($result['rows'][0] ?? null) ? $result['rows'][0] : [];
292 $total = 0;
293 $matches = 0;
294 foreach ($row as $key => $value) {
295 $lk = strtolower((string)$key);
296 if ($lk === 'total' && is_numeric($value)) { $total = (int)$value; }
297 if ($lk === 'matches' && is_numeric($value)) { $matches = (int)$value; }
298 }
299
300 if ($total === 0) {
301 return null;
302 }
303
304 $matchPct = ($matches / max(1, $total)) * 100;
305 $this->logger->infoMessage(
306 "Redirects fallback ownership verification for prefix '{$oldPrefix}': "
307 . "{$matches}/{$total} type 1/2/3 redirects point to existing posts ({$matchPct}%)"
308 );
309
310 return $matchPct >= 80;
311 }
312
313 /**
314 * Adopt data from orphaned tables under an old prefix into current-prefix tables.
315 * Uses INSERT IGNORE to avoid duplicate key conflicts.
316 *
317 * @param string $oldPrefix
318 * @param string $currentPrefix
319 * @param array<string> $knownTables
320 * @return void
321 */
322 private function adoptDataFromPrefix(string $oldPrefix, string $currentPrefix, array $knownTables): void {
323 $this->logger->infoMessage(
324 "Beginning adoption of data from prefix '{$oldPrefix}' to '{$currentPrefix}'"
325 );
326
327 $totalAdopted = 0;
328
329 foreach ($this->getPluginTableSuffixes() as $suffix) {
330 $oldTable = $oldPrefix . $suffix;
331 if (!in_array($oldTable, $knownTables, true)) {
332 continue;
333 }
334 $newTable = $currentPrefix . $suffix;
335
336 // Check if old table exists and has rows.
337 $countResult = $this->dbCore->queryAndGetResults(
338 "SELECT COUNT(*) AS cnt FROM `{$oldTable}`",
339 ['ignore_errors' => ["doesn't exist", "not found"]]
340 );
341 if (!is_array($countResult['rows']) || empty($countResult['rows'])) {
342 continue;
343 }
344 $row = $countResult['rows'][0];
345 $countValue = is_array($row) ? ($row['cnt'] ?? $row['CNT'] ?? 0) : 0;
346 $oldCount = is_numeric($countValue) ? (int)$countValue : 0;
347 if ($oldCount === 0) {
348 continue;
349 }
350
351 // Check if new table exists (it should — auto-repair creates them).
352 $newExists = $this->dbCore->queryAndGetResults(
353 "SELECT 1 FROM `{$newTable}` LIMIT 1",
354 ['ignore_errors' => ["doesn't exist", "not found"]]
355 );
356 if (!empty($newExists['last_error'])) {
357 $this->logger->infoMessage(
358 "Target table '{$newTable}' does not exist yet. Skipping adoption for '{$suffix}'."
359 );
360 continue;
361 }
362
363 // Build a column-matched INSERT to handle schema drift between old and new tables.
364 // Old tables from older plugin versions may have fewer or different columns.
365 $commonColumns = $this->getCommonColumns($oldTable, $newTable);
366 if (empty($commonColumns)) {
367 $this->logger->infoMessage(
368 "No common columns found between '{$oldTable}' and '{$newTable}'. Skipping."
369 );
370 continue;
371 }
372
373 $columnList = implode('`, `', $commonColumns);
374 $insertQuery = "INSERT IGNORE INTO `{$newTable}` (`{$columnList}`) "
375 . "SELECT `{$columnList}` FROM `{$oldTable}`";
376 $insertResult = $this->dbCore->queryAndGetResults($insertQuery,
377 ['ignore_errors' => ["doesn't exist", "not found", "Duplicate"]]);
378
379 $affectedRows = 0;
380 if (is_array($insertResult) && isset($insertResult['rows_affected'])) {
381 $rawAffected = $insertResult['rows_affected'];
382 $affectedRows = is_numeric($rawAffected) ? (int)$rawAffected : 0;
383 }
384
385 if ($affectedRows > 0) {
386 $totalAdopted += $affectedRows;
387 $this->logger->infoMessage(
388 "Adopted {$affectedRows} rows from '{$oldTable}' into '{$newTable}'"
389 );
390 }
391 }
392
393 $this->logger->infoMessage(
394 "Adoption complete: {$totalAdopted} total rows adopted from prefix '{$oldPrefix}' to '{$currentPrefix}'"
395 );
396
397 // Record this prefix as adopted so we don't re-detect it on every page load.
398 $adoptedPrefixes = get_option('abj404_adopted_prefixes', array());
399 if (!is_array($adoptedPrefixes)) {
400 $adoptedPrefixes = array();
401 }
402 if (!in_array($oldPrefix, $adoptedPrefixes, true)) {
403 $adoptedPrefixes[] = $oldPrefix;
404 update_option('abj404_adopted_prefixes', $adoptedPrefixes, false);
405 }
406 }
407
408 /**
409 * Get the list of column names that exist in both tables.
410 * Used by adoptDataFromPrefix() to build column-matched INSERTs
411 * that survive schema drift between plugin versions.
412 *
413 * @param string $tableA
414 * @param string $tableB
415 * @return array<int, string> Column names present in both tables (lowercase).
416 */
417 private function getCommonColumns(string $tableA, string $tableB): array {
418 $colsA = $this->getTableColumns($tableA);
419 $colsB = $this->getTableColumns($tableB);
420
421 if (empty($colsA) || empty($colsB)) {
422 return [];
423 }
424
425 return array_values(array_intersect($colsA, $colsB));
426 }
427
428 /**
429 * Get column names for a table via SHOW COLUMNS.
430 *
431 * @param string $tableName
432 * @return array<int, string> Column names (lowercase).
433 */
434 private function getTableColumns(string $tableName): array {
435 $result = $this->dbCore->queryAndGetResults(
436 "SHOW COLUMNS FROM `{$tableName}`",
437 ['ignore_errors' => ["doesn't exist", "not found"]]
438 );
439
440 if (!is_array($result['rows']) || empty($result['rows'])) {
441 return [];
442 }
443
444 $columns = [];
445 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : [];
446 foreach ($rows as $row) {
447 if (!is_array($row)) {
448 continue;
449 }
450 // SHOW COLUMNS returns 'Field' key — case-insensitive lookup.
451 $colName = null;
452 foreach ($row as $key => $value) {
453 if (strtolower((string)$key) === 'field' && is_scalar($value)) {
454 $colName = strtolower((string)$value);
455 break;
456 }
457 }
458 if ($colName !== null) {
459 $columns[] = $colName;
460 }
461 }
462
463 return $columns;
464 }
465
466 }
467