Files
budget/app/Database.php
T
Ty Clifford b7eaa81501 -
2026-06-14 15:25:31 -04:00

165 lines
5.7 KiB
PHP

<?php
declare(strict_types=1);
namespace Budget;
use PDO;
use PDOException;
use RuntimeException;
final class Database
{
private PDO $pdo;
private array $config;
private string $configPath;
public function __construct(string $configPath, string $rootPath)
{
$this->configPath = $configPath;
$this->config = $this->readConfig($configPath);
$driver = (string) ($this->config['driver'] ?? 'sqlite');
if ($driver === 'mysql') {
$mysql = $this->config['mysql'] ?? [];
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$mysql['host'] ?? '127.0.0.1',
(int) ($mysql['port'] ?? 3306),
$mysql['database'] ?? 'budget_planner',
$mysql['charset'] ?? 'utf8mb4'
);
$this->pdo = new PDO($dsn, (string) ($mysql['username'] ?? ''), (string) ($mysql['password'] ?? ''), self::options());
} else {
$path = (string) ($this->config['sqlite_path'] ?? 'storage/database/budget.sqlite');
$absolutePath = str_starts_with($path, '/') ? $path : $rootPath . '/' . $path;
$directory = dirname($absolutePath);
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('Unable to create SQLite storage directory.');
}
$this->pdo = new PDO('sqlite:' . $absolutePath, null, null, self::options());
$this->pdo->exec('PRAGMA foreign_keys = ON');
$this->pdo->exec('PRAGMA journal_mode = WAL');
}
$this->migrate();
}
public function pdo(): PDO
{
return $this->pdo;
}
public function driver(): string
{
return (string) ($this->config['driver'] ?? 'sqlite');
}
public function config(): array
{
return $this->config;
}
public function migrate(): void
{
foreach (Schema::statements($this->driver()) as $statement) {
$this->pdo->exec($statement);
}
}
public function migrateSqliteToMysql(array $mysqlConfig, string $rootPath): int
{
if ($this->driver() !== 'sqlite') {
throw new RuntimeException('Automatic migration can only start while SQLite mode is active.');
}
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$mysqlConfig['host'],
(int) $mysqlConfig['port'],
$mysqlConfig['database'],
$mysqlConfig['charset'] ?? 'utf8mb4'
);
$target = new PDO($dsn, (string) $mysqlConfig['username'], (string) $mysqlConfig['password'], self::options());
foreach (Schema::statements('mysql') as $statement) {
$target->exec($statement);
}
$tables = [
'settings', 'users', 'monthly_plans', 'income_entries', 'categories',
'budget_items', 'transactions', 'dead_account_entries', 'calendar_events',
'alert_logs', 'visits', 'audit_logs',
];
$copied = 0;
$target->beginTransaction();
try {
$target->exec('SET FOREIGN_KEY_CHECKS=0');
foreach ($tables as $table) {
$rows = $this->pdo->query('SELECT * FROM ' . $table)->fetchAll();
if ($rows === []) {
continue;
}
$columns = array_keys($rows[0]);
$quoted = array_map(static fn (string $column): string => '`' . $column . '`', $columns);
$placeholders = array_map(static fn (string $column): string => ':' . $column, $columns);
$sql = sprintf(
'REPLACE INTO `%s` (%s) VALUES (%s)',
$table,
implode(', ', $quoted),
implode(', ', $placeholders)
);
$insert = $target->prepare($sql);
foreach ($rows as $row) {
$insert->execute($row);
$copied++;
}
}
$target->exec('SET FOREIGN_KEY_CHECKS=1');
$target->commit();
} catch (\Throwable $exception) {
if ($target->inTransaction()) {
$target->rollBack();
}
throw $exception;
}
$newConfig = [
'driver' => 'mysql',
'sqlite_path' => $this->config['sqlite_path'] ?? 'storage/database/budget.sqlite',
'mysql' => [
'host' => $mysqlConfig['host'],
'port' => (int) $mysqlConfig['port'],
'database' => $mysqlConfig['database'],
'username' => $mysqlConfig['username'],
'password' => $mysqlConfig['password'],
'charset' => $mysqlConfig['charset'] ?? 'utf8mb4',
],
];
$encoded = json_encode($newConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($encoded === false || file_put_contents($this->configPath, $encoded . PHP_EOL, LOCK_EX) === false) {
throw new RuntimeException('Data copied, but config/database.json could not be updated.');
}
return $copied;
}
private static function options(): array
{
return [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
}
private function readConfig(string $path): array
{
$raw = @file_get_contents($path);
$config = $raw === false ? null : json_decode($raw, true);
if (!is_array($config)) {
throw new RuntimeException('Database configuration is missing or invalid.');
}
return $config;
}
}