You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

792 lines
26 KiB

<?php
// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project
//
// All Rights Reserved. See copyright.txt for details and a complete list of authors.
// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
// $Id$
namespace TikiDevTools;
use DBDiff;
use Exception;
use PDO;
use Symfony\Component\Process\PhpExecutableFinder;
use Tiki\Process\Process;
use TWVersion;
/**
* Class CheckSchemaUpgrade is a helper to check differences between upgrade and install a tiki db
*/
class CheckSchemaUpgrade
{
const DB_URL_TEMPLATE = 'http://tiki.org/ci_%d.sql';
const DB_OLD = 'OLD';
const DB_NEW = 'NEW';
/**
* @var string Tiki root folder
*/
protected $tikiRoot;
/**
* @var string existing config file
*/
protected $localConfig;
/**
* @var string Original value for the db that will be updated from last major
*/
protected $oldDbRaw;
/**
* @var array Config for the db that will be updated from last major
*/
protected $oldDb;
/**
* @var string Original value for the db that will have a clean install
*/
protected $newDbRaw;
/**
* @var array Config for the db that will have a clean install
*/
protected $newDb;
/**
* @var bool if we should use InnoDB
*/
protected $useInnoDB = true;
/**
* @var bool If it outputs the execution of db diff
*/
protected $verbose = false;
/**
* @var string previous major to compare to
*/
protected $previousMajor;
/**
* @var string folder to use for caching db versions
*/
protected $cacheFolder = "dbdiff/cache";
/**
* @var bool Ignore changes in preferences values
*/
protected $ignorePreferenceChanges = true;
/**
* CheckSchemaUpgrade constructor.
*/
public function __construct()
{
$this->tikiRoot = dirname(dirname(__DIR__));
$this->cacheFolder = __DIR__ . '/' . $this->cacheFolder;
}
/**
* Execute check
*/
public function execute()
{
$resultValue = 0;
$this->printMessage('Check schema updated started: ' . date('c'));
try {
//
// Prepare
//
$this->printMessage('Preparing and validating the environment');
$this->checkEnvironment();
$this->parseCommandLine();
$this->backupLocalConfig();
$tikiVersion = new TWVersion();
//
// Run upgrade from previous major (from latest SVN)
//
$this->printMessage('Loading database 1 from previous major version');
$this->writeLocalConfig($this->oldDb);
$dbConnectionOld = $this->prepareDb($this->oldDb);
$this->bootstrapDbWithPreviousMajor($dbConnectionOld);
$this->printMessage('Updating database 1 from previous major to version ' . $tikiVersion->getVersion());
$this->runDatabaseUpdate();
$this->scrubDbCleanThingsThatShouldChange($dbConnectionOld, $this->oldDb, self::DB_OLD);
//
// Run clean db install
//
$this->printMessage('Installing database 2 with version ' . $tikiVersion->getVersion());
$this->writeLocalConfig($this->newDb);
$dbConnectionNew = $this->prepareDb($this->newDb);
$this->runDatabaseInstall();
$this->scrubDbCleanThingsThatShouldChange($dbConnectionNew, $this->newDb, self::DB_NEW);
//
// Compare the DBS
//
$this->printMessage('Comparing Databases');
$this->runDbCompare();
} catch (\Exception $e) {
$this->printMessageError($e->getMessage());
$resultValue = 1;
}
//
// Cleanup
//
$this->printMessage('Restoring local environment');
$this->restoreLocalConfig();
$this->printMessage('Check schema updated completed: ' . date('c'));
return $resultValue;
}
/**
* Check usage
*/
protected function usage()
{
$this->printMessageError("\n" . 'How to execute this command:');
$this->printMessage(
'php check_schema_upgrade [-v] [-p] [-e=<MyISAM|InnoDB>] [-m=<major>] --db1=<user:pass@host:db> --db2=<user:pass@host:db>'
);
$this->printMessage('db1 and db2 are the databases to be used to load the schema');
$this->printMessageError('!! Both databases will be erased !!' . "\n");
}
/**
* Validate the environment
*
* @throws Exception
*/
protected function checkEnvironment()
{
$errors = 0;
if (! file_exists(__DIR__ . '/dbdiff/vendor/autoload.php')) {
$errors++;
$this->printMessageError('dbdiff/vendor/autoload.php not available, did you run composer for dbdiff?');
} else {
require_once __DIR__ . '/dbdiff/vendor/autoload.php';
}
if (! file_exists($this->tikiRoot . '/vendor_bundled/vendor/autoload.php')) {
$errors++;
$this->printMessageError(
'vendor_bundled/vendor/autoload.php not available, did you run composer for tiki?'
);
} else {
require_once $this->tikiRoot . '/vendor_bundled/vendor/autoload.php';
require_once $this->tikiRoot . '/lib/setup/twversion.class.php';
}
if (! is_dir($this->cacheFolder) && is_writable(dirname($this->cacheFolder))) {
mkdir($this->cacheFolder); // attempt to create folder if do not exists
}
if (! is_writable($this->cacheFolder)) {
// actually only a warning
$this->printMessageError(
$this->cacheFolder . ' not writable, will not be able to cache previous db version'
);
}
if (! is_writable($this->tikiRoot . '/db')) {
$errors++;
$this->printMessageError($this->tikiRoot . '/db' . ' not writable, can not configure tiki');
}
if ($errors > 0) {
$this->printMessageError('Environment errors, please fix them and run the command again');
throw new \Exception('Errors');
}
}
/**
* Load options from command line
*
* @throws Exception
*/
protected function parseCommandLine()
{
$options = $this->getOpts();
$this->previousMajor = $this->getOption($options, 'm', 'major');
$this->verbose = $this->getOption($options, 'v', 'verbose') === false ? true : false;
$this->ignorePreferenceChanges = $this->getOption($options, 'p', 'preferences') === false ? false : true;
$this->useInnoDB = strtolower($this->getOption($options, 'e', 'engine')) === 'myisam' ? false : true;
$this->oldDbRaw = $this->getOption($options, null, 'db1');
$result = $this->parseDbRaw($this->oldDbRaw);
$this->oldDb = $result;
if ($result === null) {
$this->printMessageError('Wrong value for db1, check the the right format below');
$this->usage();
throw new Exception('Wrong db1');
}
$this->newDbRaw = $this->getOption($options, null, 'db2');
$result = $this->parseDbRaw($this->newDbRaw);
$this->newDb = $result;
if ($result === null) {
$this->printMessageError('Wrong value for db2, check the right format below');
$this->usage();
throw new Exception('Wrong db2');
}
}
/**
* Parse the raw db format
*
* @param $raw
* @return array|null
*/
protected function parseDbRaw($raw)
{
$parts = explode('@', $raw);
if (count($parts) != 2) {
return null;
}
$credentials = explode(':', $parts[0]);
if (count($credentials) != 2) {
return null;
}
$hostAndDb = explode(':', $parts[1]);
if (count($hostAndDb) != 2) {
return null;
}
$result = [
'user' => $credentials[0],
'pass' => $credentials[1],
'host' => $hostAndDb[0],
'dbs' => $hostAndDb[1],
];
foreach ($result as $k => $v) {
if (empty($v)) {
return null;
}
}
return $result;
}
/**
* Backup the tiki config (if exists)
*/
protected function backupLocalConfig()
{
if (file_exists($this->tikiRoot . '/db/local.php')) {
$this->localConfig = $this->tikiRoot . '/db/schema_update_' . uniqid() . '_local.php';
rename($this->tikiRoot . '/db/local.php', $this->localConfig);
$this->printMessage(
'File: ' . $this->tikiRoot . '/db/local.php' . "\n" . ' renamed as ' . $this->localConfig
);
}
}
/**
* Restore the tiki config (if was backup)
*/
protected function restoreLocalConfig()
{
if (! empty($this->localConfig) && file_exists($this->localConfig)) {
rename($this->localConfig, $this->tikiRoot . '/db/local.php');
$this->printMessage(
'File: ' . $this->tikiRoot . '/db/local.php' . "\n" . ' restored from ' . $this->localConfig
);
}
}
/**
* Write a basic Tiki configuration file
*
* @param array $dbConfig
*/
protected function writeLocalConfig($dbConfig)
{
$TWV = new TWVersion();
$local = '<?php' . "\n"
. '$db_tiki = "mysqli";' . "\n"
. '$dbversion_tiki = "' . $TWV->getBaseVersion() . '";' . "\n"
. '$host_tiki = "' . $dbConfig['host'] . '";' . "\n"
. '$user_tiki = "' . $dbConfig['user'] . '";' . "\n"
. '$pass_tiki = "' . $dbConfig['pass'] . '";' . "\n"
. '$dbs_tiki = "' . $dbConfig['dbs'] . '";' . "\n"
. '$client_charset = "utf8";' . "\n";
file_put_contents($this->tikiRoot . '/db/local.php', $local);
}
/**
* Prepare the db to load tiki info (drop and create)
*
* @param $dbConfig
* @return PDO
*/
protected function prepareDb($dbConfig)
{
$db = new PDO('mysql:host=' . $dbConfig['host'], $dbConfig['user'], $dbConfig['pass']);
$db->query('DROP DATABASE IF EXISTS `' . $dbConfig['dbs'] . '`;');
$db->query(
'CREATE DATABASE `' . $dbConfig['dbs'] . '` DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci;'
);
$db->query('USE `' . $dbConfig['dbs'] . '`');
return $db;
}
/**
* Loads the a SQL file from a major into Tiki database (so we can run upgrade)
*
* @param PDO $dbConnection
* @return bool
* @throws Exception
*/
protected function bootstrapDbWithPreviousMajor($dbConnection)
{
$TWV = new TWVersion();
$velements = explode('.', $TWV->getBaseVersion());
$major = (int)$velements[0];
$sql = '';
if (! empty($this->previousMajor)) {
$tryMajor = (int)$this->previousMajor;
$sql = $this->loadDbFileByMajor($tryMajor);
} else {
$tryMajor = $major - 1;
while ($tryMajor >= 12) {
$sql = $this->loadDbFileByMajor($tryMajor);
if (! empty($sql)) {
break;
}
$tryMajor--;
}
}
$this->printMessage('Loading the database for major version: ' . $tryMajor);
if (! empty($sql)) {
if ($this->runSQL($sql, $dbConnection) !== false) {
return true;
} else {
$err = $dbConnection->errorInfo();
$this->printMessageError('Error running the database load: ' . json_encode($err));
}
} else {
$this->printMessageError('Could not retrieve valid SQL');
}
$this->printMessageError(
'Failed to load the db for previous Major, last attempt done for version ' . $tryMajor . ' currently at ' . $major
);
throw new \Exception('Fail to load old db');
}
/**
* Attempts to load a given major from cache or from the redirect in tiki website
*
* @param $major
* @return bool|string
*/
protected function loadDbFileByMajor($major)
{
$cachedDbFile = $this->cacheFolder . '/ci_' . $major . '.sql';
if (file_exists($cachedDbFile)) {
$sql = file_get_contents($cachedDbFile);
return $sql;
}
$dbContent = file_get_contents(sprintf(self::DB_URL_TEMPLATE, $major));
/** @noinspection SyntaxError */
if (! empty($dbContent) && strpos($dbContent, 'CREATE TABLE `tiki_schema`') !== false) { //check that looks like a sql file
$sql = $dbContent;
file_put_contents($cachedDbFile, $dbContent);
return $sql;
}
// TODO: try to install old version of Tiki to get the db generated, instead of rely om pre generated files
return '';
}
/**
* Execute a set of statements contained in one SQL string
*
* @see \Tiki\Installer\Installer::runFile for original code
*
* @param string $sql
* @param PDO $dbConnection
* @return bool
*/
protected function runSQL($sql, $dbConnection)
{
// split the file into several queries?
$statements = preg_split("#(;\s*\n)|(;\s*\r\n)#", $sql);
$status = true;
foreach ($statements as $statement) {
if (trim($statement)) {
if (preg_match('/^\s*(?!-- )/m', $statement)) {// If statement is not commented
if ($this->useInnoDB) {
// Convert all MyISAM statments to InnoDB
$statement = str_ireplace("MyISAM", "InnoDB", $statement);
} else {
$statement = str_ireplace("InnoDB", "MyISAM", $statement);
}
if ($dbConnection->exec($statement) === false) {
$err = $dbConnection->errorInfo();
$this->printMessageError('Error running the database load: ' . json_encode($err));
$this->printMessage($statement);
$status = false;
}
}
}
}
return $status;
}
/**
* Calls the Tiki console to execute a db update
*
* @throws Exception
*/
protected function runDatabaseUpdate()
{
$phpFinder = new PhpExecutableFinder();
$process = new Process(
[
$phpFinder->find(),
'console.php',
'database:update',
]
);
$process->setWorkingDirectory($this->tikiRoot);
$process->setTimeout($this->getProcessTimeout());
$process->run();
echo $process->getOutput() . $process->getErrorOutput();
if ($process->getExitCode() !== 0) {
$this->printMessageError('Error while running the database update');
throw new Exception('Error db update');
}
}
/**
* Calls the Tiki console to execute a clean db install
*
* @throws Exception
*/
protected function runDatabaseInstall()
{
$phpFinder = new PhpExecutableFinder();
$process = new Process(
[
$phpFinder->find(),
'console.php',
'database:install',
'--useInnoDB',
$this->useInnoDB ? '1' : '0',
]
);
$process->setWorkingDirectory($this->tikiRoot);
$process->setTimeout($this->getProcessTimeout());
$process->run();
echo $process->getOutput() . $process->getErrorOutput();
if ($process->getExitCode() !== 0) {
$this->printMessageError('Error while running the database install');
throw new Exception('Error db install');
}
}
/**
* Cleans the db from the things that should vary in a normal Tiki installation to help in the compare
*
* @param PDO $dbConnection
* @param array $dbConfig
* @param string $whatDb OLD|NEW
*/
protected function scrubDbCleanThingsThatShouldChange($dbConnection, $dbConfig, $whatDb)
{
// clean index rebuild related tables and tables marked as unused
$statement = $dbConnection->prepare(
"SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = :db AND (TABLE_NAME LIKE 'index_%' OR TABLE_NAME LIKE 'zzz_unused_%')"
);
$result = $statement->execute([':db' => $dbConfig['dbs']]);
if ($result === false) {
$this->printMessageError('Error retrieving list of tables: ' . json_encode($dbConnection->errorInfo()));
} else {
foreach ($statement->fetchAll() as $info) {
$dbConnection->exec("DROP TABLE IF EXISTS `" . $info['TABLE_NAME'] . "`");
}
}
$dbConnection->exec("DELETE FROM `tiki_preferences` WHERE name LIKE 'unified_%'");
$dbConnection->exec("DROP TABLE IF EXISTS `index_pref_en`");
// set a well defined date for some records
$dbConnection->exec("UPDATE `tiki_schema` SET `install_date` = '2001-01-01 01:01:01'");
// remove messages from action log (are not part of the schema)
$dbConnection->exec("DELETE FROM `tiki_actionlog`");
// image gallery data was removed from tiki.sql, but preserving data in upgrades just in case, so ignore the differences
$dbConnection->exec("DELETE FROM `tiki_live_support_modules` WHERE `name` = 'image galleries'");
$dbConnection->exec("DELETE FROM `tiki_actionlog_conf` WHERE `objectType` = 'image gallery'");
$dbConnection->exec("DELETE FROM `tiki_score` WHERE `event` LIKE 'tiki.image%'");
$dbConnection->exec("DELETE FROM `tiki_menu_options` WHERE `name` = 'Image Galleries'");
$dbConnection->exec("DELETE FROM `tiki_menu_options` WHERE `section` = 'feature_image_galleries_comments'");
// reload tiki_live_support_modules in the upgraded tiki to account for the case where an old entry is removed
$dbConnection->exec("CREATE TABLE `tiki_live_support_modules_tmp` AS SELECT * FROM `tiki_live_support_modules` ORDER BY `modId`");
$dbConnection->exec("ALTER TABLE `tiki_live_support_modules_tmp` CHANGE COLUMN `modId` `modId` int NULL");
$dbConnection->exec("UPDATE `tiki_live_support_modules_tmp` SET `modId`=NULL");
$dbConnection->exec("DELETE FROM `tiki_live_support_modules`");
$dbConnection->exec("ALTER TABLE `tiki_live_support_modules` AUTO_INCREMENT = 1");
$dbConnection->exec("INSERT INTO `tiki_live_support_modules` SELECT * FROM `tiki_live_support_modulestmp`");
$dbConnection->exec("DROP TABLE `tiki_live_support_modules_tmp`");
// reload tiki_menu_options in the upgraded tiki to account for the case where an old entry is removed
$dbConnection->exec("CREATE TABLE `tiki_menu_options_tmp` AS SELECT * FROM `tiki_menu_options` ORDER BY menuId,type,name,url,position");
$dbConnection->exec("ALTER TABLE `tiki_menu_options_tmp` CHANGE COLUMN optionId optionId int NULL");
$dbConnection->exec("UPDATE `tiki_menu_options_tmp` SET optionId=NULL");
$dbConnection->exec("DELETE FROM `tiki_menu_options`");
$dbConnection->exec("ALTER TABLE `tiki_menu_options` AUTO_INCREMENT = 1");
$dbConnection->exec("INSERT INTO `tiki_menu_options` SELECT * FROM `tiki_menu_options_tmp`");
$dbConnection->exec("DROP TABLE `tiki_menu_options_tmp`");
// reload tiki_actionlog_conf in the upgraded tiki to account for the case where an old entry is removed
$dbConnection->exec("CREATE TABLE `tiki_actionlog_conf_tmp` AS SELECT * FROM `tiki_actionlog_conf` ORDER BY action,objectType,status");
$dbConnection->exec("ALTER TABLE `tiki_actionlog_conf_tmp` CHANGE COLUMN id id int NULL");
$dbConnection->exec("UPDATE `tiki_actionlog_conf_tmp` SET id=NULL");
$dbConnection->exec("DELETE FROM `tiki_actionlog_conf`");
$dbConnection->exec("ALTER TABLE `tiki_actionlog_conf` AUTO_INCREMENT = 1");
$dbConnection->exec("INSERT INTO `tiki_actionlog_conf` SELECT * FROM `tiki_actionlog_conf_tmp`");
$dbConnection->exec("DROP TABLE `tiki_actionlog_conf_tmp`");
// reload tiki_sefurl_regex_out in the upgraded tiki to account for the case where an old entry is removed
$dbConnection->exec("CREATE TABLE `tiki_sefurl_regex_out_tmp` AS SELECT * FROM `tiki_sefurl_regex_out` ORDER BY `order`, `id`");
$dbConnection->exec("ALTER TABLE `tiki_sefurl_regex_out_tmp` CHANGE COLUMN id id int NULL");
$dbConnection->exec("UPDATE `tiki_sefurl_regex_out_tmp` SET id=NULL");
$dbConnection->exec("DELETE FROM `tiki_sefurl_regex_out`");
$dbConnection->exec("ALTER TABLE `tiki_sefurl_regex_out` AUTO_INCREMENT = 1");
$dbConnection->exec("INSERT INTO `tiki_sefurl_regex_out` SELECT * FROM `tiki_sefurl_regex_out_tmp`");
$dbConnection->exec("DROP TABLE `tiki_sefurl_regex_out_tmp`");
// normalize the DB entries to use 4 spaces instead of tabs (after the migration from tabs to spaces)
$dbConnection->exec("UPDATE `tiki_score` SET data = REPLACE(data, ' {', ' {')");
}
/**
* Execute the DB comparision between the instance that was upgraded and the instance that did the clean install
*
* @throws Exception
*/
protected function runDbCompare()
{
$outputFile = tempnam(sys_get_temp_dir(), 'dbdiff_');
$argv = $GLOBALS['argv'];
$fakeArgv = [
$argv[0],
sprintf('--server1=%s:%s@%s', $this->oldDb['user'], $this->oldDb['pass'], $this->oldDb['host']),
sprintf('--server2=%s:%s@%s', $this->newDb['user'], $this->newDb['pass'], $this->newDb['host']),
'--type=all',
'--include=all',
sprintf('--template=%s', __DIR__ . '/dbdiff/tiki.tmpl'),
'--nocomments=true',
sprintf('server1.%s:server2.%s', $this->oldDb['dbs'], $this->newDb['dbs']),
sprintf('--output=%s', $outputFile),
];
$GLOBALS['argv'] = $fakeArgv;
$dbdiff = new DBDiff\DBDiff();
$errorLevel = error_reporting();
ob_start();
try {
error_reporting($errorLevel & ~E_NOTICE); // DBDiff returns some notices of undefined offsets
$dbdiff->run();
error_reporting($errorLevel);
} catch (\Exception $e) {
error_reporting($errorLevel);
ob_end_flush();
throw $e;
}
$output = ob_get_clean();
if ($this->verbose) {
echo $output;
}
$GLOBALS['argv'] = $argv;
$originalResult = trim(file_get_contents($outputFile));
unlink($outputFile);
if ($this->ignorePreferenceChanges) {
$result = $this->filterPreferencesChanges($originalResult);
} else {
$result = $originalResult;
}
if (empty($result)) {
$this->printMessage("\n*** Database upgrade validated with success! ***\n");
return;
}
$this->printMessageError("\n*** Issues found while validating database upgrade, see below ***\n");
$this->printMessageError('== Result of the db Analysis - missing statements ==');
echo $result . "\n";
$this->printMessageError('====================================================' . "\n");
throw new Exception('DB compare error');
}
protected function filterPreferencesChanges($results)
{
$parts = explode("\n", $results);
$result = array_filter(
$parts,
function ($item) {
/** @noinspection SyntaxError */
if (
strncmp($item, 'DELETE FROM `tiki_preferences`', 30) === 0
|| strncmp($item, 'INSERT INTO `tiki_preferences`', 30) === 0
) {
return false;
}
return true;
}
);
return implode("\n", $result);
}
/**
* Print a normal message
*
* @param $message
* @param null $outputPath
*/
protected function printMessage($message, $outputPath = null)
{
echo "\033[0;32m" . $message . "\033[0m" . PHP_EOL;
if (! empty($outputPath)) {
file_put_contents($outputPath, $message . PHP_EOL, FILE_APPEND);
}
}
/**
* Print an error message
*
* @param $message
* @param null $outputPath
*/
protected function printMessageError($message, $outputPath = null)
{
echo "\033[0;31m" . $message . "\033[0m" . PHP_EOL;
if (! empty($outputPath)) {
file_put_contents($outputPath, $message . PHP_EOL, FILE_APPEND);
}
}
/**
* Get the options from command line
*/
protected function getOpts()
{
$shortOpts = 'm:vpe:';
$longOpts = [
'major:',
'verbose',
'preferences',
'engine:',
'db1:',
'db2:',
];
$options = getopt($shortOpts, $longOpts);
return $options;
}
/**
* Helper to get a value of an command line option both using the short format and the long format
*
* @param $options
* @param null $short
* @param null $long
* @return null
*/
protected function getOption($options, $short = null, $long = null)
{
if (! empty($long) && array_key_exists($long, $options)) {
return $options[$long];
}
if (! empty($short) && array_key_exists($short, $options)) {
return $options[$short];
}
return null;
}
/**
* Return the Timeout Value for Symfony Process
* Either get the value from a ENV (set as part of the CI process) or assume the default value
*
* @return float
*/
protected function getProcessTimeout()
{
$defaultTimeoutForProcess = 300; // 5 minutes
if (isset($_SERVER['TIKI_CI_PROCESS_TIMEOUT'])) {
return (float)$_SERVER['TIKI_CI_PROCESS_TIMEOUT'];
}
return (float)$defaultTimeoutForProcess;
}
}
// Make sure script is run from a shell
if (PHP_SAPI !== 'cli') {
die("Please run from a shell");
}
$checker = new CheckSchemaUpgrade();
$errors = $checker->execute();
if ($errors > 0) {
exit(1);
}
exit(0);