// Package store owns all database access. Photo tables use the photos_ // prefix and are migrated by this service alone; the handful of reads // against Drizzle-owned tables (users, events, tickets, payments) are // confined to access.go. package store import ( "database/sql" "fmt" "strings" "time" _ "github.com/jackc/pgx/v5/stdlib" _ "modernc.org/sqlite" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" ) const ( Postgres = "postgres" SQLite = "sqlite" // timeLayout is a fixed-width UTC format so SQLite text timestamps // sort correctly; the backend stores ISO strings in SQLite the same way. timeLayout = "2006-01-02T15:04:05.000Z" ) type DB struct { *sql.DB Type string } func Open(cfg config.Config) (*DB, error) { switch cfg.DBType { case Postgres: sqlDB, err := sql.Open("pgx", cfg.DatabaseURL) if err != nil { return nil, fmt.Errorf("open postgres: %w", err) } sqlDB.SetMaxOpenConns(10) return &DB{DB: sqlDB, Type: Postgres}, nil case SQLite: dsn := "file:" + strings.TrimPrefix(cfg.DatabaseURL, "file:") + "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" sqlDB, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } // One writer at a time keeps the shared file friendly to the backend. sqlDB.SetMaxOpenConns(1) return &DB{DB: sqlDB, Type: SQLite}, nil default: return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType) } } // Rebind converts ?-style placeholders to $n for Postgres. Queries in this // package never contain literal question marks in strings. func (db *DB) Rebind(query string) string { if db.Type != Postgres { return query } var b strings.Builder n := 0 for _, r := range query { if r == '?' { n++ fmt.Fprintf(&b, "$%d", n) } else { b.WriteRune(r) } } return b.String() } // TimeArg converts a time for binding: time.Time for Postgres (timestamptz // columns), fixed-layout UTC text for SQLite (text columns). func (db *DB) TimeArg(t time.Time) any { if db.Type == Postgres { return t.UTC() } return t.UTC().Format(timeLayout) } // asString normalizes scanned values across drivers. func asString(v any) string { switch x := v.(type) { case nil: return "" case string: return x case []byte: return string(x) case time.Time: return x.UTC().Format(timeLayout) default: return fmt.Sprintf("%v", x) } } // asTime parses a scanned timestamp from either driver; zero time on failure. func asTime(v any) time.Time { switch x := v.(type) { case time.Time: return x.UTC() case string: return parseTime(x) case []byte: return parseTime(string(x)) default: return time.Time{} } } func parseTime(s string) time.Time { for _, layout := range []string{timeLayout, time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05"} { if t, err := time.Parse(layout, s); err == nil { return t.UTC() } } return time.Time{} }