getCode() == 1062; } } if (!interface_exists('Database')) { interface Database {} } class DatabaseMysql implements Database { var $host, $port, $socket, $username, $password, $dbname; var $tableNames = array(); var $init = array(); var $queryLogFile, $loggedQueries = ''; var $reconnect = true; var $autoBegin; var $ondestroy = 'commit'; var $queryCount = 0; var $link; var $transactions = array(); /** * Creates a MySQL connection object. * * @param array $options Possible options: * host Host name or IP address to connect to [localhost] * socket Path to UNIX socket to connect to [/var/run/mysqld/mysqld.sock] * port TCP port to connect to [3306] * dbname DB name to use * username Username * password Password * tableNames Table name mappings (virtual => real) * queryLogFile Path to query log file * reconnect Whether to reconnect on idle timeout [true] * autoBegin Whether to automatically begin a transaction on first query [false] * ondestroy commit/rollback/none during destruction [commit] * init Initialisation queries (array) */ function __construct($options) { $defOpts = array( 'host' => 'localhost', 'port' => 3306, 'socket' => '/var/run/mysqld/mysqld.sock', 'dbname' => '', 'username' => '', 'password' => '', 'reconnect' => true, 'tableNames' => array(), 'queryLogFile' => '', 'autoBegin' => false, 'ondestroy' => 'commit', 'init' => array(), ); $options += $defOpts; if ($options['socket']) { $options['host'] = 'localhost'; } foreach ($defOpts as $k => $v) { $this->$k = $options[$k]; } if ($this->username || $this->dbname) // skip for Sphinx { // READ COMMITTED is more consistent for average usage $this->init[] = 'SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED'; } } function __destruct() { $o = $this->ondestroy; if (($o === 'commit' || $o === 'rollback') && $this->transactions) { $this->transactions = array(false); $this->$o(); } if ($this->queryLogFile) { file_put_contents($this->queryLogFile, $this->loggedQueries, FILE_APPEND); } } function connect() { if ($this->socket !== NULL) $this->link = new mysqli($this->host, $this->username, $this->password, $this->dbname, $this->port, $this->socket); elseif ($this->port !== NULL) $this->link = new mysqli($this->host, $this->username, $this->password, $this->dbname, $this->port); else $this->link = new mysqli($this->host, $this->username, $this->password, $this->dbname); $errno = $this->link->connect_errno; $error = $this->link->connect_error; if ($errno) { $this->link = NULL; throw new DatabaseMysqlException($error, $errno); } else { $this->transactions = array(); $this->link->set_charset('utf8'); foreach ($this->init as $q) $this->link->query($q); } } function getDBName() { return $this->dbname; } function quoteId($name) { return "`".str_replace("`", "``", $name)."`"; } function quoteInto($str, $params) { $i = 0; $r = ''; while (($p = strpos($str, '?')) !== false) { $r .= substr($str, 0, $p) . $this->quote($params[$i++]); $str = substr($str, $p+1); } return $r.$str; } function quote($value) { if ($value === NULL) return "NULL"; if (!$this->link) $this->connect(); return "'" . $this->link->real_escape_string($value) . "'"; } function query($sql, $fetchMode = MYSQLI_STORE_RESULT) { if (!$this->link) $this->connect(); if ($this->autoBegin && !$this->transactions) $this->begin(); $this->queryCount++; if ($this->queryLogFile) { $begin = explode(' ', microtime(), 2); } $r = $this->link->query($sql, $fetchMode); if (!$r) $r = $this->check_reconnect('query', [ $sql, $fetchMode ]); if ($this->queryLogFile) { $end = explode(' ', microtime(), 2); $this->loggedQueries .= date("[Y-m-d H:i:s.").substr($end[0], 2, 6)."] [". sprintf("%.05fs", $end[1]-$begin[1]+$end[0]-$begin[0])."] $sql\n"; } return $r; } protected function check_reconnect($f, $args) { $r = false; if ($this->link->errno == 2006 && $this->reconnect && (!$this->transactions || $args[0] == "BEGIN")) { // "MySQL server has gone away" $this->connect(); $r = call_user_func_array([ $this->link, $f ], $args); if (!$r && $this->link->errno == 2006) $this->link = false; if (!$r) $st = " (reconnect failed)"; } elseif ($this->link->errno != 2006) $st = ""; elseif (!$this->reconnect) $st = " (reconnect disabled"; elseif ($this->transactions) $st = " (not reconnecting because of active transactions)"; if (!$r) throw new DatabaseMysqlException('#'.$this->link->errno.': '.$this->link->error . $st . "\nQuery: ".$args[0], $this->link->errno); return $r; } function multi_select(array $queries, $format = 0) { if (!$this->link) $this->connect(); if ($this->autoBegin && !$this->transactions) $this->begin(); $this->queryCount += count($queries); $log = ''; foreach ($queries as &$sql) { if (!is_string($sql)) { $sql = $this->select_builder($sql[0], $sql[1], $sql[2], @$sql[3]); } $log .= date("[Y-m-d H:i:s] ").$sql."\n"; } unset($sql); if ($this->queryLogFile) { file_put_contents($this->queryLogFile, $log, FILE_APPEND); } $sql = implode('; ', $queries); $r = $this->link->multi_query($sql); if (!$r) $this->check_reconnect('multi_query', [ $sql ]); $results = array(); $i = 0; foreach ($queries as $k => $q) { if ($i++) { $this->link->next_result(); } $r = $this->link->store_result(); $results[$k] = $this->fetch_all($r, $format); } return $results; } /** * Starts a transaction, supports nested calls and savepoints. * @param boolean $savepoint Creates savepoints only this parameter is true. */ function begin($savepoint = false) { $n = count($this->transactions)+1; $this->transactions[] = $savepoint ? "sp$n" : false; if ($n == 1) return $this->query("BEGIN"); elseif ($savepoint) return $this->query("SAVEPOINT sp$n"); return true; } /** * Commits transaction or releases last savepoint. * If there is no last savepoint, just returns true. */ function commit() { $r = true; $savepoint = array_pop($this->transactions); if (!$this->transactions) $r = $this->query("COMMIT"); elseif ($savepoint) $r = $this->query("RELEASE SAVEPOINT $savepoint"); return $r; } /** * Commits transaction */ function commitAll() { $r = true; if ($this->transactions) { $r = $this->query("COMMIT"); $this->transactions = []; } return $r; } /** * Rollbacks transaction */ function rollbackAll() { $r = true; if ($this->transactions) { $r = $this->query("ROLLBACK"); $this->transactions = []; } return $r; } /** * Rollbacks transaction or last savepoint. * If there is no savepoint, returns false. */ function rollback() { $r = false; $savepoint = array_pop($this->transactions); if (!$this->transactions) $r = $this->query("ROLLBACK"); elseif ($savepoint) $r = $this->query("ROLLBACK TO SAVEPOINT $savepoint"); return $r; } function errno() { return $this->link->errno; } function error() { return $this->link->error; } /** * Builds WHERE-part of an SQL query. * $where can also be a string - then it's passed as-is. * * @param array $where Query conditions: * array( * 'conditional expression', * 'field_name' => 'value', * 'field_name' => array('one', 'of', 'values'), * 'field_name < ?' => 'value', * 'field_name < DATE_SUB(?, ?)' => array('arg1', 'arg2'), * 'field1,field2' => array(array(1, 2), array(3, 4)), * ) */ function where_builder($where) { if (!is_array($where)) return $where; $wh = array(); foreach ($where as $k => $v) { if (ctype_digit("$k")) { if (is_array($v)) { $str = array_shift($v); $wh[] = $this->quoteInto($str, $v); } else { $wh[] = $v; } } elseif (($p = strrpos($k, '?')) !== false) { $wh[] = $this->quoteInto($k, (array)$v); } elseif (is_array($v)) { if (!$v) { // FIXME: It seems we should return empty result in that case throw new DatabaseMysqlException("Error: empty array for '$k IN (...)', don't know what to do"); } else { if (is_array(reset($v))) { // (a,b) IN ((1,2), (3,4)) ... foreach ($v as &$l) { $l = "(" . implode(",", array_map(array($this, 'quote'), $l)) . ")"; } $wh[] = "$k IN (" . implode(",", $v) . ")"; } else { $r = ''; $null = false; foreach ($v as $i => $l) { if ($l === NULL) { $null = true; } else { $r .= $this->quote($l).','; } } $r = $r !== '' ? "$k IN (" . substr($r, 0, -1) . ")" : ''; if ($null) { $r = $r !== '' ? "($r OR $k IS NULL)" : "$k IS NULL"; } $wh[] = $r; } } } elseif (preg_match('/^-?\d+(\.\d+)?$/s', $v)) // int/float $wh[] = "$k=$v"; elseif ($v !== NULL) $wh[] = "$k=".$this->quote($v); else $wh[] = "$k IS NULL"; } if (!$this->username && !$this->password && !$this->dbname) { // Sphinx supports neither brackets nor OR operator as of 2.0.6-release O_o $where = join(' AND ', $wh); } elseif ($where) $where = '(' . join(') AND (', $wh) . ')'; else $where = ''; return $where; } /** * Builds SQL query text. * * @param mixed $tables see $this->tablesBuilder() * @param mixed $fields Field definitions - either a string or an array. * Strings are passed to resulting query text as-is. * Arrays have the following format: * array('field1', 'alias2' => 'expression2', ...) * @param mixed $where see $this->whereBuilder() * @param array $options query options - array of: * 'CALC_FOUND_ROWS' * 'NO_CACHE' or 'CACHE' * 'FOR UPDATE' or 'LOCK IN SHARE MODE' * 'GROUP BY' => array($groupby_field1 => 'ASC', $groupby_field2 => 'DESC') * 'ORDER BY' => array($orderby_field1 => 'ASC', $orderby_field2 => 'DESC') * 'LIMIT' => array($offset, $limit) or array($limit) or just $limit * 'OFFSET' => $offset, for the case when 'LIMIT' is just $limit * * Sphinx Search extensions: * 'WITHIN GROUP ORDER BY' => array($orderby_field => 'ASC') * 'FIELD_WEIGHTS' => array('field' => , ...) * 'RANKER' => bm25|sph04|...|expr('...ranker expression...') */ function select_builder($tables, $fields, $where, $options = NULL) { if (!$options) $options = array(); else { foreach ($options as $k => $v) if (ctype_digit("$k")) $options[$v] = true; } if (is_array($fields)) { foreach ($fields as $k => $v) if (!ctype_digit("$k")) $fields[$k] = "$v AS ".$this->quoteId($k); $fields = join(',', $fields); } $more = NULL; $tables = $this->tables_builder($tables, $more); if ($more) $where = array_merge($where, (array)$more); $where = $this->where_builder($where); $sql = 'SELECT '; if (isset($options['CALC_FOUND_ROWS']) || isset($options['SQL_CALC_FOUND_ROWS'])) $sql .= 'SQL_CALC_FOUND_ROWS '; if (isset($options['NO_CACHE']) || isset($options['SQL_NO_CACHE'])) $sql .= 'SQL_NO_CACHE '; elseif (isset($options['CACHE']) || isset($options['SQL_CACHE'])) $sql .= 'SQL_CACHE '; $sql .= "$fields FROM $tables"; if ($where) { $sql .= " WHERE $where"; } if (!empty($options['GROUP BY']) && $options['GROUP BY'] !== '0') { $sql .= " GROUP BY ".$this->order_option($options['GROUP BY']); } if (!empty($options['ORDER BY']) && $options['ORDER BY'] !== '0') { $sql .= " ORDER BY ".$this->order_option($options['ORDER BY']); } if (!empty($options['WITHIN GROUP ORDER BY']) && $options['WITHIN GROUP GROUP BY'] !== '0') { // Sphinx Search extension $sql .= " WITHIN GROUP ORDER BY ".$this->order_option($options['WITHIN GROUP ORDER BY']); } $sql .= $this->limit_option($options); if (!empty($options['FIELD_WEIGHTS']) || !empty($options['RANKER'])) { // Sphinx Search extension $opt = array(); if (!empty($options['FIELD_WEIGHTS'])) { $weights = array(); foreach ($options['FIELD_WEIGHTS'] as $f => $w) { $weights[] = "`$f`=$w"; } $opt[] = "field_weights=(".implode(', ', $weights).")"; } if (!empty($options['RANKER'])) { $opt[] = "ranker=".$options['RANKER']; } $sql .= " OPTION ".implode(', ', $opt); } if (isset($options['FOR UPDATE'])) $sql .= ' FOR UPDATE'; elseif (isset($options['LOCK IN SHARE MODE'])) $sql .= ' LOCK IN SHARE MODE'; return $sql; } /** * Handles ORDER BY or GROUP BY options */ protected function order_option($g) { if (is_array($g)) { $g1 = array(); foreach ($g as $k => $v) { if (ctype_digit("$k")) $g1[] = $v; else $g1[] = "$k $v"; } $g = join(',', $g1); } return $g; } /** * Handles a single LIMIT or LIMIT and OFFSET options. */ protected function limit_option($options) { if (isset($options['LIMIT'])) { $g = $options['LIMIT']; if (is_array($g)) $g = join(',', $g); elseif ($g && isset($options['OFFSET'])) $g = "$options[OFFSET], $g"; if ($g) return " LIMIT $g"; } return ''; } /** * Builds FROM-part of an SQL query. * * $tables = array( * 'table', * 'alias' => 'table', * 'alias' => array('INNER', 'table_name', $where_for_on_clause), * 'alias(ignored)' => array('INNER', $nested_tables, $on_for_join_group), * ) * or just a string "`table1` INNER JOIN `table2` ON ..." * names taken into `backticks` will be transformed using $this->tableNames */ function tables_builder($tables, &$where = NULL) { if (is_array($tables)) { $t = ''; foreach ($tables as $k => $v) { if (!is_array($v)) $v = [ 'INNER', $v, NULL ]; $join = strtolower(substr($v[0], 0, 4)); if ($join == 'righ') $join = 'RIGHT'; elseif ($join == 'left') $join = 'LEFT'; else /* if (!$join || $join == 'inne' || $join == 'join') */ $join = 'INNER'; if (is_array($v[1])) // nested join (left join (A join B on ...) on ...) { $more = NULL; $table = $this->tables_builder($v[1], $more); if ($more) $v[2] = array_merge((array)$v[2], (array)$more); if (count($v[1]) > 1) $table = "($table)"; } else { $table = (isset($this->tableNames[$v[1]]) ? $this->quoteId($this->tableNames[$v[1]]) : $v[1]); if (!ctype_digit("$k")) $table .= ' ' . $k; } if ($t) $t .= " $join JOIN $table ON ".($this->where_builder($v[2]) ?: '1=1'); else { $t = $table; $where = $v[2]; // extract ON to WHERE if only a single join is specified } } $tables = $t; } else $tables = preg_replace_callback('/((?:,|JOIN)\s*`)([^`]+)/s', array($this, 'tables_builder_pregcb1'), $tables); return $tables; } function tables_builder_pregcb1($m) { if (isset($this->tableNames[$m[2]])) return $m[1].$this->tableNames[$m[2]]; return $m[1].$m[2]; } /** * Run a SELECT query and return results. * * Usage: either * $this->select($tables, $fields, $where, $options, $format) * using $this->select_builder() or * $this->select($sql_text, $format) * using query text. * * @param int $format Return format, bitmask of MS_XX constants: * MS_RESULT = return mysqli result object to manually fetch from * MS_LIST = return rows as indexed arrays * MS_HASH = return rows as associative arrays (default) * MS_ROW = only return the first row * MS_COL = only return the first column * MS_VALUE = only return the first cell (just 1 value) */ function select($tables, $fields = '*', $where = 1, $options = NULL, $format = 0) { if (is_int($fields)) { $sql = $tables; $format = $fields; } else $sql = $this->select_builder($tables, $fields, $where, $options); if ($format & MS_RESULT) return $this->query($sql, MYSQLI_USE_RESULT); $res = $this->query($sql); return $this->fetch_all($res, $format); } function fetch_all($res, $format = 0) { if ($format & MS_LIST) $r = $this->get_rows($res); else $r = $this->get_assocs($res); if (!$r) $r = array(); if ($format & MS_ROW) { if (!count($r)) return NULL; if ($format & MS_COL) { $r = $r[0]; $k = array_keys($r); $r = $r[$k[0]]; } else $r = $r[0]; } elseif ($format & MS_COL) { $k = false; foreach ($r as $i => $v) { if (!$k) { $k = array_keys($v); $k = $k[0]; } $r[$i] = $v[$k]; } } return $r; } function found_rows() { return $this->select('SELECT FOUND_ROWS()', MS_VALUE); } /** * Delete a set of rows. * @param mixed $tables see $this->tables_builder() * @param mixed $where see $this->where_builder() * @param array $options Options for query: * 'LIMIT' => array($limit, $offset) or array($limit) or just $limit * 'OFFSET' => $offset, for the case when 'LIMIT' is just $limit */ function delete($tables, $where, $options = NULL) { $tables = $this->tables_builder($tables); $where = $this->where_builder($where) ?: '1=1'; $sql = "DELETE FROM $tables WHERE $where"; $sql .= $this->limit_option($options); $this->query($sql); return $this->link->affected_rows; } /** * Builds an INSERT query. * @param string $table Table name to insert rows to. * @param array $rows Array of table rows to be inserted. * @param string $action Conflict action: NULL, 'REPLACE', 'IGNORE' or 'UPDATE' * REPLACE: delete matching rows, then insert all rows (MySQL REPLACE) * IGNORE: ignore matching rows, insert missing rows (MySQL INSERT IGNORE) * UPDATE: update matching rows, insert missing rows (MySQL INSERT ... ON DUPLICATE KEY UPDATE) * @param array|string $uniqueKey Single unique key for conflict checking * @param array|NULL $updateCols Columns to update in case of a conflict */ function insert_builder($table, $rows, $action = NULL, $uniqueKey = NULL, $updateCols = NULL) { if (isset($this->tableNames[$table])) { $table = $this->tableNames[$table]; } $key = array_keys($rows[0]); foreach ($rows as &$r) { $rs = array(); foreach ($key as &$k) $rs[] = $this->quote($r[$k]); $r = "(".implode(",", $rs).")"; } $sphinx = !$this->username && !$this->password && !$this->dbname; foreach ($key as &$k) if (strpos($k, '`') === false && (!$sphinx || $k !== 'id')) $k = $this->quoteId($k); $sql = ($action == "REPLACE" ? "REPLACE" : "INSERT" . ($action == "IGNORE" ? " IGNORE" : "")). " INTO $table (".implode(",",$key).") VALUES ".implode(",",$rows); if ($action == "UPDATE") { if ($uniqueKey) { $uniqueKey = array_flip(is_array($uniqueKey) ? $uniqueKey : array_map('trim', explode(",", $uniqueKey))); $cond = $uniqueKey; foreach ($cond as $k => $v) $v = "$k!=VALUES($k)"; // Trigger ERROR 1242 (21000): Subquery returns more than 1 row if trying to update based on different key conflict $cond = "CASE WHEN ".implode(" OR ", $cond)." THEN (SELECT 1 UNION SELECT 2) ELSE "; } if ($updateCols) $key = (array)$updateCols; foreach ($key as &$k) { if ($uniqueKey && isset($uniqueKey[$k])) $k = "$k=($cond $k END)"; else $k = "$k=VALUES($k)"; } $sql .= " ON DUPLICATE KEY UPDATE ".implode(",",$key); } return $sql; } /** * Insert a single row into $table and return inserted ID. * @param string $table Table name to insert row to. * @param array $rows Row to be inserted. * @return int $insert_id Autoincrement ID of inserted row (if appropriate). */ function insert_row($table, $row) { $sql = $this->insert_builder($table, array($row)); if ($this->query($sql)) return $this->link->insert_id; return NULL; } function insert_id() { return $this->link->insert_id; } /** * Update row(s) in $table. * $this->update($table, $set, $where, $options); * * @param string $table Table name to update. * @param array $set Assoc array with values for update query. * @param array $where Conditions for update query, see $this->where_builder(). * @param array $options Options for update query: * 'LIMIT' => array($limit, $offset) or array($limit) or just $limit * 'OFFSET' => $offset, for the case when 'LIMIT' is just $limit */ function update($table, $rows, $where = NULL, $options = NULL) { if (!$rows) return false; if (count(func_get_args()) == 2) throw new Exception(__CLASS__."::update(table, rows) is the old syntax, use upsert()"); $sql = array(); foreach ((array)$rows as $k => $v) { if (!ctype_digit("$k")) $sql[] = $k.'='.$this->quote($v); else $sql[] = $v; } $where = $this->where_builder($where) ?: '1=1'; $sql = 'UPDATE ' . $this->tables_builder($table) . ' SET ' . implode(', ', $sql) . ' WHERE ' . $where; $sql .= $this->limit_option($options); if ($this->query($sql)) return $this->link->affected_rows; return false; } /** * INSERT / REPLACE / INSERT IGNORE / INSERT OR UPDATE */ function insert($table, $rows, $onConflict = NULL, $uniqueKey = NULL) { if (!$rows || !is_array($rows)) return false; $first = reset($rows); if (!is_array($first)) $rows = array($rows); $sql = $this->insert_builder($table, $rows, $onConflict, $uniqueKey); if ($this->query($sql)) return $this->link->affected_rows; return false; } function insert_ignore($table, $rows, $uniqueKey = NULL) { return $this->insert($table, $rows, 'IGNORE', $uniqueKey); } function upsert($table, $rows, $uniqueKey = NULL, $updateCols = NULL) { return $this->insert($table, $rows, 'UPDATE', $uniqueKey, $updateCols); } function replace($table, $rows, $uniqueKey = NULL) { return $this->insert($table, $rows, 'REPLACE', $uniqueKey); } protected function get_rows($res) { $r = array(); if ($res) { while ($row = $res->fetch_row()) $r[] = $row; $res->free(); } return $r; } protected function get_assocs($res) { $r = array(); if ($res) { while ($row = $res->fetch_assoc()) $r[] = $row; $res->free(); } return $r; } }