<?php
/**
* Handle communication with the database
* This is a very thin layer between you and PDO.
*/
class Database
{
/**
* @var \PDO Database handle
*/
private static ?PDO $dbh = null;
private static bool $returnErrors;
private static ?string $lastError = null;
private static array $explainList = array();
private static int $queryCount = 0;
private static float $queryTime = 0;
/**
* Connect to the DB if not already connected.
*/
public static function init(bool $returnErrors = false): bool
{
if (self::$dbh !== null)
return true;
self::$returnErrors = $returnErrors;
try {
self::$dbh = new PDO(CONFIG_SQL_DSN, CONFIG_SQL_USER, CONFIG_SQL_PASS, [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => true,
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4', // Somehow needed, even if charset=utf8mb4 is in DSN?
]);
} catch (PDOException $e) {
if (self::$returnErrors)
return false;
Util::traceError('Connecting to the local database failed: ' . $e->getMessage());
}
if (CONFIG_DEBUG) {
Database::exec("SET SESSION sql_mode='STRICT_ALL_TABLES,NO_ENGINE_SUBSTITUTION,ERROR_FOR_DIVISION_BY_ZERO'");
Database::exec("SET SESSION innodb_strict_mode=ON");
register_shutdown_function(function() {
self::examineLoggedQueries();
});
}
return true;
}
/**
* If you just need the first row of a query you can use this.
*
* @return array|boolean Associative array representing row, or false if no row matches the query
*/
public static function queryFirst(string $query, array $args = [], bool $ignoreError = null)
{
$res = self::simpleQuery($query, $args, $ignoreError);
if ($res === false)
return false;
return $res->fetch();
}
/**
* If you need all rows for a query as plain array you can use this.
* Don't use this if you want to do further processing of the data, to save some
* memory.
*
* @return array|bool List of associative arrays representing rows, or false on error
*/
public static function queryAll(string $query, array $args = [], bool $ignoreError = null)
{
$res = self::simpleQuery($query, $args, $ignoreError);
if ($res === false)
return false;
return $res->fetchAll();
}
/**
* Fetch the first column of the query as a plain list-of-values array.
*
* @return array|bool List of values representing first column of query
*/
public static function queryColumnArray(string $query, array $args = [], bool $ignoreError = null)
{
$res = self::simpleQuery($query, $args, $ignoreError);
if ($res === false)
return false;
return $res->fetchAll(PDO::FETCH_COLUMN, 0);
}
/**
* Fetch two columns as key => value list.
*
* @return array|bool Associative array, first column is key, second column is value
*/
public static function queryKeyValueList(string $query, array $args = [], bool $ignoreError = null)
{
$res = self::simpleQuery($query, $args, $ignoreError);
if ($res === false)
return false;
return $res->fetchAll(PDO::FETCH_KEY_PAIR);
}
/**
* Fetch and group by first column. First column is key, value is a list of rows with remaining columns.
* [
* col1 => [
* [col2, col3],
* [col2, col3],
* ],
* ...,
* ]
*
* @return array|bool Associative array, first column is key, remaining columns are array values
*/
public static function queryGroupList(string $query, array $args = [], bool $ignoreError = null)
{
$res = self::simpleQuery($query, $args, $ignoreError);
if ($res === false)
return false;
return $res->fetchAll(PDO::FETCH_GROUP);
}
/**
* Fetch and use first column as key of returned array.
* This is like queryGroup list, but it is assumed that the first column is unique, so
* the remaining columns won't be wrapped in another array.
*
* @return array|bool Associative array, first column is key, remaining columns are array values
*/
public static function queryIndexedList(string $query, array $args = [], bool $ignoreError = null)
{
$res = self::simpleQuery($query, $args, $ignoreError);
if ($res === false)
return false;
return $res->fetchAll(PDO::FETCH_GROUP | PDO::FETCH_UNIQUE);
}
/**
* Execute the given query and return the number of rows affected.
* Mostly useful for UPDATEs or INSERTs
*
* @param string $query Query to run
* @param array $args Arguments to query
* @param ?bool $ignoreError Ignore query errors and just return false
* @return int|boolean Number of rows affected, or false on error
*/
public static function exec(string $query, array $args = [], bool $ignoreError = null)
{
$res = self::simpleQuery($query, $args, $ignoreError);
if ($res === false)
return false;
return $res->rowCount();
}
/**
* Get id (primary key) of last row inserted.
*
* @return int the id
*/
public static function lastInsertId(): int
{
return self::$dbh->lastInsertId();
}
/**
* @return ?string return last error returned by query
*/
public static function lastError(): ?string
{
return self::$lastError;
}
/**
* Execute the given query and return the corresponding PDOStatement object
* Note that this will re-use PDOStatements, so if you run the same
* query again with different params, do not rely on the first PDOStatement
* still being valid. If you need to do something fancy, use Database::prepare
*
* @return \PDOStatement|false The query result object
*/
public static function simpleQuery(string $query, array $args = [], bool $ignoreError = null)
{
self::init();
if (CONFIG_DEBUG && !isset(self::$explainList[$query]) && preg_match('/^\s*SELECT/is', $query)) {
self::$explainList[$query] = [$args];
}
// Support passing nested arrays for IN statements, automagically refactor
$oquery = $query;
self::handleArrayArgument($query, $args);
// Now turn any bools into 0 or 1, since PDO unfortunately only does (string)<bool>, which
// results in an empty string for false
foreach ($args as &$arg) {
if ($arg === false) {
$arg = '0';
} elseif ($arg === true) {
$arg = '1';
}
}
try {
$stmt = self::$dbh->prepare($query);
$start = microtime(true);
if ($stmt->execute($args) === false) {
self::$lastError = implode("\n", $stmt->errorInfo());
if ($ignoreError === true || ($ignoreError === null && self::$returnErrors))
return false;
Util::traceError("Database Error: \n" . self::$lastError);
}
if (CONFIG_DEBUG) {
$duration = microtime(true) - $start;
self::$queryTime += $duration;
$duration = round($duration, 3);
if (isset(self::$explainList[$oquery])) {
self::$explainList[$oquery][] = $duration;
} elseif ($duration > 0.1) {
error_log('SLOW ****** ' . $duration . "s *********\n" . $query);
}
self::$queryCount += 1;
}
return $stmt;
} catch (Exception $e) {
self::$lastError = '(' . $e->getCode() . ') ' . $e->getMessage();
if ($ignoreError === true || ($ignoreError === null && self::$returnErrors))
return false;
Util::traceError("Database Error: \n" . self::$lastError);
}
return false;
}
public static function examineLoggedQueries()
{
foreach (self::$explainList as $q => $a) {
self::explainQuery($q, $a);
}
}
private static function explainQuery(string $query, array $data)
{
$args = array_shift($data);
$slow = false;
$veryslow = false;
foreach ($data as &$ts) {
if ($ts > 0.004) {
$slow = true;
if ($ts > 0.015) {
$ts = "[$ts]";
$veryslow = true;
}
}
}
if (!$slow)
return;
unset($ts);
$res = self::simpleQuery('EXPLAIN ' . $query, $args, true);
if ($res === false)
return;
$rows = $res->fetchAll();
if (empty($rows))
return;
$log = $veryslow;
$lens = array();
foreach (array_keys($rows[0]) as $key) {
$lens[$key] = strlen($key);
}
foreach ($rows as $row) {
if (!$log && $row['rows'] > 20 && preg_match('/filesort|temporary/i', $row['Extra'])) {
$log = true;
}
foreach ($row as $key => $col) {
$l = strlen($col);
if ($l > $lens[$key]) {
$lens[$key] = $l;
}
}
}
if (!$log)
return;
error_log('Possible slow query: ' . $query);
error_log('Times: ' . implode(', ', $data));
$border = $head = '';
foreach ($lens as $key => $len) {
$border .= '+' . str_repeat('-', $len + 2);
$head .= '| ' . str_pad($key, $len) . ' ';
}
$border .= '+';
$head .= '|';
error_log("\n" . $border . "\n" . $head . "\n" . $border);
foreach ($rows as $row) {
$line = '';
foreach ($lens as $key => $len) {
$line .= '| '. str_pad($row[$key], $len) . ' ';
}
error_log($line . "|");
}
error_log($border);
}
/**
* Convert nested array argument to multiple arguments.
* If you have:
* $query = 'SELECT * FROM tbl WHERE bcol = :bool AND col IN (:list)
* $args = ( 'bool' => 1, 'list' => ('foo', 'bar') )
* it results in:
* $query = '...WHERE bcol = :bool AND col IN (:list_0, :list_1)
* $args = ( 'bool' => 1, 'list_0' => 'foo', 'list_1' => 'bar' )
*
* @param string $query sql query string
* @param array $args query arguments
* @return void
*/
private static function handleArrayArgument(string &$query, array &$args)
{
$again = false;
foreach (array_keys($args) as $key) {
if (is_numeric($key) || $key === '?')
continue;
if (is_array($args[$key])) {
if (empty($args[$key])) {
// Empty list - what to do? We try to generate a query string that will not yield any result
$args[$key] = 'asdf' . mt_rand(0,PHP_INT_MAX) . mt_rand(0,PHP_INT_MAX)
. mt_rand(0,PHP_INT_MAX) . '@' . microtime(true);
continue;
}
$newkey = $key;
if ($newkey[0] !== ':') {
$newkey = ":$newkey";
}
$new = array();
foreach ($args[$key] as $subIndex => $sub) {
if (is_array($sub)) {
$new[] = '(' . $newkey . '_' . $subIndex . ')';
$again = true;
} else {
$new[] = $newkey . '_' . $subIndex;
}
$args[$newkey . '_' . $subIndex] = $sub;
}
unset($args[$key]);
$new = implode(',', $new);
$query = preg_replace('/' . $newkey . '\b/', $new, $query);
}
}
if ($again) {
self::handleArrayArgument($query, $args);
}
}
/**
* Simply calls PDO::prepare and returns the PDOStatement.
* You must call PDOStatement::execute manually on it.
*/
public static function prepare(string $query)
{
self::init();
self::$queryCount += 1; // Cannot know actual count
return self::$dbh->prepare($query);
}
/**
* Insert row into table, returning the generated key.
* This requires the table to have an AUTO_INCREMENT column and
* usually requires the given $uniqueValues to span across a UNIQUE index.
* The code first tries to SELECT the key for the given values without
* inserting first. This means this function is best used for cases
* where you expect that the entry already exists in the table, so
* only one SELECT will run. For all the entries that do not exist,
* an INSERT or INSERT IGNORE is run, depending on whether $additionalValues
* is empty or not. Another reason we don't run the INSERT (IGNORE) first
* is that it will increase the AUTO_INCREMENT value on InnoDB, even when
* no INSERT took place. So if you expect a lot of collisions you might
* use this function to prevent your A_I value from counting up too
* quickly.
* Other than that, this is just a dumb version of running INSERT and then
* getting the LAST_INSERT_ID(), or doing a query for the existing ID in
* case of a key collision.
*
* @param string $table table to insert into
* @param string $aiKey name of the AUTO_INCREMENT column
* @param array $uniqueValues assoc array containing columnName => value mapping
* @param ?array $additionalValues assoc array containing columnName => value mapping
* @return int AUTO_INCREMENT value matching the given unique values entry
*/
public static function insertIgnore(string $table, string $aiKey, array $uniqueValues, array $additionalValues = null): int
{
// Sanity checks
if (array_key_exists($aiKey, $uniqueValues)) {
Util::traceError("$aiKey must not be in \$uniqueValues");
}
if (is_array($additionalValues) && array_key_exists($aiKey, $additionalValues)) {
Util::traceError("$aiKey must not be in \$additionalValues");
}
// Simple SELECT first
$selectSql = 'SELECT ' . $aiKey . ' FROM ' . $table . ' WHERE 1';
foreach ($uniqueValues as $key => $value) {
$selectSql .= ' AND ' . $key . ' = :' . $key;
}
$selectSql .= ' LIMIT 1';
$res = self::queryFirst($selectSql, $uniqueValues);
if ($res !== false) {
// Exists
if (!empty($additionalValues)) {
// Simulate ON DUPLICATE KEY UPDATE ...
$updateSql = 'UPDATE ' . $table . ' SET ';
$first = true;
foreach ($additionalValues as $key => $value) {
if ($first) {
$first = false;
} else {
$updateSql .= ', ';
}
$updateSql .= $key . ' = :' . $key;
}
$updateSql .= ' WHERE ' . $aiKey . ' = :' . $aiKey;
$additionalValues[$aiKey] = $res[$aiKey];
Database::exec($updateSql, $additionalValues);
}
return $res[$aiKey];
}
// Does not exist:
if (empty($additionalValues)) {
$combined =& $uniqueValues;
} else {
$combined = $uniqueValues + $additionalValues;
}
// Aight, try INSERT or INSERT IGNORE
$insertSql = 'INTO ' . $table . ' (' . implode(', ', array_keys($combined))
. ') VALUES (:' . implode(', :', array_keys($combined)) . ')';
if (empty($additionalValues)) {
// Simple INSERT IGNORE
$insertSql = 'INSERT IGNORE ' . $insertSql;
} else {
// INSERT ... ON DUPLICATE (in case we have a race)
$insertSql = 'INSERT ' . $insertSql . ' ON DUPLICATE KEY UPDATE ';
$first = true;
foreach ($additionalValues as $key => $value) {
if ($first) {
$first = false;
} else {
$insertSql .= ', ';
}
$insertSql .= $key . ' = VALUES(' . $key . ')';
}
}
self::exec($insertSql, $combined);
// Insert done, retrieve key again
$res = self::queryFirst($selectSql, $uniqueValues);
if ($res === false) {
Util::traceError('Could not find value in table ' . $table . ' that was just inserted');
}
return $res[$aiKey];
}
public static function getQueryCount(): int
{
return self::$queryCount;
}
public static function getQueryTime(): int
{
return self::$queryTime;
}
}