praiadeseselle/wire/core/WireDatabasePDO.php

1730 lines
46 KiB
PHP
Raw Permalink Normal View History

2022-03-08 15:55:41 +01:00
<?php namespace ProcessWire;
/**
* ProcessWire PDO Database
*
* Serves as a wrapper to PHPs PDO class
*
* ProcessWire 3.x, Copyright 2021 by Ryan Cramer
* https://processwire.com
*
*/
/**
* Database class provides a layer on top of mysqli
*
* #pw-summary All database operations in ProcessWire are performed via this PDO-style database class.
*
* @method void unknownColumnError($column) #pw-internal
* @property bool $debugMode
*
*/
class WireDatabasePDO extends Wire implements WireDatabase {
const operatorTypeComparison = 0;
const operatorTypeBitwise = 1;
const operatorTypeAny = 2;
/**
* Log of all queries performed in this instance
*
* @var array
*
*/
protected $queryLog = array();
/**
* Max queries allowedin the query log (set from $config->dbQueryLogMax)
*
* @var int
*
*/
protected $queryLogMax = 500;
/**
* Whether queries will be logged
*
*/
protected $debugMode = false;
/**
* Cached result from getTables() method
*
* @var array
*
*/
protected $tablesCache = array();
/**
* Data for read-write PDO connection
*
* @var array
*
*/
protected $writer = array(
'pdo' => null,
'init' => false,
'commands' => array(
// commands that rewrite a writable connection
'alter',
'call',
'comment',
'commit',
'create',
'delete',
'drop',
'insert',
'lock',
'merge',
'rename',
'replace',
'rollback',
'savepoint',
'set',
'start',
'truncate',
'unlock',
'update',
)
);
/**
* Data for read-only PDO connection
*
* @var array
*
*/
protected $reader = array(
'pdo' => null,
'has' => false, // is reader available?
'init' => false, // is reader initalized?
'allow' => true, // is reader allowed? (false when in transaction, etc.)
);
/**
* Last used PDO connection
*
* @var null|\PDO
*
*/
protected $pdoLast = null;
/**
* Whether or not our _init() has been called for the current $pdo connection
*
* @var bool
*
*/
protected $init = false;
/**
* Strip 4-byte characters in “quote” and “escapeStr” methods? (only when dbEngine is not utf8mb4)
*
* @var bool
*
*/
protected $stripMB4 = false;
/**
* Lowercase value of $config->dbEngine
*
* @var string
*
*/
protected $engine = '';
/**
* Lowercase value of $config->dbCharset
*
* @var string
*
*/
protected $charset = '';
/**
* Regular comparison operators
*
* @var array
*
*/
protected $comparisonOperators = array('=', '<', '>', '>=', '<=', '<>', '!=');
/**
* Bitwise comparison operators
*
* @var array
*
*/
protected $bitwiseOperators = array('&', '~', '&~', '|', '^', '<<', '>>');
/**
* Substitute variable names according to engine as used by getVariable() method
*
* @var array
*
*/
protected $subVars = array(
'myisam' => array(),
'innodb' => array(
'ft_min_word_len' => 'innodb_ft_min_token_size',
'ft_max_word_len' => 'innodb_ft_max_token_size',
),
);
/**
* PDO connection settings
*
*/
private $pdoConfig = array(
'dsn' => '',
'user' => '',
'pass' => '',
'options' => '',
'reader' => array(
'dsn' => '',
'user' => '',
'pass' => '',
'options' => '',
),
);
/**
* Cached values from getVariable method
*
* @var array associative of name => value
*
*/
protected $variableCache = array();
/**
* Cached InnoDB stopwords (keys are the stopwords and values are irrelevant)
*
* @var array|null Becomes array once loaded
*
*/
protected $stopwordCache = null;
/**
* Create a new PDO instance from ProcessWire $config API variable
*
* If you need to make other PDO connections, just instantiate a new WireDatabasePDO (or native PDO)
* rather than calling this getInstance method.
*
* #pw-internal
*
* @param Config $config
*
* @return WireDatabasePDO
* @throws WireException
*
*/
public static function getInstance(Config $config) {
if(!class_exists('\PDO')) {
throw new WireException('Required PDO class (database) not found - please add PDO support to your PHP.');
}
$username = $config->dbUser;
$password = $config->dbPass;
$charset = $config->dbCharset;
$options = $config->dbOptions;
$reader = $config->dbReader;
$initCommand = str_replace('{charset}', $charset, $config->dbInitCommand);
if(!is_array($options)) $options = array();
if(!isset($options[\PDO::ATTR_ERRMODE])) {
$options[\PDO::ATTR_ERRMODE] = \PDO::ERRMODE_EXCEPTION;
}
if($initCommand && !isset($options[\PDO::MYSQL_ATTR_INIT_COMMAND])) {
$options[\PDO::MYSQL_ATTR_INIT_COMMAND] = $initCommand;
}
$dsnArray = array(
'socket' => $config->dbSocket,
'name' => $config->dbName,
'host' => $config->dbHost,
'port' => $config->dbPort,
);
$data = array(
'dsn' => self::dsn($dsnArray),
'user' => $username,
'pass' => $password,
'options' => $options,
);
if(!empty($reader)) {
if(isset($reader['host']) || isset($reader['socket'])) {
// single reader
$reader['dsn'] = self::dsn(array_merge($dsnArray, $reader));
$reader = array_merge($data, $reader);
$data['reader'] = $reader;
} else {
// multiple readers
$readers = array();
foreach($reader as $r) {
if(empty($r['host']) && empty($r['socket'])) continue;
$r['dsn'] = self::dsn(array_merge($dsnArray, $r));
$readers[] = array_merge($data, $r);
}
$data['reader'] = $readers;
}
}
$database = new WireDatabasePDO($data);
$database->setDebugMode($config->debug);
$config->wire($database);
// $database->_init();
return $database;
}
/**
* Create a PDO DSN string from array
*
* #pw-internal
*
* @param array $options May contain keys: 'name', 'host', 'port', 'socket' (if applies), 'type' (default=mysql)
*
* @return string
* @since 3.0.175
*
*/
static public function dsn(array $options) {
$defaults = array(
'type' => 'mysql',
'socket' => '',
'name' => '',
'host' => '',
'port' => '',
);
$options = array_merge($defaults, $options);
if($options['socket']) {
// if socket is provided ignore $host and $port and use socket instead
$dsn = "mysql:unix_socket=$options[socket];dbname=$options[name];";
} else {
$dsn = "mysql:dbname=$options[name];host=$options[host]";
if($options['port']) $dsn .= ";port=$options[port]";
}
return $dsn;
}
/**
* Construct WireDatabasePDO
*
* ~~~~~
* // The following are required to construct a WireDatabasePDO
* $dsn = 'mysql:dbname=mydb;host=myhost;port=3306';
* $username = 'username';
* $password = 'password';
* $driver_options = []; // optional
*
* // Construct option A
* $db = new WireDatabasePDO($dsn, $username, $password, $driver_options);
*
* // Construct option B
* $db = new WireDatabasePDO([
* 'dsn' => $dsn,
* 'user' => $username,
* 'pass' => $password,
* 'options' => $driver_options, // optional
* 'reader' => [ // optional
* 'dsn' => '…',
*
* ],
*
* ]);
* ~~~~~
*
* #pw-internal
*
* @param string|array $dsn DSN string or (3.0.175+) optionally use array of connection options and omit all remaining arguments.
* @param null $username
* @param null $password
* @param array $driver_options
*
*/
public function __construct($dsn, $username = null, $password = null, array $driver_options = array()) {
parent::__construct();
if(is_array($dsn) && isset($dsn['dsn'])) {
// configuration data provided in $dsn argument array
if($username !== null && empty($dsn['user'])) $dsn['user'] = $username;
if($password !== null && empty($dsn['pass'])) $dsn['pass'] = $password;
if(!isset($dsn['options'])) $dsn['options'] = $driver_options;
$this->pdoConfig = array_merge($this->pdoConfig, $dsn);
if(!empty($this->pdoConfig['reader'])) {
if(!empty($this->pdoConfig['reader']['dsn'])) {
// single reader
$this->reader['has'] = true;
} else if(!empty($this->pdoConfig['reader'][0]['dsn'])) {
// multiple readers
$this->reader['has'] = true;
}
}
} else {
// configuration data in direct arguments
$this->pdoConfig['dsn'] = $dsn;
$this->pdoConfig['user'] = $username;
$this->pdoConfig['pass'] = $password;
$this->pdoConfig['options'] = $driver_options;
}
// $this->pdo();
}
/**
* Additional initialization after DB connection established and Wire instance populated
*
* #pw-internal
*
* @param \PDO|null
*
*/
public function _init($pdo = null) {
if(!$this->isWired()) return;
if($pdo === $this->reader['pdo']) {
if($this->reader['init']) return;
$this->reader['init'] = true;
} else {
if($this->writer['init']) return;
$this->writer['init'] = true;
if($pdo === null) $pdo = $this->writer['pdo'];
}
$config = $this->wire()->config;
if(empty($this->engine)) {
$this->engine = strtolower($config->dbEngine);
$this->charset = strtolower($config->dbCharset);
$this->stripMB4 = $config->dbStripMB4 && $this->charset != 'utf8mb4';
$this->queryLogMax = (int) $config->dbQueryLogMax;
}
if($config->debug && $pdo) {
// custom PDO statement for debug mode
$this->debugMode = true;
$pdo->setAttribute(
\PDO::ATTR_STATEMENT_CLASS,
array(__NAMESPACE__ . "\\WireDatabasePDOStatement", array($this))
);
}
$sqlModes = $config->dbSqlModes;
if(is_array($sqlModes)) {
// ["5.7.0" => "remove:mode1,mode2/add:mode3"]
foreach($sqlModes as $minVersion => $commands) {
if(strpos($commands, '/') !== false) {
$commands = explode('/', $commands);
} else {
$commands = array($commands);
}
foreach($commands as $modes) {
$modes = trim($modes);
if(empty($modes)) continue;
$action = 'set';
if(strpos($modes, ':')) list($action, $modes) = explode(':', $modes);
$this->sqlMode(trim($action), trim($modes), $minVersion, $pdo);
}
}
}
}
/**
* Return the actual current PDO connection instance
*
* If connection is lost, this will restore it automatically.
*
* #pw-group-PDO
*
* @param string|\PDOStatement|null SQL, statement, or statement type (reader or primary) (3.0.175+)
*
* @return \PDO
*
*/
public function pdo($type = null) {
if($type === null) return $this->pdoWriter();
return $this->pdoType($type);
}
/**
* Return read-write (primary) PDO connection
*
* @return \PDO
* @since 3.0.175
*
*/
protected function pdoWriter() {
if(!$this->writer['pdo']) {
$this->writer['init'] = false;
$pdo = new \PDO(
$this->pdoConfig['dsn'],
$this->pdoConfig['user'],
$this->pdoConfig['pass'],
$this->pdoConfig['options']
);
$this->writer['pdo'] = $pdo;
$this->_init($pdo);
} else {
$pdo = $this->writer['pdo'];
}
$this->pdoLast = $pdo;
return $pdo;
}
/**
* Return read-only PDO connection if available or read/write PDO connection if not
*
* @return \PDO
* @since 3.0.175
*
*/
protected function pdoReader() {
if(!$this->allowReader()) return $this->pdoWriter();
if($this->reader['pdo']) {
$pdo = $this->reader['pdo'];
$this->pdoLast = $pdo;
return $pdo;
}
$this->reader['init'] = false;
$lastException = null;
if(isset($this->pdoConfig['reader']['dsn'])) {
// just one reader
$readers = array($this->pdoConfig['reader']);
} else {
// randomly select a reader
$readers = $this->pdoConfig['reader'];
shuffle($readers);
}
do {
// try readers till we find one that gives us a connection
$reader = array_shift($readers);
try {
$pdo = new \PDO($reader['dsn'], $reader['user'], $reader['pass'], $reader['options']);
} catch(\PDOException $e) {
$pdo = null;
$lastException = $e;
}
} while(!$pdo && count($readers));
if(!$pdo) throw $lastException;
$this->reader['pdo'] = $pdo;
$this->_init($pdo);
$this->pdoLast = $pdo;
return $pdo;
}
/**
* Return correct PDO instance type (reader or writer) based on given statement
*
* @param string|\PDOStatement $statement
* @param bool $getName Get name of PDO type rather than instance? (default=false)
* @return \PDO|string
*
*/
protected function pdoType(&$statement, $getName = false) {
$reader = 'reader';
$writer = 'writer';
if(!$this->reader['has']) return $getName ? $writer : $this->pdoWriter();
if($statement === $writer || $statement === $reader) {
$type = $statement;
} else if(!$this->reader['has']) {
$type = $writer;
} else if(!is_string($statement)) {
// PDOStatement or other, always return write
// @todo add support for inspection of PDOStatement
$type = $writer;
} else if(stripos($statement, 'select') === 0) {
$type = $reader;
} else if(stripos($statement, 'insert') === 0) {
$type = $writer;
} else {
$pos = strpos($statement, ' ');
$word = strtolower(($pos ? substr($statement, 0, $pos) : $statement));
if($word === 'set') {
// all 'set' commands are read-only allowed except autocommit and transaction
$word = trim(substr($statement, $pos + 1, 12));
if(stripos($word, 'autocommit') === 0 || stripos($word, 'transaction') === 0) {
$type = $writer;
} else {
$type = $reader;
}
} else if($word === 'lock') {
if(!$getName) $this->allowReader(false);
$type = $writer;
} else if($word === 'unlock') {
if(!$getName) $this->allowReader(true);
$type = $writer;
} else {
$type = in_array($word, $this->writer['commands']) ? $writer : $reader;
}
}
if($type === $reader && !$this->reader['allow']) $type = $writer;
if($getName) return $type;
return $type === 'reader' ? $this->pdoReader() : $this->pdoWriter();
}
/**
* Return last used PDO connection
*
* @return \PDO
* @since 3.0.175
*
*/
protected function pdoLast() {
if($this->pdoLast) {
$pdo = $this->pdoLast;
if($pdo === $this->reader['pdo'] && !$this->reader['allow']) $pdo = null;
} else {
$pdo = null;
}
if($pdo === null) $pdo = $this->pdoWriter();
return $pdo;
}
/**
* Fetch the SQLSTATE associated with the last operation on the statement handle
*
* #pw-group-PDO
*
* @return string
* @link http://php.net/manual/en/pdostatement.errorcode.php
*
*/
public function errorCode() {
return $this->pdoLast()->errorCode();
}
/**
* Fetch extended error information associated with the last operation on the database handle
*
* #pw-group-PDO
*
* @return array
* @link http://php.net/manual/en/pdo.errorinfo.php
*
*/
public function errorInfo() {
return $this->pdoLast()->errorInfo();
}
/**
* Retrieve a database connection attribute
*
* #pw-group-PDO
*
* @param int $attribute
* @return mixed
* @link http://php.net/manual/en/pdo.getattribute.php
*
*/
public function getAttribute($attribute) {
return $this->pdoLast()->getAttribute($attribute);
}
/**
* Sets an attribute on the database handle
*
* #pw-group-PDO
*
* @param int $attribute
* @param mixed $value
* @return bool
* @link http://php.net/manual/en/pdo.setattribute.php
*
*/
public function setAttribute($attribute, $value) {
return $this->pdoLast()->setAttribute($attribute, $value);
}
/**
* Returns the ID of the last inserted row or sequence value
*
* #pw-group-PDO
*
* @param string|null $name
* @return string
* @link http://php.net/manual/en/pdo.lastinsertid.php
*
*/
public function lastInsertId($name = null) {
return $this->pdoWriter()->lastInsertId($name);
}
/**
* Executes an SQL statement, returning a result set as a PDOStatement object
*
* #pw-group-PDO
*
* @param string $statement
* @param string $note
* @return \PDOStatement
* @link http://php.net/manual/en/pdo.query.php
*
*/
public function query($statement, $note = '') {
if($this->debugMode) $this->queryLog($statement, $note);
$pdo = $this->pdoType($statement);
return $pdo->query($statement);
}
/**
* Initiates a transaction
*
* #pw-group-PDO
*
* @return bool
* @link http://php.net/manual/en/pdo.begintransaction.php
*
*/
public function beginTransaction() {
$this->allowReader(false);
return $this->pdoWriter()->beginTransaction();
}
/**
* Checks if inside a transaction
*
* #pw-group-PDO
*
* @return bool
* @link http://php.net/manual/en/pdo.intransaction.php
*
*/
public function inTransaction() {
return (bool) $this->pdoWriter()->inTransaction();
}
/**
* Are transactions available with current DB engine (or table)?
*
* #pw-group-PDO
*
* @param string $table Optionally specify a table to specifically check to that table
* @return bool
*
*/
public function supportsTransaction($table = '') {
$engine = '';
if($table) {
$query = $this->pdoReader()->prepare('SHOW TABLE STATUS WHERE name=:name');
$query->bindValue(':name', $table);
$query->execute();
if($query->rowCount()) {
$row = $query->fetch(\PDO::FETCH_ASSOC);
$engine = empty($row['engine']) ? '' : $row['engine'];
}
$query->closeCursor();
} else {
$engine = $this->engine;
}
return strtoupper($engine) === 'INNODB';
}
/**
* Allow a new transaction to begin right now? (i.e. supported and not already in one)
*
* Returns combined result of supportsTransaction() === true and inTransaction() === false.
*
* #pw-group-PDO
*
* @param string $table Optional table that transaction will be for
* @return bool
* @since 3.0.140
*
*/
public function allowTransaction($table = '') {
return $this->supportsTransaction($table) && !$this->inTransaction();
}
/**
* Commits a transaction
*
* #pw-group-PDO
*
* @return bool
* @link http://php.net/manual/en/pdo.commit.php
*
*/
public function commit() {
$this->allowReader(true);
return $this->pdoWriter()->commit();
}
/**
* Rolls back a transaction
*
* #pw-group-PDO
*
* @return bool
* @link http://php.net/manual/en/pdo.rollback.php
*
*/
public function rollBack() {
$this->allowReader(true);
return $this->pdoWriter()->rollBack();
}
/**
* Get an array of all queries that have been executed thus far
*
* Active in ProcessWire debug mode only
*
* #pw-internal
*
* @deprecated use queryLog() method instead
* @return array
*
*/
static public function getQueryLog() {
/** @var WireDatabasePDO $database */
$database = wire('database');
return $database->queryLog();
}
/**
* Prepare an SQL statement for accepting bound parameters
*
* #pw-group-PDO
*
* @param string $statement
* @param array|string|bool $driver_options Optionally specify one of the following:
* - Boolean true for WireDatabasePDOStatement rather than PDOStatement (also assumed when debug mode is on) 3.0.162+
* - Driver options array
* - or you may specify the $note argument here
* @param string $note Debug notes to save with query in debug mode
* @return \PDOStatement|WireDatabasePDOStatement
* @link http://php.net/manual/en/pdo.prepare.php
*
*/
public function prepare($statement, $driver_options = array(), $note = '') {
if(is_string($driver_options)) {
$note = $driver_options;
$driver_options = array();
} else if($driver_options === true) {
$driver_options = array(
\PDO::ATTR_STATEMENT_CLASS => array(__NAMESPACE__ . "\\WireDatabasePDOStatement", array($this))
);
}
$pdo = $this->reader['has'] ? $this->pdoType($statement) : $this->pdoWriter();
$pdoStatement = $pdo->prepare($statement, $driver_options);
if($this->debugMode) {
if($pdoStatement instanceof WireDatabasePDOStatement) {
/** @var WireDatabasePDOStatement $pdoStatement */
$pdoStatement->setDebugNote($note);
} else {
$this->queryLog($statement, $note);
}
}
return $pdoStatement;
}
/**
* Execute an SQL statement string
*
* If given a PDOStatement, this method behaves the same as the execute() method.
*
* #pw-group-PDO
*
* @param string|\PDOStatement $statement
* @param string $note
* @return bool|int
* @throws \PDOException
* @link http://php.net/manual/en/pdo.exec.php
*
*/
public function exec($statement, $note = '') {
if(is_object($statement) && $statement instanceof \PDOStatement) {
return $this->execute($statement);
}
if($this->debugMode) $this->queryLog($statement, $note);
$pdo = $this->reader['has'] ? $this->pdoType($statement) : $this->pdoWriter();
return $pdo->exec($statement);
}
/**
* Execute a PDO statement, with retry and error handling
*
* Given a PDOStatement ($query) this method will execute the statement and return
* true or false as to whether it was successful.
*
* Unlike other PDO methods, this one (native to ProcessWire) will retry queries
* if they failed due to a lost connection. By default it will retry up to 3 times,
* but you can adjust this number as needed in the arguments.
*
* ~~~~~
* // prepare the query
* $query = $database->prepare("SELECT id, name FROM pages LIMIT 10");
* // you can do the following, rather than native PDO $query->execute();
* $database->execute($query);
* ~~~~~
*
* #pw-group-custom
*
* @param \PDOStatement $query
* @param bool $throw Whether or not to throw exception on query error (default=true)
* @param int $maxTries Deprecated/argument does nothing (was: “Max number of times it will attempt to retry query on error”)
* @return bool True on success, false on failure. Note if you want this, specify $throw=false in your arguments.
* @throws \PDOException
*
*/
public function execute(\PDOStatement $query, $throw = true, $maxTries = 3) {
try {
$result = $query->execute();
} catch(\PDOException $e) {
$result = false;
if($query->errorCode() == '42S22') {
// unknown column error
$errorInfo = $query->errorInfo();
if(preg_match('/[\'"]([_a-z0-9]+\.[_a-z0-9]+)[\'"]/i', $errorInfo[2], $matches)) {
$this->unknownColumnError($matches[1]);
}
}
if($throw) {
throw $e;
} else {
$this->error($e->getMessage());
}
if($maxTries) {} // ignore, argument no longer used
}
return $result;
}
/**
* Hookable method called by execute() method when query encounters an unknown column
*
* #pw-internal
*
* @param string $column Column format tableName.columnName
*
*/
protected function ___unknownColumnError($column) { }
/**
* Log a query, start/stop query logging, or return logged queries
*
* - To log a query, provide the $sql argument containing the query (string).
* - To retrieve the query log, call this method with no arguments.
* - Note the core only populates the query log when `$config->debug` mode is active.
* - Specify boolean true for $sql argument to reset and start query logging (3.0.173+)
* - Specify boolean false for $sql argument to stop query logging (3.0.173+)
*
* #pw-group-custom
*
* @param string $sql Query (string) to log, boolean true to reset/start query logging, boolean false to stop query logging
* @param string $note Any additional debugging notes about the query
* @return array|bool|int Returns query log array, boolean true on success, boolean false if not
*
*/
public function queryLog($sql = '', $note = '') {
if(empty($sql)) return $this->queryLog;
if($sql === true) {
$this->debugMode = true;
$this->queryLog = array();
return true;
} else if($sql === false) {
$this->debugMode = false;
return true;
}
if(!$this->debugMode) return false;
if(count($this->queryLog) > $this->queryLogMax) {
if(isset($this->queryLog['error'])) {
$qty = (int) $this->queryLog['error'];
} else {
$qty = 0;
}
$qty++;
$this->queryLog['error'] = "$qty additional queries omitted because \$config->dbQueryLogMax = $this->queryLogMax";
return false;
} else {
if($this->reader['has']) {
$type = $this->pdoType($sql, true);
$note = trim("$note [$type]");
}
$this->queryLog[] = $sql . ($note ? " -- $note" : "");
return true;
}
}
/**
* Get array of all tables in this database.
*
* Note that this method caches its result unless you specify boolean false for the $allowCache argument.
*
* #pw-group-custom
*
* @param bool $allowCache Specify false if you don't want result to be cached or pulled from cache (default=true)
* @return array Returns array of table names
*
*/
public function getTables($allowCache = true) {
if($allowCache && count($this->tablesCache)) return $this->tablesCache;
$tables = array();
$query = $this->query("SHOW TABLES");
/** @noinspection PhpAssignmentInConditionInspection */
while($col = $query->fetchColumn()) $tables[] = $col;
if($allowCache) $this->tablesCache = $tables;
return $tables;
}
/**
* Get all columns from given table
*
* By default returns array of column names. If verbose option is true then it returns
* an array of arrays, each having 'name', 'type', 'null', 'default', and 'extra' keys,
* indicating the column name, column type, whether it can be null, what its default value
* is, and any extra information, such as whether it is auto_increment. The verbose option
* also makes the return value indexed by column name (associative array).
*
* @param string $table Table name or or `table.column` to get for specific column (when combined with verbose=true)
* @param bool|int|string $verbose Include array of verbose information for each? (default=false)
* - Omit or false (bool) to just get column names.
* - True (bool) or 1 (int) to get a verbose array of information for each column, indexed by column name.
* - 2 (int) to get raw MySQL column information, indexed by column name (added 3.0.182).
* - Column name (string) to get verbose array only for only that column (added 3.0.182).
* @return array
* @since 3.0.180
*
*/
public function getColumns($table, $verbose = false) {
$columns = array();
$getColumn = $verbose && is_string($verbose) ? $verbose : '';
if(strpos($table, '.')) list($table, $getColumn) = explode('.', $table, 2);
$table = $this->escapeTable($table);
$sql = "SHOW COLUMNS FROM $table " . ($getColumn ? 'WHERE Field=:column' : '');
$query = $this->prepare($sql);
if($getColumn) $query->bindValue(':column', $getColumn);
$query->execute();
while($col = $query->fetch(\PDO::FETCH_ASSOC)) {
$name = $col['Field'];
if($verbose === 2) {
$columns[$name] = $col;
} else if($verbose) {
$columns[$name] = array(
'name' => $name,
'type' => $col['Type'],
'null' => (strtoupper($col['Null']) === 'YES' ? true : false),
'default' => $col['Default'],
'extra' => $col['Extra'],
);
} else {
$columns[] = $name;
}
}
$query->closeCursor();
if($getColumn) return isset($columns[$getColumn]) ? $columns[$getColumn] : array();
return $columns;
}
/**
* Get all indexes from given table
*
* By default it returns an array of index names. Specify true for the verbose option to get
* index `name`, `type` and `columns` (array) for each index.
*
* @param string $table Name of table to get indexes for or `table.index` (usually combined with verbose option).
* @param bool|int|string $verbose Include array of verbose information for each? (default=false)
* - Omit or false (bool) to just get index names.
* - True (bool) or 1 (int) to get a verbose array of information for each index, indexed by index name.
* - 2 (int) to get regular PHP array of raw MySQL index information.
* - Index name (string) to get verbose array only for only that index.
* @return array
* @since 3.0.182
*
*/
public function getIndexes($table, $verbose = false) {
$indexes = array();
$getIndex = $verbose && is_string($verbose) ? $verbose : '';
if($verbose === 'primary') $verbose = 'PRIMARY';
if(strpos($table, '.')) list($table, $getIndex) = explode('.', $table, 2);
$table = $this->escapeTable($table);
$sql = "SHOW INDEX FROM `$table` " . ($getIndex ? 'WHERE Key_name=:name' : '');
$query = $this->prepare($sql);
if($getIndex) $query->bindValue(':name', $getIndex);
$query->execute();
while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
$name = $row['Key_name'];
if($verbose === 2) {
$indexes[] = $row;
} else if($verbose) {
if(!isset($indexes[$name])) $indexes[$name] = array(
'name' => $name,
'type' => $row['Index_type'],
'unique' => (((int) $row['Non_unique']) ? false : true),
'columns' => array(),
);
$seq = ((int) $row['Seq_in_index']) - 1;
$indexes[$name]['columns'][$seq] = $row['Column_name'];
} else {
$indexes[] = $name;
}
}
$query->closeCursor();
if($getIndex) return isset($indexes[$getIndex]) ? $indexes[$getIndex] : array();
return $indexes;
}
/**
* Get column(s) or info for given tables primary key/index
*
* By default it returns a string with the column name compromising the primary key, i.e. `col1`.
* If the primary key is multiple columns then it returns a CSV string, like `col1,col2,col3`.
*
* If you specify boolean `true` for the verbose option then it returns an simplified array of
* information about the primary key. If you specify integer `2` then it returns an array of
* raw MySQL SHOW INDEX information.
*
* @param string $table
* @param bool|int $verbose Get array of info rather than column(s) string? (default=false)
* @return string|array
* @since 3.0.182
*
*/
public function getPrimaryKey($table, $verbose = false) {
if($verbose === 2) {
return $this->getIndexes("$table.PRIMARY", 2);
} else if($verbose) {
return $this->getIndexes($table, 'PRIMARY');
} else {
$a = $this->getIndexes($table, 'PRIMARY');
if(empty($a) || empty($a['columns'])) return '';
return implode(',', $a['columns']);
}
}
/**
* Does the given table exist in this database?
*
* #pw-group-custom
*
* @param string $table
* @return bool
* @since 3.0.133
*
*/
public function tableExists($table) {
$query = $this->prepare('SHOW TABLES LIKE ?');
$query->execute(array($table));
$result = $query->fetchColumn();
return !empty($result);
}
/**
* Does the given column exist in given table?
*
* ~~~~~
* // Standard usage:
* if($database->columnExists('pages', 'name')) {
* echo "The pages table has a 'name' column";
* }
*
* // You can also bundle table and column together:
* if($database->columnExists('pages.name')) {
* echo "The pages table has a 'name' column";
* }
*
* $exists = $database->columnExists('pages', 'name', true);
* if($exists) {
* // associative array with indexes: Name, Type, Null, Key, Default, Extra
* echo "The pages table has a 'name' column and here is verbose info: ";
* print_r($exists);
* }
* ~~~~~
*
* #pw-group-custom
*
* @param string $table Specify table name (or table and column name in format "table.column").
* @param string $column Specify column name (or omit or blank string if already specified in $table argument).
* @param bool $getInfo Return array of column info (with type info, etc.) rather than true when exists? (default=false)
* Note that the returned array is raw MySQL values from a SHOW COLUMNS command.
* @return bool|array
* @since 3.0.154
* @throws WireDatabaseException
*
*/
public function columnExists($table, $column = '', $getInfo = false) {
if(strpos($table, '.')) {
list($table, $col) = explode('.', $table, 2);
if(empty($column) || !is_string($column)) $column = $col;
}
if(empty($column)) throw new WireDatabaseException('No column specified');
$exists = false;
$table = $this->escapeTable($table);
try {
$query = $this->prepare("SHOW COLUMNS FROM `$table` WHERE Field=:column");
$query->bindValue(':column', $column, \PDO::PARAM_STR);
$query->execute();
$numRows = (int) $query->rowCount();
if($numRows) $exists = $getInfo ? $query->fetch(\PDO::FETCH_ASSOC) : true;
$query->closeCursor();
} catch(\Exception $e) {
// most likely given table does not exist
$exists = false;
}
return $exists;
}
/**
* Does table have an index with given name?
*
* ~~~~
* // simple index check
* if($database->indexExists('my_table', 'my_index')) {
* // index named my_index exists for my_table
* }
*
* // index check and get array of info if it exists
* $info = $database->indexExists('my_table', 'my_index', true);
* if($info) {
* // info is raw array of information about index from MySQL
* } else {
* // index does not exist
* }
* ~~~~
*
* @param string $table
* @param string $indexName
* @param bool $getInfo Return arrays of index information rather than boolean true? (default=false)
* Note that the verbose arrays are the raw MySQL return values from a SHOW INDEX command.
* @return bool|array Returns one of the following:
* - `false`: if index does not exist (regardless of $getInfo argument).
* - `true`: if index exists and $getInfo argument is omitted or false.
* - `array`: array of arrays with verbose information if index exists and $getInfo argument is true.
* @since 3.0.182
*
*/
public function indexExists($table, $indexName, $getInfo = false) {
$table = $this->escapeTable($table);
$query = $this->prepare("SHOW INDEX FROM `$table` WHERE Key_name=:name");
$query->bindValue(':name', $indexName, \PDO::PARAM_STR);
try {
$query->execute();
$numRows = (int) $query->rowCount();
if($numRows && $getInfo) {
$exists = array();
while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
$exists[] = $row;
}
} else {
$exists = $numRows > 0;
}
$query->closeCursor();
} catch(\Exception $e) {
// most likely given table does not exist
$exists = false;
}
return $exists;
}
/**
* Is the given string a database comparison operator?
*
* #pw-group-custom
*
* ~~~~~
* if($database->isOperator('>=')) {
* // given string is a valid database operator
* } else {
* // not a valid database operator
* }
* ~~~~~
*
* @param string $str 1-2 character operator to test
* @param bool|null|int $operatorType Specify a WireDatabasePDO::operatorType* constant (3.0.162+), or any one of the following (3.0.143+):
* - `NULL`: allow all operators (default value if not specified)
* - `FALSE`: allow only comparison operators
* - `TRUE`: allow only bitwise operators
* @param bool $get Return the operator rather than true, when valid? (default=false) Added 3.0.162
* @return bool True if valid, false if not
*
*/
public function isOperator($str, $operatorType = self::operatorTypeAny, $get = false) {
$len = strlen($str);
if($len > 2 || $len < 1) return false;
if($operatorType === null || $operatorType === self::operatorTypeAny) {
// allow all operators
$operators = array_merge($this->comparisonOperators, $this->bitwiseOperators);
} else if($operatorType === true || $operatorType === self::operatorTypeBitwise) {
// allow only bitwise operators
$operators = $this->bitwiseOperators;
} else {
// self::operatorTypeComparison
$operators = $this->comparisonOperators;
}
if($get) {
$key = array_search($str, $operators, true);
return $key === false ? false : $operators[$key];
} else {
return in_array($str, $operators, true);
}
}
/**
* Is given word a fulltext stopword for database engine?
*
* @param string $word
* @param string $engine DB engine ('myisam' or 'innodb') or omit for current engine
* @return bool
* @since 3.0.160
*
*/
public function isStopword($word, $engine = '') {
$engine = $engine === '' ? $this->engine : strtolower($engine);
if($engine === 'myisam') return DatabaseStopwords::has($word);
if($this->stopwordCache === null) $this->getStopwords($engine, true);
return isset($this->stopwordCache[strtolower($word)]);
}
/**
* Get all fulltext stopwords for database engine
*
* @param string $engine Specify DB engine of "myisam" or "innodb" or omit for current DB engine
* @param bool $flip Return flipped array where stopwords are array keys rather than values? for isset() use (default=false)
* @return array
*
*/
public function getStopwords($engine = '', $flip = false) {
$engine = $engine === '' ? $this->engine : strtolower($engine);
if($engine === 'myisam') return DatabaseStopwords::getAll();
if($this->stopwordCache === null) { // && $engine === 'innodb') {
$cache = $this->wire()->cache;
$stopwords = null;
if($cache) {
$stopwords = $cache->get('InnoDB.stopwords');
if($stopwords) $stopwords = explode(',', $stopwords);
}
if(!$stopwords) {
$query = $this->prepare('SELECT value FROM INFORMATION_SCHEMA.INNODB_FT_DEFAULT_STOPWORD');
$query->execute();
$stopwords = $query->fetchAll(\PDO::FETCH_COLUMN, 0);
$query->closeCursor();
if($cache) $cache->save('InnoDB.stopwords', implode(',', $stopwords), WireCache::expireDaily);
}
$this->stopwordCache = array_flip($stopwords);
}
return $flip ? $this->stopwordCache : array_keys($this->stopwordCache);
}
/**
* Sanitize a table name for _a-zA-Z0-9
*
* #pw-group-sanitization
*
* @param string $table String containing table name
* @return string Sanitized table name
*
*/
public function escapeTable($table) {
$table = (string) trim($table);
if(ctype_alnum($table)) return $table;
if(ctype_alnum(str_replace('_', '', $table))) return $table;
return preg_replace('/[^_a-zA-Z0-9]/', '_', $table);
}
/**
* Sanitize a column name for _a-zA-Z0-9
*
* #pw-group-sanitization
*
* @param string $col
* @return string
*
*/
public function escapeCol($col) {
return $this->escapeTable($col);
}
/**
* Sanitize a table.column string, where either part is optional
*
* #pw-group-sanitization
*
* @param string $str
* @return string
*
*/
public function escapeTableCol($str) {
if(strpos($str, '.') === false) return $this->escapeTable($str);
list($table, $col) = explode('.', $str);
return $this->escapeTable($table) . '.' . $this->escapeCol($col);
}
/**
* Sanitize comparison operator
*
* @param string $operator
* @param bool|int|null $operatorType Specify a WireDatabasePDO::operatorType* constant (default=operatorTypeComparison)
* @param string $default Default/fallback operator to return if given one is not valid (default='=')
* @return string
*
*/
public function escapeOperator($operator, $operatorType = self::operatorTypeComparison, $default = '=') {
$operator = $this->isOperator($operator, $operatorType, true);
return $operator ? $operator : $default;
}
/**
* Escape a string value, same as $db->quote() but without surrounding quotes
*
* #pw-group-sanitization
*
* @param string $str
* @return string
*
*/
public function escapeStr($str) {
return substr($this->quote($str), 1, -1);
}
/**
* Escape a string value, for backwards compatibility till PDO transition complete
*
* #pw-internal
*
* @deprecated
* @param string $str
* @return string
*
*/
public function escape_string($str) {
return $this->escapeStr($str);
}
/**
* Quote and escape a string value
*
* #pw-group-sanitization
* #pw-group-PDO
*
* @param string $str
* @return string
* @link http://php.net/manual/en/pdo.quote.php
*
*/
public function quote($str) {
if($this->stripMB4 && is_string($str) && !empty($str)) {
$str = $this->wire()->sanitizer->removeMB4($str);
}
return $this->pdoLast()->quote($str);
}
/**
* Escape a string value, plus escape characters necessary for a MySQL 'LIKE' phrase
*
* #pw-group-sanitization
*
* @param string $like
* @return string
*
*/
public function escapeLike($like) {
$like = $this->escapeStr($like);
return addcslashes($like, '%_');
}
/**
* Set whether debug mode is enabled for this database instance
*
* #pw-internal
*
* @param $debugMode
*
*/
public function setDebugMode($debugMode) {
$this->debugMode = (bool) $debugMode;
}
/**
* @param string $key
* @return mixed|null|\PDO
*
*/
public function __get($key) {
if($key === 'pdo') return $this->pdo();
if($key === 'pdoReader') return $this->pdoReader();
if($key === 'pdoWriter') return $this->pdoWriter();
if($key === 'debugMode') return $this->debugMode;
return parent::__get($key);
}
/**
* Close the PDO connection
*
* #pw-group-custom
*
*/
public function closeConnection() {
$this->reader['pdo'] = null;
$this->writer['pdo'] = null;
$this->reader['init'] = false;
$this->writer['init'] = false;
}
/**
* Get the value of a MySQL variable
*
* ~~~~~
* // Get the minimum fulltext index word length
* $value = $database->getVariable('ft_min_word_len');
* echo $value; // outputs "4"
* ~~~~~
*
* #pw-group-custom
*
* @param string $name Name of MySQL variable you want to retrieve
* @param bool $cache Allow use of cached values? (default=true)
* @param bool $sub Allow substitution of MyISAM variable names to InnoDB equivalents when InnoDB is engine? (default=true)
* @return string|int
*
*/
public function getVariable($name, $cache = true, $sub = true) {
if($sub && isset($this->subVars[$this->engine][$name])) $name = $this->subVars[$this->engine][$name];
if($cache && isset($this->variableCache[$name])) return $this->variableCache[$name];
$query = $this->prepare('SHOW VARIABLES WHERE Variable_name=:name');
$query->bindValue(':name', $name);
$query->execute();
/** @noinspection PhpUnusedLocalVariableInspection */
list($varName, $value) = $query->fetch(\PDO::FETCH_NUM);
$this->variableCache[$name] = $value;
$query->closeCursor();
return $value;
}
/**
* Get MySQL/MariaDB version
*
* Example return values:
*
* - 5.7.23
* - 10.1.34-MariaDB
*
* @return string
* @since 3.0.166
*
*/
public function getVersion() {
return $this->getVariable('version', true, false);
}
/**
* Get the regular expression engine used by database
*
* Returns one of 'ICU' (MySQL 8.0.4+) or 'HenrySpencer' (earlier versions and MariaDB)
*
* @return string
* @since 3.0.166
* @todo this will need to be updated when/if MariaDB adds version that uses ICU engine
*
*/
public function getRegexEngine() {
$version = $this->getVersion();
$name = 'MySQL';
if(strpos($version, '-')) list($version, $name) = explode('-', $version, 2);
if(strpos($name, 'mariadb') === false) {
if(version_compare($version, '8.0.4', '>=')) return 'ICU';
}
return 'HenrySpencer';
}
/**
* Get current database engine (lowercase)
*
* @return string
* @since 3.0.160
*
*/
public function getEngine() {
return $this->engine;
}
/**
* Get current database charset (lowercase)
*
* @return string
* @since 3.0.160
*
*/
public function getCharset() {
return $this->charset;
}
/**
* Retrieve new instance of WireDatabaseBackups ready to use with this connection
*
* See `WireDatabaseBackup` class for usage.
*
* #pw-group-custom
*
* @return WireDatabaseBackup
* @throws WireException|\Exception on fatal error
* @see WireDatabaseBackup::backup(), WireDatabaseBackup::restore()
*
*/
public function backups() {
$path = $this->wire()->config->paths->assets . 'backups/database/';
if(!is_dir($path)) {
$this->wire()->files->mkdir($path, true);
if(!is_dir($path)) throw new WireException("Unable to create path for backups: $path");
}
$backups = new WireDatabaseBackup($path);
$backups->setWire($this->wire());
$backups->setDatabase($this);
$backups->setDatabaseConfig($this->wire()->config);
$backups->setBackupOptions(array('user' => $this->wire()->user->name));
return $backups;
}
/**
* Get max length allowed for a fully indexed varchar column in ProcessWire
*
* #pw-internal
*
* @return int
*
*/
public function getMaxIndexLength() {
$max = 250;
if($this->charset === 'utf8mb4') {
if($this->engine === 'innodb') {
$max = 191;
}
}
return $max;
}
/**
* Enable or disable PDO reader instance, or omit argument to get current state
*
* Returns true if reader is configured and allowed
* Returns false if reader is not configured or not allowed
*
* @param bool $allow
* @return bool
* @since 3.0.175
*
*/
protected function allowReader($allow = null) {
if($allow !== null) $this->reader['allow'] = (bool) $allow;
return $this->reader['has'] && $this->reader['allow'];
}
/**
* Get SQL mode, set SQL mode, add to existing SQL mode, or remove from existing SQL mode
*
* #pw-group-custom
*
* ~~~~~
* // Get SQL mode
* $mode = $database->sqlMode();
*
* // Add an SQL mode
* $database->sqlMode('add', 'STRICT_TRANS_TABLES');
*
* // Remove SQL mode if version at least 5.7.0
* $database->sqlMode('remove', 'ONLY_FULL_GROUP_BY', '5.7.0');
* ~~~~~
*
* @param string $action Specify "get", "set", "add" or "remove". (default="get")
* @param string $mode Mode string or CSV string with SQL mode(s), i.e. "STRICT_TRANS_TABLES,ONLY_FULL_GROUP_BY".
* This argument should be omitted when using the "get" action.
* @param string $minVersion Make the given action only apply if MySQL version is at least $minVersion, i.e. "5.7.0".
* @param \PDO PDO connection to use or omit for current (default=null) 3.0.175+
* @return string|bool Returns string in "get" action, boolean false if required version not present, or true otherwise.
* @throws WireException If given an invalid $action
*
*/
public function sqlMode($action = 'get', $mode = '', $minVersion = '', $pdo = null) {
$result = true;
$modes = array();
if($pdo === null) {
$pdo = $this->pdoLast();
} else {
$this->pdoLast = $pdo;
}
if(empty($action)) $action = 'get';
if($action !== 'get' && $minVersion) {
$serverVersion = $this->getAttribute(\PDO::ATTR_SERVER_VERSION);
if(version_compare($serverVersion, $minVersion, '<')) return false;
}
if($mode) {
foreach(explode(',', $mode) as $m) {
$modes[] = $this->escapeStr(strtoupper($this->wire()->sanitizer->fieldName($m)));
}
}
switch($action) {
case 'get':
$query = $pdo->query("SELECT @@sql_mode");
$result = $query->fetchColumn();
$query->closeCursor();
break;
case 'set':
$modes = implode(',', $modes);
$result = $modes;
$pdo->exec("SET sql_mode='$modes'");
break;
case 'add':
foreach($modes as $m) {
$pdo->exec("SET sql_mode=(SELECT CONCAT(@@sql_mode,',$m'))");
}
break;
case 'remove':
foreach($modes as $m) {
$pdo->exec("SET sql_mode=(SELECT REPLACE(@@sql_mode,'$m',''))");
}
break;
default:
throw new WireException("Unknown action '$action'");
}
return $result;
}
/**
* Get current date/time ISO-8601 string or UNIX timestamp according to database
*
* @param bool $getTimestamp Get unix timestamp rather than ISO-8601 string? (default=false)
* @return string|int
* @since 3.0.183
*
*/
public function getTime($getTimestamp = false) {
$query = $this->query('SELECT ' . ($getTimestamp ? 'UNIX_TIMESTAMP()' : 'NOW()'));
$value = $query->fetchColumn();
return $getTimestamp ? (int) $value : $value;
}
}