diff --git a/pkg/store/store.go b/pkg/store/store.go new file mode 100644 index 0000000..eaa2774 --- /dev/null +++ b/pkg/store/store.go @@ -0,0 +1,124 @@ +package store + +import ( + "database/sql" + "fmt" + "strings" + "text/template" + + _ "modernc.org/sqlite" +) + +// Store is a group-namespaced key-value store backed by SQLite. +type Store struct { + db *sql.DB +} + +// New creates a Store at the given SQLite path. Use ":memory:" for tests. +func New(dbPath string) (*Store, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { + return nil, fmt.Errorf("store.New: %w", err) + } + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + db.Close() + return nil, fmt.Errorf("store.New: WAL: %w", err) + } + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS kv ( + grp TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY (grp, key) + )`); err != nil { + db.Close() + return nil, fmt.Errorf("store.New: schema: %w", err) + } + return &Store{db: db}, nil +} + +// Close closes the underlying database. +func (s *Store) Close() error { + return s.db.Close() +} + +// Get retrieves a value by group and key. +func (s *Store) Get(group, key string) (string, error) { + var val string + err := s.db.QueryRow("SELECT value FROM kv WHERE grp = ? AND key = ?", group, key).Scan(&val) + if err == sql.ErrNoRows { + return "", fmt.Errorf("store.Get: not found: %s/%s", group, key) + } + if err != nil { + return "", fmt.Errorf("store.Get: %w", err) + } + return val, nil +} + +// Set stores a value by group and key, overwriting if exists. +func (s *Store) Set(group, key, value string) error { + _, err := s.db.Exec( + `INSERT INTO kv (grp, key, value) VALUES (?, ?, ?) + ON CONFLICT(grp, key) DO UPDATE SET value = excluded.value`, + group, key, value, + ) + if err != nil { + return fmt.Errorf("store.Set: %w", err) + } + return nil +} + +// Delete removes a single key from a group. +func (s *Store) Delete(group, key string) error { + _, err := s.db.Exec("DELETE FROM kv WHERE grp = ? AND key = ?", group, key) + if err != nil { + return fmt.Errorf("store.Delete: %w", err) + } + return nil +} + +// Count returns the number of keys in a group. +func (s *Store) Count(group string) (int, error) { + var n int + err := s.db.QueryRow("SELECT COUNT(*) FROM kv WHERE grp = ?", group).Scan(&n) + if err != nil { + return 0, fmt.Errorf("store.Count: %w", err) + } + return n, nil +} + +// DeleteGroup removes all keys in a group. +func (s *Store) DeleteGroup(group string) error { + _, err := s.db.Exec("DELETE FROM kv WHERE grp = ?", group) + if err != nil { + return fmt.Errorf("store.DeleteGroup: %w", err) + } + return nil +} + +// Render loads all key-value pairs from a group and renders a Go template. +func (s *Store) Render(tmplStr, group string) (string, error) { + rows, err := s.db.Query("SELECT key, value FROM kv WHERE grp = ?", group) + if err != nil { + return "", fmt.Errorf("store.Render: query: %w", err) + } + defer rows.Close() + + vars := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return "", fmt.Errorf("store.Render: scan: %w", err) + } + vars[k] = v + } + + tmpl, err := template.New("render").Parse(tmplStr) + if err != nil { + return "", fmt.Errorf("store.Render: parse: %w", err) + } + var b strings.Builder + if err := tmpl.Execute(&b, vars); err != nil { + return "", fmt.Errorf("store.Render: exec: %w", err) + } + return b.String(), nil +} diff --git a/pkg/store/store_test.go b/pkg/store/store_test.go new file mode 100644 index 0000000..1782ed2 --- /dev/null +++ b/pkg/store/store_test.go @@ -0,0 +1,81 @@ +package store + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetGet_Good(t *testing.T) { + s, err := New(":memory:") + require.NoError(t, err) + defer s.Close() + + err = s.Set("config", "theme", "dark") + require.NoError(t, err) + + val, err := s.Get("config", "theme") + require.NoError(t, err) + assert.Equal(t, "dark", val) +} + +func TestGet_Bad_NotFound(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _, err := s.Get("config", "missing") + assert.Error(t, err) +} + +func TestDelete_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("config", "key", "val") + err := s.Delete("config", "key") + require.NoError(t, err) + + _, err = s.Get("config", "key") + assert.Error(t, err) +} + +func TestCount_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("grp", "a", "1") + _ = s.Set("grp", "b", "2") + _ = s.Set("other", "c", "3") + + n, err := s.Count("grp") + require.NoError(t, err) + assert.Equal(t, 2, n) +} + +func TestDeleteGroup_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("grp", "a", "1") + _ = s.Set("grp", "b", "2") + err := s.DeleteGroup("grp") + require.NoError(t, err) + + n, _ := s.Count("grp") + assert.Equal(t, 0, n) +} + +func TestRender_Good(t *testing.T) { + s, _ := New(":memory:") + defer s.Close() + + _ = s.Set("user", "pool", "pool.lthn.io:3333") + _ = s.Set("user", "wallet", "iz...") + + tmpl := `{"pool":"{{ .pool }}","wallet":"{{ .wallet }}"}` + out, err := s.Render(tmpl, "user") + require.NoError(t, err) + assert.Contains(t, out, "pool.lthn.io:3333") + assert.Contains(t, out, "iz...") +}