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 / ngram / NGramLastUpdatedEpochMigration.php

NGramLastUpdatedEpochMigration.php in 404 Solution trunk, at includes/ngram/NGramLastUpdatedEpochMigration.php

183 lines 6.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 * Converts the n-gram cache `last_updated` column from a datetime/timestamp
9 * type to bigint Unix epoch seconds, before the generic schema diff runs.
10 *
11 * A direct ALTER from datetime to bigint lets MySQL coerce
12 * "2026-06-13 03:04:05" into a YYYYMMDDHHMMSS-style number, not an epoch. This
13 * migration performs the conversion explicitly via a temporary epoch column so
14 * the generic schema verifier never applies that unsafe implicit conversion.
15 *
16 * Pure schema/data-access concern: it owns no n-gram cache business logic and
17 * is constructed on demand by {@see ABJ_404_Solution_DatabaseUpgradeNGram}.
18 */
19 class ABJ_404_Solution_NGramLastUpdatedEpochMigration {
20
21 /** @var ABJ_404_Solution_DatabaseCore */
22 private $dbCore;
23
24 /** @var ABJ_404_Solution_Logging */
25 private $logger;
26
27 /**
28 * @param ABJ_404_Solution_DatabaseCore $dbCore
29 * @param ABJ_404_Solution_Logging $logger
30 */
31 public function __construct($dbCore, $logger) {
32 $this->dbCore = $dbCore;
33 $this->logger = $logger;
34 }
35
36 /**
37 * Ensure the n-gram cache `last_updated` column is bigint epoch seconds.
38 *
39 * @param mixed $tableName Physical n-gram cache table name.
40 * @return bool True when the table is already safe for generic schema
41 * verification, or after conversion succeeds.
42 */
43 public function ensureEpochColumn($tableName): bool {
44 $tableName = is_scalar($tableName) ? (string)$tableName : '';
45 if ($tableName === '') {
46 $this->logger->warn('Skipping n-gram last_updated epoch migration because the table name was empty.');
47 return false;
48 }
49
50 $lastUpdatedColumn = $this->getColumnMetadata($tableName, 'last_updated');
51 if ($lastUpdatedColumn === null) {
52 $tempColumn = $this->getColumnMetadata($tableName, 'last_updated_epoch');
53 if ($tempColumn !== null) {
54 return $this->renameEpochTempColumn($tableName);
55 }
56 return true;
57 }
58
59 $type = strtolower($this->columnValue($lastUpdatedColumn, 'Type'));
60 if (strpos($type, 'datetime') === false && strpos($type, 'timestamp') === false) {
61 return true;
62 }
63
64 $tempColumn = $this->getColumnMetadata($tableName, 'last_updated_epoch');
65 if ($tempColumn === null && !$this->runMigrationQuery(
66 "ALTER TABLE {$tableName}
67 ADD COLUMN `last_updated_epoch` bigint(20) NOT NULL DEFAULT 0
68 COMMENT 'Last time N-grams were computed as Unix epoch seconds'
69 AFTER `ngram_count`",
70 'add temporary n-gram epoch timestamp column'
71 )) {
72 return false;
73 }
74
75 if (!$this->runMigrationQuery(
76 "UPDATE {$tableName}
77 SET `last_updated_epoch` = COALESCE(
78 TIMESTAMPDIFF(SECOND, '1970-01-01 00:00:00', `last_updated`),
79 0
80 )",
81 'copy legacy n-gram datetimes into epoch timestamp column'
82 )) {
83 return false;
84 }
85
86 if (!$this->runMigrationQuery(
87 "ALTER TABLE {$tableName}
88 DROP COLUMN `last_updated`",
89 'drop legacy n-gram datetime timestamp column'
90 )) {
91 return false;
92 }
93
94 if (!$this->renameEpochTempColumn($tableName)) {
95 return false;
96 }
97
98 $convertedColumn = $this->getColumnMetadata($tableName, 'last_updated');
99 $convertedType = $convertedColumn !== null ? strtolower($this->columnValue($convertedColumn, 'Type')) : '';
100 if (strpos($convertedType, 'bigint') === false) {
101 $this->logger->warn("N-gram last_updated epoch migration did not leave a bigint column on {$tableName}.");
102 return false;
103 }
104
105 $this->logger->infoMessage("Migrated {$tableName}.last_updated from datetime to bigint Unix epoch seconds.");
106 return true;
107 }
108
109 /**
110 * @param string $tableName
111 * @return bool
112 */
113 private function renameEpochTempColumn(string $tableName): bool {
114 return $this->runMigrationQuery(
115 "ALTER TABLE {$tableName}
116 CHANGE COLUMN `last_updated_epoch` `last_updated` bigint(20) NOT NULL DEFAULT 0
117 COMMENT 'Last time N-grams were computed as Unix epoch seconds'",
118 'rename temporary n-gram epoch timestamp column'
119 );
120 }
121
122 /**
123 * @param string $tableName
124 * @param string $columnName
125 * @return array<string, mixed>|null
126 */
127 private function getColumnMetadata(string $tableName, string $columnName): ?array {
128 $result = $this->dbCore->queryAndGetResults(
129 "SHOW COLUMNS FROM {$tableName}",
130 array('log_errors' => false)
131 );
132 $rows = isset($result['rows']) && is_array($result['rows']) ? $result['rows'] : array();
133 foreach ($rows as $row) {
134 if (!is_array($row)) {
135 continue;
136 }
137 $columnRow = array();
138 foreach ($row as $key => $value) {
139 if (is_string($key)) {
140 $columnRow[$key] = $value;
141 }
142 }
143 if (strtolower($this->columnValue($columnRow, 'Field')) === strtolower($columnName)) {
144 return $columnRow;
145 }
146 }
147 return null;
148 }
149
150 /**
151 * @param array<string, mixed> $row
152 * @param string $wantedKey
153 * @return string
154 */
155 private function columnValue(array $row, string $wantedKey): string {
156 foreach ($row as $key => $value) {
157 if (strtolower((string)$key) === strtolower($wantedKey) && is_scalar($value)) {
158 return (string)$value;
159 }
160 }
161 return '';
162 }
163
164 /**
165 * @param string $query
166 * @param string $action
167 * @return bool
168 */
169 private function runMigrationQuery(string $query, string $action): bool {
170 global $wpdb;
171 // Schema-bootstrap migration for a system-generated plugin table name.
172 // Runs before generic schema diff to avoid unsafe datetime-to-bigint coercion.
173 // DAO-bypass-approved: one-shot schema/data migration over a system-generated plugin table name.
174 $result = $wpdb->query($query);
175 $lastError = isset($wpdb->last_error) && is_string($wpdb->last_error) ? $wpdb->last_error : '';
176 if ($result !== false && $lastError === '') {
177 return true;
178 }
179 $this->logger->warn("Failed to {$action}: " . ($lastError !== '' ? $lastError : 'wpdb query returned false'));
180 return false;
181 }
182 }
183