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.
 
 
 
 
 
 

590 lines
18 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 CheckSqlEngineConversion is a helper to check differences between install a InnoDB Tiki and upgrade from MyISAM
*/
class CheckSqlEngineConversion
{
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 it outputs the execution of db diff
*/
protected $verbose = false;
/**
* CheckSchemaUpgrade constructor.
*/
public function __construct()
{
$this->tikiRoot = dirname(dirname(__DIR__));
}
/**
* Execute check
*/
public function execute()
{
$resultValue = 0;
$this->printMessage('Check sql engine conversion 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 as MyISAM');
$this->writeLocalConfig($this->oldDb);
$dbConnectionOld = $this->prepareDb($this->oldDb);
$this->runDatabaseInstall(false);
$this->printMessage('Updating database 1 from MyISAM to InnoDB');
$this->runSqlEngineConversionScript($dbConnectionOld);
$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(true);
$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_sql_engine_conversion.php [-v] --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_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->verbose = $this->getOption($options, 'v', 'verbose') === false ? true : false;
$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/sql_engine_conversion_' . 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 = "utf8mb4";' . "\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 utf8mb4 COLLATE utf8mb4_unicode_ci;'
);
$db->query('USE `' . $dbConfig['dbs'] . '`');
return $db;
}
/**
* Executes the conversion script from MyISAM to InnoDB
* @param $dbConnection
* @return bool
* @throws Exception
*/
protected function runSqlEngineConversionScript($dbConnection)
{
$sqlFile = dirname(dirname(__DIR__)) . DIRECTORY_SEPARATOR . 'db' . DIRECTORY_SEPARATOR . 'tiki_convert_myisam_to_innodb.sql';
$sql = file_get_contents($sqlFile);
if (! empty($sql)) {
if ($this->runSQL($sql, $dbConnection) !== false) {
return true;
} else {
$err = $dbConnection->errorInfo();
$this->printMessageError('Error running the conversion script: ' . json_encode($err));
}
} else {
$this->printMessageError('Could not retrieve valid SQL');
}
throw new \Exception('Fail to run conversion script from MyISAM to InnoDB');
}
/**
* Execute a set of statements contained in one SQL string
*
* @see \Tiki\Installer\Installer::runFile for original code
*
* @param string $sql
* @param PDO $dbConnection
* @param bool $convertToInnoDB if should run a automated conversion as part of the script from MyISAM to InnoDB
* @return bool
*/
protected function runSQL($sql, $dbConnection, $convertToInnoDB = false)
{
// 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 ($convertToInnoDB) {
// Convert all MyISAM statments to InnoDB
$statement = str_ireplace("MyISAM", "InnoDB", $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 clean db install
*
* @param bool $useInnoDB If the db should be bootstrap with InnoDB (true) or MyISAM (false)
* @throws Exception
*/
protected function runDatabaseInstall($useInnoDB)
{
$phpFinder = new PhpExecutableFinder();
$process = new Process(
[
$phpFinder->find(),
'console.php',
'database:install',
'--useInnoDB',
$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'] . "`");
}
}
}
/**
* 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=schema',
'--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;
$result = trim(file_get_contents($outputFile));
unlink($outputFile);
if (empty($result)) {
$this->printMessage("\n*** Database engine change validated with success! ***\n");
return;
}
$this->printMessageError("\n*** Issues found while validating database engine change, see below ***\n");
$this->printMessageError('== Result of the db Analysis =======================' . "\n");
echo $result . "\n";
$this->printMessageError('====================================================' . "\n");
throw new Exception('DB compare error');
}
/**
* 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 = 'v';
$longOpts = [
'verbose',
'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 = 120; // 2 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 CheckSqlEngineConversion();
$errors = $checker->execute();
if ($errors > 0) {
exit(1);
}
exit(0);