feat(core): Konfiguration, PDO-Datenbankschicht und Migrationen
SQLite als Standard (keine Einrichtung noetig, WAL aktiviert), optional MySQL/MariaDB. Beide Treiber nutzen dieselben vorbereiteten Statements. Die Migrationen laufen automatisch beim ersten Request; jede Migration liefert das SQL je Treiber. Damit entfaellt ein manueller Schritt beim Deployment ueber FTP. data/ enthaelt eine eigene .htaccess als zweite Absicherung, falls das Verzeichnis beim Hoster doch einmal im Webroot landet.
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# Notbremse: Selbst wenn dieses Verzeichnis versehentlich im Webroot
|
||||
# landet, darf niemand die Datenbank oder hochgeladene Logos abrufen.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Initiales Schema.
|
||||
* Zeitstempel werden bewusst als UTC-Strings (Y-m-d H:i:s) gespeichert,
|
||||
* damit SQLite und MySQL identisch behandelt werden koennen.
|
||||
*/
|
||||
return [
|
||||
'sqlite' => [
|
||||
'CREATE TABLE IF NOT EXISTS links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
target_url TEXT NOT NULL,
|
||||
title TEXT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
click_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_clicked_at TEXT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_links_created_at ON links (created_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS visits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
link_id INTEGER NOT NULL,
|
||||
visited_at TEXT NOT NULL,
|
||||
referrer_host TEXT NULL,
|
||||
referrer_url TEXT NULL,
|
||||
user_agent TEXT NULL,
|
||||
device_type TEXT NOT NULL DEFAULT "unknown",
|
||||
browser TEXT NOT NULL DEFAULT "unknown",
|
||||
platform TEXT NOT NULL DEFAULT "unknown",
|
||||
visitor_hash TEXT NULL,
|
||||
FOREIGN KEY (link_id) REFERENCES links (id) ON DELETE CASCADE
|
||||
)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_visits_link_time ON visits (link_id, visited_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS qr_designs (
|
||||
link_id INTEGER PRIMARY KEY,
|
||||
foreground_color TEXT NOT NULL DEFAULT "#000000",
|
||||
background_color TEXT NOT NULL DEFAULT "#ffffff",
|
||||
size INTEGER NOT NULL DEFAULT 512,
|
||||
margin INTEGER NOT NULL DEFAULT 4,
|
||||
error_correction TEXT NOT NULL DEFAULT "M",
|
||||
logo_path TEXT NULL,
|
||||
logo_scale INTEGER NOT NULL DEFAULT 22,
|
||||
logo_punchout INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (link_id) REFERENCES links (id) ON DELETE CASCADE
|
||||
)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS settings (
|
||||
key_name TEXT PRIMARY KEY,
|
||||
value TEXT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
identifier TEXT NOT NULL,
|
||||
attempted_at TEXT NOT NULL,
|
||||
successful INTEGER NOT NULL DEFAULT 0
|
||||
)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_login_attempts ON login_attempts (identifier, attempted_at)',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'CREATE TABLE IF NOT EXISTS links (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(190) NOT NULL,
|
||||
target_url TEXT NOT NULL,
|
||||
title VARCHAR(255) NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
click_count INT NOT NULL DEFAULT 0,
|
||||
last_clicked_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY uniq_links_slug (slug),
|
||||
KEY idx_links_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS visits (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
link_id INT NOT NULL,
|
||||
visited_at DATETIME NOT NULL,
|
||||
referrer_host VARCHAR(255) NULL,
|
||||
referrer_url TEXT NULL,
|
||||
user_agent TEXT NULL,
|
||||
device_type VARCHAR(20) NOT NULL DEFAULT "unknown",
|
||||
browser VARCHAR(40) NOT NULL DEFAULT "unknown",
|
||||
platform VARCHAR(40) NOT NULL DEFAULT "unknown",
|
||||
visitor_hash VARCHAR(64) NULL,
|
||||
KEY idx_visits_link_time (link_id, visited_at),
|
||||
CONSTRAINT fk_visits_link FOREIGN KEY (link_id) REFERENCES links (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS qr_designs (
|
||||
link_id INT PRIMARY KEY,
|
||||
foreground_color VARCHAR(7) NOT NULL DEFAULT "#000000",
|
||||
background_color VARCHAR(7) NOT NULL DEFAULT "#ffffff",
|
||||
size INT NOT NULL DEFAULT 512,
|
||||
margin INT NOT NULL DEFAULT 4,
|
||||
error_correction VARCHAR(1) NOT NULL DEFAULT "M",
|
||||
logo_path VARCHAR(255) NULL,
|
||||
logo_scale INT NOT NULL DEFAULT 22,
|
||||
logo_punchout TINYINT(1) NOT NULL DEFAULT 1,
|
||||
updated_at DATETIME NOT NULL,
|
||||
CONSTRAINT fk_qr_link FOREIGN KEY (link_id) REFERENCES links (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS settings (
|
||||
key_name VARCHAR(190) PRIMARY KEY,
|
||||
value TEXT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
identifier VARCHAR(64) NOT NULL,
|
||||
attempted_at DATETIME NOT NULL,
|
||||
successful TINYINT(1) NOT NULL DEFAULT 0,
|
||||
KEY idx_login_attempts (identifier, attempted_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Mehrere Kurzlink-Domains auf einer Installation.
|
||||
*
|
||||
* Ein Kurzlink gehoert ab hier zu genau einer Domain; Slugs muessen nur
|
||||
* noch INNERHALB einer Domain eindeutig sein. "qr.example.de/messe" und
|
||||
* "short.example.de/messe" sind damit zwei verschiedene Links.
|
||||
*
|
||||
* Bestandslinks bekommen die Kennung 0 ("noch nicht zugeordnet"). Beim
|
||||
* ersten Zugriff legt die Anwendung aus APP_BASE_URL die Standarddomain
|
||||
* an und traegt sie bei diesen Links nach (siehe DomainRepository).
|
||||
* Ein Sentinel-Wert statt NULL, weil SQLite NULL-Werte in einem
|
||||
* UNIQUE-Index als jeweils verschieden behandelt - die Eindeutigkeit
|
||||
* waere sonst nicht durchgesetzt.
|
||||
*/
|
||||
return [
|
||||
'sqlite' => [
|
||||
'CREATE TABLE IF NOT EXISTS domains (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host TEXT NOT NULL UNIQUE,
|
||||
base_url TEXT NOT NULL,
|
||||
label TEXT NULL,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
)',
|
||||
|
||||
// SQLite kann eine UNIQUE-Spalte nicht nachtraeglich aendern,
|
||||
// deshalb wird die Tabelle neu aufgebaut.
|
||||
'PRAGMA foreign_keys = OFF',
|
||||
|
||||
'CREATE TABLE links_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain_id INTEGER NOT NULL DEFAULT 0,
|
||||
slug TEXT NOT NULL,
|
||||
target_url TEXT NOT NULL,
|
||||
title TEXT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
click_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_clicked_at TEXT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (domain_id, slug)
|
||||
)',
|
||||
|
||||
'INSERT INTO links_new
|
||||
(id, domain_id, slug, target_url, title, is_active, click_count, last_clicked_at, created_at, updated_at)
|
||||
SELECT id, 0, slug, target_url, title, is_active, click_count, last_clicked_at, created_at, updated_at
|
||||
FROM links',
|
||||
|
||||
'DROP TABLE links',
|
||||
'ALTER TABLE links_new RENAME TO links',
|
||||
|
||||
'CREATE INDEX IF NOT EXISTS idx_links_created_at ON links (created_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_links_domain ON links (domain_id)',
|
||||
|
||||
'PRAGMA foreign_keys = ON',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'CREATE TABLE IF NOT EXISTS domains (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
host VARCHAR(190) NOT NULL,
|
||||
base_url VARCHAR(255) NOT NULL,
|
||||
label VARCHAR(255) NULL,
|
||||
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL,
|
||||
UNIQUE KEY uniq_domains_host (host)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4',
|
||||
|
||||
'ALTER TABLE links ADD COLUMN domain_id INT NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE links DROP INDEX uniq_links_slug',
|
||||
'ALTER TABLE links ADD UNIQUE KEY uniq_links_domain_slug (domain_id, slug)',
|
||||
'ALTER TABLE links ADD INDEX idx_links_domain (domain_id)',
|
||||
],
|
||||
];
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* Konfiguration aus .env-Datei und Umgebungsvariablen.
|
||||
*
|
||||
* Reihenfolge: Werte aus der .env-Datei gewinnen, danach echte
|
||||
* Umgebungsvariablen (getenv / $_ENV / $_SERVER), zuletzt der Default.
|
||||
*/
|
||||
final class Config
|
||||
{
|
||||
/** @var array<string,string> */
|
||||
private array $values = [];
|
||||
|
||||
private function __construct(private readonly string $rootDir)
|
||||
{
|
||||
}
|
||||
|
||||
public static function load(string $rootDir, ?string $envFile = null): self
|
||||
{
|
||||
$config = new self(rtrim(str_replace('\\', '/', $rootDir), '/'));
|
||||
$envFile ??= $config->rootDir . '/.env';
|
||||
|
||||
if (is_readable($envFile)) {
|
||||
$config->values = self::parseEnvFile($envFile);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private static function parseEnvFile(string $path): array
|
||||
{
|
||||
$result = [];
|
||||
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
||||
|
||||
foreach ($lines ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$key, $value] = explode('=', $line, 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value);
|
||||
|
||||
// Umschliessende Anfuehrungszeichen entfernen
|
||||
if (strlen($value) >= 2) {
|
||||
$first = $value[0];
|
||||
if (($first === '"' || $first === "'") && str_ends_with($value, $first)) {
|
||||
$value = substr($value, 1, -1);
|
||||
}
|
||||
}
|
||||
|
||||
if ($key !== '') {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function get(string $key, ?string $default = null): ?string
|
||||
{
|
||||
if (array_key_exists($key, $this->values) && $this->values[$key] !== '') {
|
||||
return $this->values[$key];
|
||||
}
|
||||
|
||||
foreach ([$_ENV, $_SERVER] as $source) {
|
||||
if (isset($source[$key]) && is_scalar($source[$key]) && (string) $source[$key] !== '') {
|
||||
return (string) $source[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$fromEnv = getenv($key);
|
||||
if ($fromEnv !== false && $fromEnv !== '') {
|
||||
return $fromEnv;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
public function getString(string $key, string $default = ''): string
|
||||
{
|
||||
return $this->get($key, $default) ?? $default;
|
||||
}
|
||||
|
||||
public function getInt(string $key, int $default = 0): int
|
||||
{
|
||||
$value = $this->get($key);
|
||||
|
||||
return $value === null || !is_numeric($value) ? $default : (int) $value;
|
||||
}
|
||||
|
||||
public function getBool(string $key, bool $default = false): bool
|
||||
{
|
||||
$value = $this->get($key);
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
public function rootDir(): string
|
||||
{
|
||||
return $this->rootDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest einen ggf. relativen Pfad gegen das Projekt-Stammverzeichnis auf.
|
||||
*/
|
||||
public function path(string $relative): string
|
||||
{
|
||||
$relative = str_replace('\\', '/', $relative);
|
||||
|
||||
$isAbsolute = str_starts_with($relative, '/')
|
||||
|| (bool) preg_match('#^[A-Za-z]:/#', $relative);
|
||||
|
||||
return $isAbsolute ? $relative : $this->rootDir . '/' . ltrim($relative, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rueckfall-Basis-URL, wenn APP_BASE_URL nicht gesetzt ist.
|
||||
* Wird von der Anwendung aus dem (geprueften) Host der Anfrage
|
||||
* gesetzt - der Host-Header selbst wird hier bewusst nicht gelesen.
|
||||
*/
|
||||
private ?string $fallbackBaseUrl = null;
|
||||
|
||||
public function setFallbackBaseUrl(string $url): void
|
||||
{
|
||||
$url = trim($url);
|
||||
|
||||
if ($url !== '') {
|
||||
$this->fallbackBaseUrl = rtrim($url, '/');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basis-URL ohne abschliessenden Slash.
|
||||
*
|
||||
* Ist APP_BASE_URL nicht gesetzt, wird der von der Anwendung
|
||||
* gepruefte Host verwendet. Ein erfundener Host-Header kann damit
|
||||
* nicht in erzeugte Links oder QR-Codes gelangen.
|
||||
*/
|
||||
public function baseUrl(): string
|
||||
{
|
||||
$configured = $this->get('APP_BASE_URL');
|
||||
if ($configured !== null) {
|
||||
return rtrim($configured, '/');
|
||||
}
|
||||
|
||||
return $this->fallbackBaseUrl ?? 'http://localhost';
|
||||
}
|
||||
|
||||
/**
|
||||
* IP-Adressen und CIDR-Bereiche, deren X-Forwarded-*-Header
|
||||
* ausgewertet werden duerfen. Standard: keine.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function trustedProxies(): array
|
||||
{
|
||||
return \App\Support\IpRange::parseList($this->getString('TRUSTED_PROXIES', ''));
|
||||
}
|
||||
|
||||
public function isDevelopment(): bool
|
||||
{
|
||||
return strtolower($this->getString('APP_ENV', 'production')) === 'development';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist ein Admin-Passwort hinterlegt (per .env)?
|
||||
*/
|
||||
public function adminPasswordHash(): ?string
|
||||
{
|
||||
$hash = $this->get('ADMIN_PASSWORD_HASH');
|
||||
|
||||
return $hash !== null && $hash !== '' ? $hash : null;
|
||||
}
|
||||
|
||||
public function adminUser(): string
|
||||
{
|
||||
return $this->getString('ADMIN_USER', 'admin');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use PDO;
|
||||
use PDOException;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Duenner PDO-Wrapper. Unterstuetzt SQLite (Standard) und MySQL/MariaDB.
|
||||
* Alle Abfragen laufen ueber vorbereitete Statements.
|
||||
*/
|
||||
final class Database
|
||||
{
|
||||
private function __construct(
|
||||
private readonly PDO $pdo,
|
||||
private readonly string $driver,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromConfig(Config $config): self
|
||||
{
|
||||
$driver = strtolower($config->getString('DB_DRIVER', 'sqlite'));
|
||||
$sqlitePath = $config->getString('DB_SQLITE_PATH', 'data/database.sqlite');
|
||||
|
||||
return match ($driver) {
|
||||
'sqlite' => self::sqlite($sqlitePath === ':memory:' ? ':memory:' : $config->path($sqlitePath)),
|
||||
'mysql', 'mariadb' => self::mysql($config),
|
||||
default => throw new RuntimeException(sprintf('Unbekannter DB_DRIVER "%s" (erlaubt: sqlite, mysql).', $driver)),
|
||||
};
|
||||
}
|
||||
|
||||
public static function sqlite(string $path): self
|
||||
{
|
||||
if ($path !== ':memory:') {
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException(sprintf('Datenbankverzeichnis "%s" konnte nicht angelegt werden.', $dir));
|
||||
}
|
||||
if (!is_writable($dir)) {
|
||||
throw new RuntimeException(sprintf('Datenbankverzeichnis "%s" ist nicht beschreibbar.', $dir));
|
||||
}
|
||||
}
|
||||
|
||||
$pdo = new PDO('sqlite:' . $path, null, null, self::defaultOptions());
|
||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||
$pdo->exec('PRAGMA journal_mode = WAL');
|
||||
$pdo->exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
return new self($pdo, 'sqlite');
|
||||
}
|
||||
|
||||
private static function mysql(Config $config): self
|
||||
{
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||
$config->getString('DB_HOST', '127.0.0.1'),
|
||||
$config->getInt('DB_PORT', 3306),
|
||||
$config->getString('DB_NAME', 'urlshort'),
|
||||
$config->getString('DB_CHARSET', 'utf8mb4'),
|
||||
);
|
||||
|
||||
try {
|
||||
$pdo = new PDO(
|
||||
$dsn,
|
||||
$config->getString('DB_USER', 'root'),
|
||||
$config->getString('DB_PASSWORD', ''),
|
||||
self::defaultOptions(),
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
throw new RuntimeException('Verbindung zur MySQL-Datenbank fehlgeschlagen: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
return new self($pdo, 'mysql');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,mixed>
|
||||
*/
|
||||
private static function defaultOptions(): array
|
||||
{
|
||||
return [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function pdo(): PDO
|
||||
{
|
||||
return $this->pdo;
|
||||
}
|
||||
|
||||
public function driver(): string
|
||||
{
|
||||
return $this->driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int,mixed> $params
|
||||
*/
|
||||
public function execute(string $sql, array $params = []): \PDOStatement
|
||||
{
|
||||
$statement = $this->pdo->prepare($sql);
|
||||
$statement->execute($params);
|
||||
|
||||
return $statement;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int,mixed> $params
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function fetchOne(string $sql, array $params = []): ?array
|
||||
{
|
||||
$row = $this->execute($sql, $params)->fetch();
|
||||
|
||||
return $row === false ? null : $row;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int,mixed> $params
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
public function fetchAll(string $sql, array $params = []): array
|
||||
{
|
||||
/** @var list<array<string,mixed>> $rows */
|
||||
$rows = $this->execute($sql, $params)->fetchAll();
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string|int,mixed> $params
|
||||
*/
|
||||
public function fetchValue(string $sql, array $params = []): mixed
|
||||
{
|
||||
$value = $this->execute($sql, $params)->fetchColumn();
|
||||
|
||||
return $value === false ? null : $value;
|
||||
}
|
||||
|
||||
public function lastInsertId(): int
|
||||
{
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
/**
|
||||
* Sehr einfache Migrationen: jede Migration ist eine PHP-Datei in
|
||||
* /migrations, die ein Array ['sqlite' => [...SQL], 'mysql' => [...SQL]]
|
||||
* zurueckgibt. Bereits ausgefuehrte Migrationen werden in der Tabelle
|
||||
* "migrations" vermerkt. Laeuft automatisch beim ersten Request.
|
||||
*/
|
||||
final class Migrator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Database $db,
|
||||
private readonly string $migrationsDir,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> Namen der ausgefuehrten Migrationen
|
||||
*/
|
||||
public function migrate(): array
|
||||
{
|
||||
$this->ensureMigrationsTable();
|
||||
|
||||
$applied = [];
|
||||
$already = $this->appliedMigrations();
|
||||
|
||||
foreach ($this->availableMigrations() as $name => $file) {
|
||||
if (in_array($name, $already, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var array<string,list<string>> $definition */
|
||||
$definition = require $file;
|
||||
$statements = $definition[$this->db->driver()] ?? $definition['sqlite'] ?? [];
|
||||
|
||||
foreach ($statements as $sql) {
|
||||
$sql = trim($sql);
|
||||
if ($sql !== '') {
|
||||
$this->db->pdo()->exec($sql);
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->execute(
|
||||
'INSERT INTO migrations (name, applied_at) VALUES (?, ?)',
|
||||
[$name, gmdate('Y-m-d H:i:s')],
|
||||
);
|
||||
$applied[] = $name;
|
||||
}
|
||||
|
||||
return $applied;
|
||||
}
|
||||
|
||||
private function ensureMigrationsTable(): void
|
||||
{
|
||||
$sql = $this->db->driver() === 'mysql'
|
||||
? 'CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(190) NOT NULL UNIQUE,
|
||||
applied_at DATETIME NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'
|
||||
: 'CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
applied_at TEXT NOT NULL
|
||||
)';
|
||||
|
||||
$this->db->pdo()->exec($sql);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function appliedMigrations(): array
|
||||
{
|
||||
$rows = $this->db->fetchAll('SELECT name FROM migrations');
|
||||
|
||||
return array_map(static fn (array $row): string => (string) $row['name'], $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string> name => absoluter Pfad
|
||||
*/
|
||||
private function availableMigrations(): array
|
||||
{
|
||||
$files = glob($this->migrationsDir . '/*.php') ?: [];
|
||||
sort($files, SORT_STRING);
|
||||
|
||||
$result = [];
|
||||
foreach ($files as $file) {
|
||||
$result[basename($file, '.php')] = $file;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user