Index: branches/5.2.x/core/kernel/db/db_load_balancer.php =================================================================== --- branches/5.2.x/core/kernel/db/db_load_balancer.php (revision 16709) +++ branches/5.2.x/core/kernel/db/db_load_balancer.php (revision 16710) @@ -1,882 +1,897 @@ dbType = $db_type; - $this->errorHandler = $error_handler; + $this->setErrorHandler($error_handler); $this->DBClusterTimeout *= 1e6; // convert to milliseconds } /** * Setups load balancer according given configuration. * * @param Array $config * @return void * @access public */ public function setup($config) { $this->servers = Array (); $this->servers[0] = Array ( 'DBHost' => $config['Database']['DBHost'], 'DBUser' => $config['Database']['DBUser'], 'DBUserPassword' => $config['Database']['DBUserPassword'], 'DBName' => $config['Database']['DBName'], 'DBLoad' => 0, ); if ( isset($config['Databases']) ) { $this->servers = array_merge($this->servers, $config['Databases']); } foreach ($this->servers as $server_index => $server_setting) { $this->serverLoads[$server_index] = $server_setting['DBLoad']; } } /** * Returns connection index to master database * * @return int * @access protected */ protected function getMasterIndex() { return 0; } /** * Returns connection index to slave database. This takes into account load ratios and lag times. * Side effect: opens connections to databases * * @return int * @access protected */ protected function getSlaveIndex() { if ( count($this->servers) == 1 || $this->Application->isAdmin || defined('CRON') ) { // Skip the load balancing if there's only one server OR in admin console OR in CRON. return 0; } elseif ( $this->slaveIndex !== false ) { // shortcut if generic reader exists already return $this->slaveIndex; } $total_elapsed = 0; $non_error_loads = $this->serverLoads; $i = $found = $lagged_slave_mode = false; // first try quickly looking through the available servers for a server that meets our criteria do { $current_loads = $non_error_loads; $overloaded_servers = $total_threads_connected = 0; while ($current_loads) { if ( $lagged_slave_mode ) { // when all slave servers are too lagged, then ignore lag and pick random server $i = $this->pickRandom($current_loads); } else { $i = $this->getRandomNonLagged($current_loads); if ( $i === false && $current_loads ) { // all slaves lagged -> pick random lagged slave then $lagged_slave_mode = true; $i = $this->pickRandom( $current_loads ); } } if ( $i === false ) { // all slaves are down -> use master as a slave $this->slaveIndex = $this->getMasterIndex(); return $this->slaveIndex; } $conn =& $this->openConnection($i); if ( !$conn ) { unset($non_error_loads[$i], $current_loads[$i]); continue; } // Perform post-connection backoff $threshold = isset($this->servers[$i]['DBMaxThreads']) ? $this->servers[$i]['DBMaxThreads'] : false; $backoff = $this->postConnectionBackoff($conn, $threshold); if ( $backoff ) { // post-connection overload, don't use this server for now $total_threads_connected += $backoff; $overloaded_servers++; unset( $current_loads[$i] ); } else { // return this server break 2; } } // no server found yet $i = false; // if all servers were down, quit now if ( !$non_error_loads ) { break; } // back off for a while // scale the sleep time by the number of connected threads, to produce a roughly constant global poll rate $avg_threads = $total_threads_connected / $overloaded_servers; usleep($this->DBAvgStatusPoll * $avg_threads); $total_elapsed += $this->DBAvgStatusPoll * $avg_threads; } while ( $total_elapsed < $this->DBClusterTimeout ); if ( $i !== false ) { // slave connection successful if ( $this->slaveIndex <= 0 && $this->serverLoads[$i] > 0 && $i !== false ) { $this->slaveIndex = $i; } } return $i; } /** * Returns random non-lagged server * * @param Array $loads * @return int * @access protected */ protected function getRandomNonLagged($loads) { // unset excessively lagged servers $lags = $this->getLagTimes(); foreach ($lags as $i => $lag) { if ( $i != 0 && isset($this->servers[$i]['DBMaxLag']) ) { if ( $lag === false ) { unset( $loads[$i] ); // server is not replicating } elseif ( $lag > $this->servers[$i]['DBMaxLag'] ) { unset( $loads[$i] ); // server is excessively lagged } } } // find out if all the slaves with non-zero load are lagged if ( !$loads || array_sum($loads) == 0 ) { return false; } // return a random representative of the remainder return $this->pickRandom($loads); } /** * Select an element from an array of non-normalised probabilities * * @param Array $weights * @return int * @access protected */ protected function pickRandom($weights) { if ( !is_array($weights) || !$weights ) { return false; } $sum = array_sum($weights); if ( $sum == 0 ) { return false; } $max = mt_getrandmax(); $rand = mt_rand(0, $max) / $max * $sum; $index = $sum = 0; foreach ($weights as $index => $weight) { $sum += $weight; if ( $sum >= $rand ) { break; } } return $index; } /** * Get lag time for each server * Results are cached for a short time in memcached, and indefinitely in the process cache * * @return Array * @access protected */ protected function getLagTimes() { if ( $this->serverLagTimes ) { return $this->serverLagTimes; } $expiry = 5; $request_rate = 10; $cache_key = 'lag_times:' . $this->servers[0]['DBHost']; $times = $this->Application->getCache($cache_key); if ( $times ) { // randomly recache with probability rising over $expiry $elapsed = adodb_mktime() - $times['timestamp']; $chance = max(0, ($expiry - $elapsed) * $request_rate); if ( mt_rand(0, $chance) != 0 ) { unset( $times['timestamp'] ); $this->serverLagTimes = $times; return $times; } } // cache key missing or expired $times = Array(); foreach ($this->servers as $index => $server) { if ($index == 0) { $times[$index] = 0; // master } else { $conn =& $this->openConnection($index); if ($conn !== false) { $times[$index] = $conn->getSlaveLag(); } } } // add a timestamp key so we know when it was cached $times['timestamp'] = adodb_mktime(); $this->Application->setCache($cache_key, $times, $expiry); // but don't give the timestamp to the caller unset($times['timestamp']); $this->serverLagTimes = $times; return $this->serverLagTimes; } /** * Determines whatever server should not be used, even, when connection was made * * @param kDBConnection $conn * @param int $threshold * @return int * @access protected */ protected function postConnectionBackoff(&$conn, $threshold) { if ( !$threshold ) { return 0; } $status = $conn->getStatus('Thread%'); return $status['Threads_running'] > $threshold ? $status['Threads_connected'] : 0; } /** * Open a connection to the server given by the specified index * Index must be an actual index into the array. * If the server is already open, returns it. * * On error, returns false. * * @param integer $i Server index * @return kDBConnection|false * @access protected */ protected function &openConnection($i) { if ( isset($this->connections[$i]) ) { $conn =& $this->connections[$i]; $this->lastUsedIndex = $i; } else { $server = $this->servers[$i]; $server['serverIndex'] = $i; $conn =& $this->reallyOpenConnection($server, $i == $this->getMasterIndex()); if ( $conn->connectionOpened ) { $this->connections[$i] =& $conn; $this->lastUsedIndex = $i; } else { $conn = false; } } if ( is_object($conn) ) { $conn->noDebuggingState = $this->noDebuggingState; if ( $this->nextQueryCachable ) { $conn->nextQueryCachable = true; $this->nextQueryCachable = false; } } return $conn; } /** * Checks if previous query execution raised an error. * * @return bool * @access public */ public function hasError() { $conn =& $this->openConnection($this->lastUsedIndex); return $conn->hasError(); } /** * Checks if connection to the server given by the specified index is opened. * * @param integer $i Server index * @return bool * @access public */ public function connectionOpened($i = null) { $conn =& $this->openConnection(isset($i) ? $i : $this->getMasterIndex()); return $conn ? $conn->connectionOpened : false; } /** * Really opens a connection. * Returns a database object whether or not the connection was successful. * * @param Array $server * @param bool $is_master * @return kDBConnection */ protected function &reallyOpenConnection($server, $is_master) { $debug_mode = $this->Application->isDebugMode(); $db_class = $debug_mode ? 'kDBConnectionDebug' : 'kDBConnection'; /** @var kDBConnection $db */ $db = $this->Application->makeClass($db_class, Array ($this->dbType, $this->errorHandler, $server['serverIndex'])); $db->debugMode = $debug_mode; $db->Connect($server['DBHost'], $server['DBUser'], $server['DBUserPassword'], $this->servers[0]['DBName'], !$is_master); return $db; } /** * Returns first field of first line of recordset if query ok or false otherwise. * * @param string $sql * @param int $offset * @return string * @access public */ public function GetOne($sql, $offset = 0) { $conn =& $this->chooseConnection($sql); return $conn->GetOne($sql, $offset); } /** * Returns first row of recordset if query ok, false otherwise. * * @param string $sql * @param int $offset * @return Array * @access public */ public function GetRow($sql, $offset = 0) { $conn =& $this->chooseConnection($sql); return $conn->GetRow($sql, $offset); } /** * Returns 1st column of recordset as one-dimensional array or false otherwise. * * Optional parameter $key_field can be used to set field name to be used as resulting array key. * * @param string $sql * @param string $key_field * @return Array * @access public */ public function GetCol($sql, $key_field = null) { $conn =& $this->chooseConnection($sql); return $conn->GetCol($sql, $key_field); } /** * Returns iterator for 1st column of a recordset or false in case of error. * * Optional parameter $key_field can be used to set field name to be used as resulting array key. * * @param string $sql * @param string $key_field * @return bool|kMySQLQueryCol */ public function GetColIterator($sql, $key_field = null) { $conn =& $this->chooseConnection($sql); return $conn->GetColIterator($sql, $key_field); } /** * Queries db with $sql query supplied and returns rows selected if any, false otherwise. * * Optional parameter $key_field allows to set one of the query fields value as key in string array. * * @param string $sql * @param string $key_field * @param boolean|null $no_debug * @return Array * @access public */ public function Query($sql, $key_field = null, $no_debug = null) { $conn =& $this->chooseConnection($sql); return $conn->Query($sql, $key_field, $no_debug); } /** * Returns iterator to a recordset, produced from running $sql query. * * Queries db with $sql query supplied and returns kMySQLQuery iterator or false in case of error. * Optional parameter $key_field allows to set one of the query fields value as key in string array. * * @param string $sql * @param string $key_field * @param boolean|null $no_debug * @param string $iterator_class * @return kMySQLQuery|bool * @access public */ public function GetIterator($sql, $key_field = null, $no_debug = null, $iterator_class = 'kMySQLQuery') { $conn =& $this->chooseConnection($sql); return $conn->GetIterator($sql, $key_field, $no_debug, $iterator_class); } /** * Free memory used to hold recordset handle. * * @access public */ public function Destroy() { $conn =& $this->openConnection($this->lastUsedIndex); $conn->Destroy(); } /** * Performs sql query, that will change database content. * * @param string $sql * @return bool * @access public */ public function ChangeQuery($sql) { $conn =& $this->chooseConnection($sql); return $conn->ChangeQuery($sql); } /** * Returns auto increment field value from insert like operation if any, zero otherwise. * * @return int * @access public */ public function getInsertID() { $conn =& $this->openConnection($this->lastUsedIndex); return $conn->getInsertID(); } /** * Returns row count affected by last query. * * @return int * @access public */ public function getAffectedRows() { $conn =& $this->openConnection($this->lastUsedIndex); return $conn->getAffectedRows(); } /** * Returns LIMIT sql clause part for specific db. * * @param int $offset * @param int $rows * @return string * @access public */ public function getLimitClause($offset, $rows) { $conn =& $this->openConnection($this->lastUsedIndex); return $conn->getLimitClause($offset, $rows); } /** * If it's a string, adds quotes and backslashes. Otherwise returns as-is. * * @param mixed $string * @return string * @access public */ public function qstr($string) { $conn =& $this->openConnection($this->lastUsedIndex); return $conn->qstr($string); } /** * Calls "qstr" function for each given array element. * * @param Array $array * @param string $function * @return Array */ public function qstrArray($array, $function = 'qstr') { $conn =& $this->openConnection($this->lastUsedIndex); return $conn->qstrArray($array, $function); } /** * Escapes string. * * @param mixed $string * @return string * @access public */ public function escape($string) { $conn =& $this->openConnection($this->lastUsedIndex); return $conn->escape($string); } /** * Returns last error code occurred. * * @return int * @access public */ public function getErrorCode() { $conn =& $this->openConnection($this->lastUsedIndex); return $conn->getErrorCode(); } /** * Returns last error message. * * @return string * @access public */ public function getErrorMsg() { $conn =& $this->openConnection($this->lastUsedIndex); return $conn->getErrorMsg(); } /** * Performs insert of given data (useful with small number of queries) * or stores it to perform multiple insert later (useful with large number of queries). * * @param Array $fields_hash * @param string $table * @param string $type * @param bool $insert_now * @return bool * @access public */ public function doInsert($fields_hash, $table, $type = 'INSERT', $insert_now = true) { $conn =& $this->openConnection( $this->getMasterIndex() ); return $conn->doInsert($fields_hash, $table, $type, $insert_now); } /** * Update given field values to given record using $key_clause. * * @param Array $fields_hash * @param string $table * @param string $key_clause * @return bool * @access public */ public function doUpdate($fields_hash, $table, $key_clause) { $conn =& $this->openConnection( $this->getMasterIndex() ); return $conn->doUpdate($fields_hash, $table, $key_clause); } /** * Allows to detect table's presence in database. * * @param string $table_name * @param bool $force * @return bool * @access public */ public function TableFound($table_name, $force = false) { $conn =& $this->openConnection($this->lastUsedIndex); return $conn->TableFound($table_name, $force); } /** * Returns query processing statistics. * * @return Array * @access public */ public function getQueryStatistics() { $conn =& $this->openConnection($this->lastUsedIndex); return $conn->getQueryStatistics(); } /** * Get status information from SHOW STATUS in an associative array. * * @param string $which * @return Array * @access public */ public function getStatus($which = '%') { $conn =& $this->openConnection($this->lastUsedIndex); return $conn->getStatus($which); } /** * When undefined method is called, then send it directly to last used slave server connection * * @param string $name * @param Array $arguments * @return mixed * @access public */ public function __call($name, $arguments) { $conn =& $this->openConnection($this->lastUsedIndex); return call_user_func_array( Array (&$conn, $name), $arguments ); } /** * Returns appropriate connection based on sql * * @param string $sql * @return kDBConnection * @access protected */ protected function &chooseConnection($sql) { if ( $this->nextQueryFromMaster ) { $this->nextQueryFromMaster = false; $index = $this->getMasterIndex(); } else { $sid = isset($this->Application->Session) ? $this->Application->GetSID() : '9999999999999999999999'; if ( preg_match('/(^[ \t\r\n]*(ALTER|CREATE|DROP|RENAME|DELETE|DO|INSERT|LOAD|REPLACE|TRUNCATE|UPDATE))|ses_' . $sid . '/', $sql) ) { $index = $this->getMasterIndex(); } else { $index = $this->getSlaveIndex(); } } $this->lastUsedIndex = $index; $conn =& $this->openConnection($index); return $conn; } /** * Get slave replication lag. It will only work if the DB user has the PROCESS privilege. * * @return int * @access public */ public function getSlaveLag() { $conn =& $this->openConnection($this->lastUsedIndex); return $conn->getSlaveLag(); } + + /** + * Sets an error handler. + * + * @param callable $error_handler Error handler. + * + * @return void + */ + public function setErrorHandler(callable $error_handler) + { + $this->errorHandler = $error_handler; + + foreach ( $this->connections as $connection ) { + $connection->setErrorHandler($error_handler); + } + } + } Index: branches/5.2.x/core/kernel/db/i_db_connection.php =================================================================== --- branches/5.2.x/core/kernel/db/i_db_connection.php (revision 16709) +++ branches/5.2.x/core/kernel/db/i_db_connection.php (revision 16710) @@ -1,262 +1,272 @@ '', 'user' => '', 'pass' => '', 'db' => ''); /** * Index of database server * * @var int * @access protected */ protected $serverIndex = 0; /** * Handle of currently processed recordset * * @var mysqli_result * @access protected */ protected $queryID = null; /** * Function to handle sql errors * - * @var Array|string - * @access public + * @var callable */ - public $errorHandler = ''; + protected $errorHandler = ''; /** * Error code * * @var int * @access protected */ protected $errorCode = 0; /** * Error message * * @var string * @access protected */ protected $errorMessage = ''; /** * Defines if database connection * operations should generate debug * information * * @var bool * @access public */ public $debugMode = false; /** * Save query execution statistics * * @var bool * @access protected */ protected $_captureStatistics = false; /** * Last query to database * * @var string * @access public */ public $lastQuery = ''; /** * Total processed queries count * * @var int * @access protected */ protected $_queryCount = 0; /** * Total time, used for serving queries * * @var Array * @access protected */ protected $_queryTime = 0; /** * Indicates, that next database query could be cached, when memory caching is enabled * * @var bool * @access public */ public $nextQueryCachable = false; /** * The "no debugging" state of the SQL queries. * * @var boolean */ public $noDebuggingState = false; /** * For backwards compatibility with kDBLoadBalancer class * * @var bool * @access public */ public $nextQueryFromMaster = false; /** * Initializes connection class with * db type to used in future * * @param string $db_type * @param string $error_handler * @param int $server_index * @access public */ public function __construct($db_type, $error_handler = '', $server_index = 0) { if ( class_exists('kApplication') ) { // prevents "Fatal Error" on 2nd installation step (when database is empty) parent::__construct(); } $this->serverIndex = $server_index; if ( !$error_handler ) { - $this->errorHandler = Array(&$this, 'handleError'); + $this->setErrorHandler(array(&$this, 'handleError')); } else { - $this->errorHandler = $error_handler; + $this->setErrorHandler($error_handler); } $this->_captureStatistics = defined('DBG_CAPTURE_STATISTICS') && DBG_CAPTURE_STATISTICS && !(defined('ADMIN') && ADMIN); } /** * Set's custom error * * @param int $code * @param string $msg * @access protected */ protected function setError($code, $msg) { $this->errorCode = $code; $this->errorMessage = $msg; } /** * Checks if previous query execution raised an error. * * @return bool * @access public */ public function hasError() { return $this->errorCode != 0; } /** * Try to connect to database server using specified parameters and set database to $db if connection made. * * @param string $host * @param string $user * @param string $pass * @param string $db * @param bool $retry * * @return bool * @access public * @throws RuntimeException When connection failed. */ public function Connect($host, $user, $pass, $db, $retry = false) { $this->connectionParams = Array ('host' => $host, 'user' => $user, 'pass' => $pass, 'db' => $db); $this->setError(0, ''); // reset error $this->connectionID = mysqli_connect($host, $user, $pass, $db); $this->errorCode = mysqli_connect_errno(); if ( is_object($this->connectionID) ) { if ( defined('DBG_SQL_MODE') ) { $this->Query('SET SQL_MODE = "' . DBG_SQL_MODE . '"'); } if ( defined('SQL_COLLATION') && defined('SQL_CHARSET') ) { $this->Query('SET NAMES \'' . SQL_CHARSET . '\' COLLATE \'' . SQL_COLLATION . '\''); } if ( !$this->hasError() ) { $this->connectionOpened = true; return true; } } $this->errorMessage = mysqli_connect_error(); $error_msg = 'Database connection failed, please check your connection settings.
Error (' . $this->errorCode . '): ' . $this->errorMessage; if ( (defined('IS_INSTALL') && IS_INSTALL) || $retry ) { trigger_error($error_msg, E_USER_WARNING); } else { $this->Application->redirectToMaintenance(); throw new RuntimeException($error_msg); } $this->connectionOpened = false; return false; } /** * Checks if connection to database is opened. * * @return bool * @access public */ public function connectionOpened() { return $this->connectionOpened; } /** * Setups the connection according given configuration. * * @param Array $config * @return bool * @access public */ public function setup($config) { if ( is_object($this->Application) ) { $this->debugMode = $this->Application->isDebugMode(); } return $this->Connect( $config['Database']['DBHost'], $config['Database']['DBUser'], $config['Database']['DBUserPassword'], $config['Database']['DBName'] ); } /** * Performs 3 reconnect attempts in case if connection to a DB was lost in the middle of script run (e.g. server restart) * * @return bool * @access protected */ protected function ReConnect() { $retry_count = 0; $connected = false; $this->connectionID->close(); while ( $retry_count < 3 ) { sleep(5); // wait 5 seconds before each reconnect attempt $connected = $this->Connect( $this->connectionParams['host'], $this->connectionParams['user'], $this->connectionParams['pass'], $this->connectionParams['db'], true ); if ( $connected ) { break; } $retry_count++; } return $connected; } /** * Shows error message from previous operation * if it failed * * @param string $sql * @param string $key_field * @param boolean|null $no_debug * @return bool * @access protected */ protected function showError($sql = '', $key_field = null, $no_debug = null) { static $retry_count = 0; if ( $no_debug === null ) { $no_debug = $this->noDebuggingState; } if ( !is_object($this->connectionID) ) { // no connection while doing mysql_query $this->errorCode = mysqli_connect_errno(); if ( $this->hasError() ) { $this->errorMessage = mysqli_connect_error(); $ret = $this->callErrorHandler($sql); if (!$ret) { exit; } } return false; } // checking if there was an error during last mysql_query $this->errorCode = $this->connectionID->errno; if ( $this->hasError() ) { $this->errorMessage = $this->connectionID->error; $ret = $this->callErrorHandler($sql); if ( ($this->errorCode == 2006 || $this->errorCode == 2013) && ($retry_count < 3) ) { // #2006 - MySQL server has gone away // #2013 - Lost connection to MySQL server during query $retry_count++; if ( $this->ReConnect() ) { return $this->Query($sql, $key_field, $no_debug); } } if (!$ret) { exit; } } else { $retry_count = 0; } return false; } /** * Sends db error to a predefined error handler * * @param $sql * @return bool * @access protected */ protected function callErrorHandler($sql) { return call_user_func($this->errorHandler, $this->errorCode, $this->errorMessage, $sql); } /** * Default error handler for sql errors * * @param int $code * @param string $msg * @param string $sql * @return bool * @access public */ public function handleError($code, $msg, $sql) { echo 'Processing SQL: ' . $sql . '
'; echo 'Error (' . $code . '): ' . $msg . '
'; return false; } /** * Returns first field of first line of recordset if query ok or false otherwise. * * @param string $sql * @param int $offset * @return string * @access public */ public function GetOne($sql, $offset = 0) { $row = $this->GetRow($sql, $offset); if ( !$row ) { return false; } return array_shift($row); } /** * Returns first row of recordset if query ok, false otherwise. * * @param string $sql * @param int $offset * @return Array * @access public */ public function GetRow($sql, $offset = 0) { $sql .= ' ' . $this->getLimitClause($offset, 1); $ret = $this->Query($sql); if ( !$ret ) { return false; } return array_shift($ret); } /** * Returns 1st column of recordset as one-dimensional array or false otherwise. * * Optional parameter $key_field can be used to set field name to be used as resulting array key. * * @param string $sql * @param string $key_field * @return Array * @access public */ public function GetCol($sql, $key_field = null) { $rows = $this->Query($sql); if ( !$rows ) { return $rows; } $i = 0; $row_count = count($rows); $ret = Array (); if ( isset($key_field) ) { while ( $i < $row_count ) { $ret[$rows[$i][$key_field]] = array_shift($rows[$i]); $i++; } } else { while ( $i < $row_count ) { $ret[] = array_shift($rows[$i]); $i++; } } return $ret; } /** * Returns iterator for 1st column of a recordset or false in case of error. * * Optional parameter $key_field can be used to set field name to be used as resulting array key. * * @param string $sql * @param string $key_field * @return bool|kMySQLQueryCol */ public function GetColIterator($sql, $key_field = null) { return $this->GetIterator($sql, $key_field, false, 'kMySQLQueryCol'); } /** * Queries db with $sql query supplied and returns rows selected if any, false otherwise. * * Optional parameter $key_field allows to set one of the query fields value as key in string array. * * @param string $sql * @param string $key_field * @param boolean|null $no_debug * @return Array * @access public */ public function Query($sql, $key_field = null, $no_debug = null) { if ( $no_debug === null ) { $no_debug = $this->noDebuggingState; } if ( !$no_debug ) { $this->_queryCount++; } $this->lastQuery = $sql; // set 1st checkpoint: begin $start_time = $this->_captureStatistics ? microtime(true) : 0; // set 1st checkpoint: end $this->setError(0, ''); // reset error $this->queryID = $this->connectionID->query($sql); if ( is_object($this->queryID) ) { $ret = Array (); if ( isset($key_field) ) { while ( $row = $this->queryID->fetch_assoc() ) { $ret[$row[$key_field]] = $row; } } else { while ( $row = $this->queryID->fetch_assoc() ) { $ret[] = $row; } } $this->Destroy(); // set 2nd checkpoint: begin if ( $this->_captureStatistics ) { $query_time = microtime(true) - $start_time; if ( $query_time > DBG_MAX_SQL_TIME && !$no_debug ) { $this->Application->logSlowQuery($sql, $query_time); } $this->_queryTime += $query_time; } // set 2nd checkpoint: end return $ret; } else { // set 2nd checkpoint: begin if ( $this->_captureStatistics ) { $this->_queryTime += microtime(true) - $start_time; } // set 2nd checkpoint: end } return $this->showError($sql, $key_field, $no_debug); } /** * Returns iterator to a recordset, produced from running $sql query. * * Queries db with $sql query supplied and returns kMySQLQuery iterator or false in case of error. * Optional parameter $key_field allows to set one of the query fields value as key in string array. * * @param string $sql * @param string $key_field * @param boolean|null $no_debug * @param string $iterator_class * @return kMySQLQuery|bool * @access public */ public function GetIterator($sql, $key_field = null, $no_debug = null, $iterator_class = 'kMySQLQuery') { if ( $no_debug === null ) { $no_debug = $this->noDebuggingState; } if ( !$no_debug ) { $this->_queryCount++; } $this->lastQuery = $sql; // set 1st checkpoint: begin $start_time = $this->_captureStatistics ? microtime(true) : 0; // set 1st checkpoint: end $this->setError(0, ''); // reset error $this->queryID = $this->connectionID->query($sql); if ( is_object($this->queryID) ) { /** @var kMySQLQuery $ret */ $ret = new $iterator_class($this->queryID, $key_field); // set 2nd checkpoint: begin if ( $this->_captureStatistics ) { $query_time = microtime(true) - $start_time; if ( $query_time > DBG_MAX_SQL_TIME && !$no_debug ) { $this->Application->logSlowQuery($sql, $query_time); } $this->_queryTime += $query_time; } // set 2nd checkpoint: end return $ret; } else { // set 2nd checkpoint: begin if ( $this->_captureStatistics ) { $this->_queryTime += microtime(true) - $start_time; } // set 2nd checkpoint: end } return $this->showError($sql, $key_field, $no_debug); } /** * Free memory used to hold recordset handle. * * @access public */ public function Destroy() { $this->queryID->free(); unset($this->queryID); } /** * Performs sql query, that will change database content. * * @param string $sql * @return bool * @access public */ public function ChangeQuery($sql) { $this->Query($sql); return !$this->hasError(); } /** * Returns auto increment field value from insert like operation if any, zero otherwise. * * @return int * @access public */ public function getInsertID() { return $this->connectionID->insert_id; } /** * Returns row count affected by last query. * * @return int * @access public */ public function getAffectedRows() { return $this->connectionID->affected_rows; } /** * Returns LIMIT sql clause part for specific db. * * @param int $offset * @param int $rows * @return string * @access public */ public function getLimitClause($offset, $rows) { if ( !($rows > 0) ) { return ''; } return 'LIMIT ' . $offset . ',' . $rows; } /** * If it's a string, adds quotes and backslashes. Otherwise returns as-is. * * @param mixed $string * @return string * @access public */ public function qstr($string) { if ( is_null($string) ) { return 'NULL'; } # This will also quote numeric values. This should be harmless, # and protects against weird problems that occur when they really # _are_ strings such as article titles and string->number->string # conversion is not 1:1. return "'" . $this->connectionID->real_escape_string($string) . "'"; } /** * Calls "qstr" function for each given array element. * * @param Array $array * @param string $function * @return Array */ public function qstrArray($array, $function = 'qstr') { return array_map(Array (&$this, $function), $array); } /** * Escapes string. * * @param mixed $string * @return string * @access public */ public function escape($string) { if ( is_null($string) ) { return 'NULL'; } $string = $this->connectionID->real_escape_string($string); // prevent double-escaping of MySQL wildcard symbols ("%" and "_") in case if they were already escaped return str_replace(Array ('\\\\%', '\\\\_'), Array ('\\%', '\\_'), $string); } /** * Returns last error code occurred. * * @return int * @access public */ public function getErrorCode() { return $this->errorCode; } /** * Returns last error message. * * @return string * @access public */ public function getErrorMsg() { return $this->errorMessage; } /** * Performs insert of given data (useful with small number of queries) * or stores it to perform multiple insert later (useful with large number of queries). * * @param Array $fields_hash * @param string $table * @param string $type * @param bool $insert_now * @return bool * @access public */ public function doInsert($fields_hash, $table, $type = 'INSERT', $insert_now = true) { static $value_sqls = Array (); if ($insert_now) { $fields_sql = '`' . implode('`,`', array_keys($fields_hash)) . '`'; } $values_sql = ''; foreach ($fields_hash as $field_name => $field_value) { $values_sql .= $this->qstr($field_value) . ','; } // don't use preg here, as it may fail when string is too long $value_sqls[] = rtrim($values_sql, ','); $insert_result = true; if ($insert_now) { $insert_count = count($value_sqls); if (($insert_count > 1) && ($value_sqls[$insert_count - 1] == $value_sqls[$insert_count - 2])) { // last two records are the same array_pop($value_sqls); } $sql = strtoupper($type) . ' INTO `' . $table . '` (' . $fields_sql . ') VALUES (' . implode('),(', $value_sqls) . ')'; $value_sqls = Array (); // reset before query to prevent repeated call from error handler to insert 2 records instead of 1 $insert_result = $this->ChangeQuery($sql); } return $insert_result; } /** * Update given field values to given record using $key_clause. * * @param Array $fields_hash * @param string $table * @param string $key_clause * @return bool * @access public */ public function doUpdate($fields_hash, $table, $key_clause) { if (!$fields_hash) return true; $fields_sql = ''; foreach ($fields_hash as $field_name => $field_value) { $fields_sql .= '`'.$field_name.'` = ' . $this->qstr($field_value) . ','; } // don't use preg here, as it may fail when string is too long $fields_sql = rtrim($fields_sql, ','); $sql = 'UPDATE `'.$table.'` SET '.$fields_sql.' WHERE '.$key_clause; return $this->ChangeQuery($sql); } /** * Allows to detect table's presence in database. * * @param string $table_name * @param bool $force * @return bool * @access public */ public function TableFound($table_name, $force = false) { static $table_found = false; if ( $table_found === false ) { $table_found = array_flip($this->GetCol('SHOW TABLES')); } if ( !preg_match('/^' . preg_quote(TABLE_PREFIX, '/') . '(.*)/', $table_name) ) { $table_name = TABLE_PREFIX . $table_name; } if ( $force ) { if ( $this->Query('SHOW TABLES LIKE ' . $this->qstr($table_name)) ) { $table_found[$table_name] = 1; } else { unset($table_found[$table_name]); } } return isset($table_found[$table_name]); } /** * Returns query processing statistics. * * @return Array * @access public */ public function getQueryStatistics() { return Array ('time' => $this->_queryTime, 'count' => $this->_queryCount); } /** * Get status information from SHOW STATUS in an associative array. * * @param string $which * @return Array * @access public */ public function getStatus($which = '%') { $status = Array (); $records = $this->Query('SHOW STATUS LIKE "' . $which . '"'); foreach ($records as $record) { $status[ $record['Variable_name'] ] = $record['Value']; } return $status; } /** * Get slave replication lag. It will only work if the DB user has the PROCESS privilege. * * @return int * @access public */ public function getSlaveLag() { try { $rows = $this->Query('SHOW SLAVE STATUS'); } catch ( RuntimeException $e ) { // When "SUPER" or "REPLICATION CLIENT" permission is missing. return 0; } // On the silenced error OR database server isn't configured for a replication. if ( $rows === false || count($rows) !== 1 ) { return 0; } $row = reset($rows); // When slave is too busy catching up with a master we'll get a NULL/empty string here. return is_numeric($row['Seconds_Behind_Master']) ? $row['Seconds_Behind_Master'] : false; } + /** + * Sets an error handler. + * + * @param callable $error_handler Error handler. + * + * @return void + */ + public function setErrorHandler(callable $error_handler) + { + $this->errorHandler = $error_handler; + } + } class kDBConnectionDebug extends kDBConnection { protected $_profileSQLs = false; /** * Info about this database connection to show in debugger report * * @var string * @access protected */ protected $serverInfoLine = ''; /** * Initializes connection class with * db type to used in future * * @param string $db_type * @param string $error_handler * @param int $server_index * @access public */ public function __construct($db_type, $error_handler = '', $server_index = 0) { parent::__construct($db_type, $error_handler, $server_index); $this->_profileSQLs = defined('DBG_SQL_PROFILE') && DBG_SQL_PROFILE; } /** * Try to connect to database server * using specified parameters and set * database to $db if connection made * * @param string $host * @param string $user * @param string $pass * @param string $db * @param bool $force_new * @param bool $retry * @return bool * @access public */ public function Connect($host, $user, $pass, $db, $force_new = false, $retry = false) { if ( defined('DBG_SQL_SERVERINFO') && DBG_SQL_SERVERINFO ) { $this->serverInfoLine = $this->serverIndex . ' (' . $host . ')'; } return parent::Connect($host, $user, $pass, $db, $force_new, $retry); } /** * Queries db with $sql query supplied and returns rows selected if any, false otherwise. * * Optional parameter $key_field allows to set one of the query fields value as key in string array. * * @param string $sql * @param string $key_field * @param boolean|null $no_debug * @return Array * @access public */ public function Query($sql, $key_field = null, $no_debug = null) { if ( $no_debug === null ) { $no_debug = $this->noDebuggingState; } if ( $no_debug ) { return parent::Query($sql, $key_field, $no_debug); } global $debugger; $this->_queryCount++; $this->lastQuery = $sql; // set 1st checkpoint: begin if ( $this->_profileSQLs ) { $queryID = $debugger->generateID(); $debugger->profileStart('sql_' . $queryID, $debugger->formatSQL($sql)); } // set 1st checkpoint: end $this->setError(0, ''); // reset error $this->queryID = $this->connectionID->query($sql); if ( is_object($this->queryID) ) { $ret = Array (); if ( isset($key_field) ) { while ( $row = $this->queryID->fetch_assoc() ) { $ret[$row[$key_field]] = $row; } } else { while ( $row = $this->queryID->fetch_assoc() ) { $ret[] = $row; } } // set 2nd checkpoint: begin if ( $this->_profileSQLs ) { $current_element = current($ret); $first_cell = count($ret) == 1 && count($current_element) == 1 ? current($current_element) : null; if ( strlen($first_cell) > 200 ) { $first_cell = substr($first_cell, 0, 50) . ' ...'; } $debugger->profileFinish('sql_' . $queryID, null, null, $this->getAffectedRows(), $first_cell, $this->_queryCount, $this->nextQueryCachable, $this->serverInfoLine); $debugger->profilerAddTotal('sql', 'sql_' . $queryID); $this->nextQueryCachable = false; } // set 2nd checkpoint: end $this->Destroy(); return $ret; } else { // set 2nd checkpoint: begin if ( $this->_profileSQLs ) { $debugger->profileFinish('sql_' . $queryID, null, null, $this->getAffectedRows(), null, $this->_queryCount, $this->nextQueryCachable, $this->serverInfoLine); $debugger->profilerAddTotal('sql', 'sql_' . $queryID); $this->nextQueryCachable = false; } // set 2nd checkpoint: end } return $this->showError($sql, $key_field); } /** * Returns iterator to a recordset, produced from running $sql query. * * Queries db with $sql query supplied and returns kMySQLQuery iterator or false in case of error. * Optional parameter $key_field allows to set one of the query fields value as key in string array. * * @param string $sql * @param string $key_field * @param boolean|null $no_debug * @param string $iterator_class * @return kMySQLQuery|bool * @access public */ public function GetIterator($sql, $key_field = null, $no_debug = null, $iterator_class = 'kMySQLQuery') { if ( $no_debug === null ) { $no_debug = $this->noDebuggingState; } if ( $no_debug ) { return parent::GetIterator($sql, $key_field, $no_debug, $iterator_class); } global $debugger; $this->_queryCount++; $this->lastQuery = $sql; // set 1st checkpoint: begin if ( $this->_profileSQLs ) { $queryID = $debugger->generateID(); $debugger->profileStart('sql_' . $queryID, $debugger->formatSQL($sql)); } // set 1st checkpoint: end $this->setError(0, ''); // reset error $this->queryID = $this->connectionID->query($sql); if ( is_object($this->queryID) ) { /** @var kMySQLQuery $ret */ $ret = new $iterator_class($this->queryID, $key_field); // set 2nd checkpoint: begin if ( $this->_profileSQLs ) { $current_row = $ret->current(); if ( count($ret) == 1 && $ret->fieldCount() == 1 ) { if ( is_array($current_row) ) { $first_cell = current($current_row); } else { $first_cell = $current_row; } } else { $first_cell = null; } if ( strlen($first_cell) > 200 ) { $first_cell = substr($first_cell, 0, 50) . ' ...'; } $debugger->profileFinish('sql_' . $queryID, null, null, $this->getAffectedRows(), $first_cell, $this->_queryCount, $this->nextQueryCachable, $this->serverInfoLine); $debugger->profilerAddTotal('sql', 'sql_' . $queryID); $this->nextQueryCachable = false; } // set 2nd checkpoint: end return $ret; } else { // set 2nd checkpoint: begin if ( $this->_profileSQLs ) { $debugger->profileFinish('sql_' . $queryID, null, null, $this->getAffectedRows(), null, $this->_queryCount, $this->nextQueryCachable, $this->serverInfoLine); $debugger->profilerAddTotal('sql', 'sql_' . $queryID); $this->nextQueryCachable = false; } // set 2nd checkpoint: end } return $this->showError($sql, $key_field); } } class kMySQLQuery implements Iterator, Countable, SeekableIterator { /** * Current index in recordset * * @var int * @access protected */ protected $position = -1; /** * Query resource * * @var mysqli_result * @access protected */ protected $result; /** * Field to act as key in a resulting array * * @var string * @access protected */ protected $keyField = null; /** * Data in current row of recordset * * @var Array * @access protected */ protected $rowData = Array (); /** * Row count in a result * * @var int * @access protected */ protected $rowCount = 0; /** * Creates new instance of a class * * @param mysqli_result $result * @param null|string $key_field */ public function __construct(mysqli_result $result, $key_field = null) { $this->result = $result; $this->keyField = $key_field; $this->rowCount = $this->result->num_rows; $this->rewind(); } /** * Moves recordset pointer to first element * * @return void * @access public * @implements Iterator::rewind */ public function rewind() { $this->seek(0); } /** * Returns value at current position * * @return mixed * @access public * @implements Iterator::current */ function current() { return $this->rowData; } /** * Returns key at current position * * @return mixed * @access public * @implements Iterator::key */ function key() { return $this->keyField ? $this->rowData[$this->keyField] : $this->position; } /** * Moves recordset pointer to next position * * @return void * @access public * @implements Iterator::next */ function next() { $this->seek($this->position + 1); } /** * Detects if current position is within recordset bounds * * @return bool * @access public * @implements Iterator::valid */ public function valid() { return $this->position < $this->rowCount; } /** * Counts recordset rows * * @return int * @access public * @implements Countable::count */ public function count() { return $this->rowCount; } /** * Counts fields in current row * * @return int * @access public */ public function fieldCount() { return count($this->rowData); } /** * Moves cursor into given position within recordset * * @param int $position * @throws OutOfBoundsException * @access public * @implements SeekableIterator::seek */ public function seek($position) { if ( $this->position == $position ) { return; } $this->position = $position; if ( $this->valid() ) { $this->result->data_seek($this->position); $this->rowData = $this->result->fetch_assoc(); } /*if ( !$this->valid() ) { throw new OutOfBoundsException('Invalid seek position (' . $position . ')'); }*/ } /** * Returns first recordset row * * @return Array * @access public */ public function first() { $this->seek(0); return $this->rowData; } /** * Closes recordset and freese memory * * @return void * @access public */ public function close() { $this->result->free(); unset($this->result); } /** * Frees memory when object is destroyed * * @return void * @access public */ public function __destruct() { $this->close(); } /** * Returns all keys * * @return Array * @access public */ public function keys() { $ret = Array (); foreach ($this as $key => $value) { $ret[] = $key; } return $ret; } /** * Returns all values * * @return Array * @access public */ public function values() { $ret = Array (); foreach ($this as $value) { $ret[] = $value; } return $ret; } /** * Returns whole recordset as array * * @return Array * @access public */ public function toArray() { $ret = Array (); foreach ($this as $key => $value) { $ret[$key] = $value; } return $ret; } } class kMySQLQueryCol extends kMySQLQuery { /** * Returns value at current position * * @return mixed * @access public * @implements Iterator::current */ function current() { return reset($this->rowData); } /** * Returns first column of first recordset row * * @return string * @access public */ public function first() { $this->seek(0); return reset($this->rowData); } } Index: branches/5.2.x/core/units/helpers/deployment_helper.php =================================================================== --- branches/5.2.x/core/units/helpers/deployment_helper.php (revision 16709) +++ branches/5.2.x/core/units/helpers/deployment_helper.php (revision 16710) @@ -1,745 +1,745 @@ _event = new kEvent('adm:OnDummy'); $this->isCommandLine = isset($GLOBALS['argv']) && count($GLOBALS['argv']); if ( !$this->isCommandLine ) { $this->ip = $this->Application->getClientIp(); } elseif ( isset($GLOBALS['argv'][3]) ) { $this->ip = $GLOBALS['argv'][3]; } } /** * Sets event, associated with deployment * * @param kEvent $event * @return void * @access public */ public function setEvent(kEvent $event) { $this->_event = $event; } /** * Adds message to script execution log * * @param string $message * @param bool $new_line * @return void * @access private */ private function toLog($message, $new_line = true) { $log_file = (defined('RESTRICTED') ? RESTRICTED : WRITEABLE) . '/project_upgrades.log'; $fp = fopen($log_file, 'a'); fwrite($fp, $message . ($new_line ? "\n" : '')); fclose($fp); chmod($log_file, 0666); } /** * Loads already applied revisions list of current module * * @return void * @access private */ private function loadAppliedRevisions() { $sql = 'SELECT AppliedDBRevisions FROM ' . TABLE_PREFIX . 'Modules WHERE Name = ' . $this->Conn->qstr($this->moduleName); $revisions = $this->Conn->GetOne($sql); $this->appliedRevisions = $revisions ? explode(',', $revisions) : Array (); } /** * Saves applied revision numbers to current module record * * @return void * @access private */ private function saveAppliedRevisions() { // maybe optimize sort($this->appliedRevisions); $fields_hash = Array ( 'AppliedDBRevisions' => implode(',', $this->appliedRevisions), ); $this->Conn->doUpdate($fields_hash, TABLE_PREFIX . 'Modules', '`Name` = ' . $this->Conn->qstr($this->moduleName)); } /** * Deploys changes from all installed modules * * @param bool $dry_run * @return bool * @access public */ public function deployAll($dry_run = false) { if ( !$this->isCommandLine ) { echo '
' . PHP_EOL;
 		}
 
 		$ret = true;
 		$this->dryRun = $dry_run;
 
 		$this->toLog(PHP_EOL . '[' . adodb_date('Y-m-d H:i:s') . '] === ' . $this->ip . ' ===');
 
 		foreach ($this->Application->ModuleInfo as $module_name => $module_info) {
 			$this->moduleName = $module_name;
 
 			if ( !file_exists($this->getModuleFile('project_upgrades.sql')) ) {
 				continue;
 			}
 
 			$ret = $ret && $this->deploy($module_name);
 		}
 
 		if ( $ret && !$this->dryRun ) {
 			$this->resetCaches();
 			$this->refreshThemes();
 			$this->dumpAssets();
 		}
 
 		if ( !$this->isCommandLine ) {
 			echo kUtil::escape($this->_runShellScript());
 			echo '
' . PHP_EOL; } return $ret; } /** * Runs user-specific shell script when deployment happens from Web * * @return string * @access protected */ protected function _runShellScript() { if ( !$this->Application->isDebugMode(false) ) { return ''; } $wrapper_script = '/usr/local/bin/guest2host_server.sh'; $script_name = FULL_PATH .'/tools/' . ($this->dryRun ? 'synchronize.sh' : 'deploy.sh'); if ( file_exists($wrapper_script) && file_exists($script_name) ) { $script_name = preg_replace('/^.*\/web/', constant('DBG_LOCAL_BASE_PATH'), $script_name); return shell_exec($wrapper_script . ' ' . $script_name . ' 2>&1'); } return ''; } /** * Deploys pending changes to a site * * @param string $module_name * @return bool * @access private */ private function deploy($module_name) { echo $this->colorText('Deploying Module "' . $module_name . '":', 'cyan', true) . PHP_EOL; if ( !$this->upgradeDatabase() ) { return false; } try { if ( $this->dryRun ) { $this->exportLanguagePack(); } else { $this->importLanguagePack(); } } catch ( Exception $e ) { echo $this->colorText('Failed with Module "' . $module_name . '".', 'red', true) . PHP_EOL . PHP_EOL; return false; } echo $this->colorText('Done with Module "' . $module_name . '".', 'green', true) . PHP_EOL . PHP_EOL; return true; } /** * Import latest languagepack (without overwrite) * * @return void * @access private */ private function importLanguagePack() { /** @var LanguageImportHelper $language_import_helper */ $language_import_helper = $this->Application->recallObject('LanguageImportHelper'); $this->out('Importing LanguagePack ... '); $filename = $this->getModuleFile('english.lang'); $language_import_helper->performImport($filename, '|0|1|2|', $this->moduleName, LANG_SKIP_EXISTING); $this->displayStatus('OK'); } /** * Exports latest language pack * * @return void * @access private */ private function exportLanguagePack() { static $languages = null; if ( !isset($languages) ) { $sql = 'SELECT LanguageId FROM ' . $this->Application->getUnitOption('lang', 'TableName') . ' WHERE Enabled = 1'; $languages = $this->Conn->GetCol($sql); } /** @var LanguageImportHelper $language_import_helper */ $language_import_helper = $this->Application->recallObject('LanguageImportHelper'); $this->out('Exporting LanguagePack ... '); $language_import_helper->performExport( EXPORT_PATH . '/' . $this->moduleName . '.lang', '|0|1|2|', $languages, '|' . $this->moduleName . '|' ); $this->displayStatus('OK'); } /** * Resets unit and section cache * * @return void * @access private */ private function resetCaches() { // 2. reset unit config cache (so new classes get auto-registered) $this->out('Resetting Configs Files Cache and Parsed System Data ... '); $this->_event->CallSubEvent('OnResetConfigsCache'); $this->displayStatus('OK'); // 3. reset sections cache $this->out('Resetting Admin Console Sections ... '); $this->_event->CallSubEvent('OnResetSections'); $this->displayStatus('OK'); // 4. reset mod-rewrite cache $this->out('Resetting ModRewrite Cache ... '); $this->_event->CallSubEvent('OnResetModRwCache'); $this->displayStatus('OK'); } /** * Rebuild theme files * * @return void * @access private */ private function refreshThemes() { $this->out('Refreshing Theme Files ... '); $this->_event->CallSubEvent('OnRebuildThemes'); $this->displayStatus('OK'); } /** * Dumps assets * * @return void */ private function dumpAssets() { $this->out('Dumping Assets ... '); $this->_event->CallSubEvent('OnDumpAssets'); $this->displayStatus('OK'); } /** * Runs database upgrade script * * @return bool * @access private */ private function upgradeDatabase() { $this->loadAppliedRevisions(); - $this->Conn->errorHandler = Array (&$this, 'handleSqlError'); + $this->Conn->setErrorHandler(array(&$this, 'handleSqlError')); $this->out('Verifying Database Revisions ... '); if ( !$this->collectDatabaseRevisions() || !$this->checkRevisionDependencies() ) { return false; } $this->displayStatus('OK'); $applied = $this->applyRevisions(); $this->saveAppliedRevisions(); return $applied; } /** * Collects database revisions from "project_upgrades.sql" file. * * @return bool * @access private */ private function collectDatabaseRevisions() { $filename = $this->getModuleFile('project_upgrades.sql'); if ( !file_exists($filename) ) { return true; } $sqls = file_get_contents($filename); preg_match_all("/# r([\d]+)([^\:]*):.*?(\n|$)/s", $sqls, $matches, PREG_SET_ORDER + PREG_OFFSET_CAPTURE); if ( !$matches ) { $this->displayStatus('FAILED' . PHP_EOL . 'No Database Revisions Found'); return false; } $revision_numbers = array(); foreach ( $matches as $index => $match ) { $revision = $match[1][0]; if ( in_array($revision, $revision_numbers) ) { $this->displayStatus('FAILED' . PHP_EOL . 'Duplicate revision #' . $revision . ' found'); return false; } $revision_numbers[] = $revision; if ( $this->revisionApplied($revision) ) { // Skip applied revisions. continue; } // Get revision sqls. $start_pos = $match[0][1] + strlen($match[0][0]); $end_pos = isset($matches[$index + 1]) ? $matches[$index + 1][0][1] : strlen($sqls); $revision_sqls = substr($sqls, $start_pos, $end_pos - $start_pos); if ( !$revision_sqls ) { // resision without sqls continue; } $this->revisionTitles[$revision] = trim($match[0][0]); $this->revisionSqls[$revision] = $revision_sqls; $revision_lependencies = $this->parseRevisionDependencies($match[2][0]); if ( $revision_lependencies ) { $this->revisionDependencies[$revision] = $revision_lependencies; } } ksort($this->revisionSqls); ksort($this->revisionDependencies); return true; } /** * Checks that all dependent revisions are either present now OR were applied before * * @return bool * @access private */ private function checkRevisionDependencies() { foreach ($this->revisionDependencies as $revision => $revision_dependencies) { foreach ($revision_dependencies as $revision_dependency) { if ( $this->revisionApplied($revision_dependency) ) { // revision dependend upon already applied -> depencency fulfilled continue; } if ( $revision_dependency >= $revision ) { $this->displayStatus('FAILED' . PHP_EOL . 'Revision #' . $revision . ' has incorrect dependency to revision #' . $revision_dependency . '. Only dependencies to older revisions are allowed!'); return false; } if ( !isset($this->revisionSqls[$revision_dependency]) ) { $this->displayStatus('FAILED' . PHP_EOL . 'Revision #' . $revision . ' depends on missing revision #' . $revision_dependency . '!'); return false; } } } return true; } /** * Runs all pending sqls * * @return bool * @access private */ private function applyRevisions() { if ( !$this->revisionSqls ) { return true; } if ( $this->dryRun ) { $this->out('Simulating Database Upgrade ... ', true); foreach ( $this->revisionSqls as $revision => $sqls ) { echo PHP_EOL . $this->colorText($this->revisionTitles[$revision], 'gray', true) . PHP_EOL; echo '...' . PHP_EOL; } echo PHP_EOL; $this->appliedRevisions = array_merge($this->appliedRevisions, array_keys($this->revisionSqls)); return true; } $this->out('Upgrading Database ... ', true); foreach ($this->revisionSqls as $revision => $sqls) { echo PHP_EOL . $this->colorText($this->revisionTitles[$revision], 'gray', true) . PHP_EOL; // 'Processing DB Revision: #' . $revision . ' ... '; $sqls = str_replace("\r\n", "\n", $sqls); // convert to linux line endings $no_comment_sqls = preg_replace("/#\s([^;]*?)\n/is", "# \\1;\n", $sqls); // add ";" to each comment end to ensure correct split $sqls = explode(";\n", $no_comment_sqls . "\n"); // ensures that last sql won't have ";" in it $sqls = array_map('trim', $sqls); try { foreach ( $sqls as $line_number => $sql ) { if ( substr($sql, 0, 1) == '#' ) { // output comment as is $this->toLog($sql); echo $this->colorText($sql, 'purple') . PHP_EOL; continue; } elseif ( $sql ) { $this->toLog($sql . ' ... ', false); $escaped_sql = $this->isCommandLine ? $sql : kUtil::escape($sql); echo mb_substr(trim(preg_replace('/(\n|\t| )+/is', ' ', $escaped_sql)), 0, self::SQL_TRIM_LENGTH) . ' ... '; $this->Conn->Query($sql); $this->toLog('OK (' . $this->Conn->getAffectedRows() . ')'); $this->displayStatus('OK (' . $this->Conn->getAffectedRows() . ')'); } } } catch (Exception $e) { // consider revisions with errors applied $this->appliedRevisions[] = $revision; $this->out('========', true); $this->toLog('========'); foreach ( array_slice($sqls, $line_number) as $sql ) { if ( substr($sql, 0, 1) == '#' ) { // Output comment as is. $this->toLog($sql); echo $this->colorText($sql, 'purple') . PHP_EOL; continue; } elseif ( $sql ) { $this->toLog($sql); $escaped_sql = $this->isCommandLine ? $sql : kUtil::escape($sql); echo $escaped_sql . ';' . PHP_EOL; } } $this->out('========', true); $this->toLog('========'); return false; } $this->appliedRevisions[] = $revision; } echo PHP_EOL; return true; } /** * Error handler for sql errors. * * @param integer $code Error code. * @param string $msg Error message. * @param string $sql SQL query. * * @return void * @throws Exception When SQL error happens. */ public function handleSqlError($code, $msg, $sql) { $error_msg = 'FAILED' . PHP_EOL . 'SQL Error #' . $code . ': ' . $msg; $this->toLog($error_msg); $this->displayStatus($error_msg); $info_msg = 'Execute rest of SQLs in this Revision (show below) by hand and '; $info_msg .= 'RUN deployment script again to complete the deployment process.'; $this->toLog($info_msg); echo $this->colorText($info_msg, 'red', true) . PHP_EOL; throw new Exception($msg, $code); } /** * Checks if given revision was already applied * * @param int $revision * @return bool * @access private */ private function revisionApplied($revision) { foreach ($this->appliedRevisions as $applied_revision) { // revision range $applied_revision = explode('-', $applied_revision, 2); if ( !isset($applied_revision[1]) ) { // convert single revision to revision range $applied_revision[1] = $applied_revision[0]; } if ( $revision >= $applied_revision[0] && $revision <= $applied_revision[1] ) { return true; } } return false; } /** * Returns path to given file in current module install folder * * @param string $filename * @return string * @access private */ private function getModuleFile($filename) { $module_folder = $this->Application->findModule('Name', $this->moduleName, 'Path'); return FULL_PATH . DIRECTORY_SEPARATOR . $module_folder . 'install/' . $filename; } /** * Extracts revisions from string in format "(1,3,5464,23342,3243)" * * @param string $string * @return Array * @access private */ private function parseRevisionDependencies($string) { if ( !$string ) { return Array (); } $string = explode(',', substr($string, 1, -1)); return array_map('trim', $string); } /** * Applies requested color and bold attributes to given text string * * @param string $text * @param string $color * @param bool $bold * @return string * @access private */ private function colorText($text, $color, $bold = false) { if ( $this->isCommandLine ) { $color_map = Array ( 'black' => 30, // dark gray (in bold) 'blue' => 34, // light blue (in bold) 'green' => 32, // light green (in bold) 'cyan' => 36, // light cyan (in bold) 'red' => 31, // light red (in bold) 'purple' => 35, // light purple (in bold) 'brown' => 33, // yellow (in bold) 'gray' => 37, // white (in bold) ); return "\033[" . ($bold ? 1 : 0) . ";" . $color_map[$color] . "m" . $text . "\033[0m"; } $html_color_map = Array ( 'black' => Array ('normal' => '#000000', 'bold' => '#666666'), 'blue' => Array ('normal' => '#00009C', 'bold' => '#3C3CFF'), 'green' => Array ('normal' => '#009000', 'bold' => '#00FF00'), 'cyan' => Array ('normal' => '#009C9C', 'bold' => '#00FFFF'), 'red' => Array ('normal' => '#9C0000', 'bold' => '#FF0000'), 'purple' => Array ('normal' => '#900090', 'bold' => '#F99CF9'), 'brown' => Array ('normal' => '#C9C909', 'bold' => '#FFFF00'), 'gray' => Array ('normal' => '#909090', 'bold' => '#FFFFFF'), ); $html_color = $html_color_map[$color][$bold ? 'bold' : 'normal']; return '' . kUtil::escape($text, kUtil::ESCAPE_HTML) . ''; } /** * Displays last command execution status * * @param string $status_text * @param bool $new_line * @return void * @access private */ private function displayStatus($status_text, $new_line = true) { $color = substr($status_text, 0, 2) == 'OK' ? 'green' : 'red'; echo $this->colorText($status_text, $color, false); if ( $new_line ) { echo PHP_EOL; } } /** * Outputs a text and escapes it if necessary * * @param string $text * @param bool $new_line * @return void */ private function out($text, $new_line = false) { if ( !$this->isCommandLine ) { $text = kUtil::escape($text); } echo $text . ($new_line ? PHP_EOL : ''); } }