Docker Compose mit drei Services (nginx auf Port 8000), Migrationsmechanik, Update- und Backup-Skript. Anwendung: Router mit CSRF-Prüfung, Anmeldung und Ersteinrichtung, zentraler Einstellungsdialog mit Reitern, ein- und ausschaltbares Debug-Log mit Maskierung von Zugangsdaten. Layout nach Vorbild des FinanzManagers: feste Navigation links, Werkzeugleiste oben, Tab-Leiste; Farbschema hell/dunkel/System. Tests: 51 PHP-Prüfungen und 7 Playwright-Tests, alle grün.
120 lines
3.3 KiB
PHP
120 lines
3.3 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace Finanzmanager;
|
||
|
||
use Finanzmanager\Services\DebugLog;
|
||
use PDO;
|
||
|
||
/**
|
||
* Schmaler PDO-Wrapper. Sämtlicher SQL-Zugriff der Anwendung läuft über
|
||
* fetchAll/fetchOne/execute – ausschließlich mit Prepared Statements.
|
||
*/
|
||
class Database
|
||
{
|
||
private static ?PDO $instance = null;
|
||
private static int $txDepth = 0;
|
||
|
||
public static function get(): PDO
|
||
{
|
||
if (self::$instance === null) {
|
||
$host = Config::get('db_host');
|
||
$name = Config::get('db_name');
|
||
|
||
self::$instance = new PDO(
|
||
"mysql:host={$host};dbname={$name};charset=utf8mb4",
|
||
(string)Config::get('db_user'),
|
||
(string)Config::get('db_pass'),
|
||
[
|
||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||
PDO::ATTR_EMULATE_PREPARES => false,
|
||
]
|
||
);
|
||
}
|
||
|
||
return self::$instance;
|
||
}
|
||
|
||
public static function query(string $sql, array $params = []): \PDOStatement
|
||
{
|
||
$start = microtime(true);
|
||
$stmt = self::get()->prepare($sql);
|
||
$stmt->execute($params);
|
||
DebugLog::sql($sql, $params, microtime(true) - $start);
|
||
return $stmt;
|
||
}
|
||
|
||
public static function fetchAll(string $sql, array $params = []): array
|
||
{
|
||
return self::query($sql, $params)->fetchAll();
|
||
}
|
||
|
||
public static function fetchOne(string $sql, array $params = []): array|false
|
||
{
|
||
return self::query($sql, $params)->fetch();
|
||
}
|
||
|
||
/** Liefert einen Skalarwert der ersten Spalte, oder $default bei leerem Ergebnis. */
|
||
public static function fetchValue(string $sql, array $params = [], mixed $default = null): mixed
|
||
{
|
||
$wert = self::query($sql, $params)->fetchColumn();
|
||
return $wert === false ? $default : $wert;
|
||
}
|
||
|
||
public static function execute(string $sql, array $params = []): int
|
||
{
|
||
return self::query($sql, $params)->rowCount();
|
||
}
|
||
|
||
public static function lastInsertId(): int
|
||
{
|
||
return (int)self::get()->lastInsertId();
|
||
}
|
||
|
||
/**
|
||
* Verschachtelbare Transaktionen: nur die äußerste öffnet und schließt
|
||
* wirklich. Nötig, weil Tests bereits eine Transaktion halten, die
|
||
* Anwendungslogik darin aber ihrerseits welche startet.
|
||
*/
|
||
public static function beginTransaction(): void
|
||
{
|
||
if (self::$txDepth === 0) {
|
||
self::get()->beginTransaction();
|
||
}
|
||
self::$txDepth++;
|
||
}
|
||
|
||
public static function commit(): void
|
||
{
|
||
if (self::$txDepth > 0) {
|
||
self::$txDepth--;
|
||
}
|
||
if (self::$txDepth === 0 && self::get()->inTransaction()) {
|
||
self::get()->commit();
|
||
}
|
||
}
|
||
|
||
public static function rollBack(): void
|
||
{
|
||
self::$txDepth = 0;
|
||
if (self::get()->inTransaction()) {
|
||
self::get()->rollBack();
|
||
}
|
||
}
|
||
|
||
/** Führt $fn in einer Transaktion aus und rollt bei jeder Ausnahme zurück. */
|
||
public static function transaktion(callable $fn): mixed
|
||
{
|
||
self::beginTransaction();
|
||
try {
|
||
$ergebnis = $fn();
|
||
self::commit();
|
||
return $ergebnis;
|
||
} catch (\Throwable $e) {
|
||
self::rollBack();
|
||
throw $e;
|
||
}
|
||
}
|
||
}
|