3012 lines
82 KiB
PHP
3012 lines
82 KiB
PHP
<?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 */
|
||
|
||
use InvalidArgumentException;
|
||
use IPS\Db\Exception;
|
||
use IPS\Db\Select;
|
||
use mysqli;
|
||
use mysqli_result;
|
||
use mysqli_stmt;
|
||
use OutOfRangeException;
|
||
use function count;
|
||
use function debug_backtrace;
|
||
use function defined;
|
||
use function floatval;
|
||
use function gettype;
|
||
use function in_array;
|
||
use function intval;
|
||
use function is_array;
|
||
use function is_float;
|
||
use function is_int;
|
||
use function is_null;
|
||
use function is_numeric;
|
||
use function is_object;
|
||
use function is_string;
|
||
use function mb_strtolower;
|
||
use function mysqli_report;
|
||
use function str_contains;
|
||
use function strlen;
|
||
use function strtolower;
|
||
use function substr;
|
||
use const DEBUG_BACKTRACE_IGNORE_ARGS;
|
||
|
||
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
|
||
{
|
||
header( ( $_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;
|
||
const ALLOW_INCDEC_VALUES = 4;
|
||
|
||
/**
|
||
* @brief Datatypes
|
||
*/
|
||
public static array $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 array $multitons;
|
||
|
||
/**
|
||
* @brief Our identifier
|
||
*/
|
||
public ?string $identifier = null;
|
||
|
||
/**
|
||
* @brief Stored connection details so we can connect on-demand
|
||
*/
|
||
protected array $connectionDetails = array();
|
||
|
||
/**
|
||
* @brief Track if we've connected
|
||
*/
|
||
protected array $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 array|null $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, bool $utf8mb4 = true, string $prefix = '', array $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::__construct();
|
||
}
|
||
|
||
/**
|
||
* Get instance
|
||
*
|
||
* @param string|null $identifier Identifier
|
||
* @param array $connectionSettings Connection settings (use when initiating a new connection)
|
||
* @return Db
|
||
*/
|
||
public static function i( ?string $identifier=NULL, array $connectionSettings=array() ) : Db
|
||
{
|
||
/* 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( SITE_FILES_PATH . '/conf_global.php' );
|
||
if (CIC2)
|
||
{
|
||
$INFO['sql_pass'] = $_SERVER['IPS_CLOUD2_DBPASS'];
|
||
$INFO['sql_read_pass'] = $_SERVER['IPS_CLOUD2_DBPASS'];
|
||
}
|
||
$connectionSettings = $INFO ?? array();
|
||
}
|
||
|
||
$readDatabase = NULL;
|
||
|
||
/* Read/Write Separation? */
|
||
if ( isset( $connectionSettings['sql_read_host'] ) and 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( bool $read=FALSE ): string
|
||
{
|
||
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( bool $read=FALSE ): mysqli
|
||
{
|
||
/* 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 Exception( $error, $errno );
|
||
}
|
||
|
||
/* Enable strict mode for IN_DEV */
|
||
if (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( bool $read=FALSE ) : void
|
||
{
|
||
/* 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 string $charset = 'utf8mb4';
|
||
|
||
/**
|
||
* @brief Collation
|
||
*/
|
||
public string $collation = 'utf8mb4_unicode_ci';
|
||
|
||
/**
|
||
* @brief Binary Collation
|
||
*/
|
||
public string $binaryCollation = 'utf8mb4_bin';
|
||
|
||
/**
|
||
* @brief Table Prefix
|
||
*/
|
||
public string $prefix = '';
|
||
|
||
/**
|
||
* @brief Query log
|
||
*/
|
||
public array $log = array();
|
||
|
||
/**
|
||
* @brief Return the query instead of executing it
|
||
* @note Only designed to work with methods that call query() vs prepared statements
|
||
*/
|
||
public bool $returnQuery = FALSE;
|
||
|
||
/**
|
||
* @brief MySQLi object for reading, if using read/write separation
|
||
*/
|
||
protected ?Mysqli $reader = NULL;
|
||
|
||
/**
|
||
* @brief Read/Write Separation Enabled
|
||
* @todo This is hacky. Do it properly later
|
||
*/
|
||
public bool $readWriteSeparation = TRUE;
|
||
|
||
/**
|
||
* @var string
|
||
*/
|
||
protected string $lastCompiledQuery = '';
|
||
|
||
/**
|
||
* Compile the query and return it instead of executing
|
||
*
|
||
* @param string $method
|
||
* @param array $params
|
||
* @return string
|
||
*/
|
||
public function returnQuery( string $method, array $params ) : string
|
||
{
|
||
$this->returnQuery = true;
|
||
$this->$method( ...$params );
|
||
return $this->lastCompiledQuery;
|
||
}
|
||
|
||
/**
|
||
* Run a query
|
||
*
|
||
* @param string $query The query
|
||
* @param int $result_mode
|
||
* @param bool $read If TRUE and read/write separation is in use, will use the "read" connection
|
||
* @return mysqli_result|bool
|
||
* @see <a href="http://uk1.php.net/manual/en/mysqli.query.php">mysqli::query</a>
|
||
*/
|
||
public function query( string $query, int $result_mode = MYSQLI_STORE_RESULT, bool $read=TRUE ): mysqli_result|bool
|
||
{
|
||
/* Store the compiled query */
|
||
$this->lastCompiledQuery = $query;
|
||
|
||
/* 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 (QUERY_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 Exception( $this->reader->error, $this->reader->errno );
|
||
}
|
||
}
|
||
else
|
||
{
|
||
$return = parent::query( $query );
|
||
if ( $return === FALSE )
|
||
{
|
||
throw new 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 bool|mysqli_result
|
||
* @throws Exception
|
||
*@see <a href="http://uk1.php.net/manual/en/mysqli.query.php">mysqli::query</a>
|
||
*/
|
||
public function forceQuery( string $query, bool $log=TRUE, bool $read=FALSE ): bool|mysqli_result
|
||
{
|
||
$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|string
|
||
*/
|
||
public function preparedQuery( string $query, array $_binds, bool $read=FALSE ): string|mysqli_stmt
|
||
{
|
||
/* 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 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 (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 = "/*" . Settings::i()->sql_database . "::" . 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 Exception( $this->reader->error, $this->reader->errno, NULL, $queryForLog, $binds );
|
||
}
|
||
}
|
||
else
|
||
{
|
||
$stmt = parent::prepare( $query );
|
||
|
||
if( $stmt === FALSE )
|
||
{
|
||
throw new 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 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|null $server Will be "read" or "write" to indicate which server was (or would be) used in read/write separation
|
||
* @return void
|
||
*/
|
||
protected function log( string $logQuery, string $server=NULL ) : void
|
||
{
|
||
/* Fix the trace a bit */
|
||
$trace = array();
|
||
$_debug = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS );
|
||
foreach( $_debug as $idx => $data )
|
||
{
|
||
if ( isset( $data['class'] ) and str_contains( $data['class'], 'IPS\Db' ) )
|
||
{
|
||
continue;
|
||
}
|
||
|
||
foreach( array( 'file', 'line', 'function', 'class' ) as $field )
|
||
{
|
||
if ( isset( $data[ $field ] ) )
|
||
{
|
||
$trace[ $idx ][ $field ] = $data[ $field ];
|
||
}
|
||
}
|
||
}
|
||
|
||
$this->log[] = array(
|
||
'query' => $logQuery,
|
||
'server' => $server,
|
||
'backtrace' => $trace,
|
||
'extra' => NULL,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Build SELECT statement
|
||
*
|
||
* @param array|string $columns The columns (as an array) to select or an expression
|
||
* @param array|string|Select $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 int|array|null $limit Rows to fetch or array( offset, limit )
|
||
* @param array|string|null $group Column(s) to GROUP BY
|
||
* @param array|string|null $having HAVING clause (same format as WHERE clause)
|
||
* @param int $flags Bitwise flags
|
||
* @return Select
|
||
* @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)
|
||
*/
|
||
public function select( array|string $columns, array|string|Select $table, array|string $where=NULL, string $order=NULL, int|array $limit=NULL, array|string $group=NULL, array|string $having=NULL, int $flags=0 ): Select
|
||
{
|
||
$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 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 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 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 int|array|null $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 Db|Select
|
||
*/
|
||
public function union( array $selects, ?string $order, int|array|null $limit, ?string $group=NULL, ?bool $unionAll=FALSE, int $flags=0, array|string $where=NULL, string $querySelect='*' ): Select|Db
|
||
{
|
||
/* 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 Select( $query, $binds, $this );
|
||
$return->isUnion = TRUE;
|
||
return $return;
|
||
}
|
||
|
||
/**
|
||
* Run INSERT statement and return insert ID
|
||
*
|
||
* @param string $table Table name
|
||
* @param array|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?
|
||
* @return int|string|mysqli_stmt
|
||
* @see <a href='http://dev.mysql.com/doc/refman/5.1/en/insert.html'>INSERT Syntax</a>
|
||
* @see replace
|
||
*/
|
||
public function insert( string $table, Select|array $set, bool $odkUpdate=FALSE, bool $ignoreErrors=FALSE ): int|string|mysqli_stmt
|
||
{
|
||
/* 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
|
||
*
|
||
* @param string $table Table name
|
||
* @param array|Select $set Values to insert
|
||
* @param bool $getInsertId If TRUE, returns the insert ID rather than the number of affected rows
|
||
* @return int|string|mysqli_stmt
|
||
* @see <a href='http://dev.mysql.com/doc/refman/5.1/en/replace.html'>REPLACE Syntax</a>
|
||
*/
|
||
public function replace( string $table, array|Select $set, bool $getInsertId=FALSE ): int|string|mysqli_stmt
|
||
{
|
||
/* 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 $string The string to be escaped.
|
||
* @return string An escaped string.
|
||
*/
|
||
public function real_escape_string( string $string ): string
|
||
{
|
||
/* Make sure we're connected */
|
||
$this->checkConnection(TRUE);
|
||
|
||
return $this->connectionDetails['readDatabase'] ? $this->reader->real_escape_string( $string ) : parent::real_escape_string( $string );
|
||
}
|
||
|
||
/**
|
||
* 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 $string The string to be escaped.
|
||
* @return string An escaped string.
|
||
*/
|
||
public function escape_string( string $string ): string
|
||
{
|
||
/* Make sure we're connected */
|
||
$this->checkConnection(TRUE);
|
||
|
||
return $this->connectionDetails['readDatabase'] ? $this->reader->escape_string( $string ) : parent::escape_string( $string );
|
||
}
|
||
|
||
/**
|
||
* Build the replace or insert into query
|
||
*
|
||
* @param string $type INSERT|REPLACE
|
||
* @param string $table Table name
|
||
* @param array|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( string $type, string $table, Select|array $set ): array
|
||
{
|
||
$columns = NULL;
|
||
|
||
/* Is a statement? */
|
||
if ( $set instanceof Select )
|
||
{
|
||
$query = "{$type} INTO `{$this->prefix}{$table}` " . $set->query;
|
||
$binds = $set->binds;
|
||
}
|
||
elseif ( count( $set ) == 2 and isset( $set[1] ) and $set[1] instanceof 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 */
|
||
$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
|
||
*
|
||
* @param array|string $table Table Name, or array( Table Name => Identifier )
|
||
* @param array|string $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
|
||
* @return int|string|mysqli_stmt
|
||
* @li \IPS\Db::LOW_PRIORITY Will use LOW_PRIORITY
|
||
* @li \IPS\Db::IGNORE Will use IGNORE
|
||
* @see <a href='http://dev.mysql.com/doc/refman/5.1/en/update.html'>UPDATE Syntax</a>
|
||
*/
|
||
public function update( array|string $table, array|string $set, mixed $where='', array $joins=array(), int|array $limit=NULL, int $flags=0 ): int|string|mysqli_stmt
|
||
{
|
||
$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 )
|
||
{
|
||
if ( $flags & static::ALLOW_INCDEC_VALUES and str_contains( $v, '`' ) )
|
||
{
|
||
$_set[] = "`{$k}`={$v}";
|
||
}
|
||
else
|
||
{
|
||
$_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
|
||
*
|
||
* @param array|string $table Table Name or array of table names
|
||
* @param mixed $where WHERE clause (see \IPS\Db::compileWhereClause for details)
|
||
* @param mixed|null $order ORDER BY clause
|
||
* @param mixed|null $limit LIMIT clause (see \IPS\Db::select for details)
|
||
* @param mixed|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 Select|string|mysqli_stmt
|
||
* @see <a href='http://dev.mysql.com/doc/refman/5.1/en/delete.html'>DELETE Syntax</a>
|
||
*/
|
||
public function delete( array|string $table, mixed $where=NULL, mixed $order=NULL, mixed $limit=NULL, mixed $statementColumn=NULL, string $deleteWhat='', bool $statementReverse=FALSE ): Select|string|mysqli_stmt
|
||
{
|
||
/* 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 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 array|string|null $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( array|string|null $data ): array
|
||
{
|
||
$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 array|int $data Rows to fetch or array( offset, limit )
|
||
* @return string
|
||
*/
|
||
public function compileLimitClause( array|int $data ): string
|
||
{
|
||
$limit = NULL;
|
||
if( is_array( $data ) )
|
||
{
|
||
$offset = intval( $data[0] );
|
||
$limit = intval( $data[1] );
|
||
}
|
||
else
|
||
{
|
||
$offset = $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
|
||
* @param array $data Column Data (see \IPS\Db::createTable for details)
|
||
* @return string
|
||
* @see <a href='http://dev.mysql.com/doc/refman/5.1/en/create-table.html'>MySQL CREATE TABLE syntax</a>
|
||
*/
|
||
public function compileColumnDefinition( array $data ): string
|
||
{
|
||
/* 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
|
||
* @param array $data Index Data (see \IPS\Db::createTable for details)
|
||
* @return string
|
||
*@see <a href='http://dev.mysql.com/doc/refman/5.1/en/create-index.html'>MySQL CREATE INDEX syntax</a>
|
||
* @see createTable
|
||
*/
|
||
public function compileIndexDefinition( array $data ): string
|
||
{
|
||
$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( string $name ): bool
|
||
{
|
||
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( string $name, string $column ): bool
|
||
{
|
||
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( string $name, string $index ): bool
|
||
{
|
||
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)
|
||
* @return bool|mysqli_result
|
||
*@throws Exception
|
||
*/
|
||
public function createTable( array $data ): mysqli_result|bool
|
||
{
|
||
/* Make sure we're connected */
|
||
$this->checkConnection(TRUE);
|
||
|
||
return $this->query($this->_createTableQuery( $data ), read: false );
|
||
}
|
||
|
||
/**
|
||
* Create copy of table structure
|
||
*
|
||
* @param string $table The table name
|
||
* @param string $newTableName Name of table to create
|
||
* @return bool|mysqli_result
|
||
* @throws Exception
|
||
*/
|
||
public function duplicateTableStructure( string $table, string $newTableName ): mysqli_result|bool
|
||
{
|
||
/* Make sure we're connected */
|
||
$this->checkConnection(TRUE);
|
||
|
||
return $this->query("CREATE TABLE `{$this->prefix}{$newTableName}` LIKE `{$this->prefix}{$table}`", read: false );
|
||
}
|
||
|
||
/**
|
||
* Create Table Query
|
||
*
|
||
* @param array $data Table Definition (see code sample for details)
|
||
* @return string
|
||
*@see compileIndexDefinition
|
||
* @see <a href='http://dev.mysql.com/doc/refman/5.1/en/create-table.html'>MySQL CREATE TABLE syntax</a>
|
||
* @see compileColumnDefinition
|
||
*/
|
||
public function _createTableQuery( array $data ): string
|
||
{
|
||
$data = $this->updateDefinitionIndexLengths( $data );
|
||
$mysqlVersion = Db::i()->server_info;
|
||
|
||
/* Start with a basic CREATE TABLE */
|
||
$query = 'CREATE ';
|
||
if( isset( $data['temporary'] ) and $data['temporary'] )
|
||
{
|
||
$query.= 'TEMPORARY ';
|
||
}
|
||
$query .= 'TABLE ';
|
||
if( isset( $data['if_not_exists'] ) and $data['if_not_exists'] )
|
||
{
|
||
$query.= 'IF NOT EXISTS ';
|
||
}
|
||
|
||
/* Add in our create definition */
|
||
$query .= "`{$this->prefix}{$data['name']}` (\n\t";
|
||
$createDefinitons = array();
|
||
/* @var $data array */
|
||
foreach ( $data['columns'] as $field )
|
||
{
|
||
$createDefinitons[] = $this->compileColumnDefinition( $field );
|
||
}
|
||
if( isset( $data['indexes'] ) )
|
||
{
|
||
foreach ( $data['indexes'] as $index )
|
||
{
|
||
$createDefinitons[] = $this->compileIndexDefinition( $index );
|
||
}
|
||
}
|
||
$query .= implode( ",\n\t", $createDefinitons );
|
||
$query .= "\n)\n";
|
||
|
||
/* Force to InnoDB */
|
||
$query .= "ENGINE InnoDB ";
|
||
|
||
/* 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
|
||
*
|
||
* @param string $oldName The current table name
|
||
* @param string $newName The new name
|
||
* @return void
|
||
* @see <a href='http://dev.mysql.com/doc/refman/5.1/en/rename-table.html'>Rename Table</a>
|
||
* @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( string $oldName, string $newName ) : void
|
||
{
|
||
/* 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, read: false );
|
||
|
||
if( $cleanUp )
|
||
{
|
||
$this->dropTable($newName . '_DROP', TRUE);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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( string $table, string $comment=NULL, string $engine=NULL ) : void
|
||
{
|
||
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 )}' ";
|
||
}
|
||
|
||
/* Force InnoDB as the storage engine */
|
||
$query .= "ENGINE=InnoDB";
|
||
|
||
$this->query($query, read: false );
|
||
}
|
||
|
||
/**
|
||
* Is InnoDB supported for fulltext indexes?
|
||
*
|
||
* @return bool
|
||
*/
|
||
protected function _innoDbSupportsFulltextIndexes(): bool
|
||
{
|
||
/* 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
|
||
*
|
||
* @param array|string $table Table Name(s)
|
||
* @param bool $ifExists Adds an "IF EXISTS" clause to the query
|
||
* @param bool $temporary Table is temporary?
|
||
* @return bool|mysqli_result
|
||
*@see <a href='http://dev.mysql.com/doc/refman/5.1/en/drop-table.html'>DROP TABLE Syntax</a>
|
||
*/
|
||
public function dropTable( array|string $table, bool $ifExists=FALSE, bool $temporary=FALSE ): bool|mysqli_result
|
||
{
|
||
$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 ) )
|
||
) ),
|
||
read: false
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Get database tables
|
||
*
|
||
* @param string|null $prefix Optional table prefix to filter by
|
||
* @return array
|
||
*/
|
||
public function getTables( string $prefix=NULL ): array
|
||
{
|
||
$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
|
||
*
|
||
* @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 Exception
|
||
*@see createTable
|
||
*/
|
||
public function getTableDefinition( string $table, bool $columnsOnly=FALSE, bool $getCollation=FALSE ): array
|
||
{
|
||
/* 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'],
|
||
'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;
|
||
}
|
||
|
||
/**
|
||
* Alter a table and add multiple columns and indexes
|
||
*
|
||
* @param string $table
|
||
* @param array $columns
|
||
* @param array $indexes
|
||
* @return mysqli_result|bool
|
||
*/
|
||
public function addColumnsAndIndexes( string $table, array $columns=array(), array $indexes=array() ) : mysqli_result|bool
|
||
{
|
||
$query = "ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` ADD( ";
|
||
|
||
$columnClauses = [];
|
||
foreach( $columns as $column )
|
||
{
|
||
$columnClauses[] = $this->compileColumnDefinition( $column );
|
||
}
|
||
$query .= implode( ", ", $columnClauses );
|
||
|
||
/* If we have both columns and indexes, add a comma here */
|
||
if( count( $columns ) and count( $indexes ) )
|
||
{
|
||
$query .= ", ";
|
||
}
|
||
|
||
$indexClauses = [];
|
||
foreach( $indexes as $index )
|
||
{
|
||
$indexClauses[] = $this->compileIndexDefinition( $index );
|
||
}
|
||
|
||
$query .= implode( ", ", $indexClauses );
|
||
$query .= " );";
|
||
|
||
return $this->query( $query, read: false );
|
||
}
|
||
|
||
/**
|
||
* Change multiple columns/indexes in one query
|
||
*
|
||
* @param string $table
|
||
* @param array $columns
|
||
* @param array $indexes
|
||
* @return mysqli_result|bool
|
||
*/
|
||
public function changeColumnsAndIndexes( string $table, array $columns=array() ) : mysqli_result|bool
|
||
{
|
||
$query = "ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` ";
|
||
|
||
$columnClauses = [];
|
||
foreach( $columns as $columnName => $newColumnDefinition )
|
||
{
|
||
$columnClauses[] = "CHANGE COLUMN `{$columnName}` " . $this->compileColumnDefinition( $newColumnDefinition );
|
||
}
|
||
$query .= implode( ", ", $columnClauses );
|
||
$query .= ";";
|
||
|
||
return $this->query( $query, read: false );
|
||
}
|
||
|
||
/**
|
||
* Add column to table in database
|
||
*
|
||
* @param string $table Table name
|
||
* @param array $definition Column Definition (see \IPS\Db::compileColumnDefinition for details)
|
||
* @return bool|mysqli_result
|
||
*@see compileColumnDefinition
|
||
*/
|
||
public function addColumn(string $table, array $definition ): mysqli_result|bool
|
||
{
|
||
return $this->query("ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` ADD COLUMN {$this->compileColumnDefinition( $definition )}", MYSQLI_STORE_RESULT, false );
|
||
}
|
||
|
||
/**
|
||
* Modify an existing column
|
||
*
|
||
* @param string $table Table name
|
||
* @param string $column Column name
|
||
* @param array $definition New column definition (see \IPS\Db::compileColumnDefinition for details)
|
||
* @return bool|mysqli_result
|
||
*@see compileColumnDefinition
|
||
*/
|
||
public function changeColumn(string $table, string $column, array $definition ): mysqli_result|bool
|
||
{
|
||
return $this->query("ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` CHANGE COLUMN `{$this->escape_string( $column )}` {$this->compileColumnDefinition( $definition )}", MYSQLI_STORE_RESULT, false );
|
||
}
|
||
|
||
/**
|
||
* Drop a column
|
||
*
|
||
* @param string $table Table name
|
||
* @param array|string $column Column name
|
||
* @return bool|mysqli_result
|
||
*/
|
||
public function dropColumn( string $table, array|string $column ) : bool|mysqli_result
|
||
{
|
||
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};", MYSQLI_STORE_RESULT, false );
|
||
}
|
||
|
||
/**
|
||
* Add index to table in database
|
||
*
|
||
* @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
|
||
*@see compileIndexDefinition
|
||
*/
|
||
public function addIndex( string $table, array $definition, bool $discardDuplicates=TRUE ) : void
|
||
{
|
||
/* 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", Db::i()->select('*', $table), FALSE, TRUE);
|
||
$this->dropTable($table);
|
||
$this->renameTable( "{$table}_temp", $table );
|
||
}
|
||
/* Otherwise just do it normally */
|
||
else
|
||
{
|
||
$this->query("ALTER TABLE `{$this->prefix}{$this->escape_string( $table )}` {$this->buildIndex( $table, $definition )}", MYSQLI_STORE_RESULT, false );
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Modify an existing index
|
||
*
|
||
* @param string $table Table name
|
||
* @param string $index Index name
|
||
* @param array $definition New index definition (see \IPS\Db::compileIndexDefinition for details)
|
||
* @return mixed
|
||
*@see compileIndexDefinition
|
||
*/
|
||
public function changeIndex( string $table, string $index, array $definition ): mixed
|
||
{
|
||
$returnQuery = $this->returnQuery;
|
||
$return = NULL;
|
||
|
||
if( $this->checkForIndex( $table, $index ) )
|
||
{
|
||
$this->dropIndex( $table, $index );
|
||
|
||
if( $returnQuery === TRUE )
|
||
{
|
||
$return = $this->lastCompiledQuery;
|
||
}
|
||
}
|
||
|
||
if ( $returnQuery )
|
||
{
|
||
$this->returnQuery = TRUE;
|
||
}
|
||
|
||
$this->addIndex( $table, $definition );
|
||
|
||
if( $returnQuery === TRUE )
|
||
{
|
||
$this->returnQuery = FALSE;
|
||
$this->lastCompiledQuery = $return . $this->lastCompiledQuery; // prepend the drop index first
|
||
$return .= $this->lastCompiledQuery;
|
||
|
||
return $return;
|
||
}
|
||
|
||
return $return;
|
||
}
|
||
|
||
/**
|
||
* Build an index query for add/change
|
||
*
|
||
* @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 string
|
||
*@see compileIndexDefinition
|
||
*/
|
||
public function buildIndex(string $table, array $definition, array $data=NULL ): string
|
||
{
|
||
$indexName = $definition['name'];
|
||
|
||
if ( $data === NULL )
|
||
{
|
||
$data = $this->getTableDefinition($table, FALSE, TRUE);
|
||
}
|
||
|
||
/* Add the index to the table definition */
|
||
$data['indexes'][ $indexName ] = $definition;
|
||
|
||
/* Reduce sub_part if required */
|
||
$data = $this->updateDefinitionIndexLengths( $data );
|
||
|
||
/* Force to InnoDB */
|
||
$return = "ENGINE=InnoDB, ";
|
||
|
||
/* Extract the key we want to add */
|
||
$definition = (array) $data['indexes'][ $indexName ];
|
||
|
||
return $return . "ADD {$this->compileIndexDefinition( $definition )}";
|
||
}
|
||
|
||
/**
|
||
* Drop an index
|
||
*
|
||
* @param string $table Table name
|
||
* @param array|string $index Column name
|
||
* @return string|int|bool|mysqli_result
|
||
*/
|
||
public function dropIndex( string $table, array|string $index ): string|int|bool|mysqli_result
|
||
{
|
||
$index = ( is_array( $index ) ) ? $index : array( $index );
|
||
|
||
$indexes = array();
|
||
|
||
if( 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};", MYSQLI_STORE_RESULT, false );
|
||
}
|
||
else
|
||
{
|
||
/* Even if we do not run a query here, we need to reset this */
|
||
Db::i()->returnQuery = FALSE;
|
||
}
|
||
|
||
return $return;
|
||
}
|
||
catch( 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( string $column, array $values, bool $reverse=FALSE ): string
|
||
{
|
||
$where = array();
|
||
|
||
if( !empty( $values ) )
|
||
{
|
||
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|Select $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( string $column, array|Select $values, bool $reverse=FALSE ): string
|
||
{
|
||
$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 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 array|string $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( array|string $column, string $string, bool $escape=TRUE, bool $trailingWildcard=TRUE, bool $leadingWildcard=FALSE, bool $reverse=FALSE ): array
|
||
{
|
||
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|null $prefix Column prefix (optional)
|
||
* @return string
|
||
* @throws InvalidArgumentException
|
||
*/
|
||
public function bitwiseWhere( array $definition, string $key, bool $value=TRUE, string $prefix=NULL ): string
|
||
{
|
||
$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( array|string $data ): array
|
||
{
|
||
$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( array|string $data ): array
|
||
{
|
||
$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;
|
||
|
||
/* Force the use of InnoDB */
|
||
$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( $definition['columns'][ $column ] ) )
|
||
{
|
||
continue;
|
||
}
|
||
|
||
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 ( ! empty( $thisLength ) or $isText )
|
||
{
|
||
$length += $thisLength;
|
||
}
|
||
}
|
||
|
||
if ( ( $length * $multiplier > $maxLen ) or $hasText )
|
||
{
|
||
foreach( $index['columns'] as $i => $column )
|
||
{
|
||
if ( ! isset( $definition['columns'][ $column ] ) )
|
||
{
|
||
continue;
|
||
}
|
||
|
||
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 ( ! 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 mysqli_result|bool
|
||
*/
|
||
public function createDatabase( string $name ): mysqli_result|bool
|
||
{
|
||
return ( $this->query("CREATE DATABASE ". $this->escape_string( "{$name}" ), MYSQLI_STORE_RESULT, false ) );
|
||
}
|
||
|
||
/**
|
||
* @brief Cached table data
|
||
*/
|
||
public array $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( string $tableName ): bool
|
||
{
|
||
/* Does the table even exist? */
|
||
if( !$this->checkForTable( $tableName ) )
|
||
{
|
||
return FALSE;
|
||
}
|
||
|
||
/* Make sure we have the table information */
|
||
$this->getTableData( $tableName );
|
||
|
||
/* Now determine if we're over our limits and return appropriately */
|
||
if( $this->cachedTableData[ $tableName ]['rows'] > UPGRADE_MANUAL_THRESHOLD)
|
||
{
|
||
return TRUE;
|
||
}
|
||
|
||
if( $this->cachedTableData[ $tableName ]['size'] > UPGRADE_LARGE_TABLE_SIZE)
|
||
{
|
||
return TRUE;
|
||
}
|
||
|
||
return FALSE;
|
||
}
|
||
|
||
/**
|
||
* Return the rows and size for the specified table.
|
||
* Used to get an approximate count for large tables.
|
||
*
|
||
* @param string $tableName
|
||
* @return array
|
||
*/
|
||
public function getTableData( string $tableName ) : array
|
||
{
|
||
/* Have we gathered the table data yet? */
|
||
if( !isset( $this->cachedTableData[ $tableName ] ) )
|
||
{
|
||
$this->cachedTableData[ $tableName ] = array( 'rows' => 0, 'size' => 0 );
|
||
|
||
if ( !CIC )
|
||
{
|
||
/* Outside of Cloud we don't know if it's InnoDB, etc */
|
||
$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'];
|
||
|
||
if ( CIC )
|
||
{
|
||
/* Less accurate but much faster */
|
||
$this->cachedTableData[ $tableName ]['rows'] = $data['Rows'];
|
||
}
|
||
}
|
||
}
|
||
|
||
return $this->cachedTableData[ $tableName ];
|
||
}
|
||
|
||
/**
|
||
* Strip comments from a .sql file
|
||
*
|
||
* @param string $contents Contents from SQL file
|
||
* @return string
|
||
*/
|
||
public static function stripComments( string $contents ): string
|
||
{
|
||
$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|null $query Query
|
||
* @param array $binds Any binds in the query
|
||
* @return string|null
|
||
*/
|
||
public static function _replaceBinds( ?string $query, array $binds ): ?string
|
||
{
|
||
/* Replace ?s with the actual values */
|
||
if( count( $binds ) )
|
||
{
|
||
foreach ( $binds as $b )
|
||
{
|
||
$b = ( $b instanceof Select ) ? (string) $b : $b;
|
||
$query = preg_replace( '/\?/', var_export( $b, TRUE ), $query, 1 );
|
||
}
|
||
}
|
||
|
||
return $query;
|
||
}
|
||
}
|