PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.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 / database / DatabaseCollationHelper.php

DatabaseCollationHelper.php in 404 Solution 4.3.0, at includes/database/DatabaseCollationHelper.php

313 lines 12.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 /**
8 * Resolves and reconciles MySQL collation for plugin tables and columns.
9 *
10 * Extracted from DatabaseCore as part of the (3/6) DatabaseCore decomposition.
11 * Owns four cohesive responsibilities that all turn on knowing the effective
12 * collation of plugin tables/columns so cross-collation comparisons do not
13 * blow up plugin queries:
14 *
15 * 1. Sanitizing raw collation identifiers (strip non-word characters so they
16 * are safe to interpolate in SQL).
17 * 2. Discovering the effective table-level and column-level collation via
18 * SHOW CREATE TABLE and information_schema, with safe fallbacks.
19 * 3. Resolving the preferred utf8mb4 collation from the wpdb connection.
20 * 4. Auto-recovering from a collation-mismatch error at query time: under a
21 * static recursion guard plus a 1-hour cooldown, run correctCollations()
22 * and retry the failing query.
23 *
24 * This class holds no DatabaseCore back-reference. It receives:
25 * - a query-runner callable bound over DatabaseCore::queryAndGetResults
26 * (signature: function(string, array<string,mixed>): array<string,mixed>);
27 * - a DDL reader callable bound over the table-name resolver
28 * (signature: function(string): string);
29 * - getter/setter callables for runtime flags
30 * (signatures: function(string): mixed, function(string, mixed, int): void);
31 * - a result-harvester callable bound over DatabaseWpdbResultHarvester
32 * (signature: function(array<string,mixed>): void, by-reference);
33 * - the plugin logger and an optional clock.
34 *
35 * The recursion guard is a static property on this class (it must survive across
36 * helper invocations within a single request) and is functionally identical to
37 * the former DatabaseCore::$collationRecoveryInProgress.
38 */
39 class ABJ_404_Solution_DatabaseCollationHelper {
40
41 /** @var int Cooldown after a collation-recovery attempt (seconds). */
42 const COLLATION_RECOVERY_COOLDOWN_SECONDS = 3600;
43
44 /** @var bool Prevent recursive collation auto-recovery within one request. */
45 private static $collationRecoveryInProgress = false;
46
47 /** @var callable(string, array<string,mixed>): array<string,mixed> */
48 private $queryRunner;
49
50 /** @var callable(string): string */
51 private $ddlReader;
52
53 /** @var callable(string): mixed */
54 private $runtimeFlagGetter;
55
56 /** @var callable(string, mixed, int): void */
57 private $runtimeFlagSetter;
58
59 /** @var callable(array<string,mixed>): void */
60 private $resultHarvester;
61
62 /** @var ABJ_404_Solution_Logging */
63 private $logger;
64
65 /** @var ABJ_404_Solution_Clock|null */
66 private $clock;
67
68 /**
69 * @param callable(string, array<string,mixed>): array<string,mixed> $queryRunner
70 * Runs a SQL query through the centralized error-handling pipeline and
71 * returns its result array. Bound by DatabaseCore over queryAndGetResults().
72 * @param callable(string): string $ddlReader
73 * Returns the SHOW CREATE TABLE output for the given table name (or '').
74 * @param callable(string): mixed $runtimeFlagGetter
75 * Returns the current value of a runtime flag (transient with option fallback).
76 * @param callable(string, mixed, int): void $runtimeFlagSetter
77 * Persists a runtime-flag value with a TTL.
78 * @param callable(array<string,mixed>): void $resultHarvester
79 * Copies wpdb->last_error / rows_affected / insert_id into the result array,
80 * by reference. Bound by DatabaseCore over DatabaseWpdbResultHarvester.
81 * @param ABJ_404_Solution_Logging $logger
82 * @param ABJ_404_Solution_Clock|null $clock Optional; lazily resolved when null.
83 */
84 public function __construct(
85 callable $queryRunner,
86 callable $ddlReader,
87 callable $runtimeFlagGetter,
88 callable $runtimeFlagSetter,
89 callable $resultHarvester,
90 $logger,
91 $clock = null
92 ) {
93 $this->queryRunner = $queryRunner;
94 $this->ddlReader = $ddlReader;
95 $this->runtimeFlagGetter = $runtimeFlagGetter;
96 $this->runtimeFlagSetter = $runtimeFlagSetter;
97 $this->resultHarvester = $resultHarvester;
98 $this->logger = $logger;
99 $this->clock = $clock;
100 }
101
102 /**
103 * Reset the static recursion guard. Intended for test setUp/tearDown only.
104 *
105 * @return void
106 */
107 public static function resetRecursionGuardForTests(): void {
108 self::$collationRecoveryInProgress = false;
109 }
110
111 /**
112 * Sanitize a raw collation identifier so it is safe to interpolate in SQL.
113 *
114 * Strips every character that is not [A-Za-z0-9_].
115 *
116 * @param string $collation
117 * @return string
118 */
119 public function sanitizeCollationIdentifier($collation): string {
120 if (!is_string($collation) || $collation === '') {
121 return '';
122 }
123 $sanitized = preg_replace('/[^A-Za-z0-9_]/', '', $collation);
124 return $sanitized !== null ? $sanitized : '';
125 }
126
127 /**
128 * Get the table-level default collation for a given table.
129 *
130 * Queries SHOW CREATE TABLE for the COLLATE clause; falls back to
131 * information_schema.TABLES.TABLE_COLLATION; then to utf8mb4_unicode_ci.
132 * Result is validated through sanitizeCollationIdentifier().
133 *
134 * @param string $tableName Fully-qualified table name (including prefix).
135 * @return string
136 */
137 public function getTableCollationString(string $tableName): string {
138 $fallback = 'utf8mb4_unicode_ci';
139 $ddl = ($this->ddlReader)($tableName);
140 if (preg_match('/COLLATE[= ]([A-Za-z0-9_]+)/i', $ddl, $m)) {
141 $sanitized = $this->sanitizeCollationIdentifier($m[1]);
142 return $sanitized !== '' ? $sanitized : $fallback;
143 }
144 global $wpdb;
145 if (isset($wpdb) && method_exists($wpdb, 'prepare')) {
146 /** @var wpdb $wpdb */
147 $sql = $wpdb->prepare(
148 "SELECT TABLE_COLLATION FROM information_schema.TABLES "
149 . "WHERE TABLE_SCHEMA = DATABASE() "
150 . "AND TABLE_NAME = %s "
151 . "LIMIT 1",
152 $tableName
153 );
154 if (is_string($sql) && $sql !== '') {
155 $result = ($this->queryRunner)($sql, array('log_errors' => false));
156 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
157 if (!empty($rows) && is_array($rows[0])) {
158 $row = array_change_key_case($rows[0]);
159 $collation = $row['table_collation'] ?? '';
160 if (is_string($collation) && $collation !== '') {
161 $sanitized = $this->sanitizeCollationIdentifier($collation);
162 return $sanitized !== '' ? $sanitized : $fallback;
163 }
164 }
165 }
166 }
167 return $fallback;
168 }
169
170 /**
171 * Get the column-level collation for a specific column in a table.
172 *
173 * Queries information_schema.COLUMNS for the COLLATION_NAME. Falls back
174 * to getTableCollationString() if the column query fails, then ultimately
175 * to utf8mb4_unicode_ci. Result is validated through
176 * sanitizeCollationIdentifier().
177 *
178 * @param string $tableName Fully-qualified table name (including prefix).
179 * @param string $columnName Column name to look up.
180 * @return string
181 */
182 public function getColumnCollationString(string $tableName, string $columnName): string {
183 $fallback = 'utf8mb4_unicode_ci';
184 global $wpdb;
185 if (!isset($wpdb) || !method_exists($wpdb, 'prepare')) {
186 return $this->getTableCollationString($tableName);
187 }
188 /** @var wpdb $wpdb */
189 $sql = $wpdb->prepare(
190 "SELECT COLLATION_NAME FROM information_schema.COLUMNS "
191 . "WHERE TABLE_SCHEMA = DATABASE() "
192 . "AND TABLE_NAME = %s "
193 . "AND COLUMN_NAME = %s "
194 . "LIMIT 1",
195 $tableName,
196 $columnName
197 );
198 if (!is_string($sql) || $sql === '') {
199 return $this->getTableCollationString($tableName);
200 }
201 $result = ($this->queryRunner)($sql, array('log_errors' => false));
202 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
203 if (empty($rows) || !is_array($rows[0])) {
204 return $this->getTableCollationString($tableName);
205 }
206 $row = array_change_key_case($rows[0]);
207 $collation = $row['collation_name'] ?? '';
208 if (!is_string($collation) || $collation === '') {
209 return $this->getTableCollationString($tableName);
210 }
211 $sanitized = $this->sanitizeCollationIdentifier($collation);
212 return $sanitized !== '' ? $sanitized : $fallback;
213 }
214
215 /**
216 * Return the preferred utf8mb4 collation for this wpdb connection.
217 *
218 * If wpdb->collate already names a utf8mb4_* collation, use it; otherwise
219 * fall back to utf8mb4_unicode_ci.
220 *
221 * @return string
222 */
223 public function getPreferredUtf8mb4Collation(): string {
224 global $wpdb;
225 if (isset($wpdb) && isset($wpdb->collate) && !empty($wpdb->collate)) {
226 $wpdbCollation = $this->sanitizeCollationIdentifier((string)$wpdb->collate);
227 if ($wpdbCollation !== '' && stripos($wpdbCollation, 'utf8mb4') !== false) {
228 return $wpdbCollation;
229 }
230 }
231 return 'utf8mb4_unicode_ci';
232 }
233
234 /**
235 * Auto-recover from a collation mismatch detected at query time.
236 *
237 * Under a static recursion guard and a 1-hour cooldown, invokes
238 * correctCollations() to converge plugin-table collations, then flushes
239 * the wpdb connection and retries the original query.
240 *
241 * @param string $query
242 * @param array<string, mixed> $result Passed by reference.
243 * @param bool $producesRows Whether the query returns result rows.
244 * @param 'OBJECT'|'OBJECT_K'|'ARRAY_A'|'ARRAY_N' $resultType wpdb output type for get_results().
245 * @return void
246 */
247 public function recoverFromCollationMismatchAndRetry(string $query, array &$result, bool $producesRows, string $resultType): void {
248 if (self::$collationRecoveryInProgress) {
249 return;
250 }
251
252 $cooldownKey = 'abj404_collation_recovery_cooldown';
253 $cooldownUntil = ($this->runtimeFlagGetter)($cooldownKey);
254 $onCooldown = is_scalar($cooldownUntil) && (int)$cooldownUntil > $this->clock()->now();
255
256 if (!$onCooldown) {
257 self::$collationRecoveryInProgress = true;
258 try {
259 $this->logger->infoMessage("Collation mismatch detected: running correctCollations() to converge plugin tables."); // allow-em-dash: original string from DataAccessTrait_Maintenance had em dash, replaced with colon
260 if (class_exists('ABJ_404_Solution_DatabaseUpgradesEtc')) {
261 $upgrades = abj_service('database_upgrades');
262 if (method_exists($upgrades, 'correctCollations')) {
263 $upgrades->components()->collationDriftUpgrade()->correctCollations();
264 }
265 }
266 } catch (Throwable $e) {
267 $this->logger->warn("correctCollations() threw during collation auto-recovery: " . $e->getMessage());
268 } finally {
269 self::$collationRecoveryInProgress = false;
270 ($this->runtimeFlagSetter)(
271 $cooldownKey,
272 $this->clock()->now() + self::COLLATION_RECOVERY_COOLDOWN_SECONDS,
273 self::COLLATION_RECOVERY_COOLDOWN_SECONDS
274 );
275 }
276 }
277
278 global $wpdb;
279 /** @var wpdb $wpdb */
280 $wpdb->flush();
281 if ($producesRows) {
282 $result['rows'] = $wpdb->get_results($query, $resultType);
283 } else {
284 $wpdb->query($query);
285 $result['rows'] = array();
286 }
287 ($this->resultHarvester)($result);
288
289 if ($result['last_error'] === '') {
290 $this->logger->debugMessage("Collation auto-recovery succeeded; query retry passed.");
291 }
292 }
293
294 /**
295 * Lazily resolve the clock instance.
296 *
297 * @return ABJ_404_Solution_Clock
298 */
299 private function clock() {
300 if ($this->clock === null) {
301 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
302 $resolved = ABJ_404_Solution_ServiceContainer::safeGet('clock');
303 if ($resolved instanceof ABJ_404_Solution_Clock) {
304 $this->clock = $resolved;
305 return $this->clock;
306 }
307 }
308 $this->clock = new ABJ_404_Solution_SystemClock();
309 }
310 return $this->clock;
311 }
312 }
313