feat(backend): establish gin sqlite service skeleton
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
const busyTimeoutMilliseconds = 5000
|
||||
|
||||
type safeError struct {
|
||||
message string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e safeError) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e safeError) Unwrap() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
func Open(ctx context.Context, path string) (*sql.DB, error) {
|
||||
absolutePath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("resolve SQLite path")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(absolutePath), 0o700); err != nil {
|
||||
return nil, errors.New("create SQLite directory")
|
||||
}
|
||||
if info, err := os.Stat(absolutePath); err == nil && info.IsDir() {
|
||||
return nil, errors.New("SQLite path is a directory")
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, errors.New("inspect SQLite path")
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite3", dataSourceName(absolutePath))
|
||||
if err != nil {
|
||||
return nil, errors.New("initialize SQLite driver")
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, safeError{
|
||||
message: "connect to SQLite",
|
||||
cause: err,
|
||||
}
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func dataSourceName(absolutePath string) string {
|
||||
slashPath := filepath.ToSlash(absolutePath)
|
||||
if filepath.VolumeName(absolutePath) != "" &&
|
||||
!strings.HasPrefix(slashPath, "/") {
|
||||
slashPath = "/" + slashPath
|
||||
}
|
||||
dsnURL := &url.URL{
|
||||
Scheme: "file",
|
||||
Path: slashPath,
|
||||
}
|
||||
query := dsnURL.Query()
|
||||
query.Set("_busy_timeout", "5000")
|
||||
query.Set("_foreign_keys", "on")
|
||||
query.Set("_journal_mode", "WAL")
|
||||
query.Set("_txlock", "immediate")
|
||||
dsnURL.RawQuery = query.Encode()
|
||||
return dsnURL.String()
|
||||
}
|
||||
|
||||
func isSafeDataSourceName(value string) bool {
|
||||
lowerValue := strings.ToLower(value)
|
||||
return strings.HasPrefix(lowerValue, "file:") &&
|
||||
strings.Contains(lowerValue, "_busy_timeout=") &&
|
||||
strings.Contains(lowerValue, "_foreign_keys=") &&
|
||||
strings.Contains(lowerValue, "_journal_mode=wal") &&
|
||||
strings.Contains(lowerValue, "_txlock=immediate")
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenConfiguresSQLiteAndClosesCleanly(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "nested", "test.db")
|
||||
db, err := Open(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v; cause = %v", err, errors.Unwrap(err))
|
||||
}
|
||||
|
||||
assertPragmaInt(t, db, "foreign_keys", 1)
|
||||
assertPragmaInt(t, db, "busy_timeout", busyTimeoutMilliseconds)
|
||||
assertPragmaString(t, db, "journal_mode", "wal")
|
||||
|
||||
if db.Stats().MaxOpenConnections != 1 {
|
||||
t.Fatalf("MaxOpenConnections = %d", db.Stats().MaxOpenConnections)
|
||||
}
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatalf("Close() error = %v", err)
|
||||
}
|
||||
if err := db.PingContext(context.Background()); err == nil {
|
||||
t.Fatal("PingContext() after Close() error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenEnforcesForeignKeys(t *testing.T) {
|
||||
db, err := Open(
|
||||
context.Background(),
|
||||
filepath.Join(t.TempDir(), "foreign-keys.db"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Open() error = %v; cause = %v", err, errors.Unwrap(err))
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE parent (id INTEGER PRIMARY KEY);
|
||||
CREATE TABLE child (
|
||||
id INTEGER PRIMARY KEY,
|
||||
parent_id INTEGER NOT NULL REFERENCES parent(id)
|
||||
);
|
||||
`); err != nil {
|
||||
t.Fatalf("create tables: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
"INSERT INTO child (id, parent_id) VALUES (1, 999)",
|
||||
); err == nil {
|
||||
t.Fatal("foreign key violation error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataSourceNameIncludesRequiredOptions(t *testing.T) {
|
||||
dsn := dataSourceName(filepath.Join(t.TempDir(), "test.db"))
|
||||
if !isSafeDataSourceName(dsn) {
|
||||
t.Fatalf("unsafe DSN options")
|
||||
}
|
||||
}
|
||||
|
||||
func assertPragmaInt(t *testing.T, db *sql.DB, name string, want int) {
|
||||
t.Helper()
|
||||
var got int
|
||||
if err := db.QueryRow("PRAGMA " + name).Scan(&got); err != nil {
|
||||
t.Fatalf("PRAGMA %s: %v", name, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("PRAGMA %s = %d, want %d", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPragmaString(t *testing.T, db *sql.DB, name, want string) {
|
||||
t.Helper()
|
||||
var got string
|
||||
if err := db.QueryRow("PRAGMA " + name).Scan(&got); err != nil {
|
||||
t.Fatalf("PRAGMA %s: %v", name, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("PRAGMA %s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user