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

266 lines
10 KiB
PHP

<?php
namespace com\fwdekker\deathnotifier;
use com\fwdekker\deathnotifier\mailer\EmailQueue;
use com\fwdekker\deathnotifier\tracking\TrackingList;
use com\fwdekker\deathnotifier\user\UserList;
use Composer\Semver\Comparator;
use Error;
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 readonly Logger $db_logger;
/**
* @var PDO the PDO object that connects to the database
*/
public readonly PDO $conn;
/**
* Opens a connection with the database at `filename`.
*
* @param string $filename the path to the database to connect to
*/
public function __construct(string $filename)
{
$this->db_logger = LoggerUtil::db_with_name($this::class);
$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 EmailQueue $email_queue the `EmailQueue` to install
* @param UserList $user_list the `UserList` to install
* @param TrackingList $tracking_list the `TrackingList` to install
* @return void
*/
public function auto_install(EmailQueue $email_queue, UserList $user_list,
TrackingList $tracking_list): void
{
$this->transaction(function () use ($email_queue, $user_list, $tracking_list) {
// Check if already installed
$stmt = $this->conn->prepare("SELECT count(*) FROM sqlite_master WHERE type = 'table';");
$stmt->execute();
if ($stmt->fetch()[0] !== 0)
return;
$this->db_logger->notice("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
$email_queue->install();
$user_list->install();
$tracking_list->install();
$this->db_logger->notice("Installation complete.");
});
}
/**
* Automatically migrates the database to the latest version.
*
* @return void
*/
public function auto_migrate(): void
{
$this->transaction(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;
$this->db_logger->notice("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();
if (Comparator::lessThan($db_version, "0.16.0")) self::migrate_0_16_0();
if (Comparator::lessThan($db_version, "0.18.0")) self::migrate_0_18_0();
// Update version
$stmt = $this->conn->prepare("UPDATE meta SET v=:version WHERE k='version';");
$stmt->bindValue(":version", Database::LATEST_VERSION);
$stmt->execute();
$this->db_logger->notice("Completed migration to v" . Database::LATEST_VERSION . ".");
});
}
/**
* Migrates the database from a previous version to one compatible with v0.5.0.
*
* @return void
*/
private function migrate_0_5_0(): void
{
$this->db_logger->notice("Migrating to v0.5.0.");
$this->conn->exec("ALTER TABLE users
ADD COLUMN email_notifications_enabled INT NOT NULL DEFAULT(1);");
}
/**
* 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->db_logger->notice("Migrating to v0.8.0.");
$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));");
$this->conn->exec("INSERT INTO new_email_tasks (type, recipient, arg1)
SELECT type, arg1, arg2
FROM email_tasks
WHERE arg1 NOT NULL;");
$this->conn->exec("DROP TABLE email_tasks;");
$this->conn->exec("ALTER TABLE new_email_tasks RENAME TO email_tasks;");
}
/**
* 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->db_logger->notice("Migrating to v0.10.0.");
$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));");
$this->conn->exec("INSERT INTO new_email_tasks (type, recipient, arg1)
SELECT type, recipient, arg1
FROM email_tasks;");
$this->conn->exec("DROP TABLE email_tasks;");
$this->conn->exec("ALTER TABLE new_email_tasks RENAME TO email_tasks;");
$this->conn->exec("UPDATE email_tasks
SET type='notify-status-changed' AND arg2='dead'
WHERE type='notify-death'");
}
/**
* Migrates the database from a previous version to one compatible with v0.16.0.
*
* @return void
* @noinspection SqlResolve Function necessarily refers to old schema which is not detected by tools
*/
private function migrate_0_16_0(): void
{
$this->db_logger->notice("Migrating to v0.16.0.");
$this->conn->exec("DROP TABLE email_tasks;");
$this->conn->exec("CREATE TABLE email_tasks(type_key TEXT NOT NULL,
recipient TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL,
PRIMARY KEY (type_key, recipient));");
}
/**
* Migrates the database from a previous version to one compatible with v0.18.0.
*
* @return void
* @noinspection SqlResolve Function necessarily refers to old schema which is not detected by tools
*/
private function migrate_0_18_0(): void
{
$this->db_logger->notice("Migrating to v0.18.0.");
$this->conn->exec("CREATE TABLE new_trackings(user_uuid TEXT NOT NULL,
person_name TEXT NOT NULL,
since INT NOT NULL DEFAULT(unixepoch()),
PRIMARY KEY (user_uuid, person_name),
FOREIGN KEY (user_uuid) REFERENCES users (uuid)
ON DELETE CASCADE
ON UPDATE CASCADE,
FOREIGN KEY (person_name) REFERENCES people (name)
ON DELETE CASCADE
ON UPDATE CASCADE);");
$this->conn->exec("INSERT INTO new_trackings (user_uuid, person_name)
SELECT user_uuid, person_name
FROM trackings;");
$this->conn->exec("DROP TRIGGER people_cull_orphans;");
$this->conn->exec("DROP TABLE trackings;");
$this->conn->exec("ALTER TABLE new_trackings RENAME TO trackings;");
$this->conn->exec("CREATE TRIGGER people_cull_orphans
AFTER DELETE ON trackings
FOR EACH ROW
WHEN (SELECT COUNT(*) FROM trackings WHERE person_name=OLD.person_name)=0
BEGIN
DELETE FROM people WHERE name=OLD.person_name;
END;");
}
/**
* Executes {@see $lambda} within a transaction, allowing nesting.
*
* If no transaction has been started when this function is invoked, a new transaction is started. If {@see $lambda}
* throws an exception, the transaction is rolled back, otherwise, if this method is not invoked while a transaction
* was already ongoing, the transaction is committed.
*
* @param callable(): void $lambda the function to execute within a transaction
*/
public function transaction(callable $lambda): void
{
$initially_in_transaction = $this->conn->inTransaction();
if (!$initially_in_transaction)
$this->conn->beginTransaction();
try {
$lambda();
} catch (Error $exception) {
if ($this->conn->inTransaction())
$this->conn->rollBack();
throw $exception;
}
if ($this->conn->inTransaction() && !$initially_in_transaction)
$this->conn->commit();
}
}