getCode() == 1062; } } if (!interface_exists('Database')) { interface Database {} } class DatabaseMysql implements Database { var $host, $port, $socket, $username, $password, $dbname; var $tableNames = 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 of first query [false] * ondestroy commit/rollback/none during destruction [commit] */ 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', ); $options += $defOpts; if ($options['socket']) { $options['host'] = 'localhost'; } foreach ($defOpts as $k => $v) { $this->$k = $options[$k]; } } 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 DatabaseException($error, $errno); } else { $this->transactions = array(); $this->link->set_charset('utf8'); if ($this->username || $this->dbname) // skip for Sphinx { // READ COMMITTED is more consistent for average usage $this->link->query('SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED'); } if ($this->autoBegin) { $this->begin(); } } } 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(); } $this->queryCount++; if ($this->queryLogFile) { $begin = explode(' ', microtime(), 2); } $r = $this->link->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"; } if (!$r) { if ($this->link->errno == 2006 && $this->reconnect && !$this->transactions) { // "MySQL server has gone away" $this->connect(); $r = $this->link->query($sql, $fetchMode); if (!$r && $this->link->errno == 2006) { $this->link = false; } } if (!$r) { throw new DatabaseException($this->link->error . "\nQuery: $sql", $this->link->errno); } } return $r; } function multi_select(array $queries, $format = 0) { if (!$this->link) { $this->connect(); } $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) { if ($this->link->errno == 2006 && $this->reconnect && !$this->transactions) { // "MySQL server has gone away" $this->connect(); $r = $this->link->multi_query($sql); if (!$r && $this->link->errno == 2006) { $this->link = false; } } if (!$r) { throw new DatabaseException($this->link->error, $this->link->errno); } } $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) { $this->transactions[] = $savepoint; $n = count($this->transactions); 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() { $savepoint = array_pop($this->transactions); if (!$this->transactions) { return $this->query("COMMIT"); } elseif ($savepoint) { return $this->query("RELEASE SAVEPOINT sp".(1+count($this->transactions))); } return true; } /** * Rollbacks transaction or last savepoint. * If there is no savepoint, returns false. */ function rollback() { $savepoint = array_pop($this->transactions); if (!$this->transactions) { return $this->query("ROLLBACK"); } elseif ($savepoint) { return $this->query("ROLLBACK TO SAVEPOINT sp".(1+count($this->transactions))); } return false; } 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 DatabaseException("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); } $where = $this->where_builder($where); $tables = $this->tables_builder($tables); $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' => array('INNER', $nested_tables), * ) * or just a string "`table1` INNER JOIN `table2` ON ..." * names taken into `backticks` will be transformed using $this->tableNames */ function tables_builder($tables) { if (is_array($tables)) { $t = ''; foreach ($tables as $k => $v) { if (!ctype_digit("$k")) { if (is_array($v)) { $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 ...) $table = '('.$this->tables_builder($v[1]).')'; else $table = (isset($this->tableNames[$v[1]]) ? $this->quoteId($this->tableNames[$v[1]]) : $v[1]) . ' ' . $k; if ($t) $t .= " $join JOIN $table ON ".($this->where_builder($v[2]) ?: '1=1'); else $t = $table; continue; } else $v = (isset($this->tableNames[$v]) ? $this->quoteId($this->tableNames[$v]) : $v) . ' ' . $k; } else $v = (isset($this->tableNames[$v]) ? $this->quoteId($this->tableNames[$v]).' '.$v : $v); if ($t) $t .= " INNER JOIN $v"; else $t = $v; } $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 boolean $onduplicatekey If true, create MySQL-specific "UPSERT" query using * INSERT .. ON DUPLICATE KEY UPDATE column=VALUES(column) for all columns. * @param boolean $replace If true, use REPLACE instead of INSERT */ function insert_builder($table, $rows, $onduplicatekey = false, $replace = false) { 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 = ($replace ? "REPLACE" : "INSERT"). " INTO $table (".implode(",",$key).") VALUES ".implode(",",$rows); if ($onduplicatekey) { foreach ($key as &$k) $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 or insert rows into $table. * Update query: $this->update($table, $set, $where, $options); * Insert-or-update query: $this->update($table, $rows); * * @param string $table Table name to update. * @param array $rows One row or array of rows for insert-or-update query. * @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 * 'REPLACE' => true to use REPLACE instead of INSERT */ function update($table, $rows, $where = NULL, $options = NULL) { if (!$rows) return false; if (is_null($where)) { if (!is_array($rows)) return false; if (!is_array(@$rows[0])) $rows = array($rows); $sql = $this->insert_builder($table, $rows, empty($options['REPLACE']), !empty($options['REPLACE'])); } else { $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; } function replace($table, $rows, $where = NULL, $options = array()) { $options['REPLACE'] = true; return $this->update($table, $rows, $where, $options); } 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; } }