-
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user