db.go
·
206 строк
·
5.3 KB
| 1 | package db |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "embed" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "io/fs" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "sort" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "time" |
| 16 | |
| 17 | _ "modernc.org/sqlite" |
| 18 | ) |
| 19 | |
| 20 | //go:embed migrations/*.sql |
| 21 | var migrationsFS embed.FS |
| 22 | |
| 23 | type DB struct { |
| 24 | *sql.DB |
| 25 | } |
| 26 | |
| 27 | func Open(ctx context.Context, dbPath string, defaultRegEnabled bool) (*DB, error) { |
| 28 | if dbPath != ":memory:" && !strings.HasPrefix(dbPath, "file::memory:") { |
| 29 | if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { |
| 30 | return nil, fmt.Errorf("create database directory: %w", err) |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | dsn := dbPath |
| 35 | if !strings.Contains(dsn, "?") { |
| 36 | dsn += "?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)" |
| 37 | } |
| 38 | |
| 39 | sqlDB, err := sql.Open("sqlite", dsn) |
| 40 | if err != nil { |
| 41 | return nil, fmt.Errorf("open sqlite %s: %w", dbPath, err) |
| 42 | } |
| 43 | |
| 44 | sqlDB.SetMaxOpenConns(1) |
| 45 | sqlDB.SetMaxIdleConns(1) |
| 46 | sqlDB.SetConnMaxLifetime(0) |
| 47 | |
| 48 | pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) |
| 49 | defer cancel() |
| 50 | if err := sqlDB.PingContext(pingCtx); err != nil { |
| 51 | _ = sqlDB.Close() |
| 52 | return nil, fmt.Errorf("ping sqlite: %w", err) |
| 53 | } |
| 54 | |
| 55 | pragmas := []string{ |
| 56 | "PRAGMA journal_mode = WAL;", |
| 57 | "PRAGMA foreign_keys = ON;", |
| 58 | "PRAGMA busy_timeout = 5000;", |
| 59 | "PRAGMA synchronous = NORMAL;", |
| 60 | } |
| 61 | for _, p := range pragmas { |
| 62 | if _, err := sqlDB.ExecContext(pingCtx, p); err != nil { |
| 63 | _ = sqlDB.Close() |
| 64 | return nil, fmt.Errorf("exec pragma %q: %w", p, err) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | d := &DB{DB: sqlDB} |
| 69 | if err := d.Migrate(pingCtx); err != nil { |
| 70 | _ = sqlDB.Close() |
| 71 | return nil, fmt.Errorf("run migrations: %w", err) |
| 72 | } |
| 73 | |
| 74 | if _, err := d.GetSetting(pingCtx, "registration_enabled"); errors.Is(err, sql.ErrNoRows) { |
| 75 | if err := d.SetSetting(pingCtx, "registration_enabled", strconv.FormatBool(defaultRegEnabled)); err != nil { |
| 76 | _ = sqlDB.Close() |
| 77 | return nil, fmt.Errorf("seed registration_enabled setting: %w", err) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | return d, nil |
| 82 | } |
| 83 | |
| 84 | func (d *DB) Migrate(ctx context.Context) error { |
| 85 | _, err := d.ExecContext(ctx, ` |
| 86 | CREATE TABLE IF NOT EXISTS schema_migrations ( |
| 87 | version TEXT PRIMARY KEY, |
| 88 | applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP |
| 89 | ); |
| 90 | `) |
| 91 | if err != nil { |
| 92 | return fmt.Errorf("create schema_migrations table: %w", err) |
| 93 | } |
| 94 | |
| 95 | entries, err := fs.ReadDir(migrationsFS, "migrations") |
| 96 | if err != nil { |
| 97 | return fmt.Errorf("read embedded migrations: %w", err) |
| 98 | } |
| 99 | |
| 100 | var files []string |
| 101 | for _, entry := range entries { |
| 102 | if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") { |
| 103 | files = append(files, entry.Name()) |
| 104 | } |
| 105 | } |
| 106 | sort.Strings(files) |
| 107 | |
| 108 | for _, name := range files { |
| 109 | var exists int |
| 110 | err := d.QueryRowContext(ctx, `SELECT 1 FROM schema_migrations WHERE version = ?`, name).Scan(&exists) |
| 111 | if err == nil { |
| 112 | continue |
| 113 | } |
| 114 | if !errors.Is(err, sql.ErrNoRows) { |
| 115 | return fmt.Errorf("check migration %s: %w", name, err) |
| 116 | } |
| 117 | |
| 118 | content, err := migrationsFS.ReadFile("migrations/" + name) |
| 119 | if err != nil { |
| 120 | return fmt.Errorf("read migration %s: %w", name, err) |
| 121 | } |
| 122 | |
| 123 | tx, err := d.BeginTx(ctx, nil) |
| 124 | if err != nil { |
| 125 | return fmt.Errorf("begin migration tx %s: %w", name, err) |
| 126 | } |
| 127 | |
| 128 | if _, err := tx.ExecContext(ctx, string(content)); err != nil { |
| 129 | _ = tx.Rollback() |
| 130 | return fmt.Errorf("execute migration %s: %w", name, err) |
| 131 | } |
| 132 | if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES (?)`, name); err != nil { |
| 133 | _ = tx.Rollback() |
| 134 | return fmt.Errorf("record migration %s: %w", name, err) |
| 135 | } |
| 136 | if err := tx.Commit(); err != nil { |
| 137 | return fmt.Errorf("commit migration %s: %w", name, err) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | return nil |
| 142 | } |
| 143 | |
| 144 | func (d *DB) GetSetting(ctx context.Context, key string) (string, error) { |
| 145 | cctx, cancel := context.WithTimeout(ctx, 3*time.Second) |
| 146 | defer cancel() |
| 147 | var val string |
| 148 | err := d.QueryRowContext(cctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&val) |
| 149 | if err != nil { |
| 150 | return "", fmt.Errorf("get setting %s: %w", key, err) |
| 151 | } |
| 152 | return val, nil |
| 153 | } |
| 154 | |
| 155 | func (d *DB) SetSetting(ctx context.Context, key, value string) error { |
| 156 | cctx, cancel := context.WithTimeout(ctx, 3*time.Second) |
| 157 | defer cancel() |
| 158 | _, err := d.ExecContext(cctx, ` |
| 159 | INSERT INTO settings (key, value) VALUES (?, ?) |
| 160 | ON CONFLICT(key) DO UPDATE SET value = excluded.value |
| 161 | `, key, value) |
| 162 | if err != nil { |
| 163 | return fmt.Errorf("set setting %s: %w", key, err) |
| 164 | } |
| 165 | return nil |
| 166 | } |
| 167 | |
| 168 | func (d *DB) GetBoolSetting(ctx context.Context, key string, fallback bool) bool { |
| 169 | val, err := d.GetSetting(ctx, key) |
| 170 | if err != nil { |
| 171 | return fallback |
| 172 | } |
| 173 | b, err := strconv.ParseBool(val) |
| 174 | if err != nil { |
| 175 | return fallback |
| 176 | } |
| 177 | return b |
| 178 | } |
| 179 | |
| 180 | func (d *DB) SetBoolSetting(ctx context.Context, key string, enabled bool) error { |
| 181 | return d.SetSetting(ctx, key, strconv.FormatBool(enabled)) |
| 182 | } |
| 183 | |
| 184 | func (d *DB) IsRegistrationEnabled(ctx context.Context, fallback bool) bool { |
| 185 | return d.GetBoolSetting(ctx, "registration_enabled", fallback) |
| 186 | } |
| 187 | |
| 188 | func (d *DB) SetRegistrationEnabled(ctx context.Context, enabled bool) error { |
| 189 | return d.SetBoolSetting(ctx, "registration_enabled", enabled) |
| 190 | } |
| 191 | |
| 192 | func (d *DB) IsExploreEnabled(ctx context.Context) bool { |
| 193 | return d.GetBoolSetting(ctx, "explore_enabled", true) |
| 194 | } |
| 195 | |
| 196 | func (d *DB) IsLandingEnabled(ctx context.Context, fallback bool) bool { |
| 197 | return d.GetBoolSetting(ctx, "landing_enabled", fallback) |
| 198 | } |
| 199 | |
| 200 | func (d *DB) IsAnonymousReadEnabled(ctx context.Context) bool { |
| 201 | return d.GetBoolSetting(ctx, "anonymous_read_enabled", true) |
| 202 | } |
| 203 | |
| 204 | func (d *DB) IsIssuesEnabled(ctx context.Context) bool { |
| 205 | return d.GetBoolSetting(ctx, "issues_enabled", true) |
| 206 | } |
| 207 |