$deps */ public function __construct(ABJ_404_Solution_DatabaseUpgradeCoordinator $owner, array $deps) { $this->owner = $owner; $this->replaceDatabaseUpgradeDependencies($deps); } /** * @param array $deps * @return void */ public function replaceDatabaseUpgradeDependencies(array $deps) { foreach ($deps as $name => $value) { $this->$name = $value; } } protected function upgrades(): ABJ_404_Solution_DatabaseUpgradeCoordinator { return $this->owner; } /** * Case-insensitive "does this column exist" probe. Delegates to the * canonical-url backfill component's implementation so the SHOW COLUMNS * probe has a single source of truth. Components that own the real probe * (the canonical-url backfill) override this; every other component inherits * this shared delegator. * * @param string $tableName Fully-qualified table name. * @param string $columnName Column to look for. * @return bool */ public function columnExists(string $tableName, string $columnName): ?bool { return $this->upgrades()->canonicalUrlBackfillUpgrade()->columnExists($tableName, $columnName); } /** * Whether a failed schema statement failed only because the schema already * reflects the change it asked for. * * Every "ensure it exists" helper in this package is a SHOW COLUMNS or * SHOW INDEX followed by an ALTER, and MySQL has no way to make those two * one statement. On a plugin update that arrives on several concurrent * requests at once, more than one of them decides the same column or index * is missing and issues the same ALTER; all but one are then told the work * is already done. That is the state the helper wanted, so the answer is to * stop -- not to retry a statement whose only obstacle is its own goal * having been met. * * Lives here rather than in each helper so the several fallback ladders ask * the classifier one way. The classification itself belongs to * {@see ABJ_404_Solution_DatabaseSchemaErrorTaxonomy::isRedundantSchemaChangeError()}, * which is also what stops the shared DAO reporter mailing these to the * developer. * * @param string $lastError What the engine said, or '' when it said nothing. * @return bool */ protected function schemaChangeWasAlreadyApplied(string $lastError): bool { if ($lastError === '') { return false; } return $this->dbCore->errorClassifier()->taxonomy()->schema() ->isRedundantSchemaChangeError($lastError); } /** * Read a resumable id cursor from a WordPress option, clamped to a * non-negative int. Shared by the chunked backfill drains so the cursor I/O * has one definition. * * @param string $option * @return int */ protected function readCursorOption(string $option): int { if ($option === '' || !function_exists('get_option')) { return 0; } $raw = ABJ_404_Solution_OptionShapeRead::scalar($option, 0); return max(0, is_scalar($raw) ? (int)$raw : 0); } /** * Persist a resumable id cursor to a WordPress option (autoload=false, * non-negative). Shared by the chunked backfill drains. * * @param string $option * @param int $cursor * @return void */ protected function writeCursorOption(string $option, int $cursor): void { if ($option === '' || !function_exists('update_option')) { return; } update_option($option, (string)max(0, $cursor), false); } /** @return string|null */ protected function getUpgradeRuntimeId() { return ABJ_404_Solution_DatabaseUpgradeRuntimeState::getRuntimeId(); } protected function getCanonicalUrlBackfillChunkSize(): int { return ABJ_404_Solution_DatabaseUpgradeRuntimeState::CANONICAL_URL_BACKFILL_CHUNK_SIZE; } protected function getCanonicalUrlBackfillTimeBudgetSec(): float { return ABJ_404_Solution_DatabaseUpgradeRuntimeState::CANONICAL_URL_BACKFILL_TIME_BUDGET_SEC; } protected function getLogsv2CanonicalUrlBackfillTimeBudgetSec(): float { return ABJ_404_Solution_DatabaseUpgradeRuntimeState::LOGSV2_CANONICAL_URL_BACKFILL_TIME_BUDGET_SEC; } protected function getLogsv2CanonicalUrlBackfillCompleteOption(): string { return ABJ_404_Solution_DatabaseUpgradeRuntimeState::LOGSV2_CANONICAL_URL_BACKFILL_COMPLETE_OPTION; } protected function getRedirectsCanonicalUrlBackfillCompleteOption(): string { return ABJ_404_Solution_DatabaseUpgradeRuntimeState::REDIRECTS_CANONICAL_URL_BACKFILL_COMPLETE_OPTION; } protected function getRedirectsDenormBackfillChunkSize(): int { return ABJ_404_Solution_DatabaseUpgradeRuntimeState::REDIRECTS_DENORM_BACKFILL_CHUNK_SIZE; } protected function getRedirectsDenormBackfillTimeBudgetSec(): float { return ABJ_404_Solution_DatabaseUpgradeRuntimeState::REDIRECTS_DENORM_BACKFILL_TIME_BUDGET_SEC; } protected function getRedirectsDenormReconcileChunkSize(): int { return ABJ_404_Solution_DatabaseUpgradeRuntimeState::REDIRECTS_DENORM_RECONCILE_CHUNK_SIZE; } protected function getRedirectsDenormReconcileTimeBudgetSec(): float { return ABJ_404_Solution_DatabaseUpgradeRuntimeState::REDIRECTS_DENORM_RECONCILE_TIME_BUDGET_SEC; } protected function getRedirectsDenormReconcileCursorOption(): string { return ABJ_404_Solution_DatabaseUpgradeRuntimeState::REDIRECTS_DENORM_RECONCILE_CURSOR_OPTION; } /** @return array */ protected function getPluginTableSuffixes(): array { return ABJ_404_Solution_DatabaseUpgradeRuntimeState::getPluginTableSuffixes(); } /** * Lowercased table prefixes of every active site on a multisite network, * including the current site. On single-site this is just the current * prefix. * * This is the authoritative tenant-isolation boundary for the cross-prefix * maintenance paths (orphan-table adoption and the mixed-case lowercase * rename). A live blog's tables are never an "orphan" to adopt or a table * the current site should rename: a sibling subsite lowercases and owns its * own tables. A genuinely orphaned old prefix (left by a prefix migration) * has NO corresponding live blog, so it is absent from this set and remains * eligible. * * When the WordPress multisite APIs are unavailable the multisite branch is * skipped and only the current prefix is returned; callers must treat that * as "could not enumerate siblings" and fall back to their content * heuristic, never as "no siblings exist". * * @return array Distinct lowercase prefixes, e.g. ['wp_', 'wp_2_']. */ protected function getActiveBlogPrefixesLowercase(): array { global $wpdb; $prefixes = array(); if (is_object($wpdb) && isset($wpdb->prefix) && is_string($wpdb->prefix) && $wpdb->prefix !== '') { $prefixes[] = strtolower($wpdb->prefix); } if (function_exists('is_multisite') && is_multisite() && function_exists('get_sites') && is_object($wpdb) && is_callable(array($wpdb, 'get_blog_prefix'))) { $siteIds = get_sites(array('number' => 0, 'fields' => 'ids')); if (is_array($siteIds)) { foreach ($siteIds as $siteId) { $prefix = $wpdb->get_blog_prefix((int)$siteId); if (is_string($prefix) && $prefix !== '') { $prefixes[] = strtolower($prefix); } } } } return array_values(array_unique($prefixes)); } /** * Find candidate prefixes that have a separate WordPress core options * table. A live options table is the tenant boundary for a second install * sharing this database. A plugin-only prefix is the retired-migration * shape; a prefix with core options is never adopted on heuristics alone. * * An unreadable or malformed options table is blocked rather than treated * as absent. This keeps the content-match heuristic from becoming an * authority when the core-table probe cannot answer safely. * * @param array $candidatePrefixes Prefixes from table-map keys. * @return array */ protected function getForeignWordPressOptionsPrefixes(array $candidatePrefixes): array { global $wpdb; $dbName = is_object($wpdb) && isset($wpdb->dbname) && is_scalar($wpdb->dbname) ? (string)$wpdb->dbname : ''; if ($dbName === '') { return array_values(array_map('strval', $candidatePrefixes)); } $foreign = array(); foreach ($candidatePrefixes as $candidatePrefix) { $prefix = strtolower((string)$candidatePrefix); if ($prefix === '' || preg_match('/^[a-z0-9_]+$/', $prefix) !== 1) { $foreign[] = $prefix; continue; } $optionsTable = $prefix . 'options'; $tableResult = $this->dbCore->queryAndGetResults( 'SELECT table_name FROM information_schema.tables ' . 'WHERE table_schema = %s AND table_name = %s', array('query_params' => array($dbName, $optionsTable)) ); if (!$this->upgradeQuerySucceeded($tableResult) || !is_array($tableResult['rows'] ?? null)) { $foreign[] = $prefix; continue; } if ($tableResult['rows'] === array()) { continue; } $tableFound = false; foreach ($tableResult['rows'] as $row) { if (!is_array($row)) { $foreign[] = $prefix; continue 2; } $tableNameKeyFound = false; foreach ($row as $key => $value) { if (strtolower((string)$key) !== 'table_name') { continue; } $tableNameKeyFound = true; if (!is_scalar($value) || strtolower((string)$value) !== $optionsTable) { $foreign[] = $prefix; continue 2; } $tableFound = true; break; } if (!$tableNameKeyFound) { $foreign[] = $prefix; continue 2; } } if (!$tableFound) { continue; } $foreign[] = $prefix; } return array_values(array_unique($foreign)); } /** @param mixed $result */ private function upgradeQuerySucceeded($result): bool { return is_array($result) && ($result['query_succeeded'] ?? null) === true && ($result['last_error'] ?? null) === '' && (!array_key_exists('timed_out', $result) || $result['timed_out'] === false); } }