feat: sqlite storage draft

This commit is contained in:
2026-02-03 09:15:29 +01:00
parent d6617cec02
commit 3f5cf0d673
7 changed files with 501 additions and 21 deletions

217
internal/storage/sqlite.go Normal file
View File

@ -0,0 +1,217 @@
package storage
import (
"database/sql"
"fmt"
"time"
"donniemarko/internal/note"
_ "modernc.org/sqlite"
)
type SQLiteStorage struct {
db *sql.DB
}
const sqliteSchema = `
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
content TEXT NOT NULL,
updated_at INTEGER NOT NULL,
size INTEGER NOT NULL,
published INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS notes_updated_at_idx ON notes(updated_at);
CREATE INDEX IF NOT EXISTS notes_path_idx ON notes(path);
`
func NewSQLiteStorage(dbPath string) (*SQLiteStorage, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if _, err := db.Exec(sqliteSchema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("init schema: %w", err)
}
return &SQLiteStorage{db: db}, nil
}
func (s *SQLiteStorage) Close() error {
if s.db == nil {
return nil
}
return s.db.Close()
}
func (s *SQLiteStorage) GetAll() []*note.Note {
rows, err := s.db.Query(`
SELECT id, path, title, content, updated_at, size, published
FROM notes
`)
if err != nil {
return []*note.Note{}
}
defer rows.Close()
var notes []*note.Note
for rows.Next() {
n, err := scanNote(rows)
if err != nil {
continue
}
notes = append(notes, n)
}
return notes
}
func (s *SQLiteStorage) Get(id string) (*note.Note, error) {
row := s.db.QueryRow(`
SELECT id, path, title, content, updated_at, size, published
FROM notes
WHERE id = ?
`, id)
n, err := scanNote(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("No note with id '%s'", id)
}
return nil, err
}
return n, nil
}
func (s *SQLiteStorage) Create(n *note.Note) error {
_, err := s.db.Exec(`
INSERT INTO notes (id, path, title, content, updated_at, size, published)
VALUES (?, ?, ?, ?, ?, ?, ?)
`,
n.ID,
n.Path,
n.Title,
n.Content,
toUnix(n.UpdatedAt),
n.Size,
boolToInt(n.Published),
)
if err != nil {
return fmt.Errorf("insert note: %w", err)
}
return nil
}
func (s *SQLiteStorage) Delete(id string) {
_, _ = s.db.Exec(`DELETE FROM notes WHERE id = ?`, id)
}
func (s *SQLiteStorage) Update(id string, n *note.Note) {
_, _ = s.db.Exec(`
UPDATE notes
SET path = ?, title = ?, content = ?, updated_at = ?, size = ?, published = ?
WHERE id = ?
`,
n.Path,
n.Title,
n.Content,
toUnix(n.UpdatedAt),
n.Size,
boolToInt(n.Published),
id,
)
}
func (s *SQLiteStorage) Search(query string) []*note.Note {
pattern := "%" + query + "%"
rows, err := s.db.Query(`
SELECT id, path, title, content, updated_at, size, published
FROM notes
WHERE lower(content) LIKE lower(?)
`, pattern)
if err != nil {
return []*note.Note{}
}
defer rows.Close()
var notes []*note.Note
for rows.Next() {
n, err := scanNote(rows)
if err != nil {
continue
}
notes = append(notes, n)
}
return notes
}
func (s *SQLiteStorage) Count() int {
row := s.db.QueryRow(`SELECT COUNT(*) FROM notes`)
var count int
if err := row.Scan(&count); err != nil {
return 0
}
return count
}
type scanner interface {
Scan(dest ...interface{}) error
}
func scanNote(row scanner) (*note.Note, error) {
var (
id string
path string
title string
content string
updated int64
size int64
published int
)
if err := row.Scan(&id, &path, &title, &content, &updated, &size, &published); err != nil {
return nil, err
}
return &note.Note{
ID: id,
Path: path,
Title: title,
Content: content,
UpdatedAt: fromUnix(updated),
Size: size,
Published: published != 0,
}, nil
}
func toUnix(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.UTC().Unix()
}
func fromUnix(v int64) time.Time {
if v == 0 {
return time.Time{}
}
return time.Unix(v, 0).UTC()
}
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}

View File

@ -0,0 +1,159 @@
package storage
import (
"path/filepath"
"testing"
"time"
"donniemarko/internal/note"
)
func newSQLiteStorage(t *testing.T) *SQLiteStorage {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "notes.db")
st, err := NewSQLiteStorage(dbPath)
if err != nil {
t.Fatalf("new sqlite storage: %v", err)
}
t.Cleanup(func() {
_ = st.Close()
})
return st
}
func sampleNote(id, path, title, content string, tstamp time.Time) *note.Note {
return &note.Note{
ID: id,
Path: path,
Title: title,
Content: content,
UpdatedAt: tstamp.Add(2 * time.Hour),
Size: int64(len(content)),
Published: true,
}
}
func TestSQLiteStorage_CreateGet(t *testing.T) {
st := newSQLiteStorage(t)
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
n := sampleNote("n1", "notes/alpha.md", "Alpha", "# Alpha", ts)
if err := st.Create(n); err != nil {
t.Fatalf("create note: %v", err)
}
got, err := st.Get("n1")
if err != nil {
t.Fatalf("get note: %v", err)
}
if got.Title != n.Title || got.Path != n.Path || got.Content != n.Content {
t.Fatalf("unexpected note fields: %+v", got)
}
if !got.UpdatedAt.Equal(n.UpdatedAt) || got.Size != n.Size {
t.Fatalf("unexpected time fields: %+v", got)
}
if !got.Published {
t.Fatalf("expected published to be true")
}
}
func TestSQLiteStorage_GetAll(t *testing.T) {
st := newSQLiteStorage(t)
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "two", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
all := st.GetAll()
if len(all) != 2 {
t.Fatalf("expected 2 notes, got %d", len(all))
}
}
func TestSQLiteStorage_Update(t *testing.T) {
st := newSQLiteStorage(t)
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
n := sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)
if err := st.Create(n); err != nil {
t.Fatalf("create note: %v", err)
}
updated := sampleNote("n1", "notes/alpha.md", "Alpha Updated", "two", ts.Add(24*time.Hour))
st.Update("n1", updated)
got, err := st.Get("n1")
if err != nil {
t.Fatalf("get note: %v", err)
}
if got.Title != "Alpha Updated" || got.Content != "two" {
t.Fatalf("update did not persist: %+v", got)
}
}
func TestSQLiteStorage_Delete(t *testing.T) {
st := newSQLiteStorage(t)
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
st.Delete("n1")
if st.Count() != 0 {
t.Fatalf("expected count 0 after delete")
}
if _, err := st.Get("n1"); err == nil {
t.Fatalf("expected error for missing note")
}
}
func TestSQLiteStorage_Search(t *testing.T) {
st := newSQLiteStorage(t)
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "Rust tips", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "Golang tutorial", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
results := st.Search("rust")
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].ID != "n1" {
t.Fatalf("expected rust match to be n1")
}
}
func TestSQLiteStorage_Count(t *testing.T) {
st := newSQLiteStorage(t)
if st.Count() != 0 {
t.Fatalf("expected empty count to be 0")
}
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "two", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
if st.Count() != 2 {
t.Fatalf("expected count 2")
}
}