diff --git a/go.mod b/go.mod index e966ed4..452ae79 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24 require ( github.com/bogem/id3v2 v1.2.0 github.com/caarlos0/env/v10 v10.0.0 + github.com/mattn/go-sqlite3 v1.14.28 ) require golang.org/x/text v0.3.2 // indirect diff --git a/go.sum b/go.sum index 32c849b..b15aebf 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/bogem/id3v2 v1.2.0 h1:hKDF+F1gOgQ5r1QmBCEZUk4MveJbKxCeIDSBU7CQ4oI= github.com/bogem/id3v2 v1.2.0/go.mod h1:t78PK5AQ56Q47kizpYiV6gtjj3jfxlz87oFpty8DYs8= github.com/caarlos0/env/v10 v10.0.0 h1:yIHUBZGsyqCnpTkbjk8asUlx6RFhhEs+h7TOBdgdzXA= github.com/caarlos0/env/v10 v10.0.0/go.mod h1:ZfulV76NvVPw3tm591U4SwL3Xx9ldzBP9aGxzeN7G18= +github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= +github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go new file mode 100644 index 0000000..a670279 --- /dev/null +++ b/internal/storage/sqlite.go @@ -0,0 +1,123 @@ +package storage + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + + _ "github.com/mattn/go-sqlite3" // Драйвер для SQLite +) + +var ( + // ErrNotFound возвращается, когда запись не найдена в хранилище. + ErrNotFound = errors.New("not found") +) + +// SQLiteStorage реализует интерфейс interfaces.TrackStorage для SQLite. +type SQLiteStorage struct { + db *sql.DB +} + +// NewSQLiteStorage создает и инициализирует новое хранилище SQLite. +// Он также проверяет и создает таблицу, если она не существует. +func NewSQLiteStorage(ctx context.Context, dbPath string) (*SQLiteStorage, error) { + // Убедимся, что директория для файла БД существует + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create data directory: %w", err) + } + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + // Устанавливаем 1 соединение, т.к. SQLite плохо работает с конкурентной записью. + // Для наших целей этого более чем достаточно. + db.SetMaxOpenConns(1) + + storage := &SQLiteStorage{db: db} + + if err := storage.initSchema(ctx); err != nil { + return nil, fmt.Errorf("failed to initialize schema: %w", err) + } + + slog.Info("SQLite storage initialized successfully", "path", dbPath) + return storage, nil +} + +// initSchema создает таблицу для кэша, если она еще не существует. +func (s *SQLiteStorage) initSchema(ctx context.Context) error { + const ddl = ` + CREATE TABLE IF NOT EXISTS tracks_cache ( + yandex_track_id TEXT PRIMARY KEY, + telegram_file_id TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + );` + + _, err := s.db.ExecContext(ctx, ddl) + return err +} + +// Get получает telegram_file_id по yandex_track_id. +// Возвращает ErrNotFound, если запись не найдена. +func (s *SQLiteStorage) Get(ctx context.Context, yandexTrackID string) (string, error) { + const op = "storage.sqlite.Get" + stmt, err := s.db.PrepareContext(ctx, "SELECT telegram_file_id FROM tracks_cache WHERE yandex_track_id = ?") + if err != nil { + return "", fmt.Errorf("%s: %w", op, err) + } + defer stmt.Close() + + var fileID string + err = stmt.QueryRowContext(ctx, yandexTrackID).Scan(&fileID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotFound + } + return "", fmt.Errorf("%s: %w", op, err) + } + + return fileID, nil +} + +// Set сохраняет новую запись в кэш. +func (s *SQLiteStorage) Set(ctx context.Context, yandexTrackID, telegramFileID string) error { + const op = "storage.sqlite.Set" + stmt, err := s.db.PrepareContext(ctx, "INSERT INTO tracks_cache(yandex_track_id, telegram_file_id) VALUES(?, ?)") + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + defer stmt.Close() + + _, err = stmt.ExecContext(ctx, yandexTrackID, telegramFileID) + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + + return nil +} + +// Count возвращает общее количество записей в кэше. +func (s *SQLiteStorage) Count(ctx context.Context) (int, error) { + const op = "storage.sqlite.Count" + var count int + err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM tracks_cache").Scan(&count) + if err != nil { + return 0, fmt.Errorf("%s: %w", op, err) + } + return count, nil +} + +// Close закрывает соединение с базой данных. +func (s *SQLiteStorage) Close() error { + return s.db.Close() +} diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go new file mode 100644 index 0000000..76a81e4 --- /dev/null +++ b/internal/storage/sqlite_test.go @@ -0,0 +1,70 @@ +package storage + +import ( + "context" + "errors" + "testing" +) + +func TestSQLiteStorage(t *testing.T) { + ctx := context.Background() + + // Используем in-memory базу данных для тестов + storage, err := NewSQLiteStorage(ctx, ":memory:") + if err != nil { + t.Fatalf("Failed to create in-memory storage: %v", err) + } + defer storage.Close() + + t.Run("Set and Get", func(t *testing.T) { + yandexID := "12345" + telegramID := "file_abcde" + + err := storage.Set(ctx, yandexID, telegramID) + if err != nil { + t.Fatalf("Set() error = %v", err) + } + + gotID, err := storage.Get(ctx, yandexID) + if err != nil { + t.Fatalf("Get() error = %v", err) + } + + if gotID != telegramID { + t.Errorf("Get() got = %v, want %v", gotID, telegramID) + } + }) + + t.Run("Get not found", func(t *testing.T) { + _, err := storage.Get(ctx, "non_existent_id") + if !errors.Is(err, ErrNotFound) { + t.Errorf("Expected ErrNotFound, got %v", err) + } + }) + + t.Run("Count", func(t *testing.T) { + // Сначала очистим (в in-memory это не нужно, но для полноты) + // или просто проверим текущее состояние + count, err := storage.Count(ctx) + if err != nil { + t.Fatalf("Count() error = %v", err) + } + if count != 1 { // У нас осталась одна запись с предыдущего теста + t.Errorf("Count() got = %d, want 1", count) + } + + // Добавим еще одну запись + err = storage.Set(ctx, "67890", "file_fghij") + if err != nil { + t.Fatalf("Set() error = %v", err) + } + + newCount, err := storage.Count(ctx) + if err != nil { + t.Fatalf("Count() error = %v", err) + } + if newCount != 2 { + t.Errorf("Count() got = %d, want 2", newCount) + } + }) +}