driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); } public function ensureSchema(): void { $fetchTable = $this->table('fetches'); $rateTable = $this->table('rates'); if ($this->driver === 'pgsql') { $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} ( id SERIAL PRIMARY KEY, provider VARCHAR(64) NOT NULL, trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual', base_currency VARCHAR(10) NOT NULL, rate_date DATE NOT NULL, fetched_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $this->pdo->exec("ALTER TABLE {$fetchTable} ADD COLUMN IF NOT EXISTS trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'"); $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} ( id SERIAL PRIMARY KEY, fetch_id INTEGER NOT NULL REFERENCES {$fetchTable}(id) ON DELETE CASCADE, currency_code VARCHAR(10) NOT NULL, current_value NUMERIC(20,10) NOT NULL )"); $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)"); $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)"); $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)"); $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)"); } elseif ($this->driver === 'mysql') { $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} ( id INTEGER PRIMARY KEY AUTO_INCREMENT, provider VARCHAR(64) NOT NULL, trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual', base_currency VARCHAR(10) NOT NULL, rate_date DATE NOT NULL, fetched_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY {$fetchTable}_base_fetch_idx (base_currency, fetched_at, id), KEY {$fetchTable}_rate_date_idx (rate_date) )"); $this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'"); $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} ( id INTEGER PRIMARY KEY AUTO_INCREMENT, fetch_id INTEGER NOT NULL, currency_code VARCHAR(10) NOT NULL, current_value DECIMAL(20,10) NOT NULL, KEY {$rateTable}_fetch_idx (fetch_id), KEY {$rateTable}_currency_idx (currency_code) )"); } else { $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider VARCHAR(64) NOT NULL, trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual', base_currency VARCHAR(10) NOT NULL, rate_date DATE NOT NULL, fetched_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'"); $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} ( id INTEGER PRIMARY KEY AUTOINCREMENT, fetch_id INTEGER NOT NULL, currency_code VARCHAR(10) NOT NULL, current_value DECIMAL(20,10) NOT NULL )"); $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)"); $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)"); $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)"); $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)"); } } public function getLatestFetch(?string $baseCurrency = null): ?array { $sql = 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches'); $params = []; if ($baseCurrency !== null && trim($baseCurrency) !== '') { $sql .= ' WHERE base_currency = :base_currency'; $params['base_currency'] = strtoupper(trim($baseCurrency)); } $sql .= ' ORDER BY fetched_at DESC, id DESC LIMIT 1'; $stmt = $this->pdo->prepare($sql); $stmt->execute($params); $row = $stmt->fetch(PDO::FETCH_ASSOC); return is_array($row) ? $this->normalizeFetch($row) : null; } public function listLatestFetches(): array { $stmt = $this->pdo->query( 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches') . ' ORDER BY fetched_at DESC, id DESC' ); $latestByBase = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { $base = strtoupper(trim((string) ($row['base_currency'] ?? ''))); if ($base === '' || isset($latestByBase[$base])) { continue; } $latestByBase[$base] = $this->normalizeFetch($row); } ksort($latestByBase); return array_values($latestByBase); } public function listRecentFetches(int $limit = 20): array { $stmt = $this->pdo->prepare( 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches') . ' ORDER BY fetched_at DESC, id DESC LIMIT :limit' ); $stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT); $stmt->execute(); return array_map( fn (array $row): array => $this->normalizeFetch($row), $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] ); } public function getSnapshotByFetchId(int $fetchId, ?array $symbols = null): ?array { $fetch = $this->getFetchById($fetchId); if ($fetch === null) { return null; } return $fetch + [ 'rates' => $this->ratesForFetch($fetchId, $symbols), ]; } public function findNearestFetch(?string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array { $targetTs = strtotime($timestamp); if ($targetTs === false) { return null; } if ($baseCurrency !== null && trim($baseCurrency) !== '') { return $this->getNearestFetch(strtoupper(trim($baseCurrency)), $timestamp, $windowMinutes); } $candidates = []; foreach (['<=', '>='] as $operator) { $order = $operator === '<=' ? 'DESC' : 'ASC'; $stmt = $this->pdo->prepare( 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches') . ' WHERE fetched_at ' . $operator . ' :target_at ORDER BY fetched_at ' . $order . ', id ' . $order . ' LIMIT 1' ); $stmt->execute(['target_at' => $timestamp]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (is_array($row)) { $candidate = $this->normalizeFetch($row); $candidateTs = strtotime((string) ($candidate['fetched_at'] ?? '')); if ($candidateTs !== false) { $candidate['distance_seconds'] = abs($candidateTs - $targetTs); $candidates[] = $candidate; } } } if ($candidates === []) { return null; } usort($candidates, static function (array $left, array $right): int { return ((int) ($left['distance_seconds'] ?? PHP_INT_MAX)) <=> ((int) ($right['distance_seconds'] ?? PHP_INT_MAX)); }); $selected = $candidates[0]; if ($windowMinutes !== null && $windowMinutes > 0 && (int) ($selected['distance_seconds'] ?? 0) > ($windowMinutes * 60)) { return null; } return $selected; } public function getNearestFetch(string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array { $baseCurrency = strtoupper(trim($baseCurrency)); if ($baseCurrency === '') { return null; } $before = $this->findNeighborFetch($baseCurrency, $timestamp, '<='); $after = $this->findNeighborFetch($baseCurrency, $timestamp, '>='); $targetTs = strtotime($timestamp); if ($targetTs === false) { return null; } $selected = null; $selectedDiff = null; foreach ([$before, $after] as $candidate) { if (!is_array($candidate)) { continue; } $candidateTs = strtotime((string) ($candidate['fetched_at'] ?? '')); if ($candidateTs === false) { continue; } $diffSeconds = abs($candidateTs - $targetTs); if ($selected === null || $diffSeconds < (int) $selectedDiff) { $selected = $candidate; $selectedDiff = $diffSeconds; } } if ($selected === null) { return null; } if ($windowMinutes !== null && $windowMinutes > 0 && $selectedDiff !== null && $selectedDiff > ($windowMinutes * 60)) { return null; } return $selected + ['distance_seconds' => $selectedDiff]; } public function listDirectHistory(string $baseCurrency, string $targetCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array { $sql = 'SELECT r.id, f.id AS fetch_id, f.base_currency, r.currency_code AS target_currency, r.current_value AS rate, f.rate_date, f.provider, f.fetched_at FROM ' . $this->table('rates') . ' r INNER JOIN ' . $this->table('fetches') . ' f ON f.id = r.fetch_id WHERE f.base_currency = :base_currency AND r.currency_code = :target_currency'; $params = [ 'base_currency' => strtoupper(trim($baseCurrency)), 'target_currency' => strtoupper(trim($targetCurrency)), ]; if ($from !== null && trim($from) !== '') { $sql .= ' AND f.fetched_at >= :from_at'; $params['from_at'] = $from; } if ($to !== null && trim($to) !== '') { $sql .= ' AND f.fetched_at <= :to_at'; $params['to_at'] = $to; } $sql .= ' ORDER BY f.fetched_at DESC, r.id DESC LIMIT :limit'; $stmt = $this->pdo->prepare($sql); foreach ($params as $key => $value) { $stmt->bindValue(':' . $key, $value); } $stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT); $stmt->execute(); return array_map(fn (array $row): array => $this->normalizeRate($row), $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []); } public function saveFetch(string $baseCurrency, string $provider, string $rateDate, array $rates, ?string $fetchedAt = null, string $triggerSource = 'manual'): array { $baseCurrency = strtoupper(trim($baseCurrency)); $provider = trim($provider) !== '' ? trim($provider) : 'currencyapi'; $fetchedAt = trim((string) $fetchedAt) !== '' ? trim((string) $fetchedAt) : gmdate('Y-m-d H:i:s'); $triggerSource = $this->normalizeTriggerSource($triggerSource); $normalizedRates = []; foreach ($rates as $currencyCode => $rate) { $currencyCode = strtoupper(trim((string) $currencyCode)); if ($currencyCode === '' || $currencyCode === $baseCurrency || !is_numeric($rate)) { continue; } $normalizedRates[$currencyCode] = (float) $rate; } $startedTransaction = false; if (!$this->pdo->inTransaction()) { $this->pdo->beginTransaction(); $startedTransaction = true; } try { if ($this->driver === 'pgsql') { $fetchStmt = $this->pdo->prepare( 'INSERT INTO ' . $this->table('fetches') . ' ( provider, trigger_source, base_currency, rate_date, fetched_at ) VALUES ( :provider, :trigger_source, :base_currency, :rate_date, :fetched_at ) RETURNING *' ); $fetchStmt->execute([ 'provider' => $provider, 'trigger_source' => $triggerSource, 'base_currency' => $baseCurrency, 'rate_date' => $rateDate, 'fetched_at' => $fetchedAt, ]); $fetch = $this->normalizeFetch($fetchStmt->fetch(PDO::FETCH_ASSOC) ?: []); } else { $fetchStmt = $this->pdo->prepare( 'INSERT INTO ' . $this->table('fetches') . ' ( provider, trigger_source, base_currency, rate_date, fetched_at ) VALUES ( :provider, :trigger_source, :base_currency, :rate_date, :fetched_at )' ); $fetchStmt->execute([ 'provider' => $provider, 'trigger_source' => $triggerSource, 'base_currency' => $baseCurrency, 'rate_date' => $rateDate, 'fetched_at' => $fetchedAt, ]); $fetch = $this->getFetchById((int) $this->pdo->lastInsertId()) ?? []; } $savedRates = []; if ($normalizedRates !== []) { $placeholders = []; $params = ['fetch_id' => (int) ($fetch['id'] ?? 0)]; $index = 0; foreach ($normalizedRates as $currencyCode => $rate) { $codeKey = 'currency_code_' . $index; $valueKey = 'current_value_' . $index; $placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})"; $params[$codeKey] = $currencyCode; $params[$valueKey] = $rate; $savedRates[] = [ 'fetch_id' => $fetch['id'] ?? null, 'base_currency' => $baseCurrency, 'target_currency' => $currencyCode, 'rate' => $rate, 'rate_date' => $rateDate, 'provider' => $provider, 'fetched_at' => $fetchedAt, ]; $index++; } $insert = $this->pdo->prepare( 'INSERT INTO ' . $this->table('rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders) ); $insert->execute($params); } if ($startedTransaction) { $this->pdo->commit(); } return [ 'fetch' => $fetch, 'rates' => $savedRates, ]; } catch (\Throwable $exception) { if ($startedTransaction && $this->pdo->inTransaction()) { $this->pdo->rollBack(); } throw $exception; } } public function findFetchByBaseAndFetchedAt(string $baseCurrency, string $fetchedAt): ?array { $baseCurrency = strtoupper(trim($baseCurrency)); $fetchedAt = trim($fetchedAt); if ($baseCurrency === '' || $fetchedAt === '') { return null; } $stmt = $this->pdo->prepare( 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches') . ' WHERE base_currency = :base_currency AND fetched_at = :fetched_at ORDER BY id ASC LIMIT 1' ); $stmt->execute([ 'base_currency' => $baseCurrency, 'fetched_at' => $fetchedAt, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return is_array($row) ? $this->normalizeFetch($row) : null; } private function getFetchById(int $fetchId): ?array { $stmt = $this->pdo->prepare( 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches') . ' WHERE id = :id LIMIT 1' ); $stmt->execute(['id' => $fetchId]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return is_array($row) ? $this->normalizeFetch($row) : null; } private function findNeighborFetch(string $baseCurrency, string $timestamp, string $operator): ?array { $order = $operator === '<=' ? 'DESC' : 'ASC'; $stmt = $this->pdo->prepare( 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches') . ' WHERE base_currency = :base_currency AND fetched_at ' . $operator . ' :target_at ORDER BY fetched_at ' . $order . ', id ' . $order . ' LIMIT 1' ); $stmt->execute([ 'base_currency' => $baseCurrency, 'target_at' => $timestamp, ]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return is_array($row) ? $this->normalizeFetch($row) : null; } private function ratesForFetch(int $fetchId, ?array $symbols = null): array { $sql = 'SELECT currency_code, current_value FROM ' . $this->table('rates') . ' WHERE fetch_id = :fetch_id'; $params = ['fetch_id' => $fetchId]; $normalizedSymbols = []; if (is_array($symbols)) { foreach ($symbols as $symbol) { $symbol = strtoupper(trim((string) $symbol)); if ($symbol !== '') { $normalizedSymbols[] = $symbol; } } $normalizedSymbols = array_values(array_unique($normalizedSymbols)); } if ($normalizedSymbols !== []) { $placeholders = []; foreach ($normalizedSymbols as $index => $symbol) { $key = 'symbol_' . $index; $placeholders[] = ':' . $key; $params[$key] = $symbol; } $sql .= ' AND currency_code IN (' . implode(', ', $placeholders) . ')'; } $sql .= ' ORDER BY currency_code ASC'; $stmt = $this->pdo->prepare($sql); $stmt->execute($params); $rates = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { $code = strtoupper(trim((string) ($row['currency_code'] ?? ''))); $rate = $row['current_value'] ?? null; if ($code === '' || !is_numeric($rate)) { continue; } $rates[$code] = (float) $rate; } return $rates; } private function normalizeFetch(array $row): array { return [ 'id' => isset($row['id']) ? (int) $row['id'] : null, 'provider' => (string) ($row['provider'] ?? ''), 'trigger_source' => (string) ($row['trigger_source'] ?? 'manual'), 'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')), 'rate_date' => (string) ($row['rate_date'] ?? ''), 'fetched_at' => (string) ($row['fetched_at'] ?? ''), 'created_at' => (string) ($row['created_at'] ?? ''), ]; } private function ensureColumn(string $table, string $column, string $alterSql): void { try { $stmt = $this->pdo->query('SELECT * FROM ' . $table . ' LIMIT 1'); if ($stmt instanceof \PDOStatement) { $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; if (in_array(strtolower($column), array_map('strtolower', array_keys($row)), true)) { return; } } } catch (\Throwable) { } try { $this->pdo->exec($alterSql); } catch (\Throwable) { } } private function normalizeTriggerSource(string $source): string { $source = strtolower(trim($source)); return match ($source) { 'cron', 'manual', 'api', 'migration' => $source, default => 'manual', }; } private function normalizeRate(array $row): array { return [ 'id' => isset($row['id']) ? (int) $row['id'] : null, 'fetch_id' => isset($row['fetch_id']) ? (int) $row['fetch_id'] : null, 'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')), 'target_currency' => strtoupper((string) ($row['target_currency'] ?? '')), 'rate' => is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null, 'rate_date' => (string) ($row['rate_date'] ?? ''), 'provider' => (string) ($row['provider'] ?? ''), 'fetched_at' => (string) ($row['fetched_at'] ?? ''), ]; } private function table(string $logicalName): string { return $this->tablePrefix . preg_replace('/[^a-zA-Z0-9_]/', '', $logicalName); } }