nformation_schema.COLLATION_CHARACTER_SET_APPLICABILITY ccsa WHERE ccsa.collation_name = t.table_collation AND t.table_schema = %s AND t.table_name = %s', \constant('DB_NAME'), $tableName), ARRAY_A); if ($tableDetails && !empty($tableDetails['collate']) && !empty($tableDetails['charset'])) { $this->charsetCollate = 'DEFAULT CHARACTER SET ' . $tableDetails['charset']; $this->charsetCollate .= ' COLLATE ' . $tableDetails['collate']; } } } if (empty($this->charsetCollate)) { // Fallback to current if the above existing table returned no valid charset $this->charsetCollate = $wpdb->get_charset_collate(); } return $this->charsetCollate; } /** * Remove database tables if they exist. * * @param string[] $tableNames This is not escaped, so use only the result of `$this->getTableName()`! */ public function removeTables($tableNames) { global $wpdb; foreach ($tableNames as $tableName) { // phpcs:disable WordPress.DB.PreparedSQL $tableDetails = $wpdb->get_row("SHOW TABLE STATUS LIKE '{$tableName}'"); // phpcs:enable WordPress.DB.PreparedSQL if (!$tableDetails) { continue; } // phpcs:disable WordPress.DB.PreparedSQL $wpdb->query("DROP TABLE {$tableName}"); // phpcs:enable WordPress.DB.PreparedSQL } } /** * `dbDelta` does currently not support removing indices from tables so updating e.g. `UNIQUE KEYS` does not work. * For this, you need to add a new index name and remove the old one. * * The index needs to be configured like this: * * ``` * $indexConfigurations = [ * 'PRIMARY' = ['myColumn1', 'myColumn2'] * ] * ``` * * @param string $tableName This is not escaped, so use only the result of `$this->getTableName()`! * @param array[] $indexConfigurations * @see https://whtly.com/2010/04/02/wp-dbdelta-function-cannot-modify-unique-keys/ */ public function removeIndicesFromTable($tableName, $indexConfigurations) { global $wpdb; // phpcs:disable WordPress.DB.PreparedSQL $existingIndexesNonGrouped = $wpdb->get_results("SHOW INDEX FROM {$tableName}", ARRAY_A); // phpcs:enable WordPress.DB.PreparedSQL if ($existingIndexesNonGrouped) { $removeIndexes = []; $existingIndexes = []; foreach ($existingIndexesNonGrouped as $idxRow) { $existingIndexes[$idxRow['Key_name']] = $existingIndexes[$idxRow['Key_name']] ?? []; $existingIndexes[$idxRow['Key_name']][] = $idxRow['Column_name']; } $indexNames = \array_keys($indexConfigurations); foreach ($existingIndexes as $keyName => $columns) { if (\in_array($keyName, $indexNames, \true) && \join(',', $columns) === \join(',', $indexConfigurations[$keyName])) { $removeIndexes[] = $keyName; } } $removeIndexes = \array_unique($removeIndexes); foreach ($removeIndexes as $rm) { if ($rm === 'PRIMARY') { $rm = 'PRIMARY KEY'; } else { $rm = "INDEX {$rm}"; } // phpcs:disable WordPress.DB.PreparedSQL $wpdb->query("ALTER TABLE {$tableName} DROP {$rm}"); // phpcs:enable WordPress.DB.PreparedSQL } } } /** * `dbDelta` does currently not support removing columns from tables. For this, we need to read the structure of the * table and remove the column accordingly on existence. * * @param string $tableName This is not escaped, so use only the result of `$this->getTableName()`! * @param string[] $columnNames */ public function removeColumnsFromTable($tableName, $columnNames) { global $wpdb; // phpcs:disable WordPress.DB.PreparedSQL $existingColumns = $wpdb->get_results("SHOW COLUMNS FROM {$tableName}", ARRAY_A); // phpcs:enable WordPress.DB.PreparedSQL if ($existingColumns) { $removeColumns = []; $columnNames = \array_map('strtolower', $columnNames); foreach ($existingColumns as $existingColumn) { if (\in_array(\strtolower($existingColumn['Field']), $columnNames, \true)) { $removeColumns[] = $existingColumn['Field']; } } $removeColumns = \array_unique($removeColumns); foreach ($removeColumns as $rm) { // phpcs:disable WordPress.DB.PreparedSQL $wpdb->query("ALTER TABLE {$tableName} DROP COLUMN {$rm}"); // phpcs:enable WordPress.DB.PreparedSQL } } } /** * Run an installation or dbDelta within a callable. * * @param boolean $errorlevel Set true to throw errors. * @param callable $installThisCallable Set a callable to install this one instead of the default. * @return bool Returns `false` when e.g. the migration got locked */ public function install($errorlevel = \false, $installThisCallable = null) { global $wpdb; // @codeCoverageIgnoreStart if (!\defined('PHPUNIT_FILE')) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; } // @codeCoverageIgnoreEnd // Avoid errors printed out. if ($errorlevel === \false) { $show_errors = $wpdb->show_errors(\false); $suppress_errors = $wpdb->suppress_errors(\false); $errorLevel = \error_reporting(0); } if ($installThisCallable === null) { $this->dbDelta($errorlevel); } else { \call_user_func($installThisCallable); } if ($errorlevel === \false) { $wpdb->show_errors($show_errors); $wpdb->suppress_errors($suppress_errors); \error_reporting($errorLevel); } if ($installThisCallable === null) { $this->persistPreviousVersion(); $slug = $this->getPluginConstant(Constants::PLUGIN_CONST_SLUG); /** * Modify an array of any data and when this data changes, the database version will be invalidated * and therefore the `dbDelta` will be called again. * * Use case: * * - Freemium: Add a tier to the invalidate key so we can invalidate the database version when the tier changes, * e.g. when the user switches from Lite to Pro version. * * @hook DevOwl/Utils/DatabaseVersion/InvalidateKey/$slug * @param {array} $invalidateKey * @since 1.19.21 */ $invalidateKey = \apply_filters('DevOwl/Utils/DatabaseVersion/InvalidateKey/' . $slug, []); \update_option($this->getPluginConstant(Constants::PLUGIN_CONST_OPT_PREFIX) . '_db_version', ['version' => $this->getPluginConstant(Constants::PLUGIN_CONST_VERSION), 'invalidateKey' => $invalidateKey]); } return \true; } /** * Check if the migration is locked. It uses a time span of 10 minutes (like Yoast SEO plugin). * * @see https://github.com/Yoast/wordpress-seo/blob/a5fd83173bf56bf7841d72bb6d3d33ecc4caa825/src/config/migration-status.php#L34-L46 * @param int $set * @return If `$set` is a numeric, it returns a boolean indicating if the update of the migration was successful, otherwise it returns a boolean * if the migration is locked. */ public function isMigrationLocked($set = null) { $optionName = $this->getPluginConstant(Constants::PLUGIN_CONST_OPT_PREFIX) . '_db_migration'; if (\is_numeric($set)) { return \update_option($optionName, $set); } $latestMigration = \intval(\get_option($optionName, 0)); if ($latestMigration > 0) { return $latestMigration > \strtotime('-10 minutes'); } else { return \false; } } /** * Get the current persisted database version. It is stored in the database option `PREFIX_db_version`. * * Since version <= 1.19.20 the version is stored in semver format, e.g. `5.0.14`. * Since version > 1.19.20 the version is stored in a serialized array with the following structure: * * ``` * [ * 'version' => '5.0.14', * 'invalidateKey' => [...] // See filter DevOwl/Utils/DatabaseVersion/InvalidateKey * ] * ``` */ public function getDatabaseVersion() { $optionValue = \get_option($this->getPluginConstant(Constants::PLUGIN_CONST_OPT_PREFIX) . '_db_version'); if (\is_array($optionValue)) { return $optionValue; } return ['version' => $optionValue, 'invalidateKey' => []]; } /** * Get a list of previous installed database versions. * * @return string[] */ public function getPreviousDatabaseVersions() { return \get_option($this->getPluginConstant(Constants::PLUGIN_CONST_OPT_PREFIX) . '_db_previous_version', []); } /** * Persist the previous installed versions of this plugin so we can e.g. start migrations. */ public function persistPreviousVersion() { $currentVersion = $this->getDatabaseVersion()['version']; if ($currentVersion !== \false) { $previousVersionsOptionName = $this->getPluginConstant(Constants::PLUGIN_CONST_OPT_PREFIX) . '_db_previous_version'; $previousVersions = $this->getPreviousDatabaseVersions(); // Extract only "real" versioning in semver format (x.y.z), but no prereleases \preg_match('/(\\d+\\.\\d+\\.\\d+)/', $currentVersion, $matches, \PREG_OFFSET_CAPTURE, 0); $pureVersion = $matches[0][0]; $previousVersions[] = $pureVersion; $previousVersions = \array_unique($previousVersions); \update_option($previousVersionsOptionName, $previousVersions); } } /** * Remove the previous persisted versions from the saved option. This is useful if you have * successfully finished your migration. * * @param callback $filter */ public function removePreviousPersistedVersions($filter) { $versions = $this->getPreviousDatabaseVersions(); $versions = \array_filter($versions, $filter); return \update_option($this->getPluginConstant(Constants::PLUGIN_CONST_OPT_PREFIX) . '_db_previous_version', $versions); } }