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

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

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