Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 18 Feb 2013 */ namespace IPS; /* To prevent PHP errors (extending class does not exist) revealing path */ if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * @brief Database Class * @note All functionality MUST be supported by MySQL 5.1.3 and higher. All references to the MySQL manual are therefore the 5.1 version. */ class _Db extends \mysqli { /** * SELECT flags */ const SELECT_DISTINCT = 1; const SELECT_SQL_CALC_FOUND_ROWS = 2; const SELECT_MULTIDIMENSIONAL_JOINS = 4; const SELECT_FROM_WRITE_SERVER = 8; /** * INSERT/UPDATE flags */ const LOW_PRIORITY = 1; const IGNORE = 2; /** * @brief Datatypes */ public static $dataTypes = array( 'database_column_type_numeric' => array( 'TINYINT' => 'TINYINT [±127 ⊻ 255] [1B]', 'SMALLINT' => 'SMALLINT [±3.3e4 ⊻ 6.6e4] [2B]', 'MEDIUMINT' => 'MEDIUMINT [±8.4e6 ⊻ 1.7e7] [3B]', 'INT' => 'INT [±2.1e9 ⊻ 4.3e9] [4B]', 'BIGINT' => 'BIGINT [±9.2e18 ⊻ 1.8e19] [8B]', 'DECIMAL' => 'DECIMAL', 'FLOAT' => 'FLOAT', 'BIT' => 'BIT', ), 'database_column_type_datetime' => array( 'DATE' => 'DATE', 'DATETIME' => 'DATETIME', 'TIMESTAMP' => 'TIMESTAMP', 'TIME' => 'TIME', 'YEAR' => 'YEAR', ), 'database_column_type_string' => array( 'CHAR' => 'CHAR [M≤6.6e4] [(M*w)B]', 'VARCHAR' => 'VARCHAR [M≤6.6e4] [(L+(1∨2))B]', 'TINYTEXT' => 'TINYTEXT [256B] [(L+1)B]', 'TEXT' => 'TEXT [64kB] [(L+2)B]', 'MEDIUMTEXT'=> 'MEDIUMTEXT [16MB] [(L+3)B]', 'LONGTEXT' => 'LONGTEXT [4GB] [(L+4)B]', 'BINARY' => 'BINARY [M≤6.6e4] [(M)B]', 'VARBINARY' => 'VARBINARY [M≤6.6e4] [(L+(1∨2))B]', 'TINYBLOB' => 'TINYBLOB [256B] [(L+1)B]', 'BLOB' => 'BLOB [64kB] [(L+2)B]', 'MEDIUMBLOB'=> 'MEDIUMBLOB [16MB] [(L+3)B]', 'BIGBLOB' => 'BIGBLOB [4GB] [(L+4)B]', 'ENUM' => 'ENUM [6.6e4] [(1∨2)B]', 'SET' => 'SET [64] [(1∨2∨3∨4∨8)B]', ) ); /** * @brief Multiton Store */ protected static $multitons; /** * Get instance * * @param mixed $identifier Identifier * @param array $connectionSettings Connection settings (use when initiating a new connection) * @return \IPS\Db */ public static function i( $identifier=NULL, $connectionSettings=array() ) { /* Did we pass a null value? */ $identifier = ( $identifier === NULL ) ? '__MAIN' : $identifier; /* Don't have an instance? */ if( !isset( static::$multitons[ $identifier ] ) ) { /* Load the default settings if necessary */ if( $identifier === '__MAIN' ) { require( \IPS\ROOT_PATH . '/conf_global.php' ); $connectionSettings = isset( $INFO ) ? $INFO : array(); } /* Connect */ static::$multitons[ $identifier ] = static::_establishConnection( get_called_class(), array( 'host' => $connectionSettings['sql_host'], 'username' => $connectionSettings['sql_user'], 'password' => $connectionSettings['sql_pass'], 'database' => $connectionSettings['sql_database'], 'port' => ( isset( $connectionSettings['sql_port'] ) and $connectionSettings['sql_port']) ? $connectionSettings['sql_port'] : NULL, 'socket' => ( isset( $connectionSettings['sql_socket'] ) and $connectionSettings['sql_socket'] ) ? $connectionSettings['sql_socket'] : NULL, ), isset( $connectionSettings['sql_utf8mb4'] ) and $connectionSettings['sql_utf8mb4'] ); /* Set charset / collation properties */ if ( static::$multitons[ $identifier ]->getCharset() === 'utf8mb4' ) { static::$multitons[ $identifier ]->charset = 'utf8mb4'; static::$multitons[ $identifier ]->collation = 'utf8mb4_unicode_ci'; static::$multitons[ $identifier ]->binaryCollation = 'utf8mb4_bin'; } else { static::$multitons[ $identifier ]->charset = 'utf8'; static::$multitons[ $identifier ]->collation = 'utf8_unicode_ci'; static::$multitons[ $identifier ]->binaryCollation = 'utf8_bin'; } /* Set the prefix */ if ( isset( $connectionSettings['sql_tbl_prefix'] ) ) { static::$multitons[ $identifier ]->prefix = $connectionSettings['sql_tbl_prefix']; } /* Read/Write Separation? */ if ( isset( $connectionSettings['sql_read_host'] ) and \IPS\READ_WRITE_SEPARATION ) { static::$multitons[ $identifier ]->reader = static::_establishConnection( 'mysqli', array( 'host' => $connectionSettings['sql_read_host'], 'username' => $connectionSettings['sql_read_user'], 'password' => $connectionSettings['sql_read_pass'], 'database' => $connectionSettings['sql_read_database'], 'port' => ( isset( $connectionSettings['sql_read_port'] ) and $connectionSettings['sql_read_port']) ? $connectionSettings['sql_read_port'] : NULL, 'socket' => ( isset( $connectionSettings['sql_read_socket'] ) and $connectionSettings['sql_read_socket'] ) ? $connectionSettings['sql_read_socket'] : NULL, ), isset( $connectionSettings['sql_utf8mb4'] ) and $connectionSettings['sql_utf8mb4'] ); } } /* Return */ return static::$multitons[ $identifier ]; } /** * Apparently, get_charset can be unavailable * * @return string */ public function getCharset() { if ( method_exists( $this, 'get_charset' ) ) { return static::get_charset()->charset; } else { return static::character_set_name(); } } /** * Establish database connection * * @note Credentials are passed as an array so that if there's an error the PHP error will say "Array" rather than reveal the credentials in any log or if display_errors is on * @param string $classname Class to use * @param string $sqlCredentials array( 'host' => MySQL Host, 'username' => MySQL Username, 'password' => MySQL Password, 'database' => MySQL Database Name, 'port' => MySQL Port or NULL, 'socket' => MySQL Socket or NULL ) * @param bool $utf8Mb4 Use UTF8MB4? * @return mysqli */ protected static function _establishConnection( $classname, $sqlCredentials, $utf8Mb4 = TRUE ) { /* Connect */ $object = @new $classname( $sqlCredentials['host'], $sqlCredentials['username'], $sqlCredentials['password'], $sqlCredentials['database'], $sqlCredentials['port'], $sqlCredentials['socket'] ); /* If the connection failed, throw an exception */ if( $error = mysqli_connect_error() ) { $errorNumber = $object->connect_errno; throw new \IPS\Db\Exception( $error, $errorNumber ); } /* Charset */ if ( $utf8Mb4 ) { if ( $object->set_charset( 'utf8mb4' ) === FALSE ) { /* If setting utf8mb4 fails, then gracefully fallback to normal utf8 */ $object->set_charset( 'utf8' ); } } else { $object->set_charset( 'utf8' ); } /* Strict mode */ if ( \IPS\IN_DEV ) { $object->query( "SET sql_mode='STRICT_ALL_TABLES,ONLY_FULL_GROUP_BY,ANSI_QUOTES'" ); } /* Return */ return $object; } /** * @brief Charset */ public $charset = 'utf8'; /** * @brief Collation */ public $collation = 'utf8_unicode_ci'; /** * @brief Binary Collation */ public $binaryCollation = 'utf8_bin'; /** * @brief Table Prefix */ public $prefix = ''; /** * @brief Query log */ public $log = array(); /** * @brief Return the query instead of executing it * @note Only designed to work with methods that call query() vs prepared statements */ public $returnQuery = FALSE; /** * @brief MySQLi object for reading, if using read/write separation */ protected $reader = NULL; /** * @brief Read/Write Separation Enabled * @todo This is hacky. Do it properly later */ public $readWriteSeparation = TRUE; /** * Run a query * * @param string $query The query * @param bool $log Should be logged? * @param bool $read If TRUE and read/write separation is in use, will use the "read" connection * @return mixed * @see mysqli::query * @throws \IPS\Db\Exception */ public function query( $query, $log = TRUE, $read=FALSE ) { /* Should we return the query instead of executing it? */ if( $this->returnQuery === TRUE ) { $this->returnQuery = FALSE; return $query; } /* Log */ if ( \IPS\QUERY_LOG and $log ) { $this->log( $query, ( $read and $this->readWriteSeparation ) ? 'read' : 'write' ); } /* Run */ if ( $read and $this->reader and $this->readWriteSeparation ) { $return = $this->reader->query( $query ); if ( $return === FALSE ) { throw new \IPS\Db\Exception( $this->reader->error, $this->reader->errno ); } } else { $return = parent::query( $query ); if ( $return === FALSE ) { throw new \IPS\Db\Exception( $this->error, $this->errno ); } } /* Return */ return $return; } /** * Force a query to run regardless of $this->returnQuery * * @param string $query The query * @param bool $log Should be logged? * @param bool $read If TRUE and read/write separation is in use, will use the "read" connection * @return mixed * @see mysqli::query * @throws \IPS\Db\Exception */ public function forceQuery( $query, $log = TRUE, $read=FALSE ) { $return = $this->returnQuery; $this->returnQuery = false; $result = $this->query( $query, $log, $read ); $this->returnQuery = $return; return $result; } /** * Run Prepared SQL Statement * * @param string $query SQL Statement * @param array $_binds Variables to bind * @param bool $read If TRUE and read/write separation is in use, will use the "read" connection * @return \mysqli_stmt */ public function preparedQuery( $query, array $_binds, $read=FALSE ) { /* Init Bind object */ $bind = new Db\Bind(); /* Sort out subqueries */ $binds = array(); $i = 0; for ( $j = 0; $j < \strlen( $query ); $j++ ) { if ( $query[ $j ] == '?' ) { if ( array_key_exists( $i, $_binds ) ) { if ( $_binds[ $i ] instanceof \IPS\Db\Select ) { $query = \substr( $query, 0, $j ) . $_binds[ $i ]->query . \substr( $query, $j + 1); $j += \strlen( $_binds[ $i ]->query ); foreach ( $_binds[ $i ]->binds as $_bind ) { $binds[] = $_bind; } } else { $binds[] = $_binds[ $i ]; } $i++; } } } /* Store the original query before the bind checks are done as NULL replaces ? which throws out the order in the query log */ $queryForLog = $query; /* Loop values to bind */ $i = 0; $longThreshold = 1048576; $sendAsLong = array(); foreach ( $binds as $bindVal ) { if( ( is_object( $bindVal ) OR is_string( $bindVal ) ) AND \strlen( (string) $bindVal ) > $longThreshold ) { $sendAsLong[ $i ] = (string) $bindVal; } $i++; switch ( gettype( $bindVal ) ) { case 'boolean': case 'integer': $bind->add( 'i', $bindVal ); break; case 'double': $bind->add( 'd', $bindVal ); break; case 'string': if( \strlen( $bindVal ) > $longThreshold ) { $bind->add( 'b', NULL ); } else { $bind->add( 's', $bindVal ); } break; case 'object': if( method_exists( $bindVal, '__toString' ) ) { if( \strlen( $bindVal ) > $longThreshold ) { $bind->add( 'b', NULL ); } else { $bind->add( 's', (string) $bindVal ); } break; } // Deliberately no break case 'NULL': case 'array': case 'resource': case 'unknown type': default: /* For NULL values, you can't bind, so we adjust the query to actually pass a NULL value */ $pos = 0; for ( $j=0; $j<$i; $j++ ) { $pos = mb_strpos( $query, '?', $pos ) + 1; } $query = mb_substr( $query, 0, $pos - 1 ) . 'NULL' . mb_substr( $query, $pos ); $i--; break; } } /* Log */ if ( \IPS\QUERY_LOG ) { /* Log */ $this->log( static::_replaceBinds( $queryForLog, $binds ), ( $read and $this->readWriteSeparation ) ? 'read' : 'write' ); } /* Return full query */ if( $this->returnQuery === TRUE ) { $this->returnQuery = FALSE; return static::_replaceBinds( $queryForLog, $binds ); } /* Add a backtrace to the query so we know where it came from if it causes issues */ $comment = '??'; $line = '?'; foreach( debug_backtrace( FALSE ) as $b ) { if ( isset( $b['line'] ) ) { $line = $b['line']; } if( isset( $b['class'] ) and !in_array( $b['class'], array( 'IPS\_Db', 'IPS\Db\_Select', 'IPS\Patterns\_ActiveRecord', 'IPS\Patterns\_ActiveRecordIterator' ) ) ) { $comment = "{$b['class']}::{$b['function']}:{$line}"; break; } } $_query = $query; $query = "/*{$comment}*/ {$query}"; /* Prepare */ if ( $read and $this->reader and $this->readWriteSeparation ) { $stmt = $this->reader->prepare( $query ); if( $stmt === FALSE ) { throw new \IPS\Db\Exception( $this->reader->error, $this->reader->errno, NULL, $queryForLog, $binds ); } } else { $stmt = parent::prepare( $query ); if( $stmt === FALSE ) { throw new \IPS\Db\Exception( $this->error, $this->errno, NULL, $queryForLog, $binds ); } } /* Bind values */ if( $bind->haveBinds() === TRUE ) { call_user_func_array( array( $stmt, 'bind_param' ), $bind->get() ); if( count( $sendAsLong ) ) { foreach( $sendAsLong as $index => $data ) { $chunks = str_split( $data, $longThreshold - 1 ); foreach( $chunks as $chunk ) { $stmt->send_long_data( $index, $chunk ); } } } } /* Execute */ $stmt->execute(); /* Handle errors */ $count = 1; while ( $stmt->error ) { /* If we hit a deadlock, try again once */ if ( $stmt->errno === 1213 and $count < 2 ) { $stmt->execute(); $count++; } /* Throw error */ else { throw new \IPS\Db\Exception( $stmt->error, $stmt->errno, NULL, $queryForLog, $binds ); } } /* Store result */ $stmt->store_result(); /* Return a Statement object */ return $stmt; } /** * Log * * @param string $logQuery Query to log * @param string $server Will be "read" or "write" to indicate which server was (or would be) used in read/write separation * @return void */ protected function log( $logQuery, $server=NULL ) { $this->log[] = array( 'query' => $logQuery, 'server' => $server, 'backtrace' => var_export( debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ), TRUE ), 'extra' => NULL, ); } /** * Build SELECT statement * * @param array|string $columns The columns (as an array) to select or an expression * @param array|string $table The table to select from. Either (string) table_name or (array) ( name, alias ) or \IPS\Db\Select object * @param array|string|NULL $where WHERE clause - see \IPS\Db::compileWhereClause() for details * @param string|NULL $order ORDER BY clause * @param array|int $limit Rows to fetch or array( offset, limit ) * @param string|NULL|array $group Column(s) to GROUP BY * @param array|string|NULL $having HAVING clause (same format as WHERE clause) * @param int $flags Bitwise flags * @li \IPS\Db::SELECT_DISTINCT Will use SELECT DISTINCT * @li \IPS\Db::SELECT_SQL_CALC_FOUND_ROWS Will add SQL_CALC_FOUND_ROWS * @li \IPS\Db::SELECT_MULTIDIMENSIONAL_JOINS Will return the result as a multidimensional array, with each joined table separately * @li \IPS\Db::SELECT_FROM_WRITE_SERVER Will send the query to the write server (if read/write separation is enabled) * @return \IPS\Db\Select * */ public function select( $columns=NULL, $table, $where=NULL, $order=NULL, $limit=NULL, $group=NULL, $having=NULL, $flags=0 ) { $binds = array(); $query = 'SELECT '; /* Flags */ if ( $flags & static::SELECT_DISTINCT ) { $query .= 'DISTINCT '; } if ( $flags & static::SELECT_SQL_CALC_FOUND_ROWS ) { $query .= 'SQL_CALC_FOUND_ROWS '; } /* Columns */ if ( is_string( $columns ) ) { $query .= $columns; } else { $query .= implode( ', ', array_map( function( $col ) { return ( mb_strpos( $col, '`' ) === FALSE ) ? ( '`' . $col . '`' ) : $col; }, $columns ) ); } /* Tables */ if ( $table instanceof \IPS\Db\Select ) { $tableQuery = $table->query; $binds = $table->binds; preg_match( '/FROM `(.+?)`( AS `(.+?)`)?/', $tableQuery, $matches ); $query .= isset( $matches[3] ) ? " FROM ( {$tableQuery} ) AS `{$matches[3]}`" : ( " FROM ( {$tableQuery} ) AS `" . md5(uniqid()) . '`' ); } elseif ( is_array( $table ) ) { if ( is_array( $table[0] ) and count( $table[0] ) ) { $tables = array(); foreach( $table as $item ) { $tables[] = " `{$this->prefix}{$item[0]}` AS `{$item[1]}`"; } $query .= " FROM " . implode( ', ', $tables ); } else { $tableName = ( $table[0] instanceof \IPS\Db\Select ) ? '(' . $table[0] . ')' : '`' . $this->prefix . $table[0] . '`'; $query .= " FROM {$tableName} AS `{$table[1]}`"; } } else { $query .= $this->prefix ? " FROM `{$this->prefix}{$table}` AS `{$table}`" : " FROM `{$table}`"; } /* WHERE */ if ( $where ) { $where = $this->compileWhereClause( $where ); $query .= ' WHERE ' . $where['clause']; $binds = array_merge( $binds, $where['binds'] ); } /* Group? */ if( $group ) { if ( is_array( $group ) ) { $query .= " GROUP BY " . implode( ',', array_map( function( $val ) { if( mb_strpos( $val, '.' ) !== FALSE ) { $pieces = explode( '.', $val ); foreach( $pieces as $k => $piece ) { $pieces[ $k ] = '`' . $piece . '`'; } return implode( '.', $pieces ); } return "`{$val}`"; }, $group ) ); } else { if( mb_strpos( $group, '.' ) !== FALSE ) { $pieces = explode( '.', $group ); foreach( $pieces as $k => $piece ) { $pieces[ $k ] = '`' . $piece . '`'; } $group = implode( '.', $pieces ); } else { $group = "`{$group}`"; } $query .= " GROUP BY {$group}"; } } /* Having? */ if( $having ) { $having = $this->compileWhereClause( $having ); $query .= ' HAVING ' . $having['clause']; $binds = array_merge( $binds, $having['binds'] ); } /* Order? */ if( $order ) { $query .= ' ORDER BY ' . $order; } /* Limit */ if( $limit ) { $query .= $this->compileLimitClause( $limit ); } /* Return */ return new \IPS\Db\Select( $query, $binds, $this, $flags & static::SELECT_MULTIDIMENSIONAL_JOINS, $flags & static::SELECT_FROM_WRITE_SERVER ); } /** * Build UNION statement * * @param array $selects Array of \IPS\Db\Select objects * @param string|NULL $order ORDER BY clause * @param array|int $limit Rows to fetch or array( offset, limit ) * @param string|null $group Group by clause * @param bool $unionAll TRUE to perform a UNION ALL, FALSE (default) to perform a regular UNION * @param int $flags Bitwise flags * @param array|string|NULL $where WHERE clause (see example) * @param string $querySelect Custom select for the outer query * @li \IPS\Db::SELECT_SQL_CALC_FOUND_ROWS Will add SQL_CALC_FOUND_ROWS * @return \IPS\Db|Select */ public function union( $selects, $order, $limit, $group=NULL, $unionAll=FALSE, $flags=0, $where=NULL, $querySelect='*' ) { /* Combine selects */ $query = array(); $binds = array(); foreach ( $selects as $s ) { $query[] = '( ' . $s->query . ' )'; $binds = array_merge( $binds, $s->binds ); } $union = $unionAll ? "UNION ALL" : "UNION"; $query = "SELECT " . ( $flags & static::SELECT_SQL_CALC_FOUND_ROWS ? "SQL_CALC_FOUND_ROWS " : "" ) . $querySelect . " FROM( " . implode( ' ' . $union . ' ', $query ) . ") derivedTable "; /* WHERE */ if ( $where ) { $where = $this->compileWhereClause( $where ); $query .= ' WHERE ' . $where['clause']; $binds = array_merge( $binds, $where['binds'] ); } /* Group */ if( $group ) { $query.= " GROUP BY " . $group; } /* Order? */ if( $order ) { $query .= ' ORDER BY ' . $order; } /* Limit */ if( $limit ) { $query .= $this->compileLimitClause( $limit ); } /* Return */ return new \IPS\Db\Select( $query, $binds, $this ); } /** * Run INSERT statement and return insert ID * * @see INSERT Syntax * @param string $table Table name * @param array|\IPS\Db\Select $set Values to insert or array of values to set for multiple rows (NB, if providing multiple rows, they MUST all contain the same columns) or a statement to do INSERT INTO SELECT FROM * @param bool $odkUpdate Append an ON DUPLICATE KEY UPDATE clause to the query. Similar to the replace() method but updates if a record is found, instead of delete and reinsert. * @param bool $ignoreErrors Ignore errors? * @see \IPS\Db::replace() * @return int * @throws \IPS\Db\Exception */ public function insert( $table, $set, $odkUpdate=FALSE, $ignoreErrors=FALSE ) { /* Build */ $query = $this->_buildInsertQuery( ( $ignoreErrors ? 'INSERT IGNORE' : 'INSERT' ), $table, $set ); /* Add "ON DUPLICATE KEY UPDATE" */ if( $odkUpdate ) { $query[0] .= " ON DUPLICATE KEY UPDATE " . implode( ', ', array_map( function( $val ){ return "{$val}=VALUES({$val})"; }, $query[2] ) ); } /* Run */ $return = $this->returnQuery; $stmt = $this->preparedQuery( $query[0], $query[1] ); if( $return === TRUE ) { return $stmt; } $insertId = $stmt->insert_id; $stmt->close(); return $insertId; } /** * Run REPLACE statament and return number of affected rows OR inserted ID * * @see REPLACE Syntax * @param string $table Table name * @param array $set Values to insert * @param bool $getInsertId If TRUE, returns the insert ID rather than the number of affected rows * @return int * @throws \IPS\Db\Exception */ public function replace( $table, $set, $getInsertId=false ) { /* Build */ $query = $this->_buildInsertQuery( 'REPLACE', $table, $set ); $return = $this->returnQuery; $stmt = $this->preparedQuery( $query[0], $query[1] ); if( $return === TRUE ) { return $stmt; } $return = $getInsertId ? $stmt->insert_id : $stmt->affected_rows; $stmt->close(); return $return; } /** * Build the replace or insert into query * * @param string $type INSERT|REPLACE * @param string $table Table name * @param array|\IPS\Db\Select $set Values to insert or array of values to set for multiple rows (NB, if providing multiple rows, they MUST all contain the same columns) or a statement to do INSERT INTO SELECT FROM * @return array 0 => query, 1 => binds, 2 => columns */ protected function _buildInsertQuery( $type, $table, $set ) { $columns = NULL; /* Is a statement? */ if ( $set instanceof \IPS\Db\Select ) { $query = "{$type} INTO `{$this->prefix}{$table}` " . $set->query; $binds = $set->binds; } else { /* Is this just one row? */ foreach ( $set as $k => $v ) { if ( !is_array( $v ) ) { $set = array( $set ); } break; } /* Compile */ $columns = NULL; $values = array(); $binds = array(); if ( count( $set ) ) { foreach ( $set as $row ) { if ( $columns === NULL ) { $columns = array_map( function( $val ){ return "`{$val}`"; }, array_keys( $row ) ); } $binds = array_merge( $binds, array_values( $row ) ); $values[] = '( ' . implode( ', ', array_fill( 0, count( $columns ), '?' ) ) . ' )'; } } else { $columns = array(); $values = array( '()' ); } /* Construct query */ $query = "{$type} INTO `{$this->prefix}{$table}` ( " . implode( ', ', $columns ) . ' ) VALUES ' . implode( ', ', $values ); } return array( 0 => $query, 1 => $binds, 2 => $columns ); } /** * Run UPDATE statement and return number of affected rows * * @see UPDATE Syntax * @param string|array $table Table Name, or array( Table Name => Identifier ) * @param string|array $set Values to set (keys should be the table columns) or pre-formatted SET clause or \IPS\Db\Select object * @param mixed $where WHERE clause (see \IPS\Db::compileWhereClause for details) * @param array $joins Tables to join * @param int|array|null $limit LIMIT clause (see \IPS\Db::select for details) * @param int $flags Bitwise flags * @li \IPS\Db::LOW_PRIORITY Will use LOW_PRIORITY * @li \IPS\Db::IGNORE Will use IGNORE * @return int * @throws \IPS\Db\Exception */ public function update( $table, $set, $where='', $joins=array(), $limit=NULL, $flags=0 ) { $binds = array(); /* Work out table */ $table = is_array( $table ) ? "`{$this->prefix}{$table[0]}` {$this->prefix}{$table[1]}" : "`{$this->prefix}{$table}` {$table}"; /* Work out joins */ $_joins = array(); foreach ( $joins as $join ) { $type = ( isset( $join['type'] ) and in_array( mb_strtoupper( $join['type'] ), array( 'LEFT', 'INNER', 'RIGHT' ) ) ) ? mb_strtoupper( $join['type'] ) : 'LEFT'; $_table = is_array( $join['from'] ) ? "`{$this->prefix}{$join['from'][0]}` {$this->prefix}{$join['from'][1]}" : "`{$this->prefix}{$join['from']}` {$join['from']}"; $on = $this->compileWhereClause( $join['where'] ); $binds = array_merge( $binds, $on['binds'] ); $_joins[] = "{$type} JOIN {$_table} ON {$on['clause']}"; } $joins = empty( $_joins ) ? '' : ( ' ' . implode( "\n", $_joins ) ); /* Work out SET clause */ if ( is_array( $set ) ) { $_set = array(); foreach ( $set as $k => $v ) { $_set[] = "`{$k}`=" . ( is_object( $v ) ? '(?)' : '?' ); $binds[] = $v; } $set = implode( ',', $_set ); } /* Compile where clause */ if ( $where !== '' ) { $_where = $this->compileWhereClause( $where ); $where = 'WHERE ' . $_where['clause']; $binds = array_merge( $binds, $_where['binds'] ); } /* Build query */ $query = 'UPDATE '; if ( $flags & static::LOW_PRIORITY ) { $query .= 'LOW_PRIORITY '; } if ( $flags & static::IGNORE ) { $query .= 'IGNORE '; } $query .= "{$table} {$joins} SET {$set} {$where} "; /* Limit */ if( $limit !== NULL ) { $query .= $this->compileLimitClause( $limit ); } /* Run it */ $return = $this->returnQuery; $stmt = $this->preparedQuery( $query, $binds ); if( $return === TRUE ) { return $stmt; } $return = $stmt->affected_rows; $stmt->close(); return $return; } /** * Run DELETE statement and return number of affected rows * * @see DELETE Syntax * @param string $table Table Name * @param string|array|\IPS\Db\Statement|null $where WHERE clause (see \IPS\Db::compileWhereClause for details) * @param string|null $order ORDER BY clause * @param int|array|null $limit LIMIT clause (see \IPS\Db::select for details) * @param string|null $statementColumn If \IPS\Db\Statement is passed, this is the name of the column that results are being loaded from * @return \IPS\Db\Statement * @throws \IPS\Db\Exception */ public function delete( $table, $where=NULL, $order=NULL, $limit=NULL, $statementColumn=NULL ) { /* TRUNCATE is faster, so use that if appropriate */ if ( $where === NULL and $limit === NULL ) { $return = $this->returnQuery; $stmt = $this->preparedQuery( "TRUNCATE `{$this->prefix}{$table}`", array() ); if( $return === TRUE ) { return $stmt; } $return = $stmt->affected_rows; $stmt->close(); return $return; } /* Basic query */ $query = "DELETE FROM `{$this->prefix}{$table}`"; /* Is a statement? */ if ( $where instanceof \IPS\Db\Statement ) { $query .= ' WHERE ' . $statementColumn . ' IN(' . $where->query . ')'; $binds = $where->binds; } /* Add where clause */ else { $binds = array(); if ( $where !== NULL ) { $_where = $this->compileWhereClause( $where ); $query .= ' WHERE ' . $_where['clause']; $binds = $_where['binds']; } } /* Order? */ if( $order !== NULL ) { $query .= ' ORDER BY ' . $order; } /* Limit */ if( $limit !== NULL ) { $query .= $this->compileLimitClause( $limit ); } /* Run it */ $return = $this->returnQuery; $stmt = $this->preparedQuery( $query, $binds ); if( $return === TRUE ) { return $stmt; } $return = $stmt->affected_rows; $stmt->close(); return $return; } /** * Compile WHERE clause * * @code // Single clause "foo IS NOT NULL" // Single clause with bound values (always bind values to ensure they are properly escaped) array( 'foo=?', 'fooValue' ) array( 'foo=? OR bar=?', 'fooValue', 'barValue' ) // Multiple clauses (will be joined with AND) with bound values array( array( 'foo=?, 'fooValue' ), array( 'bar=?', 'barValue' ) ) * @endcode * @param string|array $data See examples * @return array Array containing the WHERE clause and the values to be bound - array( 'clause' => '1=1', 'binds' => array() ) */ public function compileWhereClause( $data ) { $return = array( 'clause' => '1=1', 'binds' => array() ); if( is_string( $data ) ) { $return['clause'] = $data; } elseif ( is_array( $data ) and ! empty( $data ) ) { if ( is_string( $data[0] ) ) { $data = array( $data ); } $clauses = array(); foreach ( $data as $bit ) { if( !is_array( $bit ) ) { $clauses[] = $bit; } else { $clause = array_shift( $bit ); $binds = $bit; $i = 0; foreach ( $binds as $k => $v ) { $i++; if ( $v === NULL ) { $pos = 0; for ( $j=0; $j<$i; $j++ ) { $pos = mb_strpos( $clause, '?', $pos ) + 1; } if( mb_substr( $clause, $pos - 3, 3 ) == '!=?' ) { $clause = mb_substr( $clause, 0, $pos - 3 ) . ' IS NOT NULL' . mb_substr( $clause, $pos ); } else { $clause = mb_substr( $clause, 0, $pos - 2 ) . ' IS NULL' . mb_substr( $clause, $pos ); } $i--; unset( $binds[$k] ); } } $clauses[] = $clause; $return['binds'] = array_merge( $return['binds'], $binds ); } } $return['clause'] = implode( ' AND ', $clauses ); } return $return; } /** * Compile LIMIT clause * * @param int|array $data Rows to fetch or array( offset, limit ) * @return string */ public function compileLimitClause( $data ) { $limit = NULL; if( is_array( $data ) ) { $offset = intval( $data[0] ); $limit = intval( $data[1] ); } else { $offset = intval( $data ); } if( $limit !== NULL ) { return " LIMIT {$offset},{$limit}"; } else { return " LIMIT {$offset}"; } } /** * Compile column definition * * @code \IPS\Db::i()->compileColumnDefinition( array( 'name' => 'column_name', // Column name 'type' => 'VARCHAR', // Data type (do not specify length, etc. here) 'length' => 255, // Length. May be required or optional depending on data type. 'decimals' => 2, // Decimals. May be required or optional depending on data type. 'values' => array( 0, 1 ), // Acceptable values. Required for ENUM and SET data types. 'allow_null' => FALSE, // (Optional) Specifies whether or not NULL vavlues are allowed. Defaults to TRUE. 'default' => 'Default Value', // (Optional) Default value 'comment' => 'Column Comment', // (Optional) Column comment 'unsigned' => TRUE, // (Optional) Will specify UNSIGNED for numeric types. Defaults to FALSE. 'zerofill' => TRUE, // (Optional) Will specify ZEROFILL for numeric types. Defaults to FALSE. 'auto_increment'=> TRUE, // (Optional) Will specify auto_increment. Defaults to FALSE. 'binary' => TRUE, // (Optional) Will specify BINARY for TEXT types. Defaults to FALSE. 'primary' => TRUE, // (Optional) Will specify PRIMARY KEY. Defaults to FALSE. 'unqiue' => TRUE, // (Optional) Will specify UNIQUE. Defaults to FALSE. 'key' => TRUE, // (Optional) Will specify KEY. Defaults to FALSE. ) ); * @endcode * @see MySQL CREATE TABLE syntax * @param array $data Column Data (see \IPS\Db::createTable for details) * @return string */ public function compileColumnDefinition( $data ) { /* Specify name and type */ $definition = "`{$data['name']}` " . mb_strtoupper( $data['type'] ) . ' '; /* Some types specify length */ if( in_array( mb_strtoupper( $data['type'] ), array( 'VARCHAR', 'VARBINARY' ) ) or ( isset( $data['length'] ) and $data['length'] and in_array( mb_strtoupper( $data['type'] ), array( 'BIT', 'TINYINT', 'SMALLINT', 'MEDIUMINT', 'INT', 'INTEGER', 'BIGINT', 'REAL', 'DOUBLE', 'FLOAT', 'DECIMAL', 'NUMERIC', 'CHAR', 'BINARY' ) ) ) ) { $definition .= "({$data['length']}"; /* And some of those specify decimals (which may or may not be optional) */ if( in_array( mb_strtoupper( $data['type'] ), array( 'REAL', 'DOUBLE', 'FLOAT' ) ) or ( in_array( mb_strtoupper( $data['type'] ), array( 'DECIMAL', 'NUMERIC' ) ) and isset( $data['decimals'] ) ) ) { $definition .= ',' . $data['decimals']; } $definition .= ') '; } /* Numeric types can be UNSIGNED and ZEROFILL */ if( in_array( mb_strtoupper( $data['type'] ), array( 'TINYINT', 'SMALLINT', 'MEDIUMINT', 'INT', 'INTEGER', 'BIGINT', 'REAL', 'DOUBLE', 'FLOAT', 'DECIMAL', 'NUMERIC' ) ) ) { if( isset( $data['unsigned'] ) and $data['unsigned'] === TRUE ) { $definition .= 'UNSIGNED '; } if( isset( $data['zerofill'] ) and $data['zerofill'] === TRUE ) { $definition .= 'ZEROFILL '; } } /* ENUM and SETs have values */ if( in_array( mb_strtoupper( $data['type'] ), array( 'ENUM', 'SET' ) ) ) { $values = array(); foreach ( $data['values'] as $v ) { $values[] = "'{$this->escape_string( $v )}'"; } $definition .= '(' . implode( ',', $values ) . ') '; } /* Some types can be binary or not */ if( isset( $data['binary'] ) and $data['binary'] === TRUE and in_array( mb_strtoupper( $data['type'] ), array( 'CHAR', 'VARCHAR', 'TINYTEXT', 'TEXT', 'MEDIUMTEXT', 'LONGTEXT' ) ) ) { $definition .= 'BINARY '; } /* Text types specify a character set and collation */ if( in_array( mb_strtoupper( $data['type'] ), array( 'CHAR', 'VARCHAR', 'TINYTEXT', 'TEXT', 'MEDIUMTEXT', 'LONGTEXT', 'ENUM', 'SET' ) ) ) { $definition .= "CHARACTER SET {$this->charset} COLLATE {$this->collation} "; } /* NULL? */ if( isset( $data['allow_null'] ) and $data['allow_null'] === FALSE ) { $definition .= 'NOT NULL '; } else { $definition .= 'NULL '; } /* auto_increment? */ if( isset( $data['auto_increment'] ) and $data['auto_increment'] === TRUE ) { $definition .= 'AUTO_INCREMENT '; } else { /* Default value */ if( isset( $data['default'] ) and !in_array( mb_strtoupper( $data['type'] ), array( 'TINYTEXT', 'TEXT', 'MEDIUMTEXT', 'LONGTEXT', 'BLOB', 'MEDIUMBLOB', 'BIGBLOB', 'LONGBLOB' ) ) ) { if( $data['type'] == 'BIT' ) { $definition .= "DEFAULT {$data['default']} "; } else { $defaultValue = in_array( mb_strtoupper( $data['type'] ), array( 'TINYINT', 'SMALLINT', 'MEDIUMINT', 'INT', 'INTEGER', 'BIGINT', 'REAL', 'DOUBLE', 'FLOAT', 'DECIMAL', 'NUMERIC' ) ) ? floatval( $data['default'] ) : ( ! in_array( $data['default'], array( 'CURRENT_TIMESTAMP', 'BIT' ) ) ? '\'' . $this->escape_string( $data['default'] ) . '\'' : $data['default'] ); $definition .= "DEFAULT {$defaultValue} "; } } } /* Index? */ if( isset( $data['primary'] ) ) { $definition .= 'PRIMARY KEY '; } elseif( isset( $data['unique'] ) ) { $definition .= 'UNIQUE '; } if( isset( $data['key'] ) ) { $definition .= 'KEY '; } /* Comment */ if( isset( $data['comment'] ) and ! empty( $data['comment'] ) ) { $definition .= "COMMENT '{$this->escape_string( $data['comment'] )}'"; } /* Return */ return $definition; } /** * Compile index definition * * @code \IPS\Db::i()->compileIndexDefinition( array( 'type' => 'key', // "primary", "unique", "fulltext" or "key" 'name' => 'index_name', // Index name. Not required if type is "primary" 'length' => 200, // Index length (used when taking part of a text field, for example) 'columns' => array( 'column' ) // Columns to be in the index ) ); * @endcode * @see MySQL CREATE INDEX syntax * @see \IPS\Db::createTable * @param array $data Index Data (see \IPS\Db::createTable for details) * @return string */ public function compileIndexDefinition( $data ) { $definition = ''; /* Specify type */ switch ( \strtolower( $data['type'] ) ) { case 'primary': $definition .= 'PRIMARY KEY '; break; case 'unique': $definition .= "UNIQUE KEY `{$data['name']}` "; break; case 'fulltext': $definition .= "FULLTEXT KEY `{$data['name']}` "; break; default: $definition .= "KEY `{$data['name']}` "; break; } /* Specify columns */ $definition .= '(' . implode( ',', array_map( function ( $val, $len ) { return ( ! empty( $len ) ) ? "`{$val}`({$len})" : "`{$val}`"; }, $data['columns'], ( ( isset( $data['length'] ) AND is_array( $data['length'] ) ) ? $data['length'] : array_fill( 0, count( $data['columns'] ), null ) ) ) ) . ')'; /* Return */ return $definition; } /** * Does table exist? * * @param string $name Table Name * @return bool */ public function checkForTable( $name ) { return ( $this->forceQuery( "SHOW TABLES LIKE '". $this->escape_string( "{$this->prefix}{$name}" ) . "'" )->num_rows > 0 ); } /** * Does column exist? * * @param string $name Table Name * @param string $column Column Name * @return bool */ public function checkForColumn( $name, $column ) { return ( $this->forceQuery( "SHOW COLUMNS FROM `". $this->escape_string( "{$this->prefix}{$name}" ) . "` LIKE '". $this->escape_string( $column ) . "'" )->num_rows > 0 ); } /** * Does index exist? * * @param string $name Table Name * @param string $index Index Name * @return bool */ public function checkForIndex( $name, $index ) { return ( $this->forceQuery( "SHOW INDEXES FROM `". $this->escape_string( "{$this->prefix}{$name}" ) . "` WHERE Key_name LIKE '". $this->escape_string( $index ) . "'" )->num_rows > 0 ); } /** * Create Table * * @code \IPS\Db::createTable( array( 'name' => 'table_name', // Table name 'columns' => array( ... ), // Column data - see \IPS\Db::compileColumnDefinition for details 'indexes' => array( ... ), // (Optional) Index data - see \IPS\Db::compileIndexDefinition for details 'comment' => '...', // (Optional) Table comment 'engine' => 'MEMORY', // (Optional) Engine to use - will default to not specifying one, unless a FULLTEXT index is specified, in which case MyISAM is forced 'temporary' => TRUE, // (Optional) Will sepcify CREATE TEMPORARY TABLE - defaults to FALSE 'if_not_exists' => TRUE, // (Optional) Will sepcify CREATE TABLE name IF NOT EXISTS - defaults to FALSE ) ); * @endcode * @param array $data Table Definition (see code sample for details) * @throws \IPS\Db\Exception * @return void|string */ public function createTable( $data ) { return $this->query( $this->_createTableQuery( $data ) ); } /** * Create copy of table structure * * @param string $table The table name * @param string $newTableName Name of table to create * @throws \IPS\Db\Exception * @return void|string */ public function duplicateTableStructure( $table, $newTableName ) { return $this->query( "CREATE TABLE `{$this->prefix}{$newTableName}` LIKE `{$this->prefix}{$table}`" ); } /** * Create Table Query * * @see MySQL CREATE TABLE syntax * @see \IPS\Db::compileColumnDefinition * @see \IPS\Db::compileIndexDefinition * @param array $data Table Definition (see code sample for details) * @return string */ public function _createTableQuery( $data ) { $data = $this->updateDefinitionIndexLengths( $data ); $mysqlVersion = \IPS\Db::i()->server_info; /* Start with a basic CREATE TABLE */ $query = 'CREATE '; if( isset( $data['temporary'] ) and $data['temporary'] === TRUE ) { $query.= 'TEMPORARY '; } $query .= 'TABLE '; if( isset( $data['if_not_exists'] ) and $data['if_not_exists'] === TRUE ) { $query.= 'IF NOT EXISTS '; } /* Add in our create definition */ $query .= "`{$this->prefix}{$data['name']}` (\n\t"; $createDefinitons = array(); foreach ( $data['columns'] as $field ) { $createDefinitons[] = $this->compileColumnDefinition( $field ); } if( isset( $data['indexes'] ) ) { foreach ( $data['indexes'] as $index ) { if( $index['type'] === 'fulltext' ) { /* If this is a fulltext index, set engine to myisam but only if engine is something besides innodb or myisam OR the mysql version is less than 5.6 - in this case we assume the default engine is most likely either myisam or innodb */ if( !$this->_innoDbSupportsFulltextIndexes() OR ( isset( $data['engine'] ) AND !in_array( mb_strtoupper( $data['engine'] ), array( 'INNODB', 'MYISAM' ) ) ) ) { $data['engine'] = 'MYISAM'; } } $createDefinitons[] = $this->compileIndexDefinition( $index ); } } $query .= implode( ",\n\t", $createDefinitons ); $query .= "\n)\n"; /* Specifying a particular engine? */ if( isset( $data['engine'] ) and $data['engine'] ) { $query .= "ENGINE {$data['engine']} "; } /* Specify UTF8 */ $query .= "CHARACTER SET {$this->charset} COLLATE {$this->collation} "; /* Add comment */ if( isset( $data['comment'] ) ) { $query .= "COMMENT '{$this->escape_string( $data['comment'] )}'"; } /* Return */ return $query; } /** * Rename table * * @see Rename Table * @param string $oldName The current table name * @param string $newName The new name * @return void * @see Renaming huge InnoDB tables * @see Performance problem dropping huge InnoDB tables * @note A race condition can occur sometimes with InnoDB + innodb_file_per_table so we can't drop then rename...see above links */ public function renameTable( $oldName, $newName ) { /* Find out if the table we are renaming *to* already exists */ $cleanUp = FALSE; $query = "`{$this->prefix}{$this->escape_string( $oldName )}` TO `{$this->prefix}{$this->escape_string( $newName )}`"; if( $this->checkForTable( $newName ) ) { $query = "`{$this->prefix}{$this->escape_string( $newName )}` TO `{$this->prefix}{$this->escape_string( $newName )}_DROP`, " . $query; $cleanUp = TRUE; } $result = $this->query( "RENAME TABLE " . $query ); if( $cleanUp ) { $this->dropTable( $newName . '_DROP', TRUE ); } return $result; } /** * Alter Table * Can only update the comment and engine * @note This will not examine key lengths and adjust. * * @param string $table Table name * @param string|null $comment Table comment. NULL to not change * @param string|null $engine Engine to use. NULL to not change * @return void */ public function alterTable( $table, $comment=NULL, $engine=NULL ) { if ( $comment === NULL and $engine === NULL ) { return; } $query = "ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` "; if ( $comment !== NULL ) { $query .= "COMMENT='{$this->escape_string( $comment )}' "; } if ( $engine !== NULL ) { $query .= "ENGINE={$engine}"; } return $this->query( $query ); } /** * Find out the default storage engine * * @return string */ public function defaultEngine() { $result = $this->forceQuery( "SHOW ENGINES" ); while( $engine = $result->fetch_assoc() ) { if( mb_strtoupper( $engine['Support'] ) == 'DEFAULT' ) { return $engine['Engine']; } } return $this->_innoDbSupportsFulltextIndexes() ? 'InnoDB' : 'MyISAM'; } /** * Is InnoDB supported for fulltext indexes? * * @return bool */ protected function _innoDbSupportsFulltextIndexes() { /* MariaDB supports fulltext for InnoDB on versions higher than 10.0.5 */ if ( preg_match( '/^(\d*\.\d*(\.\d*)-)?(\d*\.\d*(\.\d*))-MariaDB/', $this->server_info, $matches ) ) { $mariaVersion = $matches[3]; return version_compare( $mariaVersion, '10.0.5', '>=' ); } /* Normal MySQL supports fulltext for InnoDB on versions higher than 5.6 */ else { return $this->server_version >= 50600; } } /** * Drop table * * @see DROP TABLE Syntax * @param string|array $table Table Name(s) * @param bool $ifExists Adds an "IF EXISTS" clause to the query * @param bool $temporary Table is temporary? * @return mixed */ public function dropTable( $table, $ifExists=FALSE, $temporary=FALSE ) { $prefix = $this->prefix; return $this->query( 'DROP ' . ( $temporary ? 'TEMPORARY ' : '' ) . 'TABLE ' . ( $ifExists ? 'IF EXISTS ' :'' ) . implode( ', ', array_map( function( $val ) use ( $prefix ) { return '`' . $prefix . $val . '`'; }, ( is_array( $table ) ? $table : array( $table ) ) ) ) ); } /** * Get the table definition for an existing table * * @see \IPS\Db::createTable * @param string $table Table Name * @param boolean $columnsOnly Fetch columns only * @param boolean $getCollation Get column collations * @return array Table definition - see IPS\Db::createTable for details * @throws \OutOfRangeException * @throws \IPS\Db\Exception */ public function getTableDefinition( $table, $columnsOnly=FALSE, $getCollation=FALSE ) { /* Set name */ $definition = array( 'name' => $table, ); /* Fetch columns */ if( !$this->checkForTable( $table ) ) { throw new \OutOfRangeException; } $query = $this->forceQuery( "SHOW FULL COLUMNS FROM `{$this->prefix}" . $this->escape_string( $table ) . '`' ); if ( $query->num_rows === 0 ) { throw new \OutOfRangeException; } while ( $row = $query->fetch_assoc() ) { /* Set basic information */ $columnDefinition = array( 'name' => $row['Field'], 'type' => '', 'length' => 0, 'decimals' => NULL, 'values' => array() ); if ( $getCollation and isset( $row['Collation'] ) ) { $columnDefinition['collation'] = $row['Collation']; } /* Parse the type */ if( mb_strpos( $row['Type'], '(' ) !== FALSE ) { /* First, we need to protect the enum options as they may have spaces before splitting */ preg_match( '/(.+?)\((.+?)\)/', $row['Type'], $matches ); $options = $matches[2]; $type = preg_replace( '/(.+?)\((.+?)\)/', "$1(___TEMP___)", $row['Type'] ); $typeInfo = explode( ' ', $type ); $typeInfo[0] = str_replace( "___TEMP___", $options, $typeInfo[0] ); /* Now we match out the options */ preg_match( '/(.+?)\((.+?)\)/', $typeInfo[0], $matches ); $columnDefinition['type'] = mb_strtoupper( $matches[1] ); if( $columnDefinition['type'] === 'ENUM' or $columnDefinition['type'] === 'SET' ) { preg_match_all( "/'(.*?)'/", $matches[2], $enum ); $columnDefinition['values'] = $enum[1]; } else { $lengthInfo = explode( ',', $matches[2] ); $columnDefinition['length'] = intval( $lengthInfo[0] ); if( isset( $lengthInfo[1] ) ) { $columnDefinition['decimals'] = intval( $lengthInfo[1] ); } } } else { $typeInfo = explode( ' ', $row['Type'] ); $columnDefinition['type'] = mb_strtoupper( $typeInfo[0] ); $columnDefinition['length'] = 0; } /* unsigned? */ $columnDefinition['unsigned'] = in_array( 'unsigned', $typeInfo ); /* zerofill? */ $columnDefinition['zerofill'] = in_array( 'zerofill', $typeInfo ); /* binary? */ $columnDefinition['binary'] = ( $row['Collation'] === $this->binaryCollation ); /* Allow NULL? */ $columnDefinition['allow_null'] = ( $row['Null'] === 'YES' ); /* Default value */ $columnDefinition['default'] = $row['Default']; if ( $columnDefinition['default'] === NULL and !$columnDefinition['allow_null'] and mb_strpos( $row['Extra'], 'auto_increment' ) === FALSE ) { $columnDefinition['default'] = ''; } /* auto_increment */ $columnDefinition['auto_increment'] = mb_strpos( $row['Extra'], 'auto_increment' ) !== FALSE; /* Comment */ $columnDefinition['comment'] = $row['Comment'] ?: ''; /* Add it in the defintion */ ksort( $columnDefinition ); $definition['columns'][ $columnDefinition['name'] ] = $columnDefinition; } if( !$columnsOnly ) { /* Fetch indexes */ $indexes = array(); $query = $this->forceQuery( "SHOW INDEXES FROM `{$this->prefix}{$table}`" ); while ( $row = $query->fetch_assoc() ) { $length = ( isset( $row['Sub_part'] ) AND ! empty( $row['Sub_part'] ) ) ? intval( $row['Sub_part'] ) : null; if( isset( $indexes[ $row['Key_name'] ] ) ) { $indexes[ $row['Key_name'] ]['length'][] = $length; $indexes[ $row['Key_name'] ]['columns'][] = $row['Column_name']; } else { $type = 'key'; if( $row['Key_name'] === 'PRIMARY' ) { $type = 'primary'; } elseif( $row['Index_type'] === 'FULLTEXT' ) { $type = 'fulltext'; } elseif( !$row['Non_unique'] ) { $type = 'unique'; } $indexes[ $row['Key_name'] ] = array( 'type' => $type, 'name' => $row['Key_name'], 'length' => array( $length ), 'columns' => array( $row['Column_name'] ) ); } } $definition['indexes'] = $indexes; /* Finally, get the table comment and engine */ $row = $this->forceQuery( "SHOW TABLE STATUS LIKE '{$this->prefix}" . $this->escape_string( $table ) . "'" )->fetch_assoc(); if( $row['Comment'] ) { $definition['comment'] = $row['Comment']; } if( $row['Collation'] ) { $definition['collation'] = $row['Collation']; } if( $row['Engine'] ) { $definition['engine'] = $row['Engine']; } } /* Return */ return $definition; } /** * Add column to table in database * * @see \IPS\Db::compileColumnDefinition * @param string $table Table name * @param array $definition Column Definition (see \IPS\Db::compileColumnDefinition for details) * @return void */ public function addColumn( $table, $definition ) { return $this->query( "ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` ADD COLUMN {$this->compileColumnDefinition( $definition )}" ); } /** * Modify an existing column * * @see \IPS\Db::compileColumnDefinition * @param string $table Table name * @param string $column Column name * @param array $definition New column definition (see \IPS\Db::compileColumnDefinition for details) * @return void */ public function changeColumn( $table, $column, $definition ) { return $this->query( "ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` CHANGE COLUMN `{$this->escape_string( $column )}` {$this->compileColumnDefinition( $definition )}" ); } /** * Drop a column * * @param string $table Table name * @param string|array $column Column name * @return void */ public function dropColumn( $table, $column ) { if( is_array( $column ) ) { $drops = array(); foreach( $column as $_column ) { $drops[] = "DROP COLUMN `{$this->escape_string( $_column )}`"; } $statement = implode( ", ", $drops ); } else { $statement = "DROP COLUMN `{$this->escape_string( $column )}`"; } return $this->query( "ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` {$statement};" ); } /** * Add index to table in database * * @see \IPS\Db::compileIndexDefinition * @param string $table Table name * @param array $definition Index Definition (see \IPS\Db::compileIndexDefinition for details) * @param bool $discardDuplicates If adding a unique index, should duplicates be discarded? (If FALSE and there are any, an exception will be thrown) * @return void */ public function addIndex( $table, $definition, $discardDuplicates=TRUE ) { /* If it's a unique index, make sure there won't be any duplicates */ if ( $discardDuplicates and in_array( $definition['type'], array( 'primary', 'unique' ) ) AND $this->returnQuery === FALSE ) { $this->duplicateTableStructure( $table, "{$table}_temp" ); $this->addIndex( "{$table}_temp", $definition, FALSE ); $this->insert( "{$table}_temp", \IPS\Db::i()->select( '*', $table ), FALSE, TRUE ); $this->dropTable( $table ); $this->renameTable( "{$table}_temp", $table ); } /* Otherwise just do it normally */ else { return $this->query( "ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` {$this->buildIndex( $table, $definition )}" ); } } /** * Modify an existing index * * @see \IPS\Db::compileIndexDefinition * @param string $table Table name * @param string $index Index name * @param array $definition New index definition (see \IPS\Db::compileIndexDefinition for details) * @return void */ public function changeIndex( $table, $index, $definition ) { $returnQuery = $this->returnQuery; $return = NULL; if( $this->checkForIndex( $table, $index ) ) { $query = $this->dropIndex( $table, $index ); if( $returnQuery === TRUE ) { $return = $query; } } if ( $returnQuery ) { $this->returnQuery = TRUE; } $query = $this->addIndex( $table, $definition ); if( $returnQuery === TRUE ) { $this->returnQuery = FALSE; $return .= $query; return $return; } return $query; } /** * Build an index query for add/change * * @see \IPS\Db::compileIndexDefinition * @param string $table Table name * @param array $definition New index definition (see \IPS\Db::compileIndexDefinition for details) * @param array|NULL $data Table definition, or null to pull from database * @return void */ public function buildIndex( $table, $definition, $data=NULL ) { $indexName = $definition['name']; if ( $data === NULL ) { $data = $this->getTableDefinition( $table, FALSE, TRUE ); } $engine = mb_strtolower( $data['engine'] ); /* Add the index to the table definition */ $data['indexes'][ $indexName ] = $definition; /* Reduce sub_part if required */ $data = $this->updateDefinitionIndexLengths( $data ); $return = ''; /* Do we need to adjust the engine because it's a fulltext index? */ if( $engine !== mb_strtolower( $data['engine'] ) ) { $return = "ENGINE={$data['engine']}, "; } /* Extract the key we want to add */ $definition = $data['indexes'][ $indexName ]; return $return . "ADD {$this->compileIndexDefinition( $definition )}"; } /** * Drop an index * * @param string $table Table name * @param string|array $index Column name * @return mixed */ public function dropIndex( $table, $index ) { $index = ( is_array( $index ) ) ? $index : array( $index ); $indexes = array(); if( \IPS\Db::i()->returnQuery ) { foreach( $index as $key => $col ) { if ( !$this->checkForIndex( $table, $col ) ) { unset( $index[$key] ); } } } foreach( $index as $col ) { $indexes[] = ( $col == 'PRIMARY KEY' ) ? "DROP " . $col : "DROP INDEX `" . $this->escape_string( $col ) . "`"; } $_index = implode( ', ', $indexes ); try { $return = ''; if ( $_index ) { $return = $this->query( "ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` {$_index};" ); } else { /* Even if we do not run a query here, we need to reset this */ \IPS\Db::i()->returnQuery = FALSE; } return $return; } catch( \IPS\Db\Exception $e ) { /* No need to stop here if index doesn't exist */ if ( $e->getCode() !== 1091 ) { throw $e; } return 0; } } /** * FIND_IN_SET * Generates a WHERE clause to determine if any value from a column containing a comma-delimined list matches any value from an array * * @param string $column Column name (which contains a comma-delimited list) * @param array $values Acceptable values * @param bool $reverse If true, will match cases where NO values from $column match any from $values * @return string Where clause * @see \IPS\Db::in() More efficient equivilant for columns that do not contain comma-delimited lists */ public function findInSet( $column, $values, $reverse=FALSE ) { $where = array(); foreach( $values as $i ) { if ( $i != NULL and is_numeric( $i ) ) { $where[] = ( $reverse ? 'NOT ' : '' ) . "FIND_IN_SET(" . $i . "," . $column . ")"; } else if ( $i != NULL and is_string( $i ) ) { $where[] = ( $reverse ? 'NOT ' : '' ) . "FIND_IN_SET('" . $this->real_escape_string( $i ) . "'," . $column . ")"; } } $statement = $reverse ? 'AND' : 'OR'; if ( ! empty( $where ) ) { return '( ' . implode( " {$statement} ", $where ) . ' )'; } else { return $reverse ? '1=1' : '1=0'; } } /** * IN * Generates a WHERE clause to determine if the value of a column matches any value from an array * * @param string $column Column name * @param array $values Acceptable values * @param bool $reverse If true, will match cases where $column does NOT match $values * @return string Where clause * @see \IPS\Db::findInSet() For columns that contain comma-delimited lists */ public function in( $column, $values, $reverse=FALSE ) { $in = array(); if( !is_array( $values ) ) { $values = array( $values ); } foreach( $values as $i ) { /* We must use the !== comparison so that 0 is not treated the same as NULL */ if ( $i !== NULL and is_numeric( $i ) and ( is_int( $i ) or is_float( $i ) ) ) { $in[] = $i; } else if ( $i != NULL and is_string( $i ) ) { $in[] = "'" . $this->real_escape_string( $i ) . "'"; } } $return = array(); if ( ! empty( $in ) ) { $return[] = $column . ( $reverse ? ' NOT' : '' ) . ' IN(' . implode( ',', $in ) . ')'; } if ( count( $return ) ) { return '( ' . implode( ' OR ', $return ) . ' )'; } else { return $reverse ? '1=1' : '1=0'; } } /** * Bitwise WHERE clause * * @param array $definition Bitwise keys as defined by the class * @param string $key The key to check for * @param bool $value Value to check for * @return string * @throws \InvalidArgumentException */ public function bitwiseWhere( $definition, $key, $value=TRUE ) { $operator = $value ? '& ' : '& ~'; foreach ( $definition as $column => $keys ) { if ( isset( $keys[ $key ] ) ) { return "(`{$column}` {$operator}{$keys[ $key ]} ) != 0"; } } throw new \InvalidArgumentException; } /** * Strip index lengths in the schema definitions - useful for a better comparison of the definitions * since different engines and charsets require different storage. Also, strip engine and collation. * * @param array|string $data Table definition (array) or table name (string) * @return array */ public function normalizeDefinition( $data ) { $definition = ( is_array( $data ) ) ? $data : $this->getTableDefinition( $data, FALSE, TRUE ); if ( isset( $definition['indexes'] ) ) { foreach( $definition['indexes'] as $key => &$index ) { /* Make sure the keys are in the correct order otherwise normal variances trigger differences just because 'columns' can come before 'length', etc */ ksort( $index ); if( isset( $index['length'] ) ) { foreach( $index['length'] as $_key => $length ) { $definition['indexes'][ $key ]['length'][ $_key ] = null; } } } } if( isset( $definition['collation'] ) ) { unset( $definition['collation'] ); } if( isset( $definition['engine'] ) ) { unset( $definition['engine'] ); } /* Prevent conflicts when schema says DEFAULT '0' but it is DEFAULT 0 and an INT type column as this is always set as a 0 anyway */ foreach( $definition['columns'] as $name => $data ) { if ( in_array( mb_strtoupper( $data['type'] ), array_keys( static::$dataTypes['database_column_type_numeric'] ) ) and ( ! in_array( mb_strtoupper( $data['type'] ), array( 'DECIMAL', 'FLOAT', 'BIT' ) ) ) and is_numeric( $data['default'] ) ) { $definition['columns'][ $name ]['default'] = intval( $data['default'] ); } } return $definition; } /** * Attempt to fix issues with keys longer than maximum allowed by DB engine * which is 1000 bytes for MyISAM and 767 for InnoDB taking into consideration the * multiplier (4 bytes per character for utf8mb4 and 3 bytes per character for UTF8) * * @param array|string $data Table definition (array) or table name (string) * @return array */ public function updateDefinitionIndexLengths( $data ) { $definition = ( is_array( $data ) ) ? $data : $this->getTableDefinition( $data, FALSE, TRUE ); $length = 0; $multiplier = ( $this->charset === 'utf8mb4' ) ? 4 : 3; $needsFixing = array(); $maxLen = 1000; if ( ( ! isset( $definition['engine'] ) OR mb_strtolower( $definition['engine'] ) == 'innodb' ) and isset( $definition['indexes'] ) ) { $definition['engine'] = $this->defaultEngine(); /* Any FULLTEXT fields? */ foreach( $definition['indexes'] as $key => $data ) { if ( $data['type'] === 'fulltext' ) { /* If this is a fulltext index, set engine to myisam but only if engine is something besides innodb or myisam OR the mysql version is less than 5.6 - in this case we assume the default engine is most likely either myisam or innodb */ if( !$this->_innoDbSupportsFulltextIndexes() OR ( isset( $definition['engine'] ) AND !in_array( mb_strtoupper( $definition['engine'] ), array( 'INNODB', 'MYISAM' ) ) ) ) { $definition['engine'] = 'myisam'; } } } } if ( \mb_strtolower( $definition['engine'] ) === 'innodb' ) { $maxLen = 767; } if ( isset( $definition['indexes'] ) ) { foreach( $definition['indexes'] as $key => $index ) { $thisLength = null; $hasText = false; foreach( $index['columns'] as $i => $column ) { $thisLength = ( isset( $index['length'][ $i ] ) ) ? $index['length'][ $i ] : ( ( (int) $definition['columns'][ $column ]['length'] or empty( $definition['columns'][ $column ]['length'] ) ) ? $definition['columns'][ $column ]['length'] : 250 ); $isText = in_array( mb_strtolower( $definition['columns'][ $column ]['type'] ), array( 'mediumtext', 'text' ) ); if ( $hasText === false and $isText === true ) { $hasText = true; } if ( isset( $definition['columns'][ $column ] ) and ( ( ! empty( $thisLength ) or $isText ) ) ) { $length += $thisLength; } /* Is this an MB4 column */ if ( $multiplier === 3 AND isset( $definition['columns'][ $column ]['collation'] ) ) { if ( mb_substr( $definition['columns'][ $column ]['collation'], 0, 7 ) === 'utf8mb4' ) { $multiplier = 4; } } } if ( ( $length * $multiplier > $maxLen ) or $hasText ) { foreach( $index['columns'] as $i => $column ) { $thisLength = ( isset( $index['length'][ $i ] ) ) ? $index['length'][ $i ] : ( (int) $definition['columns'][ $column ]['length'] ? $definition['columns'][ $column ]['length'] : 250 ); if ( isset( $definition['columns'][ $column ] ) and ( ( ! empty( $thisLength ) or in_array( mb_strtolower( $definition['columns'][ $column ]['type'] ), array( 'mediumtext', 'text' ) ) ) ) ) { /* Column name, column length, column type */ $needsFixing[ $key ][ $i ] = array( $column, $thisLength, $definition['columns'][ $column ]['type'] ); } } } $length = 0; } } if ( count( $needsFixing ) ) { foreach( $needsFixing as $key => $i ) { $totalLength = 0; $maxChars = $maxLen / $multiplier; foreach( $i as $vals ) { $totalLength += $vals[1]; } if ( $totalLength > $maxChars ) { /* Check each column can be reduced by the amount we need reducing */ $debt = 0; $reduceEachBy = ( ( 100 / $totalLength ) * $maxChars) / 100; /* Apply debt if we have any. We do not reduce integers */ foreach( $i as $x => $vals ) { if ( in_array( mb_strtoupper( $vals[2] ), array_keys( static::$dataTypes['database_column_type_numeric'] ) ) ) { $debt += $vals[1]; } } /* Recalculate value to multiply index sub lengths with (subtracting debt) */ if ( $debt < $totalLength ) { $reduceEachBy = ( ( 100 / ($totalLength - $debt) ) * ( $maxChars - $debt ) ) / 100; } foreach( $i as $x => $vals ) { /* No length? */ if ( empty( $vals[1] ) ) { $vals[1] = 250; } if ( in_array( mb_strtoupper( $vals[2] ), array_keys( static::$dataTypes['database_column_type_numeric'] ) ) ) { /* Preserve col len where possible but if the column length is greater than subpart allowed, NULL the length otherwise MySQL will complain as you cannot use subpart on non-string column. */ if ( $vals[1] > floor( $maxLen / $multiplier ) ) { $vals[1] = NULL; $i[ $x ] = $vals; } continue; } $vals[1] = floor( $vals[1] * $reduceEachBy ); $i[ $x ] = $vals; } } foreach( $i as $x => $vals ) { if ( $definition['columns'][ $definition['indexes'][ $key ]['columns'][ $x ] ]['length'] != $vals[1] ) { $definition['indexes'][ $key ]['length'][ $x ] = intval( $vals[1] ); } else { $definition['indexes'][ $key ]['length'][ $x ] = NULL; } } } } return $definition; } /** * Create database * * @param string $name Database Name * @return bool */ public function createDatabase( $name ) { return ( $this->query( "CREATE DATABASE ". $this->escape_string( "{$name}" ) ) ); } /** * Strip comments from a .sql file * * @param string $contents Contents from SQL file * @return string */ public static function stripComments( $contents ) { $contents = preg_replace( '/\/\*.+?\*\//', '', $contents ); $contents = preg_replace( '/#.*/', '', $contents ); $contents = preg_replace( '/--.*/', '', $contents ); $contents = trim( $contents ); return $contents; } /** * Replace binds in a prepared query to get the "full" query * * @param string $query Query * @param array $binds Any binds in the query * @return string */ public static function _replaceBinds( $query, $binds ) { /* Replace ?s with the actual values */ if( is_array( $binds ) AND count( $binds ) ) { foreach ( $binds as $b ) { $query = preg_replace( '/\?/', var_export( $b, TRUE ), $query, 1 ); } } return $query; } }