| 1 |
<?php |
| 2 |
|
| 3 |
if (!defined('ABSPATH')) { |
| 4 |
exit; |
| 5 |
} |
| 6 |
|
| 7 |
/** |
| 8 |
* Lowercase-rename maintenance operation for plugin tables. |
| 9 |
* |
| 10 |
* Owns making all plugin table names lowercase (for hosts running with |
| 11 |
* lower_case_table_names=0), and detecting/adopting orphaned plugin tables |
| 12 |
* left under old prefixes (from site migrations or the rename bug in |
| 13 |
* v2.35.16 through v3.x). This is a prefix-based, tenant-isolation-aware |
| 14 |
* flow: on multisite, a sibling subsite owns and lowercases its own tables, |
| 15 |
* so a table under a DIFFERENT active blog's prefix is left alone; a |
| 16 |
* genuinely orphaned prefix (no live blog) is renamed and handed to the |
| 17 |
* orphan-adoption collaborator to recover. |
| 18 |
* |
| 19 |
* Extracted from the table-bootstrap orchestrator (DatabaseUpgradeBootstrap) |
| 20 |
* because table-bootstrap orchestration and the lowercase-rename maintenance |
| 21 |
* operation are distinct concerns: this one runs unconditionally on every |
| 22 |
* createDatabaseTables() pass, has its own MySQL-variable-driven skip path, |
| 23 |
* and reaches into information_schema directly rather than the plugin's own |
| 24 |
* tables. |
| 25 |
* |
| 26 |
* Collaborator of ABJ_404_Solution_DatabaseUpgradeBootstrap, which constructs |
| 27 |
* it fresh on every call (never cached) with the upgrade coordinator, the |
| 28 |
* current DB core / logger, and a pre-computed active-blog-prefix set, so it |
| 29 |
* always observes whichever dependencies are current at call time (dbCore / |
| 30 |
* logger can be swapped at runtime via replaceDatabaseUpgradeDependencies() |
| 31 |
* on the owning component). |
| 32 |
*/ |
| 33 |
class ABJ_404_Solution_DatabaseTableLowercaseRenamer { |
| 34 |
|
| 35 |
/** @var ABJ_404_Solution_DatabaseUpgradeCoordinator */ |
| 36 |
private $coordinator; |
| 37 |
|
| 38 |
/** @var ABJ_404_Solution_DatabaseCore */ |
| 39 |
private $dbCore; |
| 40 |
|
| 41 |
/** @var ABJ_404_Solution_Logging */ |
| 42 |
private $logger; |
| 43 |
|
| 44 |
/** @var array<int, string> */ |
| 45 |
private $activeBlogPrefixesLowercase; |
| 46 |
|
| 47 |
/** |
| 48 |
* @param ABJ_404_Solution_DatabaseUpgradeCoordinator $coordinator |
| 49 |
* @param ABJ_404_Solution_DatabaseCore $dbCore Deliberately untyped at the |
| 50 |
* PHP level (matching ABJ_404_Solution_DatabaseUpgradeComponent::$dbCore): |
| 51 |
* this collaborator needs methods from both DatabaseCoreInterface |
| 52 |
* (tableNameResolver) and DatabaseQueryInterface (queryAndGetResults), |
| 53 |
* and PHP 7.4 (the plugin's floor version) has no intersection types. |
| 54 |
* Test doubles (e.g. Abj404NullDatabaseCore) implement those interfaces |
| 55 |
* without extending the concrete class, so a native type hint here would |
| 56 |
* break them. |
| 57 |
* @param ABJ_404_Solution_Logging $logger |
| 58 |
* @param array<int, string> $activeBlogPrefixesLowercase Lowercased table |
| 59 |
* prefixes of every active site on the network (or just the current |
| 60 |
* prefix on single-site), computed once by the caller before |
| 61 |
* construction since it's a pure read with no per-call state. |
| 62 |
*/ |
| 63 |
public function __construct(ABJ_404_Solution_DatabaseUpgradeCoordinator $coordinator, |
| 64 |
$dbCore, ABJ_404_Solution_Logging $logger, |
| 65 |
array $activeBlogPrefixesLowercase) { |
| 66 |
$this->coordinator = $coordinator; |
| 67 |
$this->dbCore = $dbCore; |
| 68 |
$this->logger = $logger; |
| 69 |
$this->activeBlogPrefixesLowercase = $activeBlogPrefixesLowercase; |
| 70 |
} |
| 71 |
|
| 72 |
/** Makes all plugin table names lowercase, in case someone thought it was funny to use |
| 73 |
* the lower_case_table_names=0 setting. Also detects and adopts orphaned plugin tables |
| 74 |
* under old prefixes (from site migrations or the rename bug in v2.35.16 through v3.x). |
| 75 |
* @return void |
| 76 |
*/ |
| 77 |
public function rename() { |
| 78 |
global $wpdb; |
| 79 |
|
| 80 |
// On case-insensitive MySQL (lower_case_table_names >= 1), table names |
| 81 |
// are already treated as lowercase internally. Renaming is pointless and |
| 82 |
// can cause issues on some hosting setups. |
| 83 |
// DAO-bypass-approved: Schema-bootstrap inside renameAbj404TablesToLowerCase(). Runs before plugin DAO is fully wired during DB upgrades. |
| 84 |
$lctnResult = $wpdb->get_row("SHOW VARIABLES LIKE 'lower_case_table_names'", ARRAY_A); |
| 85 |
if (is_array($lctnResult)) { |
| 86 |
$lctnValue = null; |
| 87 |
foreach ($lctnResult as $key => $value) { |
| 88 |
if (strtolower((string)$key) === 'value') { |
| 89 |
$lctnValue = $value; |
| 90 |
break; |
| 91 |
} |
| 92 |
} |
| 93 |
if (is_scalar($lctnValue) && (int)$lctnValue >= 1) { |
| 94 |
// MySQL already handles table names case-insensitively. |
| 95 |
// Still run adoption check in case of prefix mismatch. |
| 96 |
$this->coordinator->orphanAdoptionUpgrade()->adoptOrphanedTables(); |
| 97 |
return; |
| 98 |
} |
| 99 |
} |
| 100 |
|
| 101 |
// Fetch all tables containing "abj404", case-insensitive |
| 102 |
$dbNameRaw = $wpdb->dbname ?? ''; |
| 103 |
if ($dbNameRaw === '') { |
| 104 |
$this->logger->warn("Could not determine database name for lowercase rename."); |
| 105 |
return; |
| 106 |
} |
| 107 |
$dbNameEscaped = esc_sql($dbNameRaw); |
| 108 |
$dbName = is_array($dbNameEscaped) ? '' : $dbNameEscaped; |
| 109 |
$query = "SELECT table_name |
| 110 |
FROM information_schema.tables |
| 111 |
WHERE table_schema = '{$dbName}' |
| 112 |
AND LOWER(table_name) LIKE '%abj404%'"; |
| 113 |
$results = $this->dbCore->queryAndGetResults($query); |
| 114 |
|
| 115 |
if (!is_array($results['rows'])) { |
| 116 |
$this->logger->warn("Could not query information_schema tables for lowercase rename."); |
| 117 |
return; |
| 118 |
} |
| 119 |
|
| 120 |
// Tenant isolation: a sibling subsite owns and lowercases its own |
| 121 |
// tables. Never rename tables that belong to a DIFFERENT active |
| 122 |
// multisite blog (same whole-schema-scan class as the orphan-adoption |
| 123 |
// finding). A migrated old prefix with no live blog is absent from the |
| 124 |
// active-blog set and is still renamed so adoption can recover it. |
| 125 |
$currentPrefix = $this->dbCore->tableNameResolver()->getLowercasePrefix(); |
| 126 |
$activeBlogPrefixes = $this->activeBlogPrefixesLowercase; |
| 127 |
|
| 128 |
foreach ($results['rows'] as $row) { |
| 129 |
if (!is_array($row)) { |
| 130 |
continue; |
| 131 |
} |
| 132 |
// Case-insensitive key lookup: MySQL drivers return information_schema |
| 133 |
// column names in varying cases (table_name, TABLE_NAME, Table_Name). |
| 134 |
$tableName = null; |
| 135 |
foreach ($row as $key => $value) { |
| 136 |
if (strtolower((string)$key) === 'table_name' && is_scalar($value)) { |
| 137 |
$tableName = (string)$value; |
| 138 |
break; |
| 139 |
} |
| 140 |
} |
| 141 |
|
| 142 |
if (!empty($tableName)) { |
| 143 |
$lowercaseName = strtolower($tableName); |
| 144 |
|
| 145 |
// Check if the table name is already lowercase, skip if it is |
| 146 |
if ($tableName !== $lowercaseName) { |
| 147 |
$tablePrefix = $this->prefixOfAbj404Table($lowercaseName); |
| 148 |
if ($tablePrefix !== null && $tablePrefix !== $currentPrefix |
| 149 |
&& in_array($tablePrefix, $activeBlogPrefixes, true)) { |
| 150 |
// Belongs to a different active subsite. Leave it alone. |
| 151 |
continue; |
| 152 |
} |
| 153 |
// Rename the table to lowercase |
| 154 |
$renameQuery = "RENAME TABLE `{$tableName}` TO `{$lowercaseName}`"; |
| 155 |
$this->dbCore->queryAndGetResults($renameQuery, |
| 156 |
['ignore_errors' => ["already exists"]]); |
| 157 |
$this->logger->infoMessage("Renamed table {$tableName} to {$lowercaseName}\n"); |
| 158 |
} |
| 159 |
} else { |
| 160 |
$this->logger->warn("I didn't find a table name in the results of this row: " . |
| 161 |
print_r($row, true)); |
| 162 |
} |
| 163 |
} |
| 164 |
|
| 165 |
// After renaming, check for orphaned tables under old prefixes. |
| 166 |
$this->coordinator->orphanAdoptionUpgrade()->adoptOrphanedTables(); |
| 167 |
} |
| 168 |
|
| 169 |
/** |
| 170 |
* Extract the table prefix from a lowercase plugin table name: everything |
| 171 |
* before the first "abj404" segment (e.g. "wp_2_abj404_redirects" -> "wp_2_"). |
| 172 |
* Returns null when the name has no abj404 segment. |
| 173 |
* |
| 174 |
* @param string $lowercaseTableName |
| 175 |
* @return string|null |
| 176 |
*/ |
| 177 |
private function prefixOfAbj404Table(string $lowercaseTableName): ?string { |
| 178 |
$pos = strpos($lowercaseTableName, 'abj404'); |
| 179 |
if ($pos === false) { |
| 180 |
return null; |
| 181 |
} |
| 182 |
$prefix = substr($lowercaseTableName, 0, $pos); |
| 183 |
return is_string($prefix) ? $prefix : null; |
| 184 |
} |
| 185 |
} |
| 186 |
|