feat: initial sqlite store
This commit is contained in:
parent
52814db43d
commit
9918c9918f
10 changed files with 560 additions and 6 deletions
38
store/sqlite/migrations.go
Normal file
38
store/sqlite/migrations.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
|
||||
"git.juancwu.dev/juancwu/pase/store"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Migrate applies all pending migrations to db using the SQLite dialect.
|
||||
// It is safe to call repeatedly; already-applied migrations are skipped.
|
||||
//
|
||||
// Run from a single instance at startup. There is no advisory locking; if you
|
||||
// need it, gate the call behind your own coordination primitive.
|
||||
func Migrate(ctx context.Context, db *sql.DB) error {
|
||||
migrations, err := store.LoadMigrations(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("pase/sqlite: load migrations: %w", err)
|
||||
}
|
||||
|
||||
m := &store.Migrator{
|
||||
DB: db,
|
||||
Dialect: store.SQLiteDialect{},
|
||||
Migrations: migrations,
|
||||
// SQLite's TEXT timestamp convention. INTEGER PK is implicit ROWID alias.
|
||||
CreateTableSQL: `CREATE TABLE IF NOT EXISTS pase_schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
)`,
|
||||
}
|
||||
return m.Migrate(ctx)
|
||||
}
|
||||
94
store/sqlite/store.go
Normal file
94
store/sqlite/store.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// Package sqlite provides a SQLite-backed implementation of store.Store.
|
||||
// Tested with "modernc.org/sqlite" other sqlite drivers may yield different results.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/pase/store"
|
||||
)
|
||||
|
||||
// Store is a SQLite-backed implementation of store.Store.
|
||||
//
|
||||
// All queries are written with ? placeholders and rebound through the dialect
|
||||
// at construction time. Every method is intended to be a single round-trip;
|
||||
// methods that need atomicity (e.g. ConsumeToken) use a single statement or
|
||||
// an explicit transaction — never read-modify-write through Go.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
dialect store.Dialect
|
||||
|
||||
// Pre-rebound query strings. One field per query keeps the SQL text out
|
||||
// of method bodies and makes it easy to audit by reading the struct.
|
||||
q store.Queries
|
||||
}
|
||||
|
||||
// NewStore wraps an existing *sql.DB. It does not own the connection pool;
|
||||
// the caller is responsible for opening, configuring, and closing it.
|
||||
//
|
||||
// Foreign key enforcement is required; configure it on the connection (e.g.
|
||||
// via DSN `?_pragma=foreign_keys(1)` for modernc.org/sqlite, or
|
||||
// `?_fk=1` for mattn/go-sqlite3) before passing the *sql.DB in.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
d := store.SQLiteDialect{}
|
||||
s := &Store{db: db, dialect: d}
|
||||
|
||||
s.q = store.CanonicalQueries.Rebind(d)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// CreateUser inserts a user row. Timestamps will be overwritten if they are set.
|
||||
func (s *Store) CreateUser(ctx context.Context, u *store.User) error {
|
||||
now := time.Now()
|
||||
u.CreatedAt = now
|
||||
u.UpdatedAt = now
|
||||
|
||||
_, err := s.db.ExecContext(ctx, s.q.CreateUser,
|
||||
u.ID, u.Email, u.EmailVerifiedAt,
|
||||
u.Username, u.UsernameNormalized,
|
||||
u.DisplayName, u.ProfileImageURL,
|
||||
u.Status, u.StatusReason,
|
||||
u.StatusChangedAt, u.StatusExpiresAt,
|
||||
u.FailedLoginCount, u.LastFailedLoginAt,
|
||||
u.CreatedAt, u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "pase_users.email") {
|
||||
return fmt.Errorf("pase/sqlite: create user: %w", store.ErrEmailAlreadyExists)
|
||||
}
|
||||
if strings.Contains(errStr, "pase_users.username_normalized") {
|
||||
return fmt.Errorf("pase/sqlite: create user: %w", store.ErrUsernameAlreadyExists)
|
||||
}
|
||||
return fmt.Errorf("pase/sqlite: create user: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByID returns the user with the given id.
|
||||
func (s *Store) GetUserByID(ctx context.Context, id string) (*store.User, error) {
|
||||
row := s.db.QueryRowContext(ctx, s.q.GetUserByID, id)
|
||||
|
||||
var u store.User
|
||||
err := row.Scan(
|
||||
&u.ID, &u.Email, &u.EmailVerifiedAt,
|
||||
&u.Username, &u.UsernameNormalized, &u.DisplayName, &u.ProfileImageURL,
|
||||
&u.Status, &u.StatusReason,
|
||||
&u.StatusChangedAt, &u.StatusExpiresAt,
|
||||
&u.FailedLoginCount, &u.LastFailedLoginAt,
|
||||
&u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("pase/sqlite: get user by id: %w", store.ErrUserNotFound)
|
||||
}
|
||||
return nil, fmt.Errorf("pase/sqlite: get user by id: %w", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
71
store/sqlite/store_test.go
Normal file
71
store/sqlite/store_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"git.juancwu.dev/juancwu/pase/store/sqlite"
|
||||
"git.juancwu.dev/juancwu/pase/store/storetest"
|
||||
|
||||
// modernc.org/sqlite registers the "sqlite" driver. Pure Go, no CGO.
|
||||
// Imported only by tests; the sqlite package itself stays driver-agnostic.
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// newSQLiteStore opens a fresh in-memory SQLite, applies migrations, and
|
||||
// returns a Store ready to be exercised by the storetest suite. Each call
|
||||
// produces an isolated database — no cross-test pollution.
|
||||
//
|
||||
// DSN notes:
|
||||
// - file::memory: form is required so the driver parses query params.
|
||||
// Plain ":memory:?..." would silently ignore them.
|
||||
// - _pragma=foreign_keys(1) enables ON DELETE CASCADE on this connection.
|
||||
// - _time_format=sqlite makes the driver write time.Time as
|
||||
// "2006-01-02 15:04:05.999999999-07:00", which Go's stdlib can read back
|
||||
// via sql.NullTime.Scan.
|
||||
func newSQLiteStore(t *testing.T) storetest.SuiteStore {
|
||||
t.Helper()
|
||||
|
||||
db, err := sql.Open("sqlite", "file::memory:?_pragma=foreign_keys(1)&_time_format=sqlite")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
// In-memory databases are per-connection by default. Pin the pool to a
|
||||
// single connection so subsequent calls hit the same database.
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
if err := sqlite.Migrate(context.Background(), db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
return sqlite.NewStore(db)
|
||||
}
|
||||
|
||||
// TestSQLiteStore runs the full Store contract suite against the SQLite
|
||||
// implementation. Add new dialect-specific tests below; they should be
|
||||
// rare — anything testable as a contract belongs in storetest.
|
||||
func TestSQLiteStore(t *testing.T) {
|
||||
storetest.RunSuite(t, newSQLiteStore)
|
||||
}
|
||||
|
||||
// TestMigrate_idempotent stays here because migrations are dialect-specific
|
||||
// (per-package embed.FS) and not part of the Store interface contract.
|
||||
func TestMigrate_idempotent(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", "file::memory:?_pragma=foreign_keys(1)&_time_format=sqlite")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
ctx := context.Background()
|
||||
if err := sqlite.Migrate(ctx, db); err != nil {
|
||||
t.Fatalf("first Migrate: %v", err)
|
||||
}
|
||||
if err := sqlite.Migrate(ctx, db); err != nil {
|
||||
t.Fatalf("second Migrate should succeed, got: %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue