configPath = $configPath; $this->config = $this->readConfig($configPath); $sqliteOverride = getenv('BUDGET_SQLITE_PATH'); if (is_string($sqliteOverride) && $sqliteOverride !== '') { $this->config['driver'] = 'sqlite'; $this->config['sqlite_path'] = $sqliteOverride; } $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; } }