Support config-driven choice between MySQL and SQLite via DB_DRIVER constant, defaulting to MySQL for backward compatibility. All SQL adaptation lives in Database.php (UDFs + query rewriting), so model files need no changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> SQLite: remove FK constraints, revert 0→null sentinel changes The SQLite schema had FOREIGN KEY constraints that don't exist in the MySQL schema. These forced a cascade of 0→null changes to satisfy FK enforcement. Removing them keeps the two backends behaviorally consistent and minimises the diff. Real SQLite compat fixes (UDFs, query rewriting, rowCount→count, Router fixes, EditAlbum guard) are preserved. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
518 lines
12 KiB
PHP
518 lines
12 KiB
PHP
<?php
|
|
/*****************************************************************************
|
|
* Database.php
|
|
* Contains model class Database.
|
|
*
|
|
* Kabuki CMS (C) 2013-2025, Aaron van Geffen
|
|
*****************************************************************************/
|
|
|
|
class Database
|
|
{
|
|
private $connection;
|
|
private $driver;
|
|
private $query_count = 0;
|
|
private $logged_queries = [];
|
|
|
|
public function __construct($driver, array $options)
|
|
{
|
|
$this->driver = $driver;
|
|
|
|
try
|
|
{
|
|
if ($driver === 'sqlite')
|
|
{
|
|
$this->connection = new PDO("sqlite:" . $options['file'], null, null, [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
]);
|
|
$this->connection->exec('PRAGMA journal_mode=WAL');
|
|
$this->registerSQLiteFunctions();
|
|
}
|
|
else
|
|
{
|
|
$this->connection = new PDO(
|
|
"mysql:host={$options['host']};dbname={$options['name']};charset=utf8mb4",
|
|
$options['user'], $options['password'], [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
PDO::ATTR_EMULATE_PREPARES => false,
|
|
]);
|
|
}
|
|
}
|
|
// Give up if we have a connection error.
|
|
catch (PDOException $e)
|
|
{
|
|
http_response_code(503);
|
|
echo '<h2>Database Connection Problems</h2><p>Our software could not connect to the database. We apologise for any inconvenience and ask you to check back later.</p>';
|
|
exit;
|
|
}
|
|
}
|
|
|
|
private function registerSQLiteFunctions()
|
|
{
|
|
$pdo = $this->connection;
|
|
|
|
$pdo->sqliteCreateFunction('CONCAT', function () {
|
|
return implode('', func_get_args());
|
|
}, -1);
|
|
|
|
$pdo->sqliteCreateFunction('IF', function ($cond, $t, $f) {
|
|
return $cond ? $t : $f;
|
|
}, 3);
|
|
|
|
$pdo->sqliteCreateFunction('FROM_UNIXTIME', function ($ts) {
|
|
return date('Y-m-d H:i:s', $ts);
|
|
}, 1);
|
|
|
|
$pdo->sqliteCreateFunction('UNIX_TIMESTAMP', function () {
|
|
return time();
|
|
}, 0);
|
|
|
|
$pdo->sqliteCreateFunction('CURRENT_TIMESTAMP', function () {
|
|
return date('Y-m-d H:i:s');
|
|
}, 0);
|
|
}
|
|
|
|
private function rewriteForSQLite($sql)
|
|
{
|
|
// REPLACE INTO → INSERT OR REPLACE INTO
|
|
$sql = preg_replace('/\bREPLACE\s+INTO\b/i', 'INSERT OR REPLACE INTO', $sql);
|
|
|
|
// ON DUPLICATE KEY UPDATE → ON CONFLICT(slug) DO UPDATE SET
|
|
// When present, strip INSERT IGNORE down to INSERT (OR IGNORE conflicts with ON CONFLICT).
|
|
if (preg_match('/\bON\s+DUPLICATE\s+KEY\s+UPDATE\b/i', $sql))
|
|
{
|
|
$sql = preg_replace('/\bINSERT\s+IGNORE\s+INTO\b/i', 'INSERT INTO', $sql);
|
|
$sql = preg_replace(
|
|
'/\bON\s+DUPLICATE\s+KEY\s+UPDATE\s+(.+)/i',
|
|
'ON CONFLICT(slug) DO UPDATE SET $1',
|
|
$sql
|
|
);
|
|
}
|
|
else
|
|
{
|
|
// INSERT IGNORE INTO → INSERT OR IGNORE INTO
|
|
$sql = preg_replace('/\bINSERT\s+IGNORE\s+INTO\b/i', 'INSERT OR IGNORE INTO', $sql);
|
|
}
|
|
|
|
// UPDATE table AS alias SET ... → UPDATE table SET ... (with alias replaced by table name)
|
|
if (preg_match('/\bUPDATE\s+(\w+)\s+AS\s+(\w+)\s+SET\b/i', $sql, $m))
|
|
{
|
|
$table = $m[1];
|
|
$alias = $m[2];
|
|
$sql = preg_replace('/\bUPDATE\s+\w+\s+AS\s+\w+\s+SET\b/i', "UPDATE $table SET", $sql);
|
|
$sql = preg_replace('/\b' . preg_quote($alias, '/') . '\.(\w+)\b/', "$table.$1", $sql);
|
|
}
|
|
|
|
// LIMIT :offset, :limit → LIMIT :limit OFFSET :offset
|
|
$sql = preg_replace(
|
|
'/\bLIMIT\s+:offset\s*,\s*:limit\b/i',
|
|
'LIMIT :limit OFFSET :offset',
|
|
$sql
|
|
);
|
|
|
|
return $sql;
|
|
}
|
|
|
|
public function getQueryCount()
|
|
{
|
|
return $this->query_count;
|
|
}
|
|
|
|
public function getLoggedQueries()
|
|
{
|
|
return $this->logged_queries;
|
|
}
|
|
|
|
/**
|
|
* Fetches a row from a given statement/recordset, using field names as keys.
|
|
*/
|
|
public function fetchAssoc($stmt)
|
|
{
|
|
return $stmt->fetch(PDO::FETCH_ASSOC);
|
|
}
|
|
|
|
/**
|
|
* Fetches a row from a given statement/recordset, encapsulating into an object.
|
|
*/
|
|
public function fetchObject($stmt, $class)
|
|
{
|
|
return $stmt->fetchObject($class);
|
|
}
|
|
|
|
/**
|
|
* Fetches a row from a given statement/recordset, using numeric keys.
|
|
*/
|
|
public function fetchNum($stmt)
|
|
{
|
|
return $stmt->fetch(PDO::FETCH_NUM);
|
|
}
|
|
|
|
/**
|
|
* Destroys a given statement/recordset.
|
|
*/
|
|
public function free($stmt)
|
|
{
|
|
return $stmt->closeCursor();
|
|
}
|
|
|
|
/**
|
|
* Returns the amount of rows in a given statement/recordset.
|
|
*/
|
|
public function rowCount($stmt)
|
|
{
|
|
return $stmt->rowCount();
|
|
}
|
|
|
|
/**
|
|
* Returns the amount of fields in a given statement/recordset.
|
|
*/
|
|
public function columnCount($stmt)
|
|
{
|
|
return $stmt->columnCount();
|
|
}
|
|
|
|
/**
|
|
* Returns the id of the row created by a previous query.
|
|
*/
|
|
public function insertId($name = null)
|
|
{
|
|
return $this->connection->lastInsertId($name);
|
|
}
|
|
|
|
/**
|
|
* Start a transaction.
|
|
*/
|
|
public function beginTransaction()
|
|
{
|
|
return $this->connection->beginTransaction();
|
|
}
|
|
|
|
/**
|
|
* Rollback changes in a transaction.
|
|
*/
|
|
public function rollback()
|
|
{
|
|
return $this->connection->rollBack();
|
|
}
|
|
|
|
/**
|
|
* Commit changes in a transaction.
|
|
*/
|
|
public function commit()
|
|
{
|
|
return $this->connection->commit();
|
|
}
|
|
|
|
private function expandPlaceholders($db_string, array &$db_values)
|
|
{
|
|
foreach ($db_values as $key => &$value)
|
|
{
|
|
if (str_contains($db_string, ':' . $key))
|
|
{
|
|
if (is_array($value))
|
|
{
|
|
throw new UnexpectedValueException('Array ' . $key .
|
|
' is used as a scalar placeholder. Did you mean to use \'@\' instead?');
|
|
}
|
|
|
|
// Prepare date/time values
|
|
if (is_a($value, 'DateTime'))
|
|
{
|
|
$value = $value->format('Y-m-d H:i:s');
|
|
}
|
|
}
|
|
elseif (str_contains($db_string, '@' . $key))
|
|
{
|
|
if (!is_array($value))
|
|
{
|
|
throw new UnexpectedValueException('Scalar value ' . $key .
|
|
' is used as an array placeholder. Did you mean to use \':\' instead?');
|
|
}
|
|
|
|
// Create placeholders for all array elements
|
|
$placeholders = array_map(fn($num) => ':' . $key . $num, range(0, count($value) - 1));
|
|
$db_string = str_replace('@' . $key, implode(', ', $placeholders), $db_string);
|
|
}
|
|
else
|
|
{
|
|
// throw new Exception('Warning: unused key in query: ' . $key);
|
|
}
|
|
}
|
|
|
|
return $db_string;
|
|
}
|
|
|
|
/**
|
|
* Escapes and quotes a string using values passed, and executes the query.
|
|
*/
|
|
public function query($db_string, array $db_values = []): PDOStatement
|
|
{
|
|
// One more query...
|
|
$this->query_count++;
|
|
|
|
// Error out if hardcoded strings are detected
|
|
if (strpos($db_string, '\'') !== false)
|
|
throw new UnexpectedValueException('Hack attempt: illegal character (\') used in query.');
|
|
|
|
if (defined('DB_LOG_QUERIES') && DB_LOG_QUERIES)
|
|
$this->logged_queries[] = $db_string;
|
|
|
|
try
|
|
{
|
|
// Preprocessing/checks: prepare any arrays for binding
|
|
$db_string = $this->expandPlaceholders($db_string, $db_values);
|
|
|
|
// SQLite query rewriting
|
|
if ($this->driver === 'sqlite')
|
|
$db_string = $this->rewriteForSQLite($db_string);
|
|
|
|
// Prepare query for execution
|
|
$statement = $this->connection->prepare($db_string);
|
|
|
|
// Bind parameters... the hard way, due to a limit/offset hack.
|
|
// NB: bindParam binds by reference, hence &$value here.
|
|
foreach ($db_values as $key => &$value)
|
|
{
|
|
// Assumption: both scalar and array values are preprocessed to use named ':' placeholders
|
|
if (!str_contains($db_string, ':' . $key))
|
|
continue;
|
|
|
|
if (!is_array($value))
|
|
{
|
|
$statement->bindParam(':' . $key, $value);
|
|
continue;
|
|
}
|
|
|
|
foreach (array_values($value) as $num => &$element)
|
|
{
|
|
$statement->bindParam(':' . $key . $num, $element);
|
|
}
|
|
}
|
|
|
|
$statement->execute();
|
|
return $statement;
|
|
}
|
|
catch (PDOException $e)
|
|
{
|
|
ob_start();
|
|
|
|
$debug = ob_get_clean();
|
|
|
|
throw new Exception($e->getMessage() . "\n" . var_export($e->errorInfo, true) . "\n" . var_export($db_values, true));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes a query, returning an object of the row it returns.
|
|
*/
|
|
public function queryObject($class, $db_string, $db_values = [])
|
|
{
|
|
$res = $this->query($db_string, $db_values);
|
|
|
|
$object = $this->fetchObject($res, $class);
|
|
$this->free($res);
|
|
|
|
return $object ?: null;
|
|
}
|
|
|
|
/**
|
|
* Executes a query, returning an array of objects of all the rows returns.
|
|
*/
|
|
public function queryObjects($class, $db_string, $db_values = [])
|
|
{
|
|
$res = $this->query($db_string, $db_values);
|
|
|
|
$rows = [];
|
|
while ($object = $this->fetchObject($res, $class))
|
|
$rows[] = $object;
|
|
|
|
$this->free($res);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* Executes a query, returning an array of all the rows it returns.
|
|
*/
|
|
public function queryRow($db_string, array $db_values = [])
|
|
{
|
|
$res = $this->query($db_string, $db_values);
|
|
|
|
$row = $this->fetchNum($res);
|
|
$this->free($res);
|
|
|
|
return $row ?: [];
|
|
}
|
|
|
|
/**
|
|
* Executes a query, returning an array of all the rows it returns.
|
|
*/
|
|
public function queryRows($db_string, array $db_values = [])
|
|
{
|
|
$res = $this->query($db_string, $db_values);
|
|
|
|
$rows = [];
|
|
while ($row = $this->fetchNum($res))
|
|
$rows[] = $row;
|
|
|
|
$this->free($res);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* Executes a query, returning an array of all the rows it returns.
|
|
*/
|
|
public function queryPair($db_string, array $db_values = [])
|
|
{
|
|
$res = $this->query($db_string, $db_values);
|
|
|
|
$rows = [];
|
|
while ($row = $this->fetchNum($res))
|
|
$rows[$row[0]] = $row[1];
|
|
|
|
$this->free($res);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* Executes a query, returning an array of all the rows it returns.
|
|
*/
|
|
public function queryPairs($db_string, $db_values = array())
|
|
{
|
|
$res = $this->query($db_string, $db_values);
|
|
|
|
$rows = [];
|
|
while ($row = $this->fetchAssoc($res))
|
|
{
|
|
$key_value = reset($row);
|
|
$rows[$key_value] = $row;
|
|
}
|
|
|
|
$this->free($res);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* Executes a query, returning an associative array of all the rows it returns.
|
|
*/
|
|
public function queryAssoc($db_string, array $db_values = [])
|
|
{
|
|
$res = $this->query($db_string, $db_values);
|
|
|
|
$row = $this->fetchAssoc($res);
|
|
$this->free($res);
|
|
|
|
return $row ?: [];
|
|
}
|
|
|
|
/**
|
|
* Executes a query, returning an associative array of all the rows it returns.
|
|
*/
|
|
public function queryAssocs($db_string, array $db_values = [])
|
|
{
|
|
$res = $this->query($db_string, $db_values);
|
|
|
|
$rows = [];
|
|
while ($row = $this->fetchAssoc($res))
|
|
$rows[] = $row;
|
|
|
|
$this->free($res);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* Executes a query, returning the first value of the first row.
|
|
*/
|
|
public function queryValue($db_string, array $db_values = [])
|
|
{
|
|
$res = $this->query($db_string, $db_values);
|
|
|
|
$row = $this->fetchNum($res);
|
|
$this->free($res);
|
|
|
|
if (!$row)
|
|
return null;
|
|
|
|
return $row[0];
|
|
}
|
|
|
|
/**
|
|
* Executes a query, returning an array of the first value of each row.
|
|
*/
|
|
public function queryValues($db_string, array $db_values = [])
|
|
{
|
|
$res = $this->query($db_string, $db_values);
|
|
|
|
$rows = [];
|
|
while ($row = $this->fetchNum($res))
|
|
$rows[] = $row[0];
|
|
|
|
$this->free($res);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* This function can be used to insert data into the database in a secure way.
|
|
*/
|
|
public function insert($method, $table, $columns, $data)
|
|
{
|
|
// With nothing to insert, simply return.
|
|
if (empty($data))
|
|
return;
|
|
|
|
// Inserting data as a single row can be done as a single array.
|
|
if (!is_array($data[array_rand($data)]))
|
|
$data = [$data];
|
|
|
|
// Determine the method of insertion.
|
|
if ($this->driver === 'sqlite')
|
|
$method = $method == 'replace' ? 'INSERT OR REPLACE' : ($method == 'ignore' ? 'INSERT OR IGNORE' : 'INSERT');
|
|
else
|
|
$method = $method == 'replace' ? 'REPLACE' : ($method == 'ignore' ? 'INSERT IGNORE' : 'INSERT');
|
|
|
|
// What columns are we inserting?
|
|
$columns = array_keys($data[0]);
|
|
|
|
// Start building the query.
|
|
$db_string = $method . ' INTO ' . $table . ' (' . implode(',', $columns) . ') VALUES ';
|
|
|
|
// Create the mold for a single row insert.
|
|
$placeholders = '(' . substr(str_repeat('?, ', count($columns)), 0, -2) . '), ';
|
|
|
|
// Append it for every row we're to insert.
|
|
$values = [];
|
|
foreach ($data as $row)
|
|
{
|
|
$values = array_merge($values, array_values($row));
|
|
$db_string .= $placeholders;
|
|
}
|
|
|
|
// Get rid of the tailing comma.
|
|
$db_string = substr($db_string, 0, -2);
|
|
|
|
// Prepare for your impending demise!
|
|
$statement = $this->connection->prepare($db_string);
|
|
|
|
// Bind parameters... the hard way, due to a limit/offset hack.
|
|
foreach ($values as $key => $value)
|
|
$statement->bindValue($key + 1, $values[$key]);
|
|
|
|
// Handle errors.
|
|
try
|
|
{
|
|
$statement->execute();
|
|
return $statement;
|
|
}
|
|
catch (PDOException $e)
|
|
{
|
|
throw new Exception($e->getMessage() . '<br><br>' . $db_string . '<br><br>' . print_r($values, true));
|
|
}
|
|
}
|
|
}
|