death-notifier/src/main/php/com/fwdekker/deathnotifier/Database.php

233 lines
9.1 KiB
PHP

<?php
namespace com\fwdekker\deathnotifier;
use com\fwdekker\deathnotifier\mailer\Mailer;
use com\fwdekker\deathnotifier\trackings\TrackingManager;
use Composer\Semver\Comparator;
use Monolog\Logger;
use PDO;
/**
* Helper class for interacting with the database.
*/
class Database
{
/**
* The currently running software version.
*/
public const LATEST_VERSION = "%%DB_VERSION_NUMBER%%";
/**
* @var Logger The logger to use for logging.
*/
private Logger $logger;
/**
* @var PDO The PDO object that connects to the database.
*/
public PDO $conn;
/**
* Opens a connection with the database at `filename`.
*
* @param Logger $logger the logger to use for logging
* @param string $filename the path to the database to connect to
*/
public function __construct(Logger $logger, string $filename)
{
$this->logger = $logger;
$this->conn = new PDO("sqlite:$filename", options: array(PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION));
$this->conn->exec("PRAGMA foreign_keys = ON;");
}
/**
* Installs all necessary data structures to get the database working.
*
* @param Mailer $mailer the mailer to install
* @param UserManager $userManager the use manager to install
* @param TrackingManager $trackingManager the tracking manager to install
* @return void
*/
public function auto_install(Mailer $mailer, UserManager $userManager, TrackingManager $trackingManager): void
{
// TODO: Keep track of statistics, such as #users, mails sent, etc.
self::transaction($this->conn, function () use ($mailer, $userManager, $trackingManager) {
// Check if already installed
$stmt = $this->conn->prepare("SELECT count(*) FROM sqlite_master WHERE type = 'table';");
$stmt->execute();
if ($stmt->fetch()[0] !== 0) return Response::satisfied();
$this->logger->info("Database does not exist. Installing new database.");
// Create `meta` table
$this->conn->exec("CREATE TABLE meta(k TEXT NOT NULL UNIQUE PRIMARY KEY, v TEXT);");
$stmt = $this->conn->prepare("INSERT INTO meta (k, v) VALUES ('version', :version);");
$stmt->bindValue(":version", Database::LATEST_VERSION);
$stmt->execute();
// Create other tables
$mailer->install();
$userManager->install();
$trackingManager->install();
$this->logger->info("Installation complete.");
return Response::satisfied();
});
}
/**
* Automatically migrates the database to the latest version.
*
* @return void
*/
public function auto_migrate(): void
{
self::transaction($this->conn, function () {
// Check if migration is necessary
$stmt = $this->conn->prepare("SELECT v FROM meta WHERE k='version';");
$stmt->execute();
$db_version = $stmt->fetchAll(PDO::FETCH_ASSOC)[0]["v"];
if (Comparator::greaterThanOrEqualTo($db_version, Database::LATEST_VERSION))
return Response::satisfied();
$this->logger->info("Current db is v$db_version. Will migrate to v" . Database::LATEST_VERSION . ".");
// Get current version
$stmt = $this->conn->prepare("SELECT v FROM meta WHERE k='version';");
$stmt->execute();
$db_version = $stmt->fetchAll(PDO::FETCH_ASSOC)[0]["v"];
// Execute migration code
if (Comparator::lessThan($db_version, "0.5.0")) self::migrate_0_5_0();
if (Comparator::lessThan($db_version, "0.8.0")) self::migrate_0_8_0();
if (Comparator::lessThan($db_version, "0.10.0")) self::migrate_0_10_0();
// Update version
$stmt = $this->conn->prepare("UPDATE meta SET v=:version WHERE k='version';");
$stmt->bindValue(":version", Database::LATEST_VERSION);
$stmt->execute();
$this->logger->info("Completed migration to v" . Database::LATEST_VERSION . ".");
return Response::satisfied();
});
}
/**
* Migrates the database from a previous version to one compatible with v0.5.0.
*
* @return void
*/
private function migrate_0_5_0(): void
{
$this->logger->info("Migrating to v0.5.0.");
$res = $this->conn->exec("ALTER TABLE users
ADD COLUMN email_notifications_enabled INT NOT NULL DEFAULT(1);") !== false;
if (!$res) {
$this->logger->error("Failed migrating to v0.5.0.", ["error" => $this->conn->errorInfo()]);
$this->conn->rollBack();
Util::http_exit(500);
}
}
/**
* Migrates the database from a previous version to one compatible with v0.8.0.
*
* @return void
* @noinspection SqlResolve Function necessarily refers to old schema which is not detected by tools
*/
private function migrate_0_8_0(): void
{
$this->logger->info("Migrating to v0.8.0.");
$res =
$this->conn->exec("CREATE TABLE new_email_tasks(type TEXT NOT NULL,
recipient TEXT NOT NULL,
arg1 TEXT DEFAULT(NULL),
PRIMARY KEY (type, recipient, arg1));") !== false &&
$this->conn->exec("INSERT INTO new_email_tasks (type, recipient, arg1)
SELECT type, arg1, arg2
FROM email_tasks WHERE arg1 NOT NULL;") !== false &&
$this->conn->exec("DROP TABLE email_tasks;") !== false &&
$this->conn->exec("ALTER TABLE new_email_tasks RENAME TO email_tasks;") !== false;
if (!$res) {
$this->logger->error("Failed migrating to v0.8.0.", ["error" => $this->conn->errorInfo()]);
$this->conn->rollBack();
Util::http_exit(500);
}
}
/**
* Migrates the database from a previous version to one compatible with v0.10.0.
*
* @return void
* @noinspection SqlResolve Function necessarily refers to old schema which is not detected by tools
*/
private function migrate_0_10_0(): void
{
$this->logger->info("Migrating to v0.10.0.");
$res =
$this->conn->exec("CREATE TABLE new_email_tasks(type TEXT NOT NULL,
recipient TEXT NOT NULL,
arg1 TEXT NOT NULL DEFAULT(''),
arg2 TEXT NOT NULL DEFAULT(''),
PRIMARY KEY (type, recipient, arg1, arg2));") !== false &&
$this->conn->exec("INSERT INTO new_email_tasks (type, recipient, arg1)
SELECT type, recipient, arg1
FROM email_tasks;") !== false &&
$this->conn->exec("DROP TABLE email_tasks;") !== false &&
$this->conn->exec("ALTER TABLE new_email_tasks RENAME TO email_tasks;") !== false &&
$this->conn->exec("UPDATE email_tasks
SET type='notify-status-changed' AND arg2='dead'
WHERE type='notify-death'") !== false;
if (!$res) {
$this->logger->error("Failed migrating to v0.10.0.", ["error" => $this->conn->errorInfo()]);
$this->conn->rollBack();
Util::http_exit(500);
}
}
/**
* Executes `lambda` within a single transaction, allowing nesting.
*
* If no transaction has been started when this function is invoked, a new transaction is started. If `lambda`
* returns a satisfied `Response`, the transaction is committed, otherwise the transaction is rolled back.
*
* If a transaction has been started when this function is invoked, the transaction is continued, and `lambda` does
* not by itself commit or roll back the transaction, this function does not commit or roll back the transaction
* either.
*
* @param PDO $conn the connection to perform the transaction over
* @param callable(): (Response|null) $lambda the function to execute within the transaction, returning a satisfied
* `Response` or `null` if the transaction should be committed, or an unsatisfied `Response` if the transaction
* should be rolled back
* @return Response the `Response` returned by `lambda`, or a satisfied `Response` if `lambda` returns `void` or
* `null`
*/
public static function transaction(PDO $conn, callable $lambda): Response
{
$initially_in_transaction = $conn->inTransaction();
if (!$initially_in_transaction)
$conn->beginTransaction();
$output = $lambda() ?? Response::satisfied();
if ($conn->inTransaction() && !$initially_in_transaction)
if ($output->satisfied) $conn->commit();
else $conn->rollBack();
return $output;
}
}