266 lines
10 KiB
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();
|
|
}
|
|
}
|