This repository has been archived on 2024-09-05. You can view files and clone it, but cannot push or open issues or pull requests.
shimmie2/core/database.php

439 lines
13 KiB
PHP
Raw Normal View History

2021-12-14 18:32:47 +00:00
<?php
declare(strict_types=1);
namespace Shimmie2;
2023-01-11 00:51:57 +00:00
use FFSPHP\PDO;
use FFSPHP\PDOStatement;
2024-01-04 22:48:56 +00:00
require_once __DIR__ . '/exceptions.php';
2022-10-28 00:45:35 +00:00
enum DatabaseDriverID: string
{
2022-10-28 00:45:35 +00:00
case MYSQL = "mysql";
case PGSQL = "pgsql";
case SQLITE = "sqlite";
}
2024-01-04 22:48:56 +00:00
class DatabaseException extends SCoreException
{
public string $query;
/** @var array<string, mixed> */
2024-01-04 22:48:56 +00:00
public array $args;
/**
* @param array<string, mixed> $args
*/
2024-01-04 22:48:56 +00:00
public function __construct(string $msg, string $query, array $args)
{
parent::__construct($msg);
$this->error = $msg;
$this->query = $query;
$this->args = $args;
}
}
/**
* A class for controlled database access
*
* @phpstan-type QueryArgs array<string, string|int|bool|null>
*/
class Database
{
private string $dsn;
2019-06-14 18:17:03 +00:00
/**
* The PDO database connection object, for anyone who wants direct access.
*/
private ?PDO $db = null;
public float $dbtime = 0.0;
/**
* Meta info about the database engine.
*/
private ?DBEngine $engine = null;
/**
* How many queries this DB object has run
*/
public int $query_count = 0;
/** @var string[] */
2023-01-28 19:03:15 +00:00
public array $queries = [];
2020-01-29 00:49:21 +00:00
public function __construct(string $dsn)
{
2020-01-27 18:24:11 +00:00
$this->dsn = $dsn;
}
private function get_db(): PDO
{
if(is_null($this->db)) {
$this->db = new PDO($this->dsn);
$this->connect_engine();
$this->get_engine()->init($this->db);
$this->begin_transaction();
}
return $this->db;
}
2019-05-29 17:23:29 +00:00
private function connect_engine(): void
{
if (preg_match("/^([^:]*)/", $this->dsn, $matches)) {
2023-11-11 21:49:12 +00:00
$db_proto = $matches[1];
} else {
throw new SCoreException("Can't figure out database engine");
}
2022-10-28 00:45:35 +00:00
if ($db_proto === DatabaseDriverID::MYSQL->value) {
$this->engine = new MySQL();
2022-10-28 00:45:35 +00:00
} elseif ($db_proto === DatabaseDriverID::PGSQL->value) {
$this->engine = new PostgreSQL();
2022-10-28 00:45:35 +00:00
} elseif ($db_proto === DatabaseDriverID::SQLITE->value) {
$this->engine = new SQLite();
} else {
die_nicely(
'Unknown PDO driver: '.$db_proto,
"Please check that this is a valid driver, installing the PHP modules if needed"
);
}
}
public function begin_transaction(): void
{
if ($this->is_transaction_open() === false) {
$this->get_db()->beginTransaction();
}
}
public function is_transaction_open(): bool
{
return !is_null($this->db) && $this->db->inTransaction();
}
public function commit(): bool
{
if ($this->is_transaction_open()) {
return $this->get_db()->commit();
} else {
throw new SCoreException("Unable to call commit() as there is no transaction currently open.");
}
}
public function rollback(): bool
{
if ($this->is_transaction_open()) {
return $this->get_db()->rollback();
} else {
throw new SCoreException("Unable to call rollback() as there is no transaction currently open.");
}
}
2024-01-09 21:59:24 +00:00
public function with_savepoint(callable $callback, string $name = "sp"): mixed
{
2024-01-09 22:47:22 +00:00
global $_tracer;
2024-01-09 21:59:24 +00:00
try {
2024-01-09 22:47:22 +00:00
$_tracer->begin("Savepoint $name");
2024-01-09 21:59:24 +00:00
$this->execute("SAVEPOINT $name");
$ret = $callback();
$this->execute("RELEASE SAVEPOINT $name");
2024-01-09 22:47:22 +00:00
$_tracer->end();
2024-01-09 21:59:24 +00:00
return $ret;
} catch (\Exception $e) {
$this->execute("ROLLBACK TO SAVEPOINT $name");
2024-01-09 22:47:22 +00:00
$_tracer->end();
2024-01-09 21:59:24 +00:00
throw $e;
}
}
2023-01-11 14:04:35 +00:00
private function get_engine(): DBEngine
{
if (is_null($this->engine)) {
$this->connect_engine();
}
2023-01-11 13:27:57 +00:00
return $this->engine;
}
public function scoreql_to_sql(string $input): string
{
return $this->get_engine()->scoreql_to_sql($input);
}
2022-10-28 00:45:35 +00:00
public function get_driver_id(): DatabaseDriverID
{
2023-01-11 13:27:57 +00:00
return $this->get_engine()->id;
}
2020-03-26 16:57:08 +00:00
public function get_version(): string
{
return $this->get_engine()->get_version($this->get_db());
2020-03-26 16:57:08 +00:00
}
/**
* @param QueryArgs $args
*/
private function count_time(string $method, float $start, string $query, ?array $args): void
{
2019-09-29 13:30:55 +00:00
global $_tracer, $tracer_enabled;
2023-01-11 13:27:57 +00:00
$dur = ftime() - $start;
2023-02-05 01:26:07 +00:00
// trim whitespace
$query = preg_replace('/[\n\t ]+/m', ' ', $query);
2023-02-05 01:26:07 +00:00
$query = trim($query);
2019-09-29 13:30:55 +00:00
if ($tracer_enabled) {
2023-11-11 21:49:12 +00:00
$_tracer->complete($start * 1000000, $dur * 1000000, "DB Query", ["query" => $query, "args" => $args, "method" => $method]);
}
2023-01-28 19:03:15 +00:00
$this->queries[] = $query;
2019-09-29 13:30:55 +00:00
$this->query_count++;
2019-07-06 22:01:22 +00:00
$this->dbtime += $dur;
}
public function set_timeout(?int $time): void
{
$this->get_engine()->set_timeout($this->get_db(), $time);
}
2023-11-11 21:49:12 +00:00
public function notify(string $channel, ?string $data = null): void
2020-10-03 12:50:37 +00:00
{
$this->get_engine()->notify($this->get_db(), $channel, $data);
2020-10-03 12:50:37 +00:00
}
/**
* @param QueryArgs $args
*/
public function _execute(string $query, array $args = []): PDOStatement
{
try {
2024-01-04 22:48:56 +00:00
return $this->get_db()->execute(
2020-01-26 13:19:35 +00:00
"-- " . str_replace("%2F", "/", urlencode($_GET['q'] ?? '')). "\n" .
$query,
$args
2019-10-02 08:05:48 +00:00
);
2023-01-11 11:15:26 +00:00
} catch (\PDOException $pdoe) {
2024-01-04 22:48:56 +00:00
throw new DatabaseException($pdoe->getMessage(), $query, $args);
2019-09-29 13:30:55 +00:00
}
}
/**
* Execute an SQL query with no return
*
* @param QueryArgs $args
*/
public function execute(string $query, array $args = []): PDOStatement
{
$_start = ftime();
$st = $this->_execute($query, $args);
$this->count_time("execute", $_start, $query, $args);
return $st;
}
/**
* Execute an SQL query and return a 2D array.
*
* @param QueryArgs $args
* @return array<array<string, mixed>>
*/
2020-02-01 22:51:30 +00:00
public function get_all(string $query, array $args = []): array
{
2023-01-11 13:27:57 +00:00
$_start = ftime();
$data = $this->_execute($query, $args)->fetchAll();
$this->count_time("get_all", $_start, $query, $args);
return $data;
}
/**
* Execute an SQL query and return a iterable object for use with generators.
*
* @param QueryArgs $args
*/
2020-02-01 22:51:30 +00:00
public function get_all_iterable(string $query, array $args = []): PDOStatement
{
2023-01-11 13:27:57 +00:00
$_start = ftime();
$data = $this->_execute($query, $args);
$this->count_time("get_all_iterable", $_start, $query, $args);
return $data;
}
/**
* Execute an SQL query and return a single row.
*
* @param QueryArgs $args
* @return array<string, mixed>
*/
2020-02-01 22:51:30 +00:00
public function get_row(string $query, array $args = []): ?array
{
2023-01-11 13:27:57 +00:00
$_start = ftime();
$row = $this->_execute($query, $args)->fetch();
$this->count_time("get_row", $_start, $query, $args);
return $row ? $row : null;
}
/**
* Execute an SQL query and return the first column of each row.
*
* @param QueryArgs $args
* @return array<mixed>
*/
2020-02-01 22:51:30 +00:00
public function get_col(string $query, array $args = []): array
{
2023-01-11 13:27:57 +00:00
$_start = ftime();
$res = $this->_execute($query, $args)->fetchAll(PDO::FETCH_COLUMN);
$this->count_time("get_col", $_start, $query, $args);
return $res;
}
/**
* Execute an SQL query and return the first column of each row as a single iterable object.
*
* @param QueryArgs $args
*/
public function get_col_iterable(string $query, array $args = []): \Generator
{
2023-01-11 13:27:57 +00:00
$_start = ftime();
$stmt = $this->_execute($query, $args);
$this->count_time("get_col_iterable", $_start, $query, $args);
foreach ($stmt as $row) {
yield $row[0];
}
}
/**
* Execute an SQL query and return the the first column => the second column.
*
* @param QueryArgs $args
* @return array<string, mixed>
*/
2020-02-01 22:51:30 +00:00
public function get_pairs(string $query, array $args = []): array
{
2023-01-11 13:27:57 +00:00
$_start = ftime();
$res = $this->_execute($query, $args)->fetchAll(PDO::FETCH_KEY_PAIR);
$this->count_time("get_pairs", $_start, $query, $args);
return $res;
}
/**
* Execute an SQL query and return the the first column => the second column as an iterable object.
*
* @param QueryArgs $args
*/
public function get_pairs_iterable(string $query, array $args = []): \Generator
{
2023-01-11 13:27:57 +00:00
$_start = ftime();
$stmt = $this->_execute($query, $args);
$this->count_time("get_pairs_iterable", $_start, $query, $args);
foreach ($stmt as $row) {
yield $row[0] => $row[1];
}
}
/**
2020-01-27 22:22:07 +00:00
* Execute an SQL query and return a single value, or null.
*
* @param QueryArgs $args
*/
2024-01-15 15:08:22 +00:00
public function get_one(string $query, array $args = []): mixed
{
2023-01-11 13:27:57 +00:00
$_start = ftime();
$row = $this->_execute($query, $args)->fetch();
$this->count_time("get_one", $_start, $query, $args);
2020-01-27 22:22:07 +00:00
return $row ? $row[0] : null;
}
/**
* Execute an SQL query and returns a bool indicating if any data was returned
*
* @param QueryArgs $args
*/
public function exists(string $query, array $args = []): bool
{
2023-01-11 13:27:57 +00:00
$_start = ftime();
$row = $this->_execute($query, $args)->fetch();
$this->count_time("exists", $_start, $query, $args);
2023-11-11 21:49:12 +00:00
if ($row == null) {
return false;
}
return true;
}
/**
* Get the ID of the last inserted row.
*/
public function get_last_insert_id(string $seq): int
{
if ($this->get_engine()->id == DatabaseDriverID::PGSQL) {
$id = $this->get_db()->lastInsertId($seq);
} else {
$id = $this->get_db()->lastInsertId();
}
assert(is_numeric($id));
return (int)$id;
}
/**
* Create a table from pseudo-SQL.
*/
public function create_table(string $name, string $data): void
{
if (is_null($this->engine)) {
$this->connect_engine();
}
$data = trim($data, ", \t\n\r\0\x0B"); // mysql doesn't like trailing commas
2023-01-11 13:27:57 +00:00
$this->execute($this->get_engine()->create_table_sql($name, $data));
}
/**
* Returns the number of tables present in the current database.
*
* @throws SCoreException
*/
public function count_tables(): int
{
2023-01-11 13:27:57 +00:00
if ($this->get_engine()->id === DatabaseDriverID::MYSQL) {
return count(
$this->get_all("SHOW TABLES")
);
2023-01-11 13:27:57 +00:00
} elseif ($this->get_engine()->id === DatabaseDriverID::PGSQL) {
return count(
$this->get_all("SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'")
);
2023-01-11 13:27:57 +00:00
} elseif ($this->get_engine()->id === DatabaseDriverID::SQLITE) {
return count(
$this->get_all("SELECT name FROM sqlite_master WHERE type = 'table'")
);
} else {
2024-01-15 20:34:53 +00:00
$did = (string)$this->get_engine()->id;
throw new SCoreException("Can't count tables for database type {$did}");
}
}
2019-11-24 15:59:14 +00:00
2019-11-27 12:13:04 +00:00
public function raw_db(): PDO
{
return $this->get_db();
2019-11-24 15:59:14 +00:00
}
2023-11-11 21:49:12 +00:00
public function standardise_boolean(string $table, string $column, bool $include_postgres = false): void
{
2022-10-28 00:45:35 +00:00
$d = $this->get_driver_id();
if ($d == DatabaseDriverID::MYSQL) {
# In mysql, ENUM('Y', 'N') is secretly INTEGER where Y=1 and N=2.
# BOOLEAN is secretly TINYINT where true=1 and false=0.
# So we can cast directly from ENUM to BOOLEAN which gives us a
# column of values 'true' and 'invalid but who cares lol', which
# we can then UPDATE to be 'true' and 'false'.
$this->execute("ALTER TABLE $table MODIFY COLUMN $column BOOLEAN;");
$this->execute("UPDATE $table SET $column=0 WHERE $column=2;");
}
2022-10-28 00:45:35 +00:00
if ($d == DatabaseDriverID::SQLITE) {
# SQLite doesn't care about column types at all, everything is
# text, so we can in-place replace a char with a bool
$this->execute("UPDATE $table SET $column = ($column IN ('Y', 1))");
}
2022-10-28 00:45:35 +00:00
if ($d == DatabaseDriverID::PGSQL && $include_postgres) {
2023-02-19 11:24:33 +00:00
$this->execute("ALTER TABLE $table ADD COLUMN {$column}_b BOOLEAN DEFAULT FALSE NOT NULL");
$this->execute("UPDATE $table SET {$column}_b = ($column = 'Y')");
2020-10-26 17:28:21 +00:00
$this->execute("ALTER TABLE $table DROP COLUMN $column");
2023-02-19 11:24:33 +00:00
$this->execute("ALTER TABLE $table RENAME COLUMN {$column}_b TO $column");
2020-10-26 17:28:21 +00:00
}
}
}