feat(store): add KV store subpackage with io.Medium adapter
SQLite-backed group/key/value store extracted from core/go pkg/store. Includes Medium wrapper that maps group/key paths to the io.Medium interface — first segment is group, rest is key. Both the direct KV API (Get/Set/Delete) and Medium API (Read/Write/List) work on the same underlying data. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
c282ba0a6f
commit
65b39b0de5
4 changed files with 807 additions and 0 deletions
349
store/medium.go
Normal file
349
store/medium.go
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
goio "io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Medium wraps a Store to satisfy the io.Medium interface.
|
||||
// Paths are mapped as group/key — first segment is the group,
|
||||
// the rest is the key. List("") returns groups as directories,
|
||||
// List("group") returns keys as files.
|
||||
type Medium struct {
|
||||
s *Store
|
||||
}
|
||||
|
||||
// NewMedium creates an io.Medium backed by a KV store at the given SQLite path.
|
||||
func NewMedium(dbPath string) (*Medium, error) {
|
||||
s, err := New(dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Medium{s: s}, nil
|
||||
}
|
||||
|
||||
// AsMedium returns a Medium adapter for an existing Store.
|
||||
func (s *Store) AsMedium() *Medium {
|
||||
return &Medium{s: s}
|
||||
}
|
||||
|
||||
// Store returns the underlying KV store for direct access.
|
||||
func (m *Medium) Store() *Store {
|
||||
return m.s
|
||||
}
|
||||
|
||||
// Close closes the underlying store.
|
||||
func (m *Medium) Close() error {
|
||||
return m.s.Close()
|
||||
}
|
||||
|
||||
// splitPath splits a medium-style path into group and key.
|
||||
// First segment = group, remainder = key.
|
||||
func splitPath(p string) (group, key string) {
|
||||
clean := path.Clean(p)
|
||||
clean = strings.TrimPrefix(clean, "/")
|
||||
if clean == "" || clean == "." {
|
||||
return "", ""
|
||||
}
|
||||
parts := strings.SplitN(clean, "/", 2)
|
||||
if len(parts) == 1 {
|
||||
return parts[0], ""
|
||||
}
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
|
||||
// Read retrieves the value at group/key.
|
||||
func (m *Medium) Read(p string) (string, error) {
|
||||
group, key := splitPath(p)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("store.Read: path must include group/key: %w", os.ErrInvalid)
|
||||
}
|
||||
return m.s.Get(group, key)
|
||||
}
|
||||
|
||||
// Write stores a value at group/key.
|
||||
func (m *Medium) Write(p, content string) error {
|
||||
group, key := splitPath(p)
|
||||
if key == "" {
|
||||
return fmt.Errorf("store.Write: path must include group/key: %w", os.ErrInvalid)
|
||||
}
|
||||
return m.s.Set(group, key, content)
|
||||
}
|
||||
|
||||
// EnsureDir is a no-op — groups are created implicitly on Set.
|
||||
func (m *Medium) EnsureDir(_ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsFile returns true if a group/key pair exists.
|
||||
func (m *Medium) IsFile(p string) bool {
|
||||
group, key := splitPath(p)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
_, err := m.s.Get(group, key)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// FileGet is an alias for Read.
|
||||
func (m *Medium) FileGet(p string) (string, error) {
|
||||
return m.Read(p)
|
||||
}
|
||||
|
||||
// FileSet is an alias for Write.
|
||||
func (m *Medium) FileSet(p, content string) error {
|
||||
return m.Write(p, content)
|
||||
}
|
||||
|
||||
// Delete removes a key, or checks that a group is empty.
|
||||
func (m *Medium) Delete(p string) error {
|
||||
group, key := splitPath(p)
|
||||
if group == "" {
|
||||
return fmt.Errorf("store.Delete: path is required: %w", os.ErrInvalid)
|
||||
}
|
||||
if key == "" {
|
||||
n, err := m.s.Count(group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return fmt.Errorf("store.Delete: group not empty: %s: %w", group, os.ErrExist)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return m.s.Delete(group, key)
|
||||
}
|
||||
|
||||
// DeleteAll removes a key, or all keys in a group.
|
||||
func (m *Medium) DeleteAll(p string) error {
|
||||
group, key := splitPath(p)
|
||||
if group == "" {
|
||||
return fmt.Errorf("store.DeleteAll: path is required: %w", os.ErrInvalid)
|
||||
}
|
||||
if key == "" {
|
||||
return m.s.DeleteGroup(group)
|
||||
}
|
||||
return m.s.Delete(group, key)
|
||||
}
|
||||
|
||||
// Rename moves a key from one path to another.
|
||||
func (m *Medium) Rename(oldPath, newPath string) error {
|
||||
og, ok := splitPath(oldPath)
|
||||
ng, nk := splitPath(newPath)
|
||||
if ok == "" || nk == "" {
|
||||
return fmt.Errorf("store.Rename: both paths must include group/key: %w", os.ErrInvalid)
|
||||
}
|
||||
val, err := m.s.Get(og, ok)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.s.Set(ng, nk, val); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.s.Delete(og, ok)
|
||||
}
|
||||
|
||||
// List returns directory entries. Empty path returns groups.
|
||||
// A group path returns keys in that group.
|
||||
func (m *Medium) List(p string) ([]fs.DirEntry, error) {
|
||||
group, key := splitPath(p)
|
||||
|
||||
if group == "" {
|
||||
rows, err := m.s.db.Query("SELECT DISTINCT grp FROM kv ORDER BY grp")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.List: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []fs.DirEntry
|
||||
for rows.Next() {
|
||||
var g string
|
||||
if err := rows.Scan(&g); err != nil {
|
||||
return nil, fmt.Errorf("store.List: scan: %w", err)
|
||||
}
|
||||
entries = append(entries, &kvDirEntry{name: g, isDir: true})
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
if key != "" {
|
||||
return nil, nil // leaf node, nothing beneath
|
||||
}
|
||||
|
||||
all, err := m.s.GetAll(group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries []fs.DirEntry
|
||||
for k, v := range all {
|
||||
entries = append(entries, &kvDirEntry{name: k, size: int64(len(v))})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Stat returns file info for a group (dir) or key (file).
|
||||
func (m *Medium) Stat(p string) (fs.FileInfo, error) {
|
||||
group, key := splitPath(p)
|
||||
if group == "" {
|
||||
return nil, fmt.Errorf("store.Stat: path is required: %w", os.ErrInvalid)
|
||||
}
|
||||
if key == "" {
|
||||
n, err := m.s.Count(group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, fmt.Errorf("store.Stat: group not found: %s: %w", group, os.ErrNotExist)
|
||||
}
|
||||
return &kvFileInfo{name: group, isDir: true}, nil
|
||||
}
|
||||
val, err := m.s.Get(group, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &kvFileInfo{name: key, size: int64(len(val))}, nil
|
||||
}
|
||||
|
||||
// Open opens a key for reading.
|
||||
func (m *Medium) Open(p string) (fs.File, error) {
|
||||
group, key := splitPath(p)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("store.Open: path must include group/key: %w", os.ErrInvalid)
|
||||
}
|
||||
val, err := m.s.Get(group, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &kvFile{name: key, content: []byte(val)}, nil
|
||||
}
|
||||
|
||||
// Create creates or truncates a key. Content is stored on Close.
|
||||
func (m *Medium) Create(p string) (goio.WriteCloser, error) {
|
||||
group, key := splitPath(p)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("store.Create: path must include group/key: %w", os.ErrInvalid)
|
||||
}
|
||||
return &kvWriteCloser{s: m.s, group: group, key: key}, nil
|
||||
}
|
||||
|
||||
// Append opens a key for appending. Content is stored on Close.
|
||||
func (m *Medium) Append(p string) (goio.WriteCloser, error) {
|
||||
group, key := splitPath(p)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("store.Append: path must include group/key: %w", os.ErrInvalid)
|
||||
}
|
||||
existing, _ := m.s.Get(group, key)
|
||||
return &kvWriteCloser{s: m.s, group: group, key: key, data: []byte(existing)}, nil
|
||||
}
|
||||
|
||||
// ReadStream returns a reader for the value.
|
||||
func (m *Medium) ReadStream(p string) (goio.ReadCloser, error) {
|
||||
group, key := splitPath(p)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("store.ReadStream: path must include group/key: %w", os.ErrInvalid)
|
||||
}
|
||||
val, err := m.s.Get(group, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return goio.NopCloser(strings.NewReader(val)), nil
|
||||
}
|
||||
|
||||
// WriteStream returns a writer. Content is stored on Close.
|
||||
func (m *Medium) WriteStream(p string) (goio.WriteCloser, error) {
|
||||
return m.Create(p)
|
||||
}
|
||||
|
||||
// Exists returns true if a group or key exists.
|
||||
func (m *Medium) Exists(p string) bool {
|
||||
group, key := splitPath(p)
|
||||
if group == "" {
|
||||
return false
|
||||
}
|
||||
if key == "" {
|
||||
n, err := m.s.Count(group)
|
||||
return err == nil && n > 0
|
||||
}
|
||||
_, err := m.s.Get(group, key)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// IsDir returns true if the path is a group with entries.
|
||||
func (m *Medium) IsDir(p string) bool {
|
||||
group, key := splitPath(p)
|
||||
if key != "" || group == "" {
|
||||
return false
|
||||
}
|
||||
n, err := m.s.Count(group)
|
||||
return err == nil && n > 0
|
||||
}
|
||||
|
||||
// --- fs helper types ---
|
||||
|
||||
type kvFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
isDir bool
|
||||
}
|
||||
|
||||
func (fi *kvFileInfo) Name() string { return fi.name }
|
||||
func (fi *kvFileInfo) Size() int64 { return fi.size }
|
||||
func (fi *kvFileInfo) Mode() fs.FileMode { if fi.isDir { return fs.ModeDir | 0755 }; return 0644 }
|
||||
func (fi *kvFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (fi *kvFileInfo) IsDir() bool { return fi.isDir }
|
||||
func (fi *kvFileInfo) Sys() any { return nil }
|
||||
|
||||
type kvDirEntry struct {
|
||||
name string
|
||||
isDir bool
|
||||
size int64
|
||||
}
|
||||
|
||||
func (de *kvDirEntry) Name() string { return de.name }
|
||||
func (de *kvDirEntry) IsDir() bool { return de.isDir }
|
||||
func (de *kvDirEntry) Type() fs.FileMode { if de.isDir { return fs.ModeDir }; return 0 }
|
||||
func (de *kvDirEntry) Info() (fs.FileInfo, error) {
|
||||
return &kvFileInfo{name: de.name, size: de.size, isDir: de.isDir}, nil
|
||||
}
|
||||
|
||||
type kvFile struct {
|
||||
name string
|
||||
content []byte
|
||||
offset int64
|
||||
}
|
||||
|
||||
func (f *kvFile) Stat() (fs.FileInfo, error) {
|
||||
return &kvFileInfo{name: f.name, size: int64(len(f.content))}, nil
|
||||
}
|
||||
|
||||
func (f *kvFile) Read(b []byte) (int, error) {
|
||||
if f.offset >= int64(len(f.content)) {
|
||||
return 0, goio.EOF
|
||||
}
|
||||
n := copy(b, f.content[f.offset:])
|
||||
f.offset += int64(n)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (f *kvFile) Close() error { return nil }
|
||||
|
||||
type kvWriteCloser struct {
|
||||
s *Store
|
||||
group string
|
||||
key string
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (w *kvWriteCloser) Write(p []byte) (int, error) {
|
||||
w.data = append(w.data, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (w *kvWriteCloser) Close() error {
|
||||
return w.s.Set(w.group, w.key, string(w.data))
|
||||
}
|
||||
202
store/medium_test.go
Normal file
202
store/medium_test.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestMedium(t *testing.T) *Medium {
|
||||
t.Helper()
|
||||
m, err := NewMedium(":memory:")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { m.Close() })
|
||||
return m
|
||||
}
|
||||
|
||||
func TestMedium_ReadWrite_Good(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
|
||||
err := m.Write("config/theme", "dark")
|
||||
require.NoError(t, err)
|
||||
|
||||
val, err := m.Read("config/theme")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "dark", val)
|
||||
}
|
||||
|
||||
func TestMedium_Read_Bad_NoKey(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
_, err := m.Read("config")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMedium_Read_Bad_NotFound(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
_, err := m.Read("config/missing")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMedium_IsFile_Good(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
_ = m.Write("grp/key", "val")
|
||||
|
||||
assert.True(t, m.IsFile("grp/key"))
|
||||
assert.False(t, m.IsFile("grp/nope"))
|
||||
assert.False(t, m.IsFile("grp"))
|
||||
}
|
||||
|
||||
func TestMedium_Delete_Good(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
_ = m.Write("grp/key", "val")
|
||||
|
||||
err := m.Delete("grp/key")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, m.IsFile("grp/key"))
|
||||
}
|
||||
|
||||
func TestMedium_Delete_Bad_NonEmptyGroup(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
_ = m.Write("grp/key", "val")
|
||||
|
||||
err := m.Delete("grp")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMedium_DeleteAll_Good(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
_ = m.Write("grp/a", "1")
|
||||
_ = m.Write("grp/b", "2")
|
||||
|
||||
err := m.DeleteAll("grp")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, m.Exists("grp"))
|
||||
}
|
||||
|
||||
func TestMedium_Rename_Good(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
_ = m.Write("old/key", "val")
|
||||
|
||||
err := m.Rename("old/key", "new/key")
|
||||
require.NoError(t, err)
|
||||
|
||||
val, err := m.Read("new/key")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "val", val)
|
||||
assert.False(t, m.IsFile("old/key"))
|
||||
}
|
||||
|
||||
func TestMedium_List_Good_Groups(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
_ = m.Write("alpha/a", "1")
|
||||
_ = m.Write("beta/b", "2")
|
||||
|
||||
entries, err := m.List("")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 2)
|
||||
|
||||
names := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
names[e.Name()] = true
|
||||
assert.True(t, e.IsDir())
|
||||
}
|
||||
assert.True(t, names["alpha"])
|
||||
assert.True(t, names["beta"])
|
||||
}
|
||||
|
||||
func TestMedium_List_Good_Keys(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
_ = m.Write("grp/a", "1")
|
||||
_ = m.Write("grp/b", "22")
|
||||
|
||||
entries, err := m.List("grp")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, entries, 2)
|
||||
}
|
||||
|
||||
func TestMedium_Stat_Good(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
_ = m.Write("grp/key", "hello")
|
||||
|
||||
// Stat group
|
||||
info, err := m.Stat("grp")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, info.IsDir())
|
||||
|
||||
// Stat key
|
||||
info, err = m.Stat("grp/key")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5), info.Size())
|
||||
assert.False(t, info.IsDir())
|
||||
}
|
||||
|
||||
func TestMedium_Exists_IsDir_Good(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
_ = m.Write("grp/key", "val")
|
||||
|
||||
assert.True(t, m.Exists("grp"))
|
||||
assert.True(t, m.Exists("grp/key"))
|
||||
assert.True(t, m.IsDir("grp"))
|
||||
assert.False(t, m.IsDir("grp/key"))
|
||||
assert.False(t, m.Exists("nope"))
|
||||
}
|
||||
|
||||
func TestMedium_Open_Read_Good(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
_ = m.Write("grp/key", "hello world")
|
||||
|
||||
f, err := m.Open("grp/key")
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
|
||||
data, err := io.ReadAll(f)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello world", string(data))
|
||||
}
|
||||
|
||||
func TestMedium_CreateClose_Good(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
|
||||
w, err := m.Create("grp/key")
|
||||
require.NoError(t, err)
|
||||
_, _ = w.Write([]byte("streamed"))
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
val, err := m.Read("grp/key")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "streamed", val)
|
||||
}
|
||||
|
||||
func TestMedium_Append_Good(t *testing.T) {
|
||||
m := newTestMedium(t)
|
||||
_ = m.Write("grp/key", "hello")
|
||||
|
||||
w, err := m.Append("grp/key")
|
||||
require.NoError(t, err)
|
||||
_, _ = w.Write([]byte(" world"))
|
||||
require.NoError(t, w.Close())
|
||||
|
||||
val, err := m.Read("grp/key")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "hello world", val)
|
||||
}
|
||||
|
||||
func TestMedium_AsMedium_Good(t *testing.T) {
|
||||
s, err := New(":memory:")
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
m := s.AsMedium()
|
||||
require.NoError(t, m.Write("grp/key", "val"))
|
||||
|
||||
// Accessible through both APIs
|
||||
val, err := s.Get("grp", "key")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "val", val)
|
||||
|
||||
val, err = m.Read("grp/key")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "val", val)
|
||||
}
|
||||
153
store/store.go
Normal file
153
store/store.go
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a key does not exist in the store.
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
// 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: %s/%s: %w", group, key, ErrNotFound)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// GetAll returns all key-value pairs in a group.
|
||||
func (s *Store) GetAll(group string) (map[string]string, error) {
|
||||
rows, err := s.db.Query("SELECT key, value FROM kv WHERE grp = ?", group)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store.GetAll: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, fmt.Errorf("store.GetAll: scan: %w", err)
|
||||
}
|
||||
result[k] = v
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("store.GetAll: rows: %w", err)
|
||||
}
|
||||
return result, 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
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", fmt.Errorf("store.Render: rows: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
103
store/store_test.go
Normal file
103
store/store_test.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
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 TestGetAll_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")
|
||||
|
||||
all, err := s.GetAll("grp")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]string{"a": "1", "b": "2"}, all)
|
||||
}
|
||||
|
||||
func TestGetAll_Good_Empty(t *testing.T) {
|
||||
s, _ := New(":memory:")
|
||||
defer s.Close()
|
||||
|
||||
all, err := s.GetAll("empty")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, all)
|
||||
}
|
||||
|
||||
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...")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue