Files
IPB/system/Db/Db.php
T
2025-12-19 16:21:27 -08:00

2906 lines
79 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @brief Database Class
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @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;
}
// Make sure PHP 8.1 uses MySQL errors as we expect
\mysqli_report( MYSQLI_REPORT_OFF );
/**
* @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_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;
/**
* @brief Our identifier
*/
public $identifier;
/**
* @brief Stored connection details so we can connect on-demand
*/
protected $connectionDetails = array();
/**
* @brief Track if we've connected
*/
protected $connections = array( 'read' => FALSE, 'write' => FALSE );
/**
* Constructor
*
* @note Overridden so we can delay connecting to the server until we actually need to
* @param string $host Host
* @param string $username Username
* @param string $password Password
* @param string $database Database name
* @param int $port Port
* @param string $socket Socket
* @param bool $utf8mb4 Use UTF8MB4?
* @param string $prefix Table prefix to use
* @param NULL|array $readDatabase If using read/write, the connection details for the read database
* @return void
*/
public function __construct( $host = NULL, $username = NULL, $password = NULL, $database = "", $port = NULL, $socket = NULL, $utf8mb4 = true, $prefix = '', $readDatabase = NULL )
{
$this->connectionDetails = array(
'host' => $host ?? ini_get("mysqli.default_host"),
'username' => $username ?? ini_get("mysqli.default_user"),
'password' => $password ?? ini_get("mysqli.default_pw"),
'database' => $database,
'port' => $port ?? ini_get("mysqli.default_port"),
'socket' => $socket ?? ini_get("mysqli.default_socket"),
'utf8mb4' => $utf8mb4,
'readDatabase' => $readDatabase
);
/* Set the prefix */
$this->prefix = $prefix;
/* Now initialize the object so we can connect later */
parent::init();
}
/**
* 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\SITE_FILES_PATH . '/conf_global.php' );
if ( \IPS\CIC2 )
{
$INFO['sql_pass'] = $_SERVER['IPS_CLOUD2_DBPASS'];
$INFO['sql_read_pass'] = $_SERVER['IPS_CLOUD2_DBPASS'];
}
$connectionSettings = isset( $INFO ) ? $INFO : array();
}
$readDatabase = NULL;
/* Read/Write Separation? */
if ( isset( $connectionSettings['sql_read_host'] ) and \IPS\READ_WRITE_SEPARATION )
{
$readDatabase = 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,
);
}
static::$multitons[ $identifier ] = new static(
$connectionSettings['sql_host'],
$connectionSettings['sql_user'],
$connectionSettings['sql_pass'],
$connectionSettings['sql_database'],
( isset( $connectionSettings['sql_port'] ) and $connectionSettings['sql_port']) ? $connectionSettings['sql_port'] : NULL,
( isset( $connectionSettings['sql_socket'] ) and $connectionSettings['sql_socket'] ) ? $connectionSettings['sql_socket'] : NULL,
isset( $connectionSettings['sql_utf8mb4'] ) and $connectionSettings['sql_utf8mb4'],
$connectionSettings['sql_tbl_prefix'] ?? '',
$readDatabase
);
static::$multitons[ $identifier ]->identifier = $identifier;
}
/* Return */
return static::$multitons[ $identifier ];
}
/**
* Apparently, get_charset can be unavailable
*
* @param bool $read Read only connection?
* @return string
*/
public function getCharset( $read=FALSE )
{
if ( method_exists( $this, 'get_charset' ) )
{
return ( $read AND $this->connectionDetails['readDatabase'] ) ? $this->reader->get_charset()->charset : static::get_charset()->charset;
}
else
{
return ( $read AND $this->connectionDetails['readDatabase'] ) ? $this->reader->character_set_name() : static::character_set_name();
}
}
/**
* Establish database connection
*
* @param bool $read Connect to read database (if specified)?
* @return mysqli
*/
protected function _establishConnection( $read = FALSE )
{
/* Which details to use? */
$sqlCredentials = $this->connectionDetails;
$logDatabase = 'database';
if( $read AND $this->connectionDetails['readDatabase'] )
{
$sqlCredentials = $this->connectionDetails['readDatabase'];
$logDatabase = 'read database';
$this->reader = new \mysqli(
$sqlCredentials['host'],
$sqlCredentials['username'],
$sqlCredentials['password'],
$sqlCredentials['database'],
$sqlCredentials['port'],
$sqlCredentials['socket']
);
$error = $this->reader->connect_error;
$errno = $this->reader->connect_errno;
}
else
{
$logDatabase = 'write database';
/* Connect */
parent::real_connect(
$sqlCredentials['host'],
$sqlCredentials['username'],
$sqlCredentials['password'],
$sqlCredentials['database'],
$sqlCredentials['port'],
$sqlCredentials['socket']
);
$error = mysqli_connect_error();
$errno = $this->connect_errno;
}
/* Store a log entry so we can track */
$this->log( "Connected to the " . $logDatabase, ( $read and $this->connectionDetails['readDatabase'] ) ? 'read' : 'write' );
/* If the connection failed, throw an exception */
if( $error )
{
throw new \IPS\Db\Exception( $error, $errno );
}
/* Enable strict mode for IN_DEV */
if ( \IPS\IN_DEV )
{
if( $read AND $this->connectionDetails['readDatabase'] )
{
$this->reader->query( "SET sql_mode='STRICT_ALL_TABLES,ONLY_FULL_GROUP_BY,ANSI_QUOTES'" );
}
else
{
parent::query( "SET sql_mode='STRICT_ALL_TABLES,ONLY_FULL_GROUP_BY,ANSI_QUOTES'" );
}
}
/* Charset */
if( $read AND $this->connectionDetails['readDatabase'] )
{
if ( $this->connectionDetails['utf8mb4'] )
{
if ( $this->reader->set_charset( 'utf8mb4' ) === FALSE )
{
/* If setting utf8mb4 fails, then gracefully fallback to normal utf8 */
$this->reader->set_charset( 'utf8' );
}
}
else
{
$this->reader->set_charset( 'utf8' );
}
}
else
{
if ( $this->connectionDetails['utf8mb4'] )
{
if ( $this->set_charset( 'utf8mb4' ) === FALSE )
{
/* If setting utf8mb4 fails, then gracefully fallback to normal utf8 */
$this->set_charset( 'utf8' );
}
}
else
{
$this->set_charset( 'utf8' );
}
}
/* Set charset / collation properties */
if ( $this->getCharset( $read ) === 'utf8mb4' )
{
$this->charset = 'utf8mb4';
$this->collation = 'utf8mb4_unicode_ci';
$this->binaryCollation = 'utf8mb4_bin';
}
else
{
$this->charset = 'utf8';
$this->collation = 'utf8_unicode_ci';
$this->binaryCollation = 'utf8_bin';
}
/* Return */
return $this;
}
/**
* Check if we are connected, and connect if not
*
* @param bool $read Is this a read query (i.e. connect to reader)?
* @return void
*/
public function checkConnection( $read = FALSE )
{
/* If we aren't using read/write separation, we only have one connection */
if( !$this->connectionDetails['readDatabase'] )
{
$read = FALSE;
}
/* Have we already connected? */
if( $this->connections[ $read ? 'read' : 'write' ] === TRUE )
{
return;
}
/* Connect */
$this->_establishConnection( $read );
/* And then flag that the connection was successful */
$this->connections[ $read ? 'read' : 'write' ] = TRUE;
}
/**
* @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 <a href="http://uk1.php.net/manual/en/mysqli.query.php">mysqli::query</a>
* @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;
}
/* Make sure we're connected */
$this->checkConnection( $read );
/* 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 <a href="http://uk1.php.net/manual/en/mysqli.query.php">mysqli::query</a>
* @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 )
{
/* Make sure we're connected */
$this->checkConnection( ( $read AND $this->readWriteSeparation ) );
/* 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', 'IteratorIterator' ) ) )
{
$comment = "{$b['class']}::{$b['function']}:{$line}";
break;
}
}
$_query = $query;
$query = "/*" . \IPS\Settings::i()->sql_database . "::" . \IPS\Settings::i()->sql_user . "::{$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 )
{
$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 upto 3 times total */
if ( $stmt->errno === 1213 and $count <= 3 )
{
usleep(250);
$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_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, $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 ';
}
/* 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(mt_rand()) . '`' );
}
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
* @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 " . $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 );
$return->isUnion = TRUE;
return $return;
}
/**
* Run INSERT statement and return insert ID
*
* @see <a href='http://dev.mysql.com/doc/refman/5.1/en/insert.html'>INSERT Syntax</a>
* @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 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 <a href='http://dev.mysql.com/doc/refman/5.1/en/replace.html'>REPLACE Syntax</a>
* @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;
}
/**
* Escapes special characters in a string for use in an SQL statement, taking into account the current charset of the connection
*
* @see https://php.net/manual/en/mysqli.real-escape-string.php
* @param string $escapestr The string to be escaped.
* @return string An escaped string.
*/
public function real_escape_string( $escapestr )
{
/* Make sure we're connected */
$this->checkConnection( TRUE );
return $this->connectionDetails['readDatabase'] ? $this->reader->real_escape_string( $escapestr ) : parent::real_escape_string( $escapestr );
}
/**
* Escapes special characters in a string for use in an SQL statement, taking into account the current charset of the connection
*
* @see https://php.net/manual/en/mysqli.real-escape-string.php
* @param string $escapestr The string to be escaped.
* @return string An escaped string.
*/
public function escape_string( $escapestr )
{
/* Make sure we're connected */
$this->checkConnection( TRUE );
return $this->connectionDetails['readDatabase'] ? $this->reader->escape_string( $escapestr ) : parent::escape_string( $escapestr );
}
/**
* 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;
}
elseif ( \is_array( $set ) and \count( $set ) == 2 and isset( $set[1] ) and $set[1] instanceof \IPS\Db\Select )
{
$query = "{$type} INTO `{$this->prefix}{$table}` (" . $set[0] . ") " . $set[1]->query;
$binds = $set[1]->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 <a href='http://dev.mysql.com/doc/refman/5.1/en/update.html'>UPDATE Syntax</a>
* @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 <a href='http://dev.mysql.com/doc/refman/5.1/en/delete.html'>DELETE Syntax</a>
* @param string|array $table Table Name or array of table names
* @param string|array|\IPS\Db\Select|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|array|null $statementColumn If \IPS\Db\Select is passed, this is either the name of the column that results are being loaded from (and we will use a WHERE clause like WHERE {statementColumn} IN ({select-query})) or an array to map the outer table column to the inner table column (and we will JOIN the inner table and use an ON clause like ON {statementColumn[0]} IN ({statementColumn[1]}))
* @param string $deleteWhat What to delete (used when executing a multitable delete)
* @param bool $statementReverse If \IPS\Db\Select is passed, TRUE will use NOT IN() rather than IN().
* @return \IPS\Db\Select
* @throws \IPS\Db\Exception
*/
public function delete( $table, $where=NULL, $order=NULL, $limit=NULL, $statementColumn=NULL, $deleteWhat='', $statementReverse=FALSE )
{
/* Clear any size cache if it exists */
if( \is_array( $table ) )
{
foreach( $table as $_table )
{
if( isset( $this->cachedTableData[ $_table ] ) )
{
unset( $this->cachedTableData[ $_table ] );
}
}
}
else
{
if( isset( $this->cachedTableData[ $table ] ) )
{
unset( $this->cachedTableData[ $table ] );
}
}
/* TRUNCATE is faster, so use that if appropriate */
if ( $where === NULL and $limit === NULL and \is_string( $table ) )
{
$return = $this->returnQuery;
$stmt = $this->preparedQuery( "TRUNCATE `{$this->prefix}{$table}`", array() );
if( $return === TRUE )
{
return $stmt;
}
$return = $stmt->affected_rows;
$stmt->close();
/* Optimize table after truncating */
$this->preparedQuery( "OPTIMIZE TABLE `{$this->prefix}{$table}`", [] )->close();
return $return;
}
/* Start building the query */
$query = "DELETE ";
if( $deleteWhat )
{
$query .= $deleteWhat . ' ';
}
$query .= "FROM ";
if( \is_string( $table ) )
{
$query .= "`{$this->prefix}{$table}`";
}
else
{
$tables = array();
foreach( $table as $alias => $_table )
{
$alias = \is_string( $alias ) ? $alias : $_table;
$tables[] = "`{$this->prefix}{$_table}` AS `{$alias}`";
}
$query .= implode( ', ', $tables );
}
/* Is a statement? */
if ( $where instanceof \IPS\Db\Select )
{
if( \is_string( $statementColumn ) )
{
$query .= ' WHERE ' . $statementColumn . ' ' . ( $statementReverse ? 'NOT ' : '' ) . 'IN(' . $where->query . ')';
}
else
{
$query .= ' JOIN (' . $where->query . ') d ON ' . $statementColumn[0] . ' ' . ( $statementReverse ? 'NOT ' : '' ) . 'IN(d.' . $statementColumn[1] . ')';
}
$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.
'auto_increment'=> TRUE, // (Optional) Will specify auto_increment. 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 <a href='http://dev.mysql.com/doc/refman/5.1/en/create-table.html'>MySQL CREATE TABLE syntax</a>
* @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', 'REAL', 'DOUBLE', 'FLOAT', 'DECIMAL', '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( 'DECIMAL', 'NUMERIC' ) ) and isset( $data['decimals'] ) )
{
$definition .= ',' . $data['decimals'];
}
$definition .= ') ';
}
/* Numeric types can be UNSIGNED */
if( \in_array( mb_strtoupper( $data['type'] ), array( 'TINYINT', 'SMALLINT', 'MEDIUMINT', 'INT', 'INTEGER', 'BIGINT', 'NUMERIC' ) ) )
{
if( isset( $data['unsigned'] ) and $data['unsigned'] === TRUE )
{
$definition .= 'UNSIGNED ';
}
}
/* 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 ) . ') ';
}
/* 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 <a href='http://dev.mysql.com/doc/refman/5.1/en/create-index.html'>MySQL CREATE INDEX syntax</a>
* @see 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 )
{
/* Make sure we're connected */
$this->checkConnection( TRUE );
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 )
{
/* Make sure we're connected */
$this->checkConnection( TRUE );
return $this->query( "CREATE TABLE `{$this->prefix}{$newTableName}` LIKE `{$this->prefix}{$table}`" );
}
/**
* Create Table Query
*
* @see <a href='http://dev.mysql.com/doc/refman/5.1/en/create-table.html'>MySQL CREATE TABLE syntax</a>
* @see compileColumnDefinition
* @see 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 <a href='http://dev.mysql.com/doc/refman/5.1/en/rename-table.html'>Rename Table</a>
* @param string $oldName The current table name
* @param string $newName The new name
* @return void
* @see <a href='http://stackoverflow.com/questions/12856783/best-practice-with-mysql-innodb-to-rename-huge-table-when-table-with-same-name-a'>Renaming huge InnoDB tables</a>
* @see <a href='http://www.percona.com/blog/2011/02/03/performance-problem-with-innodb-and-drop-table/'>Performance problem dropping huge InnoDB tables</a>
* @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 <a href='http://dev.mysql.com/doc/refman/5.1/en/drop-table.html'>DROP TABLE Syntax</a>
* @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 database tables
*
* @param string|NULL $prefix Optional table prefix to filter by
* @return array
*/
public function getTables( $prefix=NULL )
{
$query = $this->query( "SHOW TABLES" );
$tables = array();
while ( $row = $query->fetch_assoc() )
{
$name = array_pop($row);
if ( $prefix === NULL OR mb_substr( $name, 0, \strlen( $prefix ) ) === $prefix )
{
$tables[] = $name;
}
}
return $tables;
}
/**
* Get the table definition for an existing table
*
* @see 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 );
/* Allow NULL? */
$columnDefinition['allow_null'] = ( $row['Null'] === 'YES' );
/* Default value */
$columnDefinition['default'] = $row['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 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 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 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 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 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
* @link in
More efficient equivilant for columns that do not contain comma-delimited lists
* @endlink
*/
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
* @link findInSet
For columns that contain comma-delimited lists
* @endlink
*/
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 ) . "'";
}
else if( $i instanceof \IPS\Db\Select )
{
$in[] = (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';
}
}
/**
* Generates a WHERE clause to perform a LIKE search
*
* @param string|array $column The column(s) we are searching (multiple columns are searched as an OR)
* @param string $string The string we are searching for
* @param bool $escape Whether or not to escape wildcards in the search string
* @param bool $trailingWildcard Add a wildcard to the end of the string
* @param bool $leadingWildcard Add a wildcard to the beginning of the string (note that database indexes cannot be used in this case)
* @param bool $reverse Perform a NOT LIKE query instead of a LIKE query
* @return array
*/
public function like( $column, $string, $escape=TRUE, $trailingWildcard=TRUE, $leadingWildcard=FALSE, $reverse=FALSE )
{
if( $escape === TRUE )
{
$string = str_replace( array( '%', '_' ), array( '\%', '\_' ), $string );
}
if( !\is_array( $column ) )
{
$column = array( $column );
}
$_not = $reverse ? 'NOT ' : '';
$searchClause = array();
if( $trailingWildcard === TRUE AND $leadingWildcard === TRUE )
{
foreach( $column as $_column )
{
$searchClause[] = "{$_column} {$_not}LIKE CONCAT( '%', ?, '%' )";
}
}
elseif( $trailingWildcard === TRUE )
{
foreach( $column as $_column )
{
$searchClause[] = "{$_column} {$_not}LIKE CONCAT( ?, '%' )";
}
}
elseif( $leadingWildcard === TRUE )
{
foreach( $column as $_column )
{
$searchClause[] = "{$_column} {$_not}LIKE CONCAT( '%', ? )";
}
}
else
{
foreach( $column as $_column )
{
$searchClause[] = "{$_column} {$_not}LIKE ?";
}
}
return array_merge( array( implode( ' OR ', $searchClause ) ), array_fill( 1, \count( $searchClause ), $string ) );
}
/**
* 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
* @param string $prefix Column prefix (optional)
* @return string
* @throws \InvalidArgumentException
*/
public function bitwiseWhere( $definition, $key, $value=TRUE, $prefix=NULL )
{
$operator = $value ? '& ' : '& ~';
foreach ( $definition as $column => $keys )
{
if ( isset( $keys[ $key ] ) )
{
$column = $prefix ? $prefix . $column : $column;
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;
}
}
}
}
$decimalTypes = array( 'DECIMAL' );
$lengthTypes = array( 'CHAR', 'VARCHAR', 'BINARY', 'VARBINARY', 'DECIMAL', 'FLOAT', 'BIT' );
foreach ( $definition['columns'] as $k => $c )
{
if( !\in_array( $c['type'], $decimalTypes ) )
{
if( array_key_exists( 'decimals', $c ) )
{
unset( $definition['columns'][ $k ]['decimals'] );
}
}
else
{
if( !array_key_exists( 'decimals', $c ) )
{
$definition['columns'][ $k ]['decimals'] = null;
}
else
{
$definition['columns'][ $k ]['decimals'] = (int) $definition['columns'][ $k ]['decimals'];
}
}
if( !\in_array( $c['type'], $lengthTypes ) )
{
if( array_key_exists( 'length', $c ) )
{
unset( $definition['columns'][ $k ]['length'] );
}
}
else
{
if( !array_key_exists( 'length', $c ) )
{
$definition['columns'][ $k ]['length'] = null;
}
else
{
$definition['columns'][ $k ]['length'] = (int) $definition['columns'][ $k ]['length'];
}
}
if ( !isset( $c['values'] ) )
{
$definition['columns'][ $k ]['values'] = array();
}
if ( $c['type'] === 'BIT' )
{
if( \is_null( $c['default'] ) )
{
$definition['columns'][ $k ]['default'] = NULL;
}
elseif( mb_strpos( $c['default'], 'b' ) === 0 )
{
$definition['columns'][ $k ]['default'] = $c['default'];
}
else
{
$definition['columns'][ $k ]['default'] = "b'{$c['default']}'";
}
}
ksort( $definition['columns'][ $k ] );
}
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' ) ) ) )
{
if( \is_numeric( $data['default'] ) )
{
$definition['columns'][ $name ]['default'] = \intval( $data['default'] );
}
/* Length is no longer supported */
if( isset( $data['length'] ) )
{
unset( $definition['columns'][ $name ]['length'] );
}
}
/* These are legacy things we no longer support as MySQL 8 has deprecated the functionality */
if( isset( $data['zerofill'] ) )
{
unset( $definition['columns'][ $name ]['zerofill'] );
}
if( isset( $data['binary'] ) )
{
unset( $definition['columns'][ $name ]['binary'] );
}
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;
/* We use 4 for utf8mb4 to prevent issues if the client attempts to switch or forgets to mark conf_global.php that utf8mb4 is used */
$multiplier = 4;
$needsFixing = array();
$maxLen = 1000;
if ( ( ! isset( $definition['engine'] ) OR mb_strtolower( $definition['engine'] ) == 'innodb' ) and isset( $definition['indexes'] ) )
{
/* Only set/change the engine if it is not already innodb - don't change innodb to myisam */
if( ( ! isset( $definition['engine'] ) OR mb_strtolower( $definition['engine'] ) != 'innodb' ) )
{
$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 )
{
if( isset( $index['length'][ $i ] ) )
{
$thisLength = $index['length'][ $i ];
}
elseif( (int) $definition['columns'][ $column ]['length'] or empty( $definition['columns'][ $column ]['length'] ) )
{
$thisLength = (int) $definition['columns'][ $column ]['length'];
}
else
{
$thisLength = 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;
}
}
if ( ( $length * $multiplier > $maxLen ) or $hasText )
{
foreach( $index['columns'] as $i => $column )
{
if( isset( $index['length'][ $i ] ) )
{
$thisLength = $index['length'][ $i ];
}
elseif( (int) $definition['columns'][ $column ]['length'] or empty( $definition['columns'][ $column ]['length'] ) )
{
$thisLength = (int) $definition['columns'][ $column ]['length'];
}
else
{
$thisLength = 250;
}
/* If this is a datetime column, the length will top out at 8 bytes max, so just use 8 as our limitation...indexing datetime columns is fairly rare for us anyways */
if ( \in_array( mb_strtoupper( $definition['columns'][ $column ]['type'] ), array_keys( static::$dataTypes['database_column_type_datetime'] ) ) )
{
$thisLength = 8;
}
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 non-strings. */
foreach( $i as $x => $vals )
{
if ( !\in_array( mb_strtoupper( $vals[2] ), array_keys( static::$dataTypes['database_column_type_string'] ) ) )
{
$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_string'] ) ) )
{
/* 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. */
$vals[1] = NULL;
$i[ $x ] = $vals;
continue;
}
$vals[1] = floor( $vals[1] * $reduceEachBy );
$i[ $x ] = $vals;
}
}
foreach( $i as $x => $vals )
{
if ( !isset( $definition['columns'][ $definition['indexes'][ $key ]['columns'][ $x ] ]['length'] ) OR ( $definition['columns'][ $definition['indexes'][ $key ]['columns'][ $x ] ]['length'] != $vals[1] AND \in_array( mb_strtoupper( $vals[2] ), array_keys( static::$dataTypes['database_column_type_string'] ) ) ) )
{
$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}" ) ) );
}
/**
* @brief Cached table data
*/
public $cachedTableData = array();
/**
* Is it recommended to run a query manually?
*
* @param string $tableName Database table to work with
* @return bool
* @note Constants \IPSUPGRADE_MANUAL_THRESHOLD and \IPS\UPGRADE_LARGE_TABLE_SIZE can be defined in constants.php
*/
public function recommendManualQuery( $tableName )
{
/* Does the table even exist? */
if( !$this->checkForTable( $tableName ) )
{
return FALSE;
}
/* Have we gathered the table data yet? */
if( !isset( $this->cachedTableData[ $tableName ] ) )
{
$this->cachedTableData[ $tableName ] = array( 'rows' => 0, 'size' => 0 );
$this->cachedTableData[ $tableName ]['rows'] = $this->select( 'count(*)', $tableName )->first();
$result = $this->forceQuery( "SHOW TABLE STATUS WHERE name LIKE '" . $this->prefix . $tableName . "'" );
while( $data = $result->fetch_assoc() )
{
$this->cachedTableData[ $tableName ]['size'] = $data['Data_length'];
}
}
/* Now determine if we're over our limits and return appropriately */
if( $this->cachedTableData[ $tableName ]['rows'] > \IPS\UPGRADE_MANUAL_THRESHOLD )
{
return TRUE;
}
if( $this->cachedTableData[ $tableName ]['size'] > \IPS\UPGRADE_LARGE_TABLE_SIZE )
{
return TRUE;
}
return FALSE;
}
/**
* 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 )
{
$b = ( $b instanceof \IPS\Db\Select ) ? (string) $b : $b;
$query = preg_replace( '/\?/', var_export( $b, TRUE ), $query, 1 );
}
}
return $query;
}
}