PluginProbe
404 Solution / 4.3.3
404 Solution v4.3.3
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.3, at includes/database/DatabaseCollationHelper.php

328 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 * 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. Responding to a query-time collation mismatch by scheduling a
21 * schema-wide correction outside the foreground request.
22 *
23 * This class holds no DatabaseCore back-reference. It receives:
24 * - a query-runner callable bound over DatabaseCore::queryAndGetResults
25 * (signature: function(string, array<string,mixed>): array<string,mixed>);
26 * - a DDL reader callable bound over the table-name resolver
27 * (signature: function(string): string);
28 * - getter/setter callables for runtime flags
29 * (signatures: function(string): mixed, function(string, mixed, int): void);
30 * - the plugin logger and an optional clock.
31 *
32 * The recursion guard is a static property on this class (it must survive across
33 * helper invocations within a single request) and is functionally identical to
34 * the former DatabaseCore::$collationRecoveryInProgress.
35 */
36 class ABJ_404_Solution_DatabaseCollationHelper {
37
38 /** @var int Cooldown after a collation-recovery attempt (seconds). */
39 const COLLATION_RECOVERY_COOLDOWN_SECONDS = 3600;
40
41 /** @var bool Prevent recursive collation-repair scheduling within one request. */
42 private static $collationSchedulingInProgress = false;
43
44 /** @var callable(string, array<string,mixed>): array<string,mixed> */
45 private $queryRunner;
46
47 /** @var callable(string): string */
48 private $ddlReader;
49
50 /** @var callable(string): mixed */
51 private $runtimeFlagGetter;
52
53 /** @var callable(string, mixed, int): void */
54 private $runtimeFlagSetter;
55
56 /** @var ABJ_404_Solution_Logging */
57 private $logger;
58
59 /** @var ABJ_404_Solution_Clock|null */
60 private $clock;
61
62 /**
63 * @param callable(string, array<string,mixed>): array<string,mixed> $queryRunner
64 * Runs a SQL query through the centralized error-handling pipeline and
65 * returns its result array. Bound by DatabaseCore over queryAndGetResults().
66 * @param callable(string): string $ddlReader
67 * Returns the SHOW CREATE TABLE output for the given table name (or '').
68 * @param callable(string): mixed $runtimeFlagGetter
69 * Returns the current value of a runtime flag (transient with option fallback).
70 * @param callable(string, mixed, int): void $runtimeFlagSetter
71 * Persists a runtime-flag value with a TTL.
72 * @param ABJ_404_Solution_Logging $logger
73 * @param ABJ_404_Solution_Clock|null $clock Optional; lazily resolved when null.
74 */
75 public function __construct(
76 callable $queryRunner,
77 callable $ddlReader,
78 callable $runtimeFlagGetter,
79 callable $runtimeFlagSetter,
80 $logger,
81 $clock = null
82 ) {
83 $this->queryRunner = $queryRunner;
84 $this->ddlReader = $ddlReader;
85 $this->runtimeFlagGetter = $runtimeFlagGetter;
86 $this->runtimeFlagSetter = $runtimeFlagSetter;
87 $this->logger = $logger;
88 $this->clock = $clock;
89 }
90
91 /**
92 * Reset the static recursion guard. Intended for test setUp/tearDown only.
93 *
94 * @return void
95 */
96 public static function resetRecursionGuardForTests(): void {
97 self::$collationSchedulingInProgress = false;
98 }
99
100 /**
101 * Sanitize a raw collation identifier so it is safe to interpolate in SQL.
102 *
103 * Strips every character that is not [A-Za-z0-9_].
104 *
105 * @param string $collation
106 * @return string
107 */
108 public function sanitizeCollationIdentifier($collation): string {
109 if (!is_string($collation) || $collation === '') {
110 return '';
111 }
112 $sanitized = preg_replace('/[^A-Za-z0-9_]/', '', $collation);
113 return $sanitized !== null ? $sanitized : '';
114 }
115
116 /**
117 * Get the table-level default collation for a given table.
118 *
119 * Queries SHOW CREATE TABLE for the COLLATE clause; falls back to
120 * information_schema.TABLES.TABLE_COLLATION; then to utf8mb4_unicode_ci.
121 * Result is validated through sanitizeCollationIdentifier().
122 *
123 * @param string $tableName Fully-qualified table name (including prefix).
124 * @return string
125 */
126 public function getTableCollationString(string $tableName): string {
127 $fallback = 'utf8mb4_unicode_ci';
128 $ddl = ($this->ddlReader)($tableName);
129 if (preg_match('/COLLATE[= ]([A-Za-z0-9_]+)/i', $ddl, $m)) {
130 $sanitized = $this->sanitizeCollationIdentifier($m[1]);
131 return $sanitized !== '' ? $sanitized : $fallback;
132 }
133 global $wpdb;
134 if (isset($wpdb) && method_exists($wpdb, 'prepare')) {
135 /** @var wpdb $wpdb */
136 $sql = $wpdb->prepare(
137 "SELECT TABLE_COLLATION FROM information_schema.TABLES "
138 . "WHERE TABLE_SCHEMA = DATABASE() "
139 . "AND TABLE_NAME = %s "
140 . "LIMIT 1",
141 $tableName
142 );
143 if (is_string($sql) && $sql !== '') {
144 $result = ($this->queryRunner)($sql, array('log_errors' => false));
145 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
146 if (!empty($rows) && is_array($rows[0])) {
147 $row = array_change_key_case($rows[0]);
148 $collation = $row['table_collation'] ?? '';
149 if (is_string($collation) && $collation !== '') {
150 $sanitized = $this->sanitizeCollationIdentifier($collation);
151 return $sanitized !== '' ? $sanitized : $fallback;
152 }
153 }
154 }
155 }
156 return $fallback;
157 }
158
159 /**
160 * Get the column-level collation for a specific column in a table.
161 *
162 * Queries information_schema.COLUMNS for the COLLATION_NAME. Falls back
163 * to getTableCollationString() if the column query fails, then ultimately
164 * to utf8mb4_unicode_ci. Result is validated through
165 * sanitizeCollationIdentifier().
166 *
167 * @param string $tableName Fully-qualified table name (including prefix).
168 * @param string $columnName Column name to look up.
169 * @return string
170 */
171 public function getColumnCollationString(string $tableName, string $columnName): string {
172 $fallback = 'utf8mb4_unicode_ci';
173 global $wpdb;
174 if (!isset($wpdb) || !method_exists($wpdb, 'prepare')) {
175 return $this->getTableCollationString($tableName);
176 }
177 /** @var wpdb $wpdb */
178 $sql = $wpdb->prepare(
179 "SELECT COLLATION_NAME FROM information_schema.COLUMNS "
180 . "WHERE TABLE_SCHEMA = DATABASE() "
181 . "AND TABLE_NAME = %s "
182 . "AND COLUMN_NAME = %s "
183 . "LIMIT 1",
184 $tableName,
185 $columnName
186 );
187 if (!is_string($sql) || $sql === '') {
188 return $this->getTableCollationString($tableName);
189 }
190 $result = ($this->queryRunner)($sql, array('log_errors' => false));
191 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
192 if (empty($rows) || !is_array($rows[0])) {
193 return $this->getTableCollationString($tableName);
194 }
195 $row = array_change_key_case($rows[0]);
196 $collation = $row['collation_name'] ?? '';
197 if (!is_string($collation) || $collation === '') {
198 return $this->getTableCollationString($tableName);
199 }
200 $sanitized = $this->sanitizeCollationIdentifier($collation);
201 return $sanitized !== '' ? $sanitized : $fallback;
202 }
203
204 /**
205 * Coerce a SQL expression to the charset and collation of an indexed
206 * comparison column without wrapping that indexed column.
207 *
208 * Mixed-collation sites are common during upgrades and partial restores.
209 * Applying CONVERT/COLLATE only to the non-indexed operand makes the
210 * equality deterministic while leaving the target column sargable.
211 * Callers must supply an internally constructed SQL expression; this
212 * method sanitizes metadata identifiers, not arbitrary SQL text.
213 *
214 * @param string $expression SQL expression used opposite the target column.
215 * @param array{table: string, column: string} $targetColumn Indexed target column metadata.
216 * @return string
217 */
218 public function coerceExpressionToColumnCollation(string $expression, array $targetColumn): string {
219 $tableName = $targetColumn['table'] ?? '';
220 $columnName = $targetColumn['column'] ?? '';
221 if ($tableName === '' || $columnName === '') {
222 throw new InvalidArgumentException('Target table and column are required for a collation-safe SQL comparison.');
223 }
224
225 $collation = $this->getColumnCollationString($tableName, $columnName);
226 $charsetParts = explode('_', $collation, 2);
227 $charset = $this->sanitizeCollationIdentifier($charsetParts[0] ?? '');
228 if ($charset === '' || $collation === '') {
229 throw new InvalidArgumentException('Target column collation could not be converted to a safe SQL identifier.');
230 }
231
232 return 'CONVERT(' . $expression . ' USING ' . $charset . ') COLLATE ' . $collation;
233 }
234
235 /**
236 * Return the preferred utf8mb4 collation for this wpdb connection.
237 *
238 * If wpdb->collate already names a utf8mb4_* collation, use it; otherwise
239 * fall back to utf8mb4_unicode_ci.
240 *
241 * @return string
242 */
243 public function getPreferredUtf8mb4Collation(): string {
244 global $wpdb;
245 if (isset($wpdb) && isset($wpdb->collate) && !empty($wpdb->collate)) {
246 $wpdbCollation = $this->sanitizeCollationIdentifier((string)$wpdb->collate);
247 if ($wpdbCollation !== '' && stripos($wpdbCollation, 'utf8mb4') !== false) {
248 return $wpdbCollation;
249 }
250 }
251 return 'utf8mb4_unicode_ci';
252 }
253
254 /**
255 * Schedule broad collation correction outside the foreground request.
256 *
257 * correctCollations() discovers every plugin table and may issue ALTER
258 * TABLE ... CONVERT for each drifted table. Running that work inline can
259 * exhaust an admin AJAX request on large sites, so the query that detected
260 * the mismatch keeps its normal degraded error result while a dedicated,
261 * deduplicated WP-Cron event performs the repair. Scheduler failures remain
262 * retryable and are logged with the adapter's underlying failure detail.
263 *
264 * @return void
265 */
266 public function scheduleCollationRecovery(): void {
267 if (self::$collationSchedulingInProgress) {
268 return;
269 }
270
271 $cooldownKey = 'abj404_collation_recovery_cooldown';
272 $cooldownUntil = ($this->runtimeFlagGetter)($cooldownKey);
273 $onCooldown = is_scalar($cooldownUntil) && (int)$cooldownUntil > $this->clock()->now();
274
275 if ($onCooldown) {
276 return;
277 }
278
279 self::$collationSchedulingInProgress = true;
280 try {
281 $scheduler = abj_cron_scheduler();
282 $scheduled = $scheduler->scheduleSingleIfMissing(
283 ABJ_404_Solution_CronScheduler::HOOK_REPAIR_COLLATIONS,
284 1
285 );
286 if ($scheduled) {
287 ($this->runtimeFlagSetter)(
288 $cooldownKey,
289 $this->clock()->now() + self::COLLATION_RECOVERY_COOLDOWN_SECONDS,
290 self::COLLATION_RECOVERY_COOLDOWN_SECONDS
291 );
292 $this->logger->infoMessage(
293 'Collation mismatch detected: schema correction scheduled for background repair.'
294 );
295 return;
296 }
297 $this->logger->warn(
298 'Could not schedule background collation repair: ' . $scheduler->lastFailureDetail()
299 );
300 } catch (Throwable $e) {
301 $this->logger->warn(
302 'Could not schedule background collation repair: ' . $e->getMessage()
303 );
304 } finally {
305 self::$collationSchedulingInProgress = false;
306 }
307 }
308
309 /**
310 * Lazily resolve the clock instance.
311 *
312 * @return ABJ_404_Solution_Clock
313 */
314 private function clock() {
315 if ($this->clock === null) {
316 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
317 $resolved = ABJ_404_Solution_ServiceContainer::safeGet('clock');
318 if ($resolved instanceof ABJ_404_Solution_Clock) {
319 $this->clock = $resolved;
320 return $this->clock;
321 }
322 }
323 $this->clock = new ABJ_404_Solution_SystemClock();
324 }
325 return $this->clock;
326 }
327 }
328