PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
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.1.19, at includes/DatabaseUpgradesEtc.php

1,572 lines 61.8 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
13 /* Functions in this class should all reference one of the following variables or support functions that do.
14 * $wpdb, $_GET, $_POST, $_SERVER, $_.*
15 * everything $wpdb related.
16 * everything $_GET, $_POST, (etc) related.
17 * Read the database, Store to the database,
18 */
19
20 class ABJ_404_Solution_DatabaseUpgradesEtc {
21
22 /** @var self|null */
23 private static $instance = null;
24
25 /** @var string|null */
26 private static $uniqID = null;
27
28 /**
29 * Per-request dedup flag for scheduleLogsv2CanonicalUrlBackfill().
30 * Mirrors DataAccess::$hitsTableRebuildScheduled — ensures the shutdown
31 * hook is registered at most once per request even if the schedule
32 * function is called from multiple paths (Captured-404s tab render +
33 * Stats panel + EmailDigest, etc.). Reset to false naturally when the
34 * PHP process ends; persistent SAPIs (PHP-FPM, mod_php) reset it
35 * implicitly between requests because static is process-local.
36 *
37 * @var bool
38 */
39 private static $logsv2CanonicalBackfillScheduled = false;
40
41 /** @var ABJ_404_Solution_DataAccess */
42 private $dao;
43
44 /** @var ABJ_404_Solution_Logging */
45 private $logger;
46
47 /** @var ABJ_404_Solution_Functions */
48 private $f;
49
50 /** @var ABJ_404_Solution_PermalinkCache */
51 private $permalinkCache;
52
53 /** @var ABJ_404_Solution_SynchronizationUtils */
54 private $syncUtils;
55
56 /** @var ABJ_404_Solution_PluginLogic */
57 private $logic;
58
59 /** @var ABJ_404_Solution_NGramFilter */
60 private $ngramFilter;
61
62 use ABJ_404_Solution_DatabaseUpgradesEtc_NGramTrait;
63 use ABJ_404_Solution_DatabaseUpgradesEtc_MaintenanceTrait;
64 use ABJ_404_Solution_DatabaseUpgradesEtc_PluginUpdateTrait;
65 use ABJ_404_Solution_DatabaseUpgradesEtc_TableRepairTrait;
66 use ABJ_404_Solution_DatabaseUpgradesEtc_IndexesTrait;
67
68 /**
69 * Constructor with dependency injection.
70 *
71 * @param ABJ_404_Solution_DataAccess|null $dataAccess Data access layer
72 * @param ABJ_404_Solution_Logging|null $logging Logging service
73 * @param ABJ_404_Solution_Functions|null $functions String utilities
74 * @param ABJ_404_Solution_PermalinkCache|null $permalinkCache Permalink cache service
75 * @param ABJ_404_Solution_SynchronizationUtils|null $syncUtils Sync utilities
76 * @param ABJ_404_Solution_PluginLogic|null $pluginLogic Business logic service
77 * @param ABJ_404_Solution_NGramFilter|null $ngramFilter N-gram filter service
78 */
79 public function __construct($dataAccess = null, $logging = null, $functions = null, $permalinkCache = null, $syncUtils = null, $pluginLogic = null, $ngramFilter = null) {
80 // Use injected dependencies or fall back to getInstance() for backward compatibility
81 $this->dao = $dataAccess !== null ? $dataAccess : abj_service('data_access');
82 $this->logger = $logging !== null ? $logging : abj_service('logging');
83 $this->f = $functions !== null ? $functions : abj_service('functions');
84 $this->permalinkCache = $permalinkCache !== null ? $permalinkCache : abj_service('permalink_cache');
85 $this->syncUtils = $syncUtils !== null ? $syncUtils : abj_service('sync_utils');
86 $this->logic = $pluginLogic !== null ? $pluginLogic : abj_service('plugin_logic');
87 $this->ngramFilter = $ngramFilter !== null ? $ngramFilter : abj_service('ngram_filter');
88 }
89
90 /** @return self */
91 public static function getInstance() {
92 if (self::$instance == null) {
93 self::$instance = new ABJ_404_Solution_DatabaseUpgradesEtc();
94 self::$uniqID = uniqid("", true);
95 }
96
97 return self::$instance;
98 }
99
100 /** Create the tables when the plugin is first activated.
101 * @param bool $updatingToNewVersion
102 * @return void
103 */
104 function createDatabaseTables($updatingToNewVersion = false, bool $force = false) {
105
106 $synchronizedKeyFromUser = "create_db_tables";
107 $uniqueID = null;
108
109 if (!$force) {
110 $uniqueID = $this->syncUtils->synchronizerAcquireLockTry($synchronizedKeyFromUser);
111
112 if ($uniqueID == '' || $uniqueID == null) {
113 $this->logger->debugMessage("Avoiding multiple calls for creating database tables.");
114 return;
115 }
116 }
117
118 // Fixed: Use finally block to ensure lock is ALWAYS released, even on fatal errors
119 try {
120 $this->reallyCreateDatabaseTables($updatingToNewVersion);
121
122 } catch (\Exception $e) {
123 $this->logger->errorMessage("Error creating database tables. ", $e);
124 throw $e; // Re-throw to propagate the error
125 } finally {
126 // Release the lock only if one was acquired (non-forced path).
127 if ($uniqueID !== null && $uniqueID !== '') {
128 $this->syncUtils->synchronizerReleaseLock($uniqueID, $synchronizedKeyFromUser);
129 }
130 }
131 }
132
133 /**
134 * @param bool $updatingToNewVersion
135 * @return void
136 */
137 private function reallyCreateDatabaseTables($updatingToNewVersion = false) {
138 if ($updatingToNewVersion) {
139 $this->correctIssuesBefore();
140 }
141
142 // MULTISITE: Process current site immediately, schedule background task for remaining sites
143 if ($this->isNetworkActivated() && !$updatingToNewVersion) {
144 // Activation path: create tables for current site + schedule background for others.
145 $currentBlogId = get_current_blog_id();
146 $this->runInitialCreateTables();
147 $this->correctCollations();
148 $this->updateTableEngineToInnoDB();
149 $this->createIndexes();
150
151 // First chunk of the canonical_url backfill runs in-band so newly
152 // upgraded small sites finish in one shot. Larger sites converge
153 // over subsequent daily-maintenance cron ticks (same method).
154 $this->backfillRedirectsCanonicalUrl();
155
156 $this->logger->infoMessage(sprintf(
157 "Network activation: Created tables for current site (ID %d). Scheduling background task for remaining sites.",
158 $currentBlogId
159 ));
160
161 $this->scheduleBackgroundMultisiteActivation($currentBlogId);
162
163 } else if ($this->isNetworkActivated() && $updatingToNewVersion) {
164 // Upgrade path on a network install: update tables for current site + schedule
165 // background upgrade for other sites (so sub-site tables are also updated).
166 $currentBlogId = get_current_blog_id();
167 $this->runInitialCreateTables();
168 $this->correctCollations();
169 $this->updateTableEngineToInnoDB();
170 $this->createIndexes();
171
172 // First chunk of the canonical_url backfill runs in-band so newly
173 // upgraded small sites finish in one shot. Larger sites converge
174 // over subsequent daily-maintenance cron ticks (same method).
175 $this->backfillRedirectsCanonicalUrl();
176
177 $this->logger->infoMessage(sprintf(
178 "Network upgrade: Updated tables for current site (ID %d). Scheduling background upgrade for remaining sites.",
179 $currentBlogId
180 ));
181
182 $this->scheduleBackgroundMultisiteUpgrade($currentBlogId);
183
184 } else {
185 // Single site (or non-network-activated): create/update tables for current site only.
186 $this->runInitialCreateTables();
187 $this->correctCollations();
188 $this->updateTableEngineToInnoDB();
189 $this->createIndexes();
190
191 // First chunk of the canonical_url backfill runs in-band so newly
192 // upgraded small sites finish in one shot. Larger sites converge
193 // over subsequent daily-maintenance cron ticks (same method).
194 $this->backfillRedirectsCanonicalUrl();
195 }
196
197 // Adopt orphaned tables AFTER target tables exist (rename handles prefix mismatches).
198 $this->renameAbj404TablesToLowerCase();
199
200 // we could do this only when a table is created or when the "meta" column is created
201 // but it doesn't take long anyway so we do it every night.
202 $this->permalinkCache->updatePermalinkCache(1);
203
204 // One-time N-gram cache initialization (async via WP-Cron to prevent blocking)
205 // MULTISITE: Use network-aware option getter to check initialization status
206 if ($this->getNetworkAwareOption('abj404_ngram_cache_initialized') !== '1') {
207 $this->logger->debugMessage("N-gram cache not initialized. Scheduling background build...");
208
209 // Schedule async rebuild via WP-Cron instead of blocking activation
210 $this->scheduleNGramCacheRebuild();
211
212 // Show admin notice that build is scheduled
213 if ($updatingToNewVersion && function_exists('add_settings_error')) {
214 $context = is_multisite() && $this->isNetworkActivated() ? ' across all sites in the network' : '';
215 $message = sprintf(
216 __('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'),
217 $context
218 );
219 add_settings_error('abj404_settings', 'ngram_cache_scheduled', $message, 'updated');
220 }
221
222 $this->logger->infoMessage("N-gram cache rebuild scheduled via WP-Cron.");
223 } else {
224 $this->logger->debugMessage("N-gram cache already initialized. Skipping rebuild.");
225 }
226
227 // Run one-time migration to relative paths (Issue #24)
228 if (get_option('abj404_migrated_to_relative_paths') !== '1') {
229 $migrationResults = $this->migrateURLsToRelativePaths();
230
231 // Show admin notice if migration occurred
232 if ($updatingToNewVersion && !empty($migrationResults['redirects_updated'])) {
233 $rawRedirectsUpdated = $migrationResults['redirects_updated'];
234 $redirectsUpdated = is_scalar($rawRedirectsUpdated) ? (int)$rawRedirectsUpdated : 0;
235 $message = sprintf(
236 _n(
237 '404 Solution: Migrated %d redirect to subdirectory-independent format.',
238 '404 Solution: Migrated %d redirects to subdirectory-independent format.',
239 $redirectsUpdated,
240 '404-solution'
241 ),
242 $redirectsUpdated
243 );
244 if (function_exists('add_settings_error')) {
245 add_settings_error('abj404_settings', 'migration_success', $message, 'updated');
246 }
247 }
248 }
249
250 if ($updatingToNewVersion) {
251 $this->correctIssuesAfter();
252 }
253 }
254
255 /**
256 * Makes all plugin table names lowercase, in case someone thought it was funny to use
257 * the lower_case_table_names=0 setting. Also detects and adopts orphaned plugin tables
258 * under old prefixes (from site migrations or the rename bug in v2.35.16–v3.x).
259 * @return void
260 */
261 function renameAbj404TablesToLowerCase() {
262 global $wpdb;
263
264 // On case-insensitive MySQL (lower_case_table_names >= 1), table names
265 // are already treated as lowercase internally. Renaming is pointless and
266 // can cause issues on some hosting setups.
267 // DAO-bypass-approved: Schema-bootstrap inside renameAbj404TablesToLowerCase() — runs before plugin DAO is fully wired during DB upgrades
268 $lctnResult = $wpdb->get_row("SHOW VARIABLES LIKE 'lower_case_table_names'", ARRAY_A);
269 if (is_array($lctnResult)) {
270 $lctnValue = null;
271 foreach ($lctnResult as $key => $value) {
272 if (strtolower((string)$key) === 'value') {
273 $lctnValue = $value;
274 break;
275 }
276 }
277 if ($lctnValue !== null && (int)$lctnValue >= 1) {
278 // MySQL already handles table names case-insensitively.
279 // Still run adoption check in case of prefix mismatch.
280 $this->adoptOrphanedTables();
281 return;
282 }
283 }
284
285 // Fetch all tables containing "abj404", case-insensitive
286 $dbNameRaw = $wpdb->dbname ?? '';
287 if ($dbNameRaw === '') {
288 $this->logger->warn("Could not determine database name for lowercase rename.");
289 return;
290 }
291 $dbNameEscaped = esc_sql($dbNameRaw);
292 $dbName = is_array($dbNameEscaped) ? '' : $dbNameEscaped;
293 $query = "SELECT table_name
294 FROM information_schema.tables
295 WHERE table_schema = '{$dbName}'
296 AND LOWER(table_name) LIKE '%abj404%'";
297 $results = $this->dao->queryAndGetResults($query);
298
299 if (!is_array($results['rows'])) {
300 $this->logger->warn("Could not query information_schema tables for lowercase rename.");
301 return;
302 }
303
304 foreach ($results['rows'] as $row) {
305 // Case-insensitive key lookup: MySQL drivers return information_schema
306 // column names in varying cases (table_name, TABLE_NAME, Table_Name).
307 $tableName = null;
308 foreach ($row as $key => $value) {
309 if (strtolower((string)$key) === 'table_name') {
310 $tableName = $value;
311 break;
312 }
313 }
314
315 if (!empty($tableName)) {
316 $lowercaseName = strtolower($tableName);
317
318 // Check if the table name is already lowercase, skip if it is
319 if ($tableName !== $lowercaseName) {
320 // Rename the table to lowercase
321 $renameQuery = "RENAME TABLE `{$tableName}` TO `{$lowercaseName}`";
322 $this->dao->queryAndGetResults($renameQuery,
323 ['ignore_errors' => ["already exists"]]);
324 $this->logger->infoMessage("Renamed table {$tableName} to {$lowercaseName}\n");
325 }
326 } else {
327 $this->logger->warn("I didn't find a table name in the results of this row: " .
328 print_r($row, true));
329 }
330 }
331
332 // After renaming, check for orphaned tables under old prefixes.
333 $this->adoptOrphanedTables();
334 }
335
336 /**
337 * Number of rows updated per chunk by backfillRedirectsCanonicalUrl().
338 * Sized so a single chunk completes well under the standard 60s query
339 * timeout even on slow disks; the chunk loop will keep going until the
340 * per-invocation budget is exhausted.
341 *
342 * Defined here (not on the trait) because trait constants require PHP 8.2+
343 * and the plugin supports PHP 7.4. The trait references this via self::
344 * which resolves to the using class at compile time.
345 */
346 const CANONICAL_URL_BACKFILL_CHUNK_SIZE = 5000;
347
348 /**
349 * Per-invocation wall-clock budget (seconds) for backfillRedirectsCanonicalUrl().
350 * Bounds how long the daily cron / activation handler will spend on this
351 * task in one call so a 350K-row site finishes over a few cron ticks
352 * instead of all in one request that risks PHP max_execution_time.
353 */
354 const CANONICAL_URL_BACKFILL_TIME_BUDGET_SEC = 25;
355
356 /**
357 * Per-invocation wall-clock budget (seconds) for backfillLogsv2CanonicalUrl().
358 * Tighter than the redirects-side budget because logsv2 backfill can also
359 * be triggered from the Captured-404s admin-tab shutdown hook, which
360 * holds a PHP-FPM worker for the duration. 15s caps worker-hold to a
361 * window short enough that concurrent visitors are unlikely to notice
362 * worker-pool pressure on shared hosts. Daily cron uses the same budget
363 * so convergence math (~25K-75K rows per invocation) is consistent.
364 */
365 const LOGSV2_CANONICAL_URL_BACKFILL_TIME_BUDGET_SEC = 15;
366
367 /**
368 * wp_options key that flips to '1' once backfillLogsv2CanonicalUrl()
369 * confirms zero NULL rows remain on logsv2.canonical_url. Once set, the
370 * read-side query can drop the COALESCE fallback and use the no-COALESCE
371 * form ("logsv2.canonical_url = redirects.canonical_url"); the planner
372 * picks the smaller side as driver and skips the Filter step (~17,000x
373 * cost reduction vs the COALESCE form per the redirects-temp-table-perf
374 * writeup).
375 *
376 * Stored as autoload=false so the option doesn't bloat the autoloaded
377 * options blob on every request — read on the captured-404s render path
378 * only, which already triggers wp_cache lookups for related options.
379 */
380 const LOGSV2_CANONICAL_URL_BACKFILL_COMPLETE_OPTION = 'abj404_logsv2_canonical_url_backfill_complete';
381
382 /**
383 * Known plugin table suffixes for adoption.
384 * @var array<int, string>
385 */
386 private const PLUGIN_TABLE_SUFFIXES = [
387 'abj404_redirects',
388 'abj404_logsv2',
389 'abj404_spelling_cache',
390 'abj404_permalink_cache',
391 'abj404_lookup',
392 'abj404_ngram_cache',
393 'abj404_logs_hits',
394 'abj404_redirect_conditions',
395 'abj404_engine_profiles',
396 'abj404_view_cache',
397 ];
398
399 /**
400 * Detect orphaned plugin tables under old prefixes and adopt their data
401 * into the current-prefix tables. Uses slug verification against the logs
402 * table to confirm ownership before adopting.
403 *
404 * @return void
405 */
406 private function adoptOrphanedTables(): void {
407 global $wpdb;
408
409 $dbNameRaw = $wpdb->dbname ?? '';
410 if ($dbNameRaw === '') {
411 return;
412 }
413 // @utf8-audit: opt-out — $wpdb->dbname is set by WordPress at
414 // bootstrap from wp-config.php; never user input.
415 $dbNameEscaped = esc_sql($dbNameRaw);
416 $dbName = is_array($dbNameEscaped) ? '' : $dbNameEscaped;
417
418 // Find all abj404 tables in the database, grouped by prefix.
419 $query = "SELECT table_name
420 FROM information_schema.tables
421 WHERE table_schema = '{$dbName}'
422 AND LOWER(table_name) LIKE '%abj404\\_%'";
423 $results = $this->dao->queryAndGetResults($query);
424
425 if (!is_array($results['rows']) || empty($results['rows'])) {
426 return;
427 }
428
429 $currentPrefix = $this->dao->getLowercasePrefix();
430
431 // Group tables by their prefix (everything before 'abj404_').
432 /** @var array<string, array<string>> prefix => [table_name, ...] */
433 $tablesByPrefix = [];
434 foreach ($results['rows'] as $row) {
435 $tableName = null;
436 foreach ($row as $key => $value) {
437 if (strtolower((string)$key) === 'table_name') {
438 $tableName = strtolower((string)$value);
439 break;
440 }
441 }
442 if ($tableName === null) {
443 continue;
444 }
445
446 $abj404Pos = strpos($tableName, 'abj404_');
447 if ($abj404Pos === false) {
448 continue;
449 }
450
451 $prefix = substr($tableName, 0, $abj404Pos);
452 $tablesByPrefix[$prefix][] = $tableName;
453 }
454
455 // Skip prefixes we've already adopted.
456 $adoptedPrefixes = get_option('abj404_adopted_prefixes', array());
457 if (!is_array($adoptedPrefixes)) {
458 $adoptedPrefixes = array();
459 }
460
461 // Process each OLD prefix (not the current one).
462 foreach ($tablesByPrefix as $oldPrefix => $tables) {
463 if ($oldPrefix === $currentPrefix) {
464 continue;
465 }
466 if (in_array($oldPrefix, $adoptedPrefixes, true)) {
467 continue;
468 }
469
470 $this->logger->infoMessage(
471 "Found orphaned plugin tables under prefix '{$oldPrefix}' "
472 . "(current prefix is '{$currentPrefix}'): " . implode(', ', $tables)
473 );
474
475 // Check if old tables have any data at all.
476 $totalRows = $this->countOldPrefixRows($oldPrefix, $tables);
477 if ($totalRows === 0) {
478 $this->logger->infoMessage(
479 "Orphaned tables under prefix '{$oldPrefix}' are all empty. Skipping adoption."
480 );
481 continue;
482 }
483
484 // Verify ownership via logs dest_url slug matching.
485 $matchResult = $this->verifyOwnershipViaLogs($oldPrefix);
486
487 if ($matchResult === null) {
488 // Logs verification returned no data — fall back to redirects post-ID check.
489 $matchResult = $this->verifyOwnershipViaRedirects($oldPrefix);
490 }
491
492 if ($matchResult !== true) {
493 // false = data doesn't match this site; null = insufficient data to verify.
494 // Either way, do not adopt — absence of veto is not permission.
495 $reason = ($matchResult === false)
496 ? "Data does not appear to belong to this site."
497 : "Insufficient data in logs and redirects to verify ownership.";
498 $this->logger->infoMessage(
499 "Orphaned tables under prefix '{$oldPrefix}' — skipping adoption. {$reason}"
500 );
501 continue;
502 }
503
504 // Ownership positively verified — adopt the data.
505 $this->adoptDataFromPrefix($oldPrefix, $currentPrefix, $tables);
506 }
507 }
508
509 /**
510 * Count total rows across all known plugin tables for a given prefix.
511 *
512 * @param string $oldPrefix
513 * @param array<int, string> $knownTables Table names actually found in information_schema.
514 * @return int
515 */
516 private function countOldPrefixRows(string $oldPrefix, array $knownTables): int {
517 $total = 0;
518 foreach (self::PLUGIN_TABLE_SUFFIXES as $suffix) {
519 $tableName = $oldPrefix . $suffix;
520 if (!in_array($tableName, $knownTables, true)) {
521 continue;
522 }
523 $result = $this->dao->queryAndGetResults(
524 "SELECT COUNT(*) AS cnt FROM `{$tableName}`",
525 ['ignore_errors' => ["doesn't exist", "not found"]]
526 );
527 if (is_array($result['rows']) && !empty($result['rows'])) {
528 $row = $result['rows'][0];
529 $cnt = is_array($row) ? (int)($row['cnt'] ?? $row['CNT'] ?? 0) : 0;
530 $total += $cnt;
531 }
532 }
533 return $total;
534 }
535
536 /**
537 * Verify ownership of orphaned tables by matching logs dest_url against
538 * current site's published post slugs.
539 *
540 * @param string $oldPrefix The old table prefix.
541 * @return bool|null true = verified, false = failed, null = no data to verify.
542 */
543 private function verifyOwnershipViaLogs(string $oldPrefix): ?bool {
544 global $wpdb;
545 $logsTable = $oldPrefix . 'abj404_logsv2';
546 // WordPress core posts table uses the original $wpdb->prefix (possibly mixed-case),
547 // NOT our lowercased prefix. Only plugin tables were renamed to lowercase.
548 $postsTable = ($wpdb->prefix ?? 'wp_') . 'posts';
549
550 // Check distinct internal dest_urls against published post slugs.
551 $query = "SELECT COUNT(*) AS total,
552 SUM(CASE WHEN matched = 1 THEN 1 ELSE 0 END) AS matches
553 FROM (
554 SELECT DISTINCT dest_url,
555 EXISTS(SELECT 1 FROM `{$postsTable}` p
556 WHERE p.post_status = 'publish'
557 AND LENGTH(p.post_name) >= 3
558 AND LOCATE(p.post_name, dest_url) > 0) AS matched
559 FROM `{$logsTable}` l
560 WHERE dest_url IS NOT NULL
561 AND dest_url != ''
562 AND dest_url != '404'
563 AND dest_url NOT LIKE 'http://%'
564 AND dest_url NOT LIKE 'https://%'
565 LIMIT 500
566 ) sub";
567
568 $result = $this->dao->queryAndGetResults($query,
569 ['ignore_errors' => ["doesn't exist", "not found"]]);
570
571 if (!is_array($result['rows']) || empty($result['rows'])) {
572 return null;
573 }
574
575 $row = $result['rows'][0];
576 $total = 0;
577 $matches = 0;
578 foreach ($row as $key => $value) {
579 $lk = strtolower((string)$key);
580 if ($lk === 'total') { $total = (int)$value; }
581 if ($lk === 'matches') { $matches = (int)$value; }
582 }
583
584 if ($total === 0) {
585 return null; // No internal dest_urls to verify.
586 }
587
588 $matchPct = ($matches / max(1, $total)) * 100;
589 $this->logger->infoMessage(
590 "Logs ownership verification for prefix '{$oldPrefix}': "
591 . "{$matches}/{$total} distinct internal dest_urls match published post slugs "
592 . "({$matchPct}%)"
593 );
594
595 return $matchPct >= 80;
596 }
597
598 /**
599 * Fallback ownership verification using redirects table post-ID existence.
600 * Weaker than slug matching but useful when logs have no internal dest_urls.
601 *
602 * @param string $oldPrefix
603 * @return bool|null true = verified, false = failed, null = no data.
604 */
605 private function verifyOwnershipViaRedirects(string $oldPrefix): ?bool {
606 global $wpdb;
607 $redirectsTable = $oldPrefix . 'abj404_redirects';
608 $postsTable = ($wpdb->prefix ?? 'wp_') . 'posts';
609
610 $query = "SELECT COUNT(*) AS total,
611 SUM(CASE WHEN p.ID IS NOT NULL THEN 1 ELSE 0 END) AS matches
612 FROM `{$redirectsTable}` r
613 LEFT JOIN `{$postsTable}` p
614 ON p.ID = CAST(r.final_dest AS UNSIGNED)
615 AND p.post_status IN ('publish', 'draft', 'private')
616 WHERE r.type IN (1, 2, 3)";
617
618 $result = $this->dao->queryAndGetResults($query,
619 ['ignore_errors' => ["doesn't exist", "not found"]]);
620
621 if (!is_array($result['rows']) || empty($result['rows'])) {
622 return null;
623 }
624
625 $row = $result['rows'][0];
626 $total = 0;
627 $matches = 0;
628 foreach ($row as $key => $value) {
629 $lk = strtolower((string)$key);
630 if ($lk === 'total') { $total = (int)$value; }
631 if ($lk === 'matches') { $matches = (int)$value; }
632 }
633
634 if ($total === 0) {
635 return null;
636 }
637
638 $matchPct = ($matches / max(1, $total)) * 100;
639 $this->logger->infoMessage(
640 "Redirects fallback ownership verification for prefix '{$oldPrefix}': "
641 . "{$matches}/{$total} type 1/2/3 redirects point to existing posts ({$matchPct}%)"
642 );
643
644 return $matchPct >= 80;
645 }
646
647 /**
648 * Adopt data from orphaned tables under an old prefix into current-prefix tables.
649 * Uses INSERT IGNORE to avoid duplicate key conflicts.
650 *
651 * @param string $oldPrefix
652 * @param string $currentPrefix
653 * @param array<string> $knownTables
654 * @return void
655 */
656 private function adoptDataFromPrefix(string $oldPrefix, string $currentPrefix, array $knownTables): void {
657 $this->logger->infoMessage(
658 "Beginning adoption of data from prefix '{$oldPrefix}' to '{$currentPrefix}'"
659 );
660
661 $totalAdopted = 0;
662
663 foreach (self::PLUGIN_TABLE_SUFFIXES as $suffix) {
664 $oldTable = $oldPrefix . $suffix;
665 if (!in_array($oldTable, $knownTables, true)) {
666 continue;
667 }
668 $newTable = $currentPrefix . $suffix;
669
670 // Check if old table exists and has rows.
671 $countResult = $this->dao->queryAndGetResults(
672 "SELECT COUNT(*) AS cnt FROM `{$oldTable}`",
673 ['ignore_errors' => ["doesn't exist", "not found"]]
674 );
675 if (!is_array($countResult['rows']) || empty($countResult['rows'])) {
676 continue;
677 }
678 $row = $countResult['rows'][0];
679 $oldCount = is_array($row) ? (int)($row['cnt'] ?? $row['CNT'] ?? 0) : 0;
680 if ($oldCount === 0) {
681 continue;
682 }
683
684 // Check if new table exists (it should — auto-repair creates them).
685 $newExists = $this->dao->queryAndGetResults(
686 "SELECT 1 FROM `{$newTable}` LIMIT 1",
687 ['ignore_errors' => ["doesn't exist", "not found"]]
688 );
689 if (!empty($newExists['last_error'])) {
690 $this->logger->infoMessage(
691 "Target table '{$newTable}' does not exist yet. Skipping adoption for '{$suffix}'."
692 );
693 continue;
694 }
695
696 // Build a column-matched INSERT to handle schema drift between old and new tables.
697 // Old tables from older plugin versions may have fewer or different columns.
698 $commonColumns = $this->getCommonColumns($oldTable, $newTable);
699 if (empty($commonColumns)) {
700 $this->logger->infoMessage(
701 "No common columns found between '{$oldTable}' and '{$newTable}'. Skipping."
702 );
703 continue;
704 }
705
706 $columnList = implode('`, `', $commonColumns);
707 $insertQuery = "INSERT IGNORE INTO `{$newTable}` (`{$columnList}`) "
708 . "SELECT `{$columnList}` FROM `{$oldTable}`";
709 $insertResult = $this->dao->queryAndGetResults($insertQuery,
710 ['ignore_errors' => ["doesn't exist", "not found", "Duplicate"]]);
711
712 $affectedRows = 0;
713 if (is_array($insertResult) && isset($insertResult['rows_affected'])) {
714 $rawAffected = $insertResult['rows_affected'];
715 $affectedRows = is_numeric($rawAffected) ? (int)$rawAffected : 0;
716 }
717
718 if ($affectedRows > 0) {
719 $totalAdopted += $affectedRows;
720 $this->logger->infoMessage(
721 "Adopted {$affectedRows} rows from '{$oldTable}' into '{$newTable}'"
722 );
723 }
724 }
725
726 $this->logger->infoMessage(
727 "Adoption complete: {$totalAdopted} total rows adopted from prefix '{$oldPrefix}' to '{$currentPrefix}'"
728 );
729
730 // Record this prefix as adopted so we don't re-detect it on every page load.
731 $adoptedPrefixes = get_option('abj404_adopted_prefixes', array());
732 if (!is_array($adoptedPrefixes)) {
733 $adoptedPrefixes = array();
734 }
735 if (!in_array($oldPrefix, $adoptedPrefixes, true)) {
736 $adoptedPrefixes[] = $oldPrefix;
737 update_option('abj404_adopted_prefixes', $adoptedPrefixes, false);
738 }
739 }
740
741 /**
742 * Get the list of column names that exist in both tables.
743 * Used by adoptDataFromPrefix() to build column-matched INSERTs
744 * that survive schema drift between plugin versions.
745 *
746 * @param string $tableA
747 * @param string $tableB
748 * @return array<int, string> Column names present in both tables (lowercase).
749 */
750 private function getCommonColumns(string $tableA, string $tableB): array {
751 $colsA = $this->getTableColumns($tableA);
752 $colsB = $this->getTableColumns($tableB);
753
754 if (empty($colsA) || empty($colsB)) {
755 return [];
756 }
757
758 return array_values(array_intersect($colsA, $colsB));
759 }
760
761 /**
762 * Get column names for a table via SHOW COLUMNS.
763 *
764 * @param string $tableName
765 * @return array<int, string> Column names (lowercase).
766 */
767 private function getTableColumns(string $tableName): array {
768 $result = $this->dao->queryAndGetResults(
769 "SHOW COLUMNS FROM `{$tableName}`",
770 ['ignore_errors' => ["doesn't exist", "not found"]]
771 );
772
773 if (!is_array($result['rows']) || empty($result['rows'])) {
774 return [];
775 }
776
777 $columns = [];
778 foreach ($result['rows'] as $row) {
779 // SHOW COLUMNS returns 'Field' key — case-insensitive lookup.
780 $colName = null;
781 foreach ($row as $key => $value) {
782 if (strtolower((string)$key) === 'field') {
783 $colName = strtolower((string)$value);
784 break;
785 }
786 }
787 if ($colName !== null) {
788 $columns[] = $colName;
789 }
790 }
791
792 return $columns;
793 }
794
795 /** When certain columns are created we have to populate data.
796 * @param string $tableName
797 * @param string $colName
798 * @return void
799 */
800 function handleSpecificCases($tableName, $colName) {
801 if (empty($tableName) || !is_string($tableName)) {
802 return;
803 }
804
805 if (strpos($tableName, 'abj404_logsv2') !== false && $colName == 'min_log_id') {
806 global $wpdb;
807 $query = ABJ_404_Solution_Functions::readFileContents(__DIR__ . "/sql/logsSetMinLogID.sql");
808 $this->dao->queryAndGetResults($query);
809 // Ensure composite index exists after backfilling min_log_id.
810 $this->ensureLogsCompositeIndex($tableName);
811 }
812 if (strpos($tableName, 'abj404_permalink_cache') !== false && $colName == 'url_length') {
813 // clear the permalink cache so that the url length column will be populated.
814 // this could be more efficient but I'll assume that's not necessary.
815 $this->dao->truncatePermalinkCacheTable();
816 }
817 }
818
819 /**
820 * Discover all permanent (non-Temp) DDL files and extract table metadata.
821 *
822 * @return array<int, array{placeholder: string, bareTableName: string, ddlContent: string}>
823 */
824 function discoverPermanentDDLFiles(): array {
825 $sqlDir = __DIR__ . '/sql';
826 $files = glob($sqlDir . '/create*Table.sql');
827 if (!is_array($files)) {
828 $files = [];
829 }
830 sort($files);
831
832 $result = [];
833 foreach ($files as $file) {
834 if (stripos(basename($file), 'Temp') !== false) {
835 continue;
836 }
837 $ddlContent = ABJ_404_Solution_Functions::readFileContents($file);
838 if (!is_string($ddlContent) || trim($ddlContent) === '') {
839 continue;
840 }
841 if (!preg_match('/\{(wp_(abj404_\w+))\}/', $ddlContent, $m)) {
842 continue;
843 }
844 // Transient staged-build tables (view_build, view_done, view_deleteme)
845 // are owned by ABJ_404_Solution_DataAccess_ViewQueriesStagedTrait.
846 // stageCreateBuildTable() creates view_build on demand, stageRenameSwap()
847 // renames it to view_done, and view_deleteme is the ephemeral previous-
848 // generation served table that gets dropped right after the swap. None
849 // of them should participate in the permanent-DDL bootstrap, repair, or
850 // missing-table check loops. Their absence between builds is normal,
851 // not a corruption signal.
852 if (in_array($m[2], array('abj404_view_build', 'abj404_view_done', 'abj404_view_deleteme'), true)) {
853 continue;
854 }
855 $result[] = [
856 'placeholder' => '{' . $m[1] . '}',
857 'bareTableName' => $m[2],
858 'ddlContent' => $ddlContent,
859 ];
860 }
861 return $result;
862 }
863
864 /** @return void */
865 function runInitialCreateTables() {
866 // Re-add a stripped `id` PRIMARY KEY (via ALTER) BEFORE any CREATE TABLE
867 // IF NOT EXISTS runs. Without this step, an existing-but-broken table
868 // (missing the file's `id` PRIMARY KEY) would survive the IF NOT EXISTS
869 // check and verifyColumns would only ALTER ADD the missing non-PK
870 // columns, leaving the table without its primary key. Lives here (not
871 // just in correctIssuesBefore) so cron callers of createDatabaseTables()
872 // — which don't pass the $updatingToNewVersion flag — also repair
873 // stripped tables instead of propagating the broken state.
874 $this->repairStrippedViewCacheTable();
875
876 foreach ($this->discoverPermanentDDLFiles() as $ddlEntry) {
877 $query = $this->applyPluginTableCharsetCollate($ddlEntry['ddlContent']);
878 $this->dao->queryAndGetResults($query);
879
880 $tableName = $this->dao->doTableNameReplacements($ddlEntry['placeholder']);
881
882 // Per-table post-CREATE verification: confirm the table actually
883 // exists on disk. queryAndGetResults logs SQL errors generically,
884 // but a silently-failing CREATE (concurrent DROP, swallowed parse
885 // error, prefix drift, or insufficient privileges) is invisible
886 // without an explicit existence check. Log per-table so the debug
887 // log identifies which DDL didn't materialize and why downstream
888 // auto-repair attempts will keep failing.
889 if (!$this->verifyTableMaterialized($tableName, $ddlEntry['placeholder'])) {
890 // Don't abort the loop — other tables can still get created.
891 continue;
892 }
893
894 // Targeted online-DDL column add(s) before the generic verifyColumns()
895 // flow runs a bare ALTER. On large logsv2 tables (multi-GB on
896 // busy sites) bare ADD COLUMN can block the table for tens of
897 // seconds; the targeted helper uses ALGORITHM=INPLACE, LOCK=NONE
898 // so InnoDB ≥ 5.6 picks the lockless online-DDL path. If the
899 // engine doesn't support it the helper falls back silently and
900 // verifyColumns() picks up the column add as a safety net.
901 if ($ddlEntry['bareTableName'] === 'abj404_logsv2') {
902 $this->ensureLogsv2CanonicalUrlColumn($tableName);
903 }
904 // Same logic for the redirects side. canonical_url is required by
905 // setupRedirect() and was added in 4.1.11; on a small fraction of
906 // sites dbDelta silently fails to add it, so every captured 404
907 // emits "Unknown column 'canonical_url' in 'field list'" until
908 // verifyColumns eventually retries. Eagerly running the targeted
909 // add closes that window.
910 if ($ddlEntry['bareTableName'] === 'abj404_redirects') {
911 $this->ensureRedirectsCanonicalUrlColumn($tableName);
912 }
913
914 $this->verifyColumns($tableName, $query);
915 }
916
917 // Table-specific post-creation steps.
918 $logsTable = $this->dao->doTableNameReplacements("{wp_abj404_logsv2}");
919 $this->ensureLogsCompositeIndex($logsTable);
920
921 // Mark view cache table as ensured so ensureViewSnapshotTableExists() skips redundant DDL.
922 ABJ_404_Solution_DataAccess::setViewSnapshotTableEnsured(true);
923 }
924
925 /**
926 * Verify that a CREATE TABLE actually materialized the named table on disk.
927 * Returns true if the table exists, false (and logs a per-table error) if not.
928 *
929 * Distinguishes silently-failing CREATEs from generic SQL errors so the
930 * debug log identifies which specific DDL didn't materialize. Common causes:
931 * concurrent DROP from a parallel cron, SQL parse error swallowed by
932 * queryAndGetResults, prefix drift between request and table_prefix in
933 * wp-config, or missing CREATE TABLE privileges on the DB user.
934 *
935 * @param string $tableName Fully-qualified table name (with prefix).
936 * @param string $placeholder Original placeholder (e.g. "{wp_abj404_redirects}") for diagnostic context.
937 * @return bool True if table exists post-CREATE, false otherwise.
938 */
939 private function verifyTableMaterialized(string $tableName, string $placeholder): bool {
940 global $wpdb;
941 if (!isset($wpdb)) {
942 return false;
943 }
944 // @utf8-audit: opt-out — $tableName is fully-qualified plugin table
945 // name from doTableNameReplacements / $wpdb->prefix; never user input.
946 // DAO-bypass-approved: Schema-bootstrap inside verifyTableMaterialized() — verifies CREATE TABLE actually materialized; DAO timeout wrapper is irrelevant for DDL existence probe
947 $found = $wpdb->get_var("SHOW TABLES LIKE '" . esc_sql($tableName) . "'");
948 if ($found === $tableName) {
949 return true;
950 }
951 $this->logger->errorMessage(
952 "CREATE TABLE did not materialize '" . $tableName . "' "
953 . "(placeholder " . $placeholder . "). "
954 . "Table is still missing on disk after CREATE TABLE IF NOT EXISTS ran. "
955 . "Likely causes: concurrent DROP from a parallel request, "
956 . "SQL parse error suppressed by queryAndGetResults, "
957 . "prefix mismatch between request and wp-config table_prefix, "
958 . "or insufficient CREATE TABLE privileges on the DB user."
959 );
960 return false;
961 }
962
963 /**
964 * @param string $createTableSql
965 * @return string
966 */
967 function applyPluginTableCharsetCollate($createTableSql) {
968 global $wpdb;
969 if (!is_string($createTableSql) || $createTableSql === '') {
970 return $createTableSql;
971 }
972 // If the statement already specifies charset/collation, don't override.
973 if (preg_match('/\b(?:default\s+)?(?:character\s+set|charset|collate)\b/i', $createTableSql)) {
974 return $createTableSql;
975 }
976
977 // Always prefer utf8mb4 for plugin tables, regardless of site defaults.
978 $collate = 'utf8mb4_unicode_ci';
979 if (!empty($wpdb->collate) && stripos($wpdb->collate, 'utf8mb4') !== false) {
980 $collate = $wpdb->collate;
981 }
982
983 return rtrim($createTableSql) . " DEFAULT CHARACTER SET utf8mb4 COLLATE {$collate}";
984 }
985
986 /**
987 * Schedule a background multisite batch operation.
988 *
989 * @param string $optionPrefix e.g. 'abj404_activation' or 'abj404_upgrade'
990 * @param string $hookName e.g. 'abj404_network_activation_background'
991 * @param string $label Human-readable label for log messages, e.g. 'activation'
992 * @param int $alreadyProcessedBlogId Blog ID already processed on this request.
993 * @return void
994 */
995 private function scheduleBackgroundMultisiteBatch(string $optionPrefix, string $hookName, string $label, int $alreadyProcessedBlogId): void {
996 update_site_option($optionPrefix . '_processed_blogs', array($alreadyProcessedBlogId));
997 update_site_option($optionPrefix . '_in_progress', true);
998
999 if (wp_next_scheduled($hookName)) {
1000 $this->logger->debugMessage("Background multisite $label already scheduled.");
1001 return;
1002 }
1003
1004 $scheduled = wp_schedule_single_event(time() + 30, $hookName);
1005
1006 if ($scheduled === false) {
1007 $this->logger->errorMessage("Failed to schedule background multisite $label. Remaining sites will not be processed automatically.");
1008 } else {
1009 $this->logger->infoMessage("Background multisite $label scheduled successfully.");
1010 }
1011 }
1012
1013 /**
1014 * Process a batch of multisite sites with the given per-site action.
1015 *
1016 * @param string $optionPrefix e.g. 'abj404_activation' or 'abj404_upgrade'
1017 * @param string $hookName e.g. 'abj404_network_activation_background'
1018 * @param string $label Human-readable label for log messages, e.g. 'activation'
1019 * @param callable $perSiteAction Called for each site (receives int $siteId).
1020 * @return bool True if all sites are done, false if more batches needed.
1021 */
1022 public function processMultisiteBatch(string $optionPrefix, string $hookName, string $label, callable $perSiteAction): bool {
1023 $processedBlogs = get_site_option($optionPrefix . '_processed_blogs', array());
1024 if (!is_array($processedBlogs)) {
1025 $processedBlogs = array();
1026 }
1027
1028 $allSites = get_sites(array('fields' => 'ids', 'number' => 0));
1029 $remainingSites = array_diff($allSites, $processedBlogs);
1030
1031 if (empty($remainingSites)) {
1032 delete_site_option($optionPrefix . '_processed_blogs');
1033 delete_site_option($optionPrefix . '_in_progress');
1034 $this->logger->infoMessage("Background multisite $label complete. All sites processed.");
1035 return true;
1036 }
1037
1038 $batchSize = 10;
1039 $sitesToProcess = array_slice($remainingSites, 0, $batchSize);
1040
1041 $this->logger->infoMessage(sprintf(
1042 "Processing multisite $label batch: %d sites (of %d remaining)",
1043 count($sitesToProcess),
1044 count($remainingSites)
1045 ));
1046
1047 foreach ($sitesToProcess as $siteId) {
1048 try {
1049 switch_to_blog($siteId);
1050 $this->logger->debugMessage(sprintf("Processing $label for site ID %d...", $siteId));
1051
1052 $perSiteAction((int)$siteId);
1053
1054 $processedBlogs[] = $siteId;
1055 update_site_option($optionPrefix . '_processed_blogs', $processedBlogs);
1056
1057 $this->logger->debugMessage(sprintf("Successfully processed $label for site ID %d", $siteId));
1058 } catch (Throwable $e) {
1059 $this->logger->errorMessage(sprintf(
1060 "Failed to process $label for site ID %d: %s",
1061 $siteId,
1062 $e->getMessage()
1063 ));
1064 $processedBlogs[] = $siteId;
1065 update_site_option($optionPrefix . '_processed_blogs', $processedBlogs);
1066 } finally {
1067 restore_current_blog();
1068 }
1069 }
1070
1071 $stillRemaining = count($remainingSites) - count($sitesToProcess);
1072 if ($stillRemaining > 0) {
1073 $this->logger->infoMessage(sprintf(
1074 "Batch complete. Rescheduling for %d remaining sites.",
1075 $stillRemaining
1076 ));
1077 wp_schedule_single_event(time() + 30, $hookName);
1078 return false;
1079 } else {
1080 delete_site_option($optionPrefix . '_processed_blogs');
1081 delete_site_option($optionPrefix . '_in_progress');
1082 $this->logger->infoMessage("Background multisite $label complete. All sites processed.");
1083 return true;
1084 }
1085 }
1086
1087 /**
1088 * Schedule a background activation for all network sites except the one that
1089 * was just activated synchronously.
1090 *
1091 * @param int $alreadyProcessedBlogId Blog ID of the site already activated.
1092 * @return void
1093 */
1094 private function scheduleBackgroundMultisiteActivation(int $alreadyProcessedBlogId): void {
1095 $this->scheduleBackgroundMultisiteBatch(
1096 'abj404_activation', 'abj404_network_activation_background', 'activation', $alreadyProcessedBlogId
1097 );
1098 }
1099
1100 /**
1101 * Process multisite activation in batches (called by WP-Cron).
1102 *
1103 * Processes remaining sites that weren't handled during initial activation.
1104 * Processes up to 10 sites per run to avoid timeouts, then reschedules itself
1105 * if more sites remain.
1106 *
1107 * @return bool True if all sites processed, false if more remain
1108 */
1109 public function processMultisiteActivationBatch(): bool {
1110 return $this->processMultisiteBatch(
1111 'abj404_activation',
1112 'abj404_network_activation_background',
1113 'activation',
1114 function (int $siteId): void {
1115 add_option('abj404_settings', '', '', false);
1116
1117 $this->runInitialCreateTables();
1118 $this->correctCollations();
1119 $this->updateTableEngineToInnoDB();
1120 $this->createIndexes();
1121 $this->backfillRedirectsCanonicalUrl();
1122 $this->renameAbj404TablesToLowerCase();
1123
1124 // Canonical self-heal prologue runs after schema creation so
1125 // SelfHealingPrologueReachabilityTest sees per-subsite activation
1126 // reach the same recovery primitives as the daily cron.
1127 $this->runSelfHealPrologue();
1128
1129 ABJ_404_Solution_PluginLogic::doRegisterCrons();
1130
1131 $logic = abj_service('plugin_logic');
1132 $logic->doUpdateDBVersionOption();
1133 }
1134 );
1135 }
1136
1137 /**
1138 * Schedule a background upgrade for all network sites except the one that
1139 * was just upgraded synchronously.
1140 *
1141 * @param int $alreadyProcessedBlogId Blog ID of the site already upgraded.
1142 * @return void
1143 */
1144 private function scheduleBackgroundMultisiteUpgrade(int $alreadyProcessedBlogId): void {
1145 $this->scheduleBackgroundMultisiteBatch(
1146 'abj404_upgrade', 'abj404_network_upgrade_background', 'upgrade', $alreadyProcessedBlogId
1147 );
1148 }
1149
1150 /**
1151 * Process multisite plugin upgrade in batches (called by WP-Cron).
1152 *
1153 * Upgrades remaining sites that weren't handled during the initial upgrade.
1154 * Processes up to 10 sites per run to avoid timeouts, then reschedules itself
1155 * if more sites remain.
1156 *
1157 * @return bool True if all sites processed, false if more remain.
1158 */
1159 public function processMultisiteUpgradeBatch(): bool {
1160 return $this->processMultisiteBatch(
1161 'abj404_upgrade',
1162 'abj404_network_upgrade_background',
1163 'upgrade',
1164 function (int $siteId): void {
1165 // Run the full upgrade sequence for this site without going through
1166 // createDatabaseTables() — that would re-schedule more background tasks.
1167 $this->correctIssuesBefore();
1168 $this->runInitialCreateTables();
1169 $this->correctCollations();
1170 $this->updateTableEngineToInnoDB();
1171 $this->createIndexes();
1172 $this->backfillRedirectsCanonicalUrl();
1173 $this->renameAbj404TablesToLowerCase();
1174 $this->correctIssuesAfter();
1175
1176 // Canonical self-heal prologue closes the per-subsite upgrade
1177 // batch so SelfHealingPrologueReachabilityTest can prove the
1178 // multisite upgrade path reaches the same recovery primitives
1179 // as the daily cron tick.
1180 $this->runSelfHealPrologue();
1181
1182 $logic = abj_service('plugin_logic');
1183 $logic->doUpdateDBVersionOption();
1184 }
1185 );
1186 }
1187
1188 /**
1189 * Create tables for all sites in a multisite network.
1190 *
1191 * This function iterates through all sites in the network and creates
1192 * the plugin's database tables for each site. This ensures that when
1193 * the plugin is network-activated, all sites have the necessary tables.
1194 *
1195 * @since 3.0.1
1196 */
1197 /**
1198 * @return void
1199 * @phpstan-ignore-next-line method.unused
1200 */
1201 private function createTablesForAllSites() {
1202 global $wpdb;
1203
1204 // Get all sites in the network
1205 $sites = get_sites(array('fields' => 'ids', 'number' => 0));
1206 $totalSites = count($sites);
1207 $successCount = 0;
1208 $failureCount = 0;
1209
1210 $this->logger->infoMessage(sprintf(
1211 "Starting network-wide table creation for %d sites.",
1212 $totalSites
1213 ));
1214
1215 foreach ($sites as $siteId) {
1216 try {
1217 // Switch to the site
1218 switch_to_blog($siteId);
1219
1220 $currentPrefix = $wpdb->prefix;
1221 $this->logger->debugMessage(sprintf(
1222 "Creating tables for site ID %d (prefix: %s)...",
1223 $siteId,
1224 $currentPrefix
1225 ));
1226
1227 // Create tables for this site
1228 $this->runInitialCreateTables();
1229 $this->correctCollations();
1230 $this->updateTableEngineToInnoDB();
1231 $this->createIndexes();
1232 $this->backfillRedirectsCanonicalUrl();
1233
1234 $successCount++;
1235 $this->logger->debugMessage(sprintf(
1236 "Successfully created tables for site ID %d (prefix: %s)",
1237 $siteId,
1238 $currentPrefix
1239 ));
1240
1241 } catch (Throwable $e) {
1242 $failureCount++;
1243 $this->logger->errorMessage(sprintf(
1244 "Failed to create tables for site ID %d (prefix: %s): %s",
1245 $siteId,
1246 $wpdb->prefix,
1247 $e->getMessage()
1248 ));
1249 } finally {
1250 // Always restore blog context
1251 restore_current_blog();
1252 }
1253 }
1254
1255 // Log summary
1256 $this->logger->infoMessage(sprintf(
1257 "Network-wide table creation complete: %d successful, %d failed out of %d total sites.",
1258 $successCount,
1259 $failureCount,
1260 $totalSites
1261 ));
1262
1263 if ($failureCount > 0) {
1264 $this->logger->errorMessage(sprintf(
1265 "Warning: Table creation failed for %d sites. Check error logs for details.",
1266 $failureCount
1267 ));
1268 }
1269 }
1270
1271
1272 /**
1273 * @param string $tableName
1274 * @param string $createTableStatementGoal
1275 * @return void
1276 */
1277 function verifyColumns($tableName, $createTableStatementGoal) {
1278 $updatesWereNeeded = false;
1279
1280 // find the differences
1281 $tableDifferences = $this->getTableDifferences($tableName, $createTableStatementGoal);
1282 $updateCols = is_array($tableDifferences['updateTheseColumns']) ? $tableDifferences['updateTheseColumns'] : [];
1283 $createCols = is_array($tableDifferences['createTheseColumns']) ? $tableDifferences['createTheseColumns'] : [];
1284 if (count($updateCols) > 0 ||
1285 count($createCols) > 0) {
1286 $updatesWereNeeded = true;
1287 }
1288 // make the changes
1289 $this->updateATableBasedOnDifferences($tableName, $tableDifferences);
1290
1291 // verify that there are now no changes that need to be made.
1292 $tableDifferences = $this->getTableDifferences($tableName, $createTableStatementGoal);
1293 $updateCols = is_array($tableDifferences['updateTheseColumns']) ? $tableDifferences['updateTheseColumns'] : [];
1294 $createCols = is_array($tableDifferences['createTheseColumns']) ? $tableDifferences['createTheseColumns'] : [];
1295
1296 if (count($updateCols) > 0 ||
1297 count($createCols) > 0) {
1298
1299 // Persistent post-update diff is usually a benign DDL-normalizer mismatch
1300 // (parser misreads a comment, column landed in a slightly-different form).
1301 // Plugin keeps functioning, so log at warn (stays in debug log without
1302 // crossing the email-threshold reporter). Defensive coding philosophy #8.
1303 $this->logger->warn("There are still differences after updating the " .
1304 $tableName . " table. " . print_r($tableDifferences, true));
1305
1306 } else if ($updatesWereNeeded) {
1307 $this->logger->infoMessage("No more differences found after updating the " .
1308 $tableName . " table columns. All is well.");
1309 }
1310 }
1311
1312 /**
1313 * @param string $tableName
1314 * @param string $createTableStatementGoal
1315 * @return array<string, mixed>
1316 */
1317 function getTableDifferences($tableName, $createTableStatementGoal) {
1318
1319 // get the current create table statement
1320 $existingTableSQL = $this->dao->getCreateTableDDL($tableName);
1321
1322 $existingTableSQL = strtolower($this->removeCommentsFromColumns($existingTableSQL));
1323 $createTableStatementGoal = strtolower(
1324 $this->removeCommentsFromColumns($createTableStatementGoal));
1325
1326 // remove the "COLLATE xxx" from the columns.
1327 $removeCollatePattern = '/collate[= ]\w+ ?/';
1328 $existingTableSQL = preg_replace($removeCollatePattern, "", $existingTableSQL) ?? '';
1329 $createTableStatementGoal = preg_replace($removeCollatePattern, "", $createTableStatementGoal) ?? '';
1330
1331 // remove the int size format from columns because it doesn't matter.
1332 $removeIntSizePattern = '/( \w*?int)(\(\d+\))/m';
1333 $existingTableSQL = preg_replace($removeIntSizePattern, "$1", $existingTableSQL) ?? '';
1334 $createTableStatementGoal = preg_replace($removeIntSizePattern, "$1", $createTableStatementGoal) ?? '';
1335
1336 // MySQL's SHOW CREATE TABLE omits "DEFAULT NULL" for TEXT/BLOB columns
1337 // (it's implicit). Normalize both sides so this doesn't flag as a mismatch.
1338 $removeTextDefaultNull = '/(text|blob|mediumtext|longtext|tinytext|mediumblob|longblob|tinyblob)\s+default\s+null/';
1339 $existingTableSQL = preg_replace($removeTextDefaultNull, "$1", $existingTableSQL) ?? $existingTableSQL;
1340 $createTableStatementGoal = preg_replace($removeTextDefaultNull, "$1", $createTableStatementGoal) ?? $createTableStatementGoal;
1341
1342 // get column names and types pattern (backticks are optional — accept both styles);
1343 // (?!key\b) guards against accidentally matching PRIMARY KEY / UNIQUE KEY lines.
1344 $colNamesAndTypesPattern = "/\s+?(`?(\w+?)`? (?!key\b)(\w.+)\s?),/";
1345 $existingTableMatches = null;
1346 $goalTableMatches = null;
1347 // match the existing table. use preg_match_all because I couldn't find an
1348 // "_all" option when using mb_ereg.
1349 preg_match_all($colNamesAndTypesPattern, $existingTableSQL, $existingTableMatches);
1350 preg_match_all($colNamesAndTypesPattern, $createTableStatementGoal, $goalTableMatches);
1351
1352 // get the matches.
1353 $goalTableMatchesColumnNames = $goalTableMatches[2];
1354 $existingTableMatchesColumnNames = $existingTableMatches[2];
1355
1356 // remove any spaces
1357 $goalTableMatchesColumnNames = array_map('trim', $goalTableMatchesColumnNames);
1358 $existingTableMatchesColumnNames = array_map('trim', $existingTableMatchesColumnNames);
1359
1360 // Safety guard: if the goal DDL produced zero column names the regex failed
1361 // to parse it (e.g. malformed or unparseable DDL). In that case never drop
1362 // any existing columns — an empty goal list would otherwise flag every real
1363 // column as "extra" and wipe the table.
1364 if (empty($goalTableMatchesColumnNames) && !empty($existingTableMatchesColumnNames)) {
1365 $this->logger->errorMessage("Goal DDL for " . $tableName .
1366 " produced no column matches -- the DDL may be malformed or unparseable. " .
1367 "Skipping column comparison to prevent data loss.");
1368 $dropTheseColumns = [];
1369 $createTheseColumns = [];
1370 return array("updateTheseColumns" => [],
1371 "dropTheseColumns" => [],
1372 "createTheseColumns" => [],
1373 "goalTableMatchesColumnDDL" => [],
1374 "existingTableMatchesColumnDDL" => [],
1375 "goalTableMatches" => $goalTableMatches,
1376 "goalTableMatchesColumnNames" => []
1377 );
1378 }
1379
1380 // see if some columns need to be created.
1381 $dropTheseColumns = array_diff($existingTableMatchesColumnNames,
1382 $goalTableMatchesColumnNames);
1383 $createTheseColumns = array_diff($goalTableMatchesColumnNames,
1384 $existingTableMatchesColumnNames);
1385
1386 // get the ddl for each column
1387 $goalTableMatchesColumnDDL = $goalTableMatches[1];
1388 $existingTableMatchesColumnDDL = $existingTableMatches[1];
1389
1390 // remove any spaces
1391 $goalTableMatchesColumnDDL = array_map('trim', $goalTableMatchesColumnDDL);
1392 $existingTableMatchesColumnDDL = array_map('trim', $existingTableMatchesColumnDDL);
1393
1394 // normalize minor differences between mysql versions (strip backticks so DDL
1395 // files using either quoting style compare equal to SHOW CREATE TABLE output)
1396 $goalTableMatchesColumnDDL = array_map([$this, 'normalizeColumnDDL'], $goalTableMatchesColumnDDL);
1397 $existingTableMatchesColumnDDL = array_map([$this, 'normalizeColumnDDL'], $existingTableMatchesColumnDDL);
1398
1399 // see if anything needs to be updated or created.
1400 $updateTheseColumns = array_diff($goalTableMatchesColumnDDL,
1401 $existingTableMatchesColumnDDL);
1402
1403 // wrap the results
1404 $results = array("updateTheseColumns" => $updateTheseColumns,
1405 "dropTheseColumns" => $dropTheseColumns,
1406 "createTheseColumns" => $createTheseColumns,
1407 "goalTableMatchesColumnDDL" => $goalTableMatchesColumnDDL,
1408 "existingTableMatchesColumnDDL" => $existingTableMatchesColumnDDL,
1409 "goalTableMatches" => $goalTableMatches,
1410 "goalTableMatchesColumnNames" => $goalTableMatchesColumnNames
1411 );
1412 return $results;
1413 }
1414
1415 /**
1416 * @param string $tableName
1417 * @param array<string, mixed> $tableDifferences
1418 * @return void
1419 */
1420 function updateATableBasedOnDifferences($tableName, $tableDifferences) {
1421
1422 /** @var array<int|string, mixed> $dropTheseColumns */
1423 $dropTheseColumns = is_array($tableDifferences['dropTheseColumns']) ? $tableDifferences['dropTheseColumns'] : [];
1424 /** @var array<int|string, mixed> $updateTheseColumns */
1425 $updateTheseColumns = is_array($tableDifferences['updateTheseColumns']) ? $tableDifferences['updateTheseColumns'] : [];
1426 /** @var array<int|string, mixed> $createTheseColumns */
1427 $createTheseColumns = is_array($tableDifferences['createTheseColumns']) ? $tableDifferences['createTheseColumns'] : [];
1428 $goalTableMatchesColumnDDL = is_array($tableDifferences['goalTableMatchesColumnDDL']) ? $tableDifferences['goalTableMatchesColumnDDL'] : [];
1429 $existingTableMatchesColumnDDL = is_array($tableDifferences['existingTableMatchesColumnDDL']) ? $tableDifferences['existingTableMatchesColumnDDL'] : [];
1430 /** @var array<int, array<int, mixed>> $goalTableMatches */
1431 $goalTableMatches = is_array($tableDifferences['goalTableMatches']) ? $tableDifferences['goalTableMatches'] : [];
1432 /** @var array<int|string, mixed> $goalTableMatchesColumnNames */
1433 $goalTableMatchesColumnNames = is_array($tableDifferences['goalTableMatchesColumnNames']) ? $tableDifferences['goalTableMatchesColumnNames'] : [];
1434
1435 // drop unnecessary columns — but never drop ALL columns (MySQL error:
1436 // "You can't delete all columns with ALTER TABLE; use DROP TABLE instead").
1437 // This happens when a table is completely restructured and every existing
1438 // column name differs from the goal schema.
1439 $existingColumnCount = count($existingTableMatchesColumnDDL);
1440 if (count($dropTheseColumns) > 0 && count($dropTheseColumns) >= $existingColumnCount) {
1441 $this->logger->warn("Skipping column drops on " . $tableName .
1442 " because it would remove all " . $existingColumnCount .
1443 " existing columns. Drops requested: " . implode(', ', $dropTheseColumns));
1444 } else {
1445 foreach ($dropTheseColumns as $colName) {
1446 $query = "alter table " . $tableName . " drop " . $colName;
1447 $this->dao->queryAndGetResults($query);
1448 $this->logger->infoMessage("I dropped a column (1): " . $query);
1449 }
1450 }
1451
1452 // say why we're doing what we're doing.
1453 if (count($updateTheseColumns) > 0) {
1454 $this->logger->infoMessage(self::$uniqID . ": On " . $tableName .
1455 " I'm updating various columns because we want: \n`" .
1456 print_r($goalTableMatchesColumnDDL, true) . "\n but we have: \n" .
1457 print_r($existingTableMatchesColumnDDL, true));
1458 }
1459
1460 // create missing columns
1461 // Normalize $goalMatchesSub using the same normalizeColumnDDL() that
1462 // getTableDifferences() uses, so array_search() can find the right index.
1463 $goalMatchesSub = is_array($goalTableMatches[1] ?? null) ? $goalTableMatches[1] : [];
1464 $goalMatchesSub = array_map([$this, 'normalizeColumnDDL'], $goalMatchesSub);
1465 foreach ($updateTheseColumns as $colDDL) {
1466 // find the colum name.
1467 $matchIndex = array_search($colDDL, $goalMatchesSub);
1468 if ($matchIndex === false) {
1469 $this->logger->warn("Could not match column DDL to goal schema, skipping: " . $colDDL);
1470 continue;
1471 }
1472 $colName = is_string($goalTableMatchesColumnNames[$matchIndex] ?? null) ? $goalTableMatchesColumnNames[$matchIndex] : '';
1473
1474 // if the column exists then update it. otherwise create it.
1475 if (!in_array($colName, $createTheseColumns)) {
1476 // update the existing column.
1477 // ALTER TABLE `mywp_abj404_redirects` CHANGE `status` `status` BIGINT(19) NOT NULL;
1478 $updateColStatement = "alter table " . $tableName . " change " . $colName .
1479 " " . $colDDL;
1480 $this->dao->queryAndGetResults($updateColStatement);
1481 $this->logger->infoMessage("I updated a column: " . $updateColStatement);
1482
1483 } else {
1484 // create the column.
1485 $createColStatement = "alter table " . $tableName . " add " . $colDDL;
1486 $this->dao->queryAndGetResults($createColStatement);
1487 $this->logger->infoMessage("I added a column: " . $createColStatement);
1488 }
1489
1490 $this->handleSpecificCases($tableName, $colName);
1491 }
1492 }
1493
1494 /** Create table DDL is returned without SQL comments of any kind.
1495 * Strips block comments (slash-star ... star-slash), line comments (-- ...),
1496 * and inline COMMENT 'text' column clauses so the column-name regex in
1497 * getTableDifferences() cannot mistake comment text for column definitions.
1498 * @param string|null $createTableDDL
1499 * @return string
1500 */
1501 function removeCommentsFromColumns($createTableDDL) {
1502 if ($createTableDDL === null) {
1503 return '';
1504 }
1505 $ddl = (string) $createTableDDL;
1506 // Strip block comments (slash-star ... star-slash), including multi-line.
1507 $ddl = preg_replace('/\/\*.*?\*\//s', '', $ddl) ?? $ddl;
1508 // Strip line comments (-- ...).
1509 $ddl = preg_replace('/--[^\r\n]*/', '', $ddl) ?? $ddl;
1510 // Strip inline COMMENT 'text', clauses from column definitions.
1511 return preg_replace('/ (?:COMMENT.+?,[\r\n])/', ",\n", $ddl) ?? $ddl;
1512 }
1513 /**
1514 * Normalize a single column DDL fragment for comparison.
1515 *
1516 * Strips backticks and unquotes integer defaults so that DDL from
1517 * SHOW CREATE TABLE (e.g. default '1') matches the goal DDL file
1518 * (e.g. default 1). Used by both getTableDifferences() and
1519 * updateATableBasedOnDifferences() — a single source of truth
1520 * prevents the two normalization sites from drifting out of sync.
1521 *
1522 * @param mixed $ddl A column DDL string (or non-string from regex match)
1523 * @return string
1524 */
1525 function normalizeColumnDDL($ddl): string {
1526 $ddlStr = is_string($ddl) ? $ddl : '';
1527 $normalized = strtolower(str_replace('`', '', trim($ddlStr)));
1528 $normalized = preg_replace("/default '(\d+)'/", 'default $1', $normalized) ?? $normalized;
1529 // MySQL omits DEFAULT NULL for nullable columns — strip it so DDL file
1530 // and SHOW CREATE TABLE produce identical normalized strings.
1531 $normalized = preg_replace('/\s+default\s+null\b/', '', $normalized) ?? $normalized;
1532 return $normalized;
1533 }
1534
1535 /**
1536 * @param string $tableName
1537 * @return void
1538 */
1539 function deleteIndexes($tableName) {
1540
1541 // get the indexes list.
1542 $results = $this->dao->queryAndGetResults("show index from " . $tableName .
1543 " where key_name != 'PRIMARY'");
1544 /** @var array<int, array<string, mixed>> $rows */
1545 $rows = isset($results['rows']) && is_array($results['rows']) ? $results['rows'] : [];
1546
1547 if (empty($rows)) {
1548 return;
1549 }
1550
1551 // find the key_name column because the case can be different on different systems.
1552 $keyNameColumn = 'key_name';
1553 $aRow = $rows[0];
1554 foreach (array_keys($aRow) as $someKey) {
1555 if ($this->f->strtolower((string)$someKey) == 'key_name') {
1556 $keyNameColumn = (string)$someKey;
1557 break;
1558 }
1559 }
1560
1561 foreach ($rows as $row) {
1562 // delete them
1563 $indexName = $row[$keyNameColumn] ?? '';
1564 if (!is_string($indexName) || $indexName === '') {
1565 continue;
1566 }
1567 $query = "alter table " . $tableName . " drop index " . $indexName;
1568 $this->dao->queryAndGetResults($query);
1569 }
1570 }
1571 }
1572