PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.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 / DatabaseUpgradesEtc.php

DatabaseUpgradesEtc.php in 404 Solution 4.2.0, at includes/DatabaseUpgradesEtc.php

625 lines 25.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 require_once __DIR__ . '/DatabaseUpgradesEtcTrait_NGram.php';
9 require_once __DIR__ . '/DatabaseUpgradesEtcTrait_Maintenance.php';
10 require_once __DIR__ . '/DatabaseUpgradesEtcTrait_PluginUpdate.php';
11 require_once __DIR__ . '/DatabaseUpgradesEtcTrait_TableRepair.php';
12 require_once __DIR__ . '/DatabaseUpgradesEtcTrait_Indexes.php';
13 require_once __DIR__ . '/DatabaseUpgradesEtcTrait_OrphanAdoption.php';
14 require_once __DIR__ . '/DatabaseUpgradesEtcTrait_MultiSite.php';
15 require_once __DIR__ . '/DatabaseUpgradesEtcTrait_SchemaDiff.php';
16
17 /* Functions in this class should all reference one of the following variables or support functions that do.
18 * $wpdb, $_GET, $_POST, $_SERVER, $_.*
19 * everything $wpdb related.
20 * everything $_GET, $_POST, (etc) related.
21 * Read the database, Store to the database,
22 */
23
24 class ABJ_404_Solution_DatabaseUpgradesEtc {
25
26 /** @var self|null */
27 private static $instance = null;
28
29 /** @var string|null */
30 private static $uniqID = null;
31
32 /**
33 * Per-request dedup flag for scheduleLogsv2CanonicalUrlBackfill().
34 * Mirrors DataAccess::$hitsTableRebuildScheduled — ensures the shutdown
35 * hook is registered at most once per request even if the schedule
36 * function is called from multiple paths (Captured-404s tab render +
37 * Stats panel + EmailDigest, etc.). Reset to false naturally when the
38 * PHP process ends; persistent SAPIs (PHP-FPM, mod_php) reset it
39 * implicitly between requests because static is process-local.
40 *
41 * @var bool
42 */
43 private static $logsv2CanonicalBackfillScheduled = false;
44
45 /** @var ABJ_404_Solution_DataAccess */
46 private $dao;
47
48 /** @var ABJ_404_Solution_DatabaseCoreInterface */
49 private $dbCore;
50
51 /** @var ABJ_404_Solution_ContentRepositoryInterface */
52 private $contentRepo;
53
54 /** @var ABJ_404_Solution_ViewBuildOrchestratorInterface */
55 private $viewBuild;
56
57 /** @var ABJ_404_Solution_ViewReadServiceInterface */
58 private $viewRead;
59
60 /** @var ABJ_404_Solution_LogsRepositoryInterface */
61 private $logsRepo;
62
63 /** @var ABJ_404_Solution_Logging */
64 private $logger;
65
66 /** @var ABJ_404_Solution_Functions */
67 private $f;
68
69 /** @var ABJ_404_Solution_PermalinkCache */
70 private $permalinkCache;
71
72 /** @var ABJ_404_Solution_SynchronizationUtils */
73 private $syncUtils;
74
75 /** @var ABJ_404_Solution_PluginLogic */
76 private $logic;
77
78 /** @var ABJ_404_Solution_NGramFilter */
79 private $ngramFilter;
80
81 use ABJ_404_Solution_DatabaseUpgradesEtc_NGramTrait;
82 use ABJ_404_Solution_DatabaseUpgradesEtc_MaintenanceTrait;
83 use ABJ_404_Solution_DatabaseUpgradesEtc_PluginUpdateTrait;
84 use ABJ_404_Solution_DatabaseUpgradesEtc_TableRepairTrait;
85 use ABJ_404_Solution_DatabaseUpgradesEtc_IndexesTrait;
86 use ABJ_404_Solution_DatabaseUpgradesEtc_OrphanAdoptionTrait;
87 use ABJ_404_Solution_DatabaseUpgradesEtc_MultiSiteTrait;
88 use ABJ_404_Solution_DatabaseUpgradesEtc_SchemaDiffTrait;
89
90 /**
91 * Constructor with dependency injection.
92 *
93 * @param ABJ_404_Solution_DataAccess|null $dataAccess Data access layer (legacy, only for getLatestPluginVersion)
94 * @param ABJ_404_Solution_Logging|null $logging Logging service
95 * @param ABJ_404_Solution_Functions|null $functions String utilities
96 * @param ABJ_404_Solution_PermalinkCache|null $permalinkCache Permalink cache service
97 * @param ABJ_404_Solution_SynchronizationUtils|null $syncUtils Sync utilities
98 * @param ABJ_404_Solution_PluginLogic|null $pluginLogic Business logic service
99 * @param ABJ_404_Solution_NGramFilter|null $ngramFilter N-gram filter service
100 */
101 public function __construct($dataAccess = null, $logging = null, $functions = null, $permalinkCache = null, $syncUtils = null, $pluginLogic = null, $ngramFilter = null) {
102 // Use injected dependencies or fall back to getInstance() for backward compatibility
103 $this->dao = $dataAccess !== null ? $dataAccess : abj_service('data_access');
104 $this->logger = $logging !== null ? $logging : abj_service('logging');
105 $this->f = $functions !== null ? $functions : abj_service('functions');
106 $this->permalinkCache = $permalinkCache !== null ? $permalinkCache : abj_service('permalink_cache');
107 $this->syncUtils = $syncUtils !== null ? $syncUtils : abj_service('sync_utils');
108 $this->logic = $pluginLogic !== null ? $pluginLogic : abj_service('plugin_logic');
109 $this->ngramFilter = $ngramFilter !== null ? $ngramFilter : abj_service('ngram_filter');
110
111 $daoClass = is_object($this->dao) ? get_class($this->dao) : '';
112 $this->dbCore = ($dataAccess !== null && $daoClass !== 'ABJ_404_Solution_DataAccess'
113 && method_exists($this->dao, 'queryAndGetResults') && method_exists($this->dao, 'doTableNameReplacements'))
114 ? $this->dao
115 : $this->dao->getDbCore();
116 $this->contentRepo = $this->dao->getContentRepo();
117 $this->viewBuild = $this->dao->getViewBuildOrchestrator();
118 $this->viewRead = $this->dao->getViewReadService();
119 $this->logsRepo = $this->dao->getLogsRepo();
120 }
121
122 /** @return self */
123 public static function getInstance() {
124 if (self::$instance == null) {
125 self::$instance = new ABJ_404_Solution_DatabaseUpgradesEtc();
126 self::$uniqID = uniqid("", true);
127 }
128
129 return self::$instance;
130 }
131
132 /** Create the tables when the plugin is first activated.
133 * @param bool $updatingToNewVersion
134 * @return void
135 */
136 function createDatabaseTables($updatingToNewVersion = false, bool $force = false) {
137
138 $synchronizedKeyFromUser = "create_db_tables";
139 $uniqueID = null;
140
141 if (!$force) {
142 $uniqueID = $this->syncUtils->synchronizerAcquireLockTry($synchronizedKeyFromUser);
143
144 if ($uniqueID == '' || $uniqueID == null) {
145 $this->logger->debugMessage("Avoiding multiple calls for creating database tables.");
146 return;
147 }
148 }
149
150 // Fixed: Use finally block to ensure lock is ALWAYS released, even on fatal errors
151 try {
152 $this->reallyCreateDatabaseTables($updatingToNewVersion);
153
154 } catch (\Exception $e) {
155 $this->logger->errorMessage("Error creating database tables. ", $e);
156 throw $e; // Re-throw to propagate the error
157 } finally {
158 // Release the lock only if one was acquired (non-forced path).
159 if ($uniqueID !== null && $uniqueID !== '') {
160 $this->syncUtils->synchronizerReleaseLock($uniqueID, $synchronizedKeyFromUser);
161 }
162 }
163 }
164
165 /**
166 * @param bool $updatingToNewVersion
167 * @return void
168 */
169 private function reallyCreateDatabaseTables($updatingToNewVersion = false) {
170 if ($updatingToNewVersion) {
171 $this->correctIssuesBefore();
172 }
173
174 // MULTISITE: Process current site immediately, schedule background task for remaining sites
175 if ($this->isNetworkActivated() && !$updatingToNewVersion) {
176 // Activation path: create tables for current site + schedule background for others.
177 $currentBlogId = get_current_blog_id();
178 $this->runInitialCreateTables();
179 $this->correctCollations();
180 $this->updateTableEngineToInnoDB();
181 $this->createIndexes();
182
183 // First chunk of the canonical_url backfill runs in-band so newly
184 // upgraded small sites finish in one shot. Larger sites converge
185 // over subsequent daily-maintenance cron ticks (same method).
186 $this->backfillRedirectsCanonicalUrl();
187
188 $this->logger->infoMessage(sprintf(
189 "Network activation: Created tables for current site (ID %d). Scheduling background task for remaining sites.",
190 $currentBlogId
191 ));
192
193 $this->scheduleBackgroundMultisiteActivation($currentBlogId);
194
195 } else if ($this->isNetworkActivated() && $updatingToNewVersion) {
196 // Upgrade path on a network install: update tables for current site + schedule
197 // background upgrade for other sites (so sub-site tables are also updated).
198 $currentBlogId = get_current_blog_id();
199 $this->runInitialCreateTables();
200 $this->correctCollations();
201 $this->updateTableEngineToInnoDB();
202 $this->createIndexes();
203
204 // First chunk of the canonical_url backfill runs in-band so newly
205 // upgraded small sites finish in one shot. Larger sites converge
206 // over subsequent daily-maintenance cron ticks (same method).
207 $this->backfillRedirectsCanonicalUrl();
208
209 $this->logger->infoMessage(sprintf(
210 "Network upgrade: Updated tables for current site (ID %d). Scheduling background upgrade for remaining sites.",
211 $currentBlogId
212 ));
213
214 $this->scheduleBackgroundMultisiteUpgrade($currentBlogId);
215
216 } else {
217 // Single site (or non-network-activated): create/update tables for current site only.
218 $this->runInitialCreateTables();
219 $this->correctCollations();
220 $this->updateTableEngineToInnoDB();
221 $this->createIndexes();
222
223 // First chunk of the canonical_url backfill runs in-band so newly
224 // upgraded small sites finish in one shot. Larger sites converge
225 // over subsequent daily-maintenance cron ticks (same method).
226 $this->backfillRedirectsCanonicalUrl();
227 }
228
229 // Adopt orphaned tables AFTER target tables exist (rename handles prefix mismatches).
230 $this->renameAbj404TablesToLowerCase();
231
232 // we could do this only when a table is created or when the "meta" column is created
233 // but it doesn't take long anyway so we do it every night.
234 $this->permalinkCache->updatePermalinkCache(1);
235
236 // One-time N-gram cache initialization (async via WP-Cron to prevent blocking)
237 // MULTISITE: Use network-aware option getter to check initialization status
238 if ($this->getNetworkAwareOption('abj404_ngram_cache_initialized') !== '1') {
239 $this->logger->debugMessage("N-gram cache not initialized. Scheduling background build...");
240
241 // Schedule async rebuild via WP-Cron instead of blocking activation
242 $this->scheduleNGramCacheRebuild();
243
244 // Show admin notice that build is scheduled
245 if ($updatingToNewVersion && function_exists('add_settings_error')) {
246 $context = is_multisite() && $this->isNetworkActivated() ? ' across all sites in the network' : '';
247 $message = sprintf(
248 __('404 Solution: N-gram spell check cache is being built in the background%s to optimize performance. This may take a few minutes on large sites.', '404-solution'),
249 $context
250 );
251 add_settings_error('abj404_settings', 'ngram_cache_scheduled', $message, 'updated');
252 }
253
254 $this->logger->infoMessage("N-gram cache rebuild scheduled via WP-Cron.");
255 } else {
256 $this->logger->debugMessage("N-gram cache already initialized. Skipping rebuild.");
257 }
258
259 // Run one-time migration to relative paths (Issue #24)
260 if (get_option('abj404_migrated_to_relative_paths') !== '1') {
261 $migrationResults = $this->migrateURLsToRelativePaths();
262
263 // Show admin notice if migration occurred
264 if ($updatingToNewVersion && !empty($migrationResults['redirects_updated'])) {
265 $rawRedirectsUpdated = $migrationResults['redirects_updated'];
266 $redirectsUpdated = is_scalar($rawRedirectsUpdated) ? (int)$rawRedirectsUpdated : 0;
267 $message = sprintf(
268 _n(
269 '404 Solution: Migrated %d redirect to subdirectory-independent format.',
270 '404 Solution: Migrated %d redirects to subdirectory-independent format.',
271 $redirectsUpdated,
272 '404-solution'
273 ),
274 $redirectsUpdated
275 );
276 if (function_exists('add_settings_error')) {
277 add_settings_error('abj404_settings', 'migration_success', $message, 'updated');
278 }
279 }
280 }
281
282 if ($updatingToNewVersion) {
283 $this->correctIssuesAfter();
284 }
285 }
286
287 /**
288 * Makes all plugin table names lowercase, in case someone thought it was funny to use
289 * the lower_case_table_names=0 setting. Also detects and adopts orphaned plugin tables
290 * under old prefixes (from site migrations or the rename bug in v2.35.16–v3.x).
291 * @return void
292 */
293 function renameAbj404TablesToLowerCase() {
294 global $wpdb;
295
296 // On case-insensitive MySQL (lower_case_table_names >= 1), table names
297 // are already treated as lowercase internally. Renaming is pointless and
298 // can cause issues on some hosting setups.
299 // DAO-bypass-approved: Schema-bootstrap inside renameAbj404TablesToLowerCase() — runs before plugin DAO is fully wired during DB upgrades
300 $lctnResult = $wpdb->get_row("SHOW VARIABLES LIKE 'lower_case_table_names'", ARRAY_A);
301 if (is_array($lctnResult)) {
302 $lctnValue = null;
303 foreach ($lctnResult as $key => $value) {
304 if (strtolower((string)$key) === 'value') {
305 $lctnValue = $value;
306 break;
307 }
308 }
309 if ($lctnValue !== null && (int)$lctnValue >= 1) {
310 // MySQL already handles table names case-insensitively.
311 // Still run adoption check in case of prefix mismatch.
312 $this->adoptOrphanedTables();
313 return;
314 }
315 }
316
317 // Fetch all tables containing "abj404", case-insensitive
318 $dbNameRaw = $wpdb->dbname ?? '';
319 if ($dbNameRaw === '') {
320 $this->logger->warn("Could not determine database name for lowercase rename.");
321 return;
322 }
323 $dbNameEscaped = esc_sql($dbNameRaw);
324 $dbName = is_array($dbNameEscaped) ? '' : $dbNameEscaped;
325 $query = "SELECT table_name
326 FROM information_schema.tables
327 WHERE table_schema = '{$dbName}'
328 AND LOWER(table_name) LIKE '%abj404%'";
329 $results = $this->dbCore->queryAndGetResults($query);
330
331 if (!is_array($results['rows'])) {
332 $this->logger->warn("Could not query information_schema tables for lowercase rename.");
333 return;
334 }
335
336 foreach ($results['rows'] as $row) {
337 // Case-insensitive key lookup: MySQL drivers return information_schema
338 // column names in varying cases (table_name, TABLE_NAME, Table_Name).
339 $tableName = null;
340 foreach ($row as $key => $value) {
341 if (strtolower((string)$key) === 'table_name') {
342 $tableName = $value;
343 break;
344 }
345 }
346
347 if (!empty($tableName)) {
348 $lowercaseName = strtolower($tableName);
349
350 // Check if the table name is already lowercase, skip if it is
351 if ($tableName !== $lowercaseName) {
352 // Rename the table to lowercase
353 $renameQuery = "RENAME TABLE `{$tableName}` TO `{$lowercaseName}`";
354 $this->dbCore->queryAndGetResults($renameQuery,
355 ['ignore_errors' => ["already exists"]]);
356 $this->logger->infoMessage("Renamed table {$tableName} to {$lowercaseName}\n");
357 }
358 } else {
359 $this->logger->warn("I didn't find a table name in the results of this row: " .
360 print_r($row, true));
361 }
362 }
363
364 // After renaming, check for orphaned tables under old prefixes.
365 $this->adoptOrphanedTables();
366 }
367
368 /**
369 * Number of rows updated per chunk by backfillRedirectsCanonicalUrl().
370 * Sized so a single chunk completes well under the standard 60s query
371 * timeout even on slow disks; the chunk loop will keep going until the
372 * per-invocation budget is exhausted.
373 *
374 * Defined here (not on the trait) because trait constants require PHP 8.2+
375 * and the plugin supports PHP 7.4. The trait references this via self::
376 * which resolves to the using class at compile time.
377 */
378 const CANONICAL_URL_BACKFILL_CHUNK_SIZE = 5000;
379
380 /**
381 * Per-invocation wall-clock budget (seconds) for backfillRedirectsCanonicalUrl().
382 * Bounds how long the daily cron / activation handler will spend on this
383 * task in one call so a 350K-row site finishes over a few cron ticks
384 * instead of all in one request that risks PHP max_execution_time.
385 */
386 const CANONICAL_URL_BACKFILL_TIME_BUDGET_SEC = 25;
387
388 /**
389 * Per-invocation wall-clock budget (seconds) for backfillLogsv2CanonicalUrl().
390 * Tighter than the redirects-side budget because logsv2 backfill can also
391 * be triggered from the Captured-404s admin-tab shutdown hook, which
392 * holds a PHP-FPM worker for the duration. 15s caps worker-hold to a
393 * window short enough that concurrent visitors are unlikely to notice
394 * worker-pool pressure on shared hosts. Daily cron uses the same budget
395 * so convergence math (~25K-75K rows per invocation) is consistent.
396 */
397 const LOGSV2_CANONICAL_URL_BACKFILL_TIME_BUDGET_SEC = 15;
398
399 /**
400 * wp_options key that flips to '1' once backfillLogsv2CanonicalUrl()
401 * confirms zero NULL rows remain on logsv2.canonical_url. Once set, the
402 * read-side query can drop the COALESCE fallback and use the no-COALESCE
403 * form ("logsv2.canonical_url = redirects.canonical_url"); the planner
404 * picks the smaller side as driver and skips the Filter step (~17,000x
405 * cost reduction vs the COALESCE form per the redirects-temp-table-perf
406 * writeup).
407 *
408 * Stored as autoload=false so the option doesn't bloat the autoloaded
409 * options blob on every request — read on the captured-404s render path
410 * only, which already triggers wp_cache lookups for related options.
411 */
412 const LOGSV2_CANONICAL_URL_BACKFILL_COMPLETE_OPTION = 'abj404_logsv2_canonical_url_backfill_complete';
413
414 /**
415 * Known plugin table suffixes for adoption.
416 * @var array<int, string>
417 */
418 private const PLUGIN_TABLE_SUFFIXES = [
419 'abj404_redirects',
420 'abj404_logsv2',
421 'abj404_spelling_cache',
422 'abj404_permalink_cache',
423 'abj404_lookup',
424 'abj404_ngram_cache',
425 'abj404_logs_hits',
426 'abj404_redirect_conditions',
427 'abj404_engine_profiles',
428 'abj404_view_cache',
429 ];
430
431 /** When certain columns are created we have to populate data.
432 * @param string $tableName
433 * @param string $colName
434 * @return void
435 */
436 function handleSpecificCases($tableName, $colName) {
437 if (empty($tableName) || !is_string($tableName)) {
438 return;
439 }
440
441 if (strpos($tableName, 'abj404_logsv2') !== false && $colName == 'min_log_id') {
442 global $wpdb;
443 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/logsSetMinLogID.sql");
444 $this->dbCore->queryAndGetResults($query);
445 // Ensure composite index exists after backfilling min_log_id.
446 $this->ensureLogsCompositeIndex($tableName);
447 }
448 if (strpos($tableName, 'abj404_permalink_cache') !== false && $colName == 'url_length') {
449 // clear the permalink cache so that the url length column will be populated.
450 // this could be more efficient but I'll assume that's not necessary.
451 $this->contentRepo->truncatePermalinkCacheTable();
452 }
453 }
454
455 /**
456 * Discover all permanent (non-Temp) DDL files and extract table metadata.
457 *
458 * @return array<int, array{placeholder: string, bareTableName: string, ddlContent: string}>
459 */
460 function discoverPermanentDDLFiles(): array {
461 $sqlDir = __DIR__ . '/sql';
462 $files = glob($sqlDir . '/create*Table.sql');
463 if (!is_array($files)) {
464 $files = [];
465 }
466 sort($files);
467
468 $result = [];
469 foreach ($files as $file) {
470 if (stripos(basename($file), 'Temp') !== false) {
471 continue;
472 }
473 $ddlContent = ABJ_404_Solution_Functions::readFileContents($file);
474 if (!is_string($ddlContent) || trim($ddlContent) === '') {
475 continue;
476 }
477 if (!preg_match('/\{(wp_(abj404_\w+))\}/', $ddlContent, $m)) {
478 continue;
479 }
480 // Transient staged-build tables (view_build, view_done, view_deleteme)
481 // are owned by ABJ_404_Solution_DataAccess_ViewQueriesStagedTrait.
482 // stageCreateBuildTable() creates view_build on demand, stageRenameSwap()
483 // renames it to view_done, and view_deleteme is the ephemeral previous-
484 // generation served table that gets dropped right after the swap. None
485 // of them should participate in the permanent-DDL bootstrap, repair, or
486 // missing-table check loops. Their absence between builds is normal,
487 // not a corruption signal.
488 if (in_array($m[2], array('abj404_view_build', 'abj404_view_done', 'abj404_view_deleteme'), true)) {
489 continue;
490 }
491 $result[] = [
492 'placeholder' => '{' . $m[1] . '}',
493 'bareTableName' => $m[2],
494 'ddlContent' => $ddlContent,
495 ];
496 }
497 return $result;
498 }
499
500 /** @return void */
501 function runInitialCreateTables() {
502 // Re-add a stripped `id` PRIMARY KEY (via ALTER) BEFORE any CREATE TABLE
503 // IF NOT EXISTS runs. Without this step, an existing-but-broken table
504 // (missing the file's `id` PRIMARY KEY) would survive the IF NOT EXISTS
505 // check and verifyColumns would only ALTER ADD the missing non-PK
506 // columns, leaving the table without its primary key. Lives here (not
507 // just in correctIssuesBefore) so cron callers of createDatabaseTables()
508 // — which don't pass the $updatingToNewVersion flag — also repair
509 // stripped tables instead of propagating the broken state.
510 $this->repairStrippedViewCacheTable();
511
512 foreach ($this->discoverPermanentDDLFiles() as $ddlEntry) {
513 $query = $this->applyPluginTableCharsetCollate($ddlEntry['ddlContent']);
514 $this->dbCore->queryAndGetResults($query);
515
516 $tableName = $this->dbCore->doTableNameReplacements($ddlEntry['placeholder']);
517
518 // Per-table post-CREATE verification: confirm the table actually
519 // exists on disk. queryAndGetResults logs SQL errors generically,
520 // but a silently-failing CREATE (concurrent DROP, swallowed parse
521 // error, prefix drift, or insufficient privileges) is invisible
522 // without an explicit existence check. Log per-table so the debug
523 // log identifies which DDL didn't materialize and why downstream
524 // auto-repair attempts will keep failing.
525 if (!$this->verifyTableMaterialized($tableName, $ddlEntry['placeholder'])) {
526 // Don't abort the loop — other tables can still get created.
527 continue;
528 }
529
530 // Targeted online-DDL column add(s) before the generic verifyColumns()
531 // flow runs a bare ALTER. On large logsv2 tables (multi-GB on
532 // busy sites) bare ADD COLUMN can block the table for tens of
533 // seconds; the targeted helper uses ALGORITHM=INPLACE, LOCK=NONE
534 // so InnoDB ≥ 5.6 picks the lockless online-DDL path. If the
535 // engine doesn't support it the helper falls back silently and
536 // verifyColumns() picks up the column add as a safety net.
537 if ($ddlEntry['bareTableName'] === 'abj404_logsv2') {
538 $this->ensureLogsv2CanonicalUrlColumn($tableName);
539 }
540 // Same logic for the redirects side. canonical_url is required by
541 // setupRedirect() and was added in 4.1.11; on a small fraction of
542 // sites dbDelta silently fails to add it, so every captured 404
543 // emits "Unknown column 'canonical_url' in 'field list'" until
544 // verifyColumns eventually retries. Eagerly running the targeted
545 // add closes that window.
546 if ($ddlEntry['bareTableName'] === 'abj404_redirects') {
547 $this->ensureRedirectsCanonicalUrlColumn($tableName);
548 }
549
550 $this->verifyColumns($tableName, $query);
551 }
552
553 // Table-specific post-creation steps.
554 $logsTable = $this->dbCore->doTableNameReplacements("{wp_abj404_logsv2}");
555 $this->ensureLogsCompositeIndex($logsTable);
556
557 // Mark view cache table as ensured so ensureViewSnapshotTableExists() skips redundant DDL.
558 ABJ_404_Solution_ViewReadService::setViewSnapshotTableEnsured(true);
559 }
560
561 /**
562 * Verify that a CREATE TABLE actually materialized the named table on disk.
563 * Returns true if the table exists, false (and logs a per-table error) if not.
564 *
565 * Distinguishes silently-failing CREATEs from generic SQL errors so the
566 * debug log identifies which specific DDL didn't materialize. Common causes:
567 * concurrent DROP from a parallel cron, SQL parse error swallowed by
568 * queryAndGetResults, prefix drift between request and table_prefix in
569 * wp-config, or missing CREATE TABLE privileges on the DB user.
570 *
571 * @param string $tableName Fully-qualified table name (with prefix).
572 * @param string $placeholder Original placeholder (e.g. "{wp_abj404_redirects}") for diagnostic context.
573 * @return bool True if table exists post-CREATE, false otherwise.
574 */
575 private function verifyTableMaterialized(string $tableName, string $placeholder): bool {
576 global $wpdb;
577 if (!isset($wpdb)) {
578 return false;
579 }
580 // @utf8-audit: opt-out — $tableName is fully-qualified plugin table
581 // name from doTableNameReplacements / $wpdb->prefix; never user input.
582 // DAO-bypass-approved: Schema-bootstrap inside verifyTableMaterialized() — verifies CREATE TABLE actually materialized; DAO timeout wrapper is irrelevant for DDL existence probe
583 $found = $wpdb->get_var("SHOW TABLES LIKE '" . esc_sql($tableName) . "'");
584 if ($found === $tableName) {
585 return true;
586 }
587 $this->logger->errorMessage(
588 "CREATE TABLE did not materialize '" . $tableName . "' "
589 . "(placeholder " . $placeholder . "). "
590 . "Table is still missing on disk after CREATE TABLE IF NOT EXISTS ran. "
591 . "Likely causes: concurrent DROP from a parallel request, "
592 . "SQL parse error suppressed by queryAndGetResults, "
593 . "prefix mismatch between request and wp-config table_prefix, "
594 . "or insufficient CREATE TABLE privileges on the DB user."
595 );
596 return false;
597 }
598
599 /**
600 * @param string $createTableSql
601 * @return string
602 */
603 function applyPluginTableCharsetCollate($createTableSql) {
604 global $wpdb;
605 if (!is_string($createTableSql) || $createTableSql === '') {
606 return $createTableSql;
607 }
608
609 // Always prefer utf8mb4 for plugin tables, regardless of site defaults.
610 $collate = 'utf8mb4_unicode_ci';
611 if (!empty($wpdb->collate) && stripos($wpdb->collate, 'utf8mb4') !== false) {
612 $collate = $wpdb->collate;
613 }
614
615 $createTableSql = str_replace('{COLLATION}', $collate, $createTableSql);
616 // If the statement already specifies charset/collation, don't override.
617 if (preg_match('/\b(?:default\s+)?(?:character\s+set|charset|collate)\b/i', $createTableSql)) {
618 return $createTableSql;
619 }
620
621 return rtrim($createTableSql) . " DEFAULT CHARACTER SET utf8mb4 COLLATE {$collate}";
622 }
623
624 }
625