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 / database / upgrades / DatabaseTableDdlExecutor.php

DatabaseTableDdlExecutor.php in 404 Solution trunk, at includes/database/upgrades/DatabaseTableDdlExecutor.php

414 lines 20.9 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 require_once __DIR__ . '/../DatabaseCollationHelper.php';
8
9 /**
10 * Permanent-DDL discovery, execution, and materialization verification for
11 * plugin tables.
12 *
13 * Owns discovering the permanent (non-Temp) CREATE-TABLE DDL files on disk
14 * (discoverPermanentDDLFiles), running them with charset/collation rewriting
15 * applied (runInitialCreateTables / createMissingPermanentTables /
16 * applyPluginTableCharsetCollate), and verifying each CREATE actually
17 * materialized the table on disk (verifyTableMaterialized).
18 *
19 * Extracted from the table-bootstrap orchestrator (DatabaseUpgradeBootstrap)
20 * for the same reason as the lowercase-rename collaborator: DDL execution is
21 * a distinct concern from bootstrap orchestration, with its own file-glob
22 * discovery and per-table materialization verification.
23 *
24 * Seeding the DATA a newly added column needs is deliberately NOT here: that
25 * is {@see ABJ_404_Solution_DatabaseUpgradeAddedColumnBackfill}, reached from
26 * the schema-diff component that knows a column was actually added.
27 *
28 * Collaborator of ABJ_404_Solution_DatabaseUpgradeBootstrap, which constructs
29 * it fresh on every call (never cached) with the upgrade coordinator and the
30 * current DB core / logger, so it always observes whichever dependencies are
31 * current at call time (dbCore / logger can be swapped at runtime via
32 * replaceDatabaseUpgradeDependencies() on the owning component).
33 */
34 class ABJ_404_Solution_DatabaseTableDdlExecutor {
35
36 /** @var ABJ_404_Solution_DatabaseUpgradeCoordinator */
37 private $coordinator;
38
39 /** @var ABJ_404_Solution_DatabaseCore */
40 private $dbCore;
41
42 /** @var ABJ_404_Solution_Logging */
43 private $logger;
44
45 /**
46 * @param ABJ_404_Solution_DatabaseUpgradeCoordinator $coordinator
47 * @param ABJ_404_Solution_DatabaseCore $dbCore Deliberately untyped at the
48 * PHP level (matching ABJ_404_Solution_DatabaseUpgradeComponent::$dbCore):
49 * this collaborator needs methods from both DatabaseCoreInterface
50 * (tableNameResolver) and DatabaseQueryInterface (queryAndGetResults,
51 * doTableNameReplacements), and PHP 7.4 (the plugin's floor version) has
52 * no intersection types. Test doubles (e.g. Abj404NullDatabaseCore)
53 * implement those interfaces without extending the concrete class, so a
54 * native type hint here would break them.
55 * @param ABJ_404_Solution_Logging $logger
56 */
57 public function __construct(ABJ_404_Solution_DatabaseUpgradeCoordinator $coordinator,
58 $dbCore, ABJ_404_Solution_Logging $logger) {
59 $this->coordinator = $coordinator;
60 $this->dbCore = $dbCore;
61 $this->logger = $logger;
62 }
63
64 /**
65 * Discover all permanent (non-Temp) DDL files and extract table metadata.
66 *
67 * @return array<int, array{placeholder: string, bareTableName: string, ddlContent: string}>
68 */
69 public function discoverPermanentDDLFiles(): array {
70 $sqlDir = __DIR__ . '/../../sql';
71 $files = glob($sqlDir . '/create*Table.sql');
72 if (!is_array($files)) {
73 $files = [];
74 }
75 sort($files);
76
77 $result = [];
78 foreach ($files as $file) {
79 if (stripos(basename($file), 'Temp') !== false) {
80 continue;
81 }
82 $ddlContent = ABJ_404_Solution_FileSystemService::readFileContents($file);
83 if (!is_string($ddlContent) || trim($ddlContent) === '') {
84 // Tie the diagnostic to this specific file: the zero-entries
85 // guard in runInitialCreateTables() only fires when EVERY
86 // file fails, so a lone empty/unreadable file among otherwise
87 // healthy ones would otherwise leave its table missing with
88 // no trail explaining why.
89 $this->logger->errorMessage(
90 'discoverPermanentDDLFiles() found ' . basename($file) . ' empty or '
91 . 'unreadable. The table this file defines will not be created or '
92 . 'repaired until the file is restored. Likely cause: a corrupted or '
93 . 'incomplete plugin installation.'
94 );
95 continue;
96 }
97 if (!preg_match('/\{(wp_(abj404_\w+))\}/', $ddlContent, $m)) {
98 $this->logger->errorMessage(
99 'discoverPermanentDDLFiles() found ' . basename($file) . ' malformed: '
100 . 'no {wp_abj404_*} table-name placeholder found. The table this file '
101 . 'defines will not be created or repaired until the file is restored. '
102 . 'Likely cause: a corrupted or incomplete plugin installation.'
103 );
104 continue;
105 }
106 // Transient staged-build tables (view_build, view_done, view_deleteme)
107 // are owned by the staged view-build collaborators.
108 // stageCreateBuildTable() creates view_build on demand, stageRenameSwap()
109 // renames it to view_done, and view_deleteme is the ephemeral previous-
110 // generation served table that gets dropped right after the swap. None
111 // of them should participate in the permanent-DDL bootstrap, repair, or
112 // missing-table check loops. Their absence between builds is normal,
113 // not a corruption signal.
114 if (in_array($m[2], array('abj404_view_build', 'abj404_view_done', 'abj404_view_deleteme'), true)) {
115 continue;
116 }
117 $result[] = [
118 'placeholder' => '{' . $m[1] . '}',
119 'bareTableName' => $m[2],
120 'ddlContent' => $ddlContent,
121 ];
122 }
123 // Extension point: add-ons can register extra permanent abj404_* tables
124 // (same entry shape as above) to join the create/verify loops; malformed
125 // entries from a misbehaving callback are dropped.
126 $filtered = apply_filters('abj404_permanent_ddl_files', $result);
127 if (!is_array($filtered)) {
128 return $result;
129 }
130 $validated = array();
131 foreach ($filtered as $entry) {
132 if (is_array($entry)
133 && isset($entry['placeholder'], $entry['bareTableName'], $entry['ddlContent'])
134 && is_string($entry['placeholder']) && trim($entry['placeholder']) !== ''
135 && is_string($entry['bareTableName']) && trim($entry['bareTableName']) !== ''
136 && is_string($entry['ddlContent']) && trim($entry['ddlContent']) !== '') {
137 $validated[] = array('placeholder' => $entry['placeholder'],
138 'bareTableName' => $entry['bareTableName'], 'ddlContent' => $entry['ddlContent']);
139 }
140 }
141 return $validated;
142 }
143
144 /** @return void */
145 public function runInitialCreateTables() {
146 // Re-add a stripped `id` PRIMARY KEY (via ALTER) BEFORE any CREATE TABLE
147 // IF NOT EXISTS runs. Without this step, an existing-but-broken table
148 // (missing the file's `id` PRIMARY KEY) would survive the IF NOT EXISTS
149 // check and verifyColumns would only ALTER ADD the missing non-PK
150 // columns, leaving the table without its primary key. Lives here (not
151 // just in correctIssuesBefore) so cron callers of createDatabaseTables()
152 // (which don't pass the $updatingToNewVersion flag) also repair
153 // stripped tables instead of propagating the broken state.
154 $this->coordinator->tableRepairUpgrade()->repairStrippedViewCacheTable();
155
156 $ngramTable = $this->dbCore->tableNameResolver()->getPrefixedTableName('abj404_ngram_cache');
157 $ngramEpochMigrationSafe = $this->coordinator->nGramUpgrade()->ensureLastUpdatedEpochColumn($ngramTable);
158
159 $ddlEntries = $this->discoverPermanentDDLFiles();
160 if (empty($ddlEntries)) {
161 // Zero DDL files discovered means no plugin table can be created
162 // or repaired this run -- the plugin cannot function. Per the
163 // defensive-coding standard ("Can the plugin still do its job
164 // after this failure?"), this is a real error, not a warning:
165 // silently doing nothing here would leave a fresh install with
166 // no tables and no diagnostic trail explaining why.
167 $this->logger->errorMessage(
168 'discoverPermanentDDLFiles() found zero permanent CREATE-TABLE '
169 . 'files (glob: includes/sql/create*Table.sql). No plugin tables '
170 . 'can be created or repaired until this is resolved. Likely '
171 . 'causes: a corrupted/incomplete plugin installation, the '
172 . 'includes/sql/ directory missing or unreadable, or glob() '
173 . 'disabled via php.ini disable_functions.'
174 );
175 }
176 foreach ($ddlEntries as $ddlEntry) {
177 if (!is_array($ddlEntry)) {
178 continue;
179 }
180 $placeholder = isset($ddlEntry['placeholder']) && is_string($ddlEntry['placeholder'])
181 ? $ddlEntry['placeholder'] : '';
182 $bareTableName = isset($ddlEntry['bareTableName']) && is_string($ddlEntry['bareTableName'])
183 ? $ddlEntry['bareTableName'] : '';
184 $ddlContent = isset($ddlEntry['ddlContent']) && is_string($ddlEntry['ddlContent'])
185 ? $ddlEntry['ddlContent'] : '';
186
187 $query = $this->applyPluginTableCharsetCollate($ddlContent);
188 $this->dbCore->queryAndGetResults($query);
189
190 $tableName = $this->dbCore->doTableNameReplacements($placeholder);
191
192 // Per-table post-CREATE verification: confirm the table actually
193 // exists on disk. queryAndGetResults logs SQL errors generically,
194 // but a silently-failing CREATE (concurrent DROP, swallowed parse
195 // error, prefix drift, or insufficient privileges) is invisible
196 // without an explicit existence check. Log per-table so the debug
197 // log identifies which DDL didn't materialize and why downstream
198 // auto-repair attempts will keep failing.
199 if (!$this->verifyTableMaterialized(array('tableName' => $tableName, 'placeholder' => $placeholder))) {
200 // Don't abort the loop. Other tables can still get created.
201 continue;
202 }
203
204 // Targeted online-DDL column add(s) before the generic verifyColumns()
205 // flow runs a bare ALTER. On large logsv2 tables (multi-GB on
206 // busy sites) bare ADD COLUMN can block the table for tens of
207 // seconds; the targeted helper uses ALGORITHM=INPLACE, LOCK=NONE
208 // so InnoDB 5.6 or newer picks the lockless online-DDL path. If the
209 // engine doesn't support it the helper falls back silently and
210 // verifyColumns() picks up the column add as a safety net.
211 if ($bareTableName === 'abj404_logsv2') {
212 $this->coordinator->canonicalUrlBackfillUpgrade()->ensureLogsv2CanonicalUrlColumn($tableName);
213 }
214 // Same logic for the redirects side. canonical_url is required by
215 // setupRedirect() and was added in 4.1.11; on a small fraction of
216 // sites dbDelta silently fails to add it, so every captured 404
217 // emits "Unknown column 'canonical_url' in 'field list'" until
218 // verifyColumns eventually retries. Eagerly running the targeted
219 // add closes that window.
220 if ($bareTableName === 'abj404_redirects') {
221 $this->coordinator->canonicalUrlBackfillUpgrade()->ensureRedirectsCanonicalUrlColumn($tableName);
222 // Denorm Step 3a (i459): same eager online-DDL add for the four
223 // derived columns (logshits, last_used, dest_for_view,
224 // published_status) so they exist before verifyColumns() and
225 // before the chunked backfill reads them. Idempotent: each
226 // column is SHOW COLUMNS-guarded, so this is a no-op once added.
227 $this->coordinator->redirectsDenormBackfillUpgrade()->ensureRedirectsDenormColumns($tableName);
228 }
229 if ($bareTableName === 'abj404_ngram_cache' && !$ngramEpochMigrationSafe) {
230 continue;
231 }
232
233 $this->coordinator->schemaDiffUpgrade()->verifyColumns($tableName, $query);
234 }
235
236 // Table-specific post-creation steps.
237 $logsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}");
238 $this->coordinator->indexesUpgrade()->ensureLogsCompositeIndex($logsTable);
239 }
240
241 /**
242 * Materialize ONLY the permanent plugin tables that are currently missing,
243 * and nothing else.
244 *
245 * This is the bounded counterpart to runInitialCreateTables(), for the
246 * per-query missing-table auto-repair path
247 * (DatabaseRepairPolicy::attemptMissingTableRepairAndRetry). That path runs
248 * inline inside whatever request happened to issue the failing query --
249 * frontend 404 dispatch, admin AJAX, REST -- so it must do the minimum work
250 * that makes the caller's retry succeed and no more.
251 *
252 * The work here is bounded by construction:
253 * - one SHOW TABLES probe per permanent DDL file (metadata only),
254 * - one CREATE TABLE IF NOT EXISTS per table that is actually missing,
255 * - one post-CREATE materialization probe for each of those.
256 * Every create*Table.sql file carries its full column list AND its full
257 * index list, so a table created here is complete: it needs no follow-up
258 * ALTER, no verifyColumns() diff (there is nothing to diff against a table
259 * built from the current DDL), and no index pass.
260 *
261 * Deliberately absent, versus runInitialCreateTables() /
262 * reallyCreateDatabaseTables(): the schema-wide collation sweep, the
263 * MyISAM-to-InnoDB engine conversion, createIndexes(), the canonical_url
264 * and denorm backfills, the lowercase-rename / orphan-adoption scan, the
265 * permalink-cache rebuild, and the relative-path URL migration. Those are
266 * drift correction and data backfill across tables that are NOT missing;
267 * they are the daily maintenance cron's job (runSelfHealPrologue), never a
268 * user-facing request's. Running them inline is what turned a single
269 * missing table on a large site into a multi-minute admin-AJAX stall.
270 *
271 * @return array<int, string> Fully-qualified names of the tables that were
272 * missing on entry and that this pass materialized. Empty when nothing
273 * was missing, or when every CREATE failed to materialize.
274 */
275 public function createMissingPermanentTables(): array {
276 $created = array();
277 foreach ($this->discoverPermanentDDLFiles() as $ddlEntry) {
278 if (!is_array($ddlEntry)) {
279 continue;
280 }
281 $placeholder = isset($ddlEntry['placeholder']) && is_string($ddlEntry['placeholder'])
282 ? $ddlEntry['placeholder'] : '';
283 $ddlContent = isset($ddlEntry['ddlContent']) && is_string($ddlEntry['ddlContent'])
284 ? $ddlEntry['ddlContent'] : '';
285 if ($placeholder === '' || $ddlContent === '') {
286 continue;
287 }
288 $tableName = $this->dbCore->doTableNameReplacements($placeholder);
289 if (!is_string($tableName) || $tableName === '') {
290 continue;
291 }
292 // Skip tables that already exist: the repair exists to close the
293 // gap for the one table the failing query named, not to re-run DDL
294 // for the whole schema on every recovered query.
295 if ($this->tableExistsOnDisk($tableName)) {
296 continue;
297 }
298
299 $this->dbCore->queryAndGetResults($this->applyPluginTableCharsetCollate($ddlContent));
300
301 if ($this->verifyTableMaterialized(array('tableName' => $tableName, 'placeholder' => $placeholder))) {
302 $created[] = $tableName;
303 }
304 }
305 return $created;
306 }
307
308 /**
309 * Metadata-only existence probe for a fully-qualified plugin table.
310 *
311 * Routed through queryAndGetResults() (not a raw $wpdb->get_var()) so it
312 * carries the DAO's query-timeout wrapper: a concurrent CREATE/ALTER/DROP
313 * can hold a metadata lock that SHOW TABLES waits on, and an unbounded wait
314 * inside a user-facing request is exactly what this repair path exists to
315 * avoid.
316 *
317 * @param string $tableName Fully-qualified table name (with prefix).
318 * @return bool
319 */
320 private function tableExistsOnDisk(string $tableName): bool {
321 if ($tableName === '') {
322 return false;
323 }
324 // @utf8-audit: opt-out - $tableName is a fully-qualified plugin table
325 // name from doTableNameReplacements / $wpdb->prefix; never user input.
326 $result = $this->dbCore->queryAndGetResults(
327 "SHOW TABLES LIKE '" . esc_sql($tableName) . "'"
328 );
329 if (!isset($result['rows']) || !is_array($result['rows']) || !isset($result['rows'][0])) {
330 return false;
331 }
332 $row = $result['rows'][0];
333 $found = is_array($row) ? reset($row) : $row;
334 return $found === $tableName;
335 }
336
337 /**
338 * Verify that a CREATE TABLE actually materialized the named table on disk.
339 * Returns true if the table exists, false (and logs a per-table error) if not.
340 *
341 * Distinguishes silently-failing CREATEs from generic SQL errors so the
342 * debug log identifies which specific DDL didn't materialize. Common causes:
343 * concurrent DROP from a parallel cron, SQL parse error swallowed by
344 * queryAndGetResults, prefix drift between request and table_prefix in
345 * wp-config, or missing CREATE TABLE privileges on the DB user.
346 *
347 * Takes a single associative array (rather than two positional strings)
348 * so the fully-qualified table name and the original placeholder --
349 * both plain strings -- cannot be silently transposed at the call site.
350 *
351 * @param array{tableName: string, placeholder: string} $context
352 * tableName: Fully-qualified table name (with prefix).
353 * placeholder: Original placeholder (e.g. "{wp_abj404_redirects}") for diagnostic context.
354 * @return bool True if table exists post-CREATE, false otherwise.
355 */
356 private function verifyTableMaterialized(array $context): bool {
357 $tableName = isset($context['tableName']) && is_string($context['tableName']) ? $context['tableName'] : '';
358 $placeholder = isset($context['placeholder']) && is_string($context['placeholder']) ? $context['placeholder'] : '';
359 if ($tableName === '') {
360 return false;
361 }
362 // Shares tableExistsOnDisk()'s queryAndGetResults() probe (same pattern
363 // as DatabaseUpgradeCollationDrift::correctCollations()) rather than a
364 // raw $wpdb->get_var(), so this metadata probe carries the DAO's
365 // query-timeout wrapper: a concurrent CREATE/ALTER/DROP racing this
366 // freshly-run CREATE TABLE can hold a metadata lock that SHOW TABLES
367 // waits on, and an unbounded wait here would block schema bootstrap
368 // indefinitely instead of surfacing as a logged, recoverable error.
369 if ($this->tableExistsOnDisk($tableName)) {
370 return true;
371 }
372 $this->logger->errorMessage(
373 "CREATE TABLE did not materialize '" . $tableName . "' "
374 . "(placeholder " . $placeholder . "). "
375 . "Table is still missing on disk after CREATE TABLE IF NOT EXISTS ran. "
376 . "Likely causes: concurrent DROP from a parallel request, "
377 . "SQL parse error suppressed by queryAndGetResults, "
378 . "prefix mismatch between request and wp-config table_prefix, "
379 . "or insufficient CREATE TABLE privileges on the DB user."
380 );
381 return false;
382 }
383
384 /**
385 * @param string $createTableSql
386 * @return string
387 */
388 public function applyPluginTableCharsetCollate($createTableSql) {
389 global $wpdb;
390 if (!is_string($createTableSql) || $createTableSql === '') {
391 return $createTableSql;
392 }
393
394 // Always prefer utf8mb4 for plugin tables, regardless of site defaults.
395 // The collation is normalized into the utf8mb4 family by the same
396 // derivation every other producer uses, so the charset written below and
397 // the collation written beside it can never name different families.
398 $rawCollate = isset($wpdb->collate) && is_scalar($wpdb->collate) ? (string)$wpdb->collate : '';
399 $collate = ABJ_404_Solution_DatabaseCollationHelper::utf8mb4CollationOrFallback($rawCollate);
400
401 $createTableSql = str_replace(
402 array('{CHARSET}', '{COLLATION}'),
403 array('utf8mb4', $collate),
404 $createTableSql
405 );
406 // Already specified AS A TABLE OPTION? Then don't override.
407 if (ABJ_404_Solution_CreateTableOptionsParser::declaresTableCharsetOrCollation($createTableSql)) {
408 return $createTableSql;
409 }
410
411 return rtrim($createTableSql) . " DEFAULT CHARACTER SET utf8mb4 COLLATE {$collate}";
412 }
413 }
414