feat(db): add SQLite schema, Store CRUD, ULID generation
Foundation layer: entities table with card support, entity_tags join table, WAL mode, busy_timeout, full CRUD operations including promote/demote lifecycle and soft/hard delete. 33 tests passing.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not_found")
|
||||
ErrAlreadyPromoted = errors.New("invalid_promote")
|
||||
ErrAlreadyFluid = errors.New("invalid_demote")
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, pragma := range []string{
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA foreign_keys = ON",
|
||||
"PRAGMA busy_timeout = 5000",
|
||||
} {
|
||||
if _, err := db.Exec(pragma); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
_, err := s.db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS entities (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
modified_at TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
glyph TEXT NOT NULL
|
||||
CHECK (glyph IN ('todo', 'event', 'note')),
|
||||
time_anchor TEXT,
|
||||
completed_at TEXT,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TEXT,
|
||||
card_type TEXT
|
||||
CHECK (card_type IN ('snippet', 'template', 'checklist', 'decision', 'link')
|
||||
OR card_type IS NULL),
|
||||
card_data TEXT,
|
||||
use_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_used_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entity_tags (
|
||||
entity_id TEXT NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||
tag TEXT NOT NULL,
|
||||
PRIMARY KEY (entity_id, tag)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_created
|
||||
ON entities(created_at DESC) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_entities_card_use
|
||||
ON entities(use_count DESC)
|
||||
WHERE card_type IS NOT NULL AND deleted_at IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_entity_tags_tag
|
||||
ON entity_tags(tag);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func DefaultPath() (string, error) {
|
||||
if env := os.Getenv("NIB_DB"); env != "" {
|
||||
return env, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dir := filepath.Join(home, ".nib")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "nib.db"), nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestOpen_CreatesFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
t.Fatal("database file not created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpen_WALMode(t *testing.T) {
|
||||
s := testStore(t)
|
||||
var mode string
|
||||
if err := s.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode != "wal" {
|
||||
t.Errorf("expected wal, got %s", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpen_ForeignKeys(t *testing.T) {
|
||||
s := testStore(t)
|
||||
var fk int
|
||||
if err := s.db.QueryRow("PRAGMA foreign_keys").Scan(&fk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fk != 1 {
|
||||
t.Errorf("expected foreign_keys=1, got %d", fk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpen_MigrateIdempotent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
s1, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("first Open: %v", err)
|
||||
}
|
||||
s1.Close()
|
||||
|
||||
s2, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("second Open: %v", err)
|
||||
}
|
||||
s2.Close()
|
||||
}
|
||||
|
||||
func TestDefaultPath_EnvOverride(t *testing.T) {
|
||||
want := "/tmp/custom-nib.db"
|
||||
t.Setenv("NIB_DB", want)
|
||||
got, err := DefaultPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("expected %s, got %s", want, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
nibulid "github.com/lerko/nib/internal/ulid"
|
||||
)
|
||||
|
||||
type Glyph string
|
||||
|
||||
const (
|
||||
GlyphNote Glyph = "note"
|
||||
GlyphTodo Glyph = "todo"
|
||||
GlyphEvent Glyph = "event"
|
||||
)
|
||||
|
||||
type CardType string
|
||||
|
||||
const (
|
||||
CardSnippet CardType = "snippet"
|
||||
CardTemplate CardType = "template"
|
||||
CardChecklist CardType = "checklist"
|
||||
CardDecision CardType = "decision"
|
||||
CardLink CardType = "link"
|
||||
)
|
||||
|
||||
func ValidGlyph(s string) bool {
|
||||
switch Glyph(s) {
|
||||
case GlyphNote, GlyphTodo, GlyphEvent:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ValidCardType(s string) bool {
|
||||
switch CardType(s) {
|
||||
case CardSnippet, CardTemplate, CardChecklist, CardDecision, CardLink:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Entity struct {
|
||||
ID string
|
||||
CreatedAt time.Time
|
||||
ModifiedAt time.Time
|
||||
Body string
|
||||
Glyph Glyph
|
||||
TimeAnchor *string
|
||||
CompletedAt *time.Time
|
||||
Pinned bool
|
||||
DeletedAt *time.Time
|
||||
CardType *CardType
|
||||
CardData *string
|
||||
UseCount int
|
||||
LastUsedAt *time.Time
|
||||
Tags []string
|
||||
}
|
||||
|
||||
type ListParams struct {
|
||||
Tag *string
|
||||
Date *string
|
||||
Since *time.Time
|
||||
CardsOnly bool
|
||||
IncludeDeleted bool
|
||||
CardTypeFilter *CardType
|
||||
Sort string
|
||||
Order string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
func DefaultListParams() ListParams {
|
||||
return ListParams{
|
||||
Sort: "created",
|
||||
Order: "desc",
|
||||
Limit: 50,
|
||||
}
|
||||
}
|
||||
|
||||
type EntityUpdate struct {
|
||||
Body *string
|
||||
Glyph *Glyph
|
||||
TimeAnchor *string
|
||||
ClearTime bool
|
||||
Pinned *bool
|
||||
CardType *CardType
|
||||
CardData *string
|
||||
Tags *[]string
|
||||
}
|
||||
|
||||
func (s *Store) Create(e *Entity) error {
|
||||
now := time.Now().UTC()
|
||||
e.ID = nibulid.New()
|
||||
e.CreatedAt = now
|
||||
e.ModifiedAt = now
|
||||
if e.Tags == nil {
|
||||
e.Tags = []string{}
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO entities (id, created_at, modified_at, body, glyph, time_anchor,
|
||||
completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.ID,
|
||||
e.CreatedAt.Format(time.RFC3339),
|
||||
e.ModifiedAt.Format(time.RFC3339),
|
||||
e.Body,
|
||||
string(e.Glyph),
|
||||
e.TimeAnchor,
|
||||
formatTimePtr(e.CompletedAt),
|
||||
boolToInt(e.Pinned),
|
||||
formatTimePtr(e.DeletedAt),
|
||||
cardTypePtr(e.CardType),
|
||||
e.CardData,
|
||||
e.UseCount,
|
||||
formatTimePtr(e.LastUsedAt),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := insertTags(tx, e.ID, e.Tags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) Get(id string) (*Entity, error) {
|
||||
e := &Entity{}
|
||||
var createdAt, modifiedAt string
|
||||
var completedAt, deletedAt, lastUsedAt sql.NullString
|
||||
var timeAnchor, cardType, cardData sql.NullString
|
||||
var pinned int
|
||||
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, created_at, modified_at, body, glyph, time_anchor,
|
||||
completed_at, pinned, deleted_at, card_type, card_data, use_count, last_used_at
|
||||
FROM entities WHERE id = ?`, id).Scan(
|
||||
&e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor,
|
||||
&completedAt, &pinned, &deletedAt, &cardType, &cardData, &e.UseCount, &lastUsedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
|
||||
e.TimeAnchor = nullToPtr(timeAnchor)
|
||||
e.CompletedAt = parseTimePtr(completedAt)
|
||||
e.Pinned = pinned != 0
|
||||
e.DeletedAt = parseTimePtr(deletedAt)
|
||||
e.CardType = nullToCardType(cardType)
|
||||
e.CardData = nullToPtr(cardData)
|
||||
e.LastUsedAt = parseTimePtr(lastUsedAt)
|
||||
|
||||
tags, err := s.loadTags(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Tags = tags
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *Store) List(params ListParams) ([]*Entity, error) {
|
||||
var where []string
|
||||
var args []any
|
||||
|
||||
if !params.IncludeDeleted {
|
||||
where = append(where, "e.deleted_at IS NULL")
|
||||
}
|
||||
if params.Tag != nil {
|
||||
where = append(where, "e.id IN (SELECT entity_id FROM entity_tags WHERE tag = ?)")
|
||||
args = append(args, *params.Tag)
|
||||
}
|
||||
if params.Date != nil {
|
||||
where = append(where, "date(e.created_at) = ?")
|
||||
args = append(args, *params.Date)
|
||||
}
|
||||
if params.Since != nil {
|
||||
where = append(where, "e.created_at >= ?")
|
||||
args = append(args, params.Since.Format(time.RFC3339))
|
||||
}
|
||||
if params.CardsOnly {
|
||||
where = append(where, "e.card_type IS NOT NULL")
|
||||
}
|
||||
if params.CardTypeFilter != nil {
|
||||
where = append(where, "e.card_type = ?")
|
||||
args = append(args, string(*params.CardTypeFilter))
|
||||
}
|
||||
|
||||
whereClause := ""
|
||||
if len(where) > 0 {
|
||||
whereClause = "WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
|
||||
orderCol := "e.created_at"
|
||||
if params.Sort == "use_count" {
|
||||
orderCol = "e.use_count"
|
||||
}
|
||||
orderDir := "DESC"
|
||||
if strings.EqualFold(params.Order, "asc") {
|
||||
orderDir = "ASC"
|
||||
}
|
||||
|
||||
limit := params.Limit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT e.id, e.created_at, e.modified_at, e.body, e.glyph, e.time_anchor,
|
||||
e.completed_at, e.pinned, e.deleted_at, e.card_type, e.card_data,
|
||||
e.use_count, e.last_used_at
|
||||
FROM entities e
|
||||
%s
|
||||
ORDER BY %s %s
|
||||
LIMIT ? OFFSET ?`, whereClause, orderCol, orderDir)
|
||||
|
||||
args = append(args, limit, params.Offset)
|
||||
|
||||
rows, err := s.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entities []*Entity
|
||||
for rows.Next() {
|
||||
e := &Entity{}
|
||||
var createdAt, modifiedAt string
|
||||
var completedAt, deletedAt, lastUsedAt sql.NullString
|
||||
var timeAnchor, cardType, cardData sql.NullString
|
||||
var pinned int
|
||||
|
||||
if err := rows.Scan(
|
||||
&e.ID, &createdAt, &modifiedAt, &e.Body, &e.Glyph, &timeAnchor,
|
||||
&completedAt, &pinned, &deletedAt, &cardType, &cardData,
|
||||
&e.UseCount, &lastUsedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
e.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
e.ModifiedAt, _ = time.Parse(time.RFC3339, modifiedAt)
|
||||
e.TimeAnchor = nullToPtr(timeAnchor)
|
||||
e.CompletedAt = parseTimePtr(completedAt)
|
||||
e.Pinned = pinned != 0
|
||||
e.DeletedAt = parseTimePtr(deletedAt)
|
||||
e.CardType = nullToCardType(cardType)
|
||||
e.CardData = nullToPtr(cardData)
|
||||
e.LastUsedAt = parseTimePtr(lastUsedAt)
|
||||
|
||||
entities = append(entities, e)
|
||||
}
|
||||
|
||||
for _, e := range entities {
|
||||
tags, err := s.loadTags(e.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Tags = tags
|
||||
}
|
||||
|
||||
return entities, nil
|
||||
}
|
||||
|
||||
func (s *Store) Update(id string, u *EntityUpdate) error {
|
||||
existing, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var sets []string
|
||||
var args []any
|
||||
|
||||
sets = append(sets, "modified_at = ?")
|
||||
args = append(args, time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
if u.Body != nil {
|
||||
sets = append(sets, "body = ?")
|
||||
args = append(args, *u.Body)
|
||||
}
|
||||
if u.Glyph != nil {
|
||||
sets = append(sets, "glyph = ?")
|
||||
args = append(args, string(*u.Glyph))
|
||||
}
|
||||
if u.ClearTime {
|
||||
sets = append(sets, "time_anchor = NULL")
|
||||
} else if u.TimeAnchor != nil {
|
||||
sets = append(sets, "time_anchor = ?")
|
||||
args = append(args, *u.TimeAnchor)
|
||||
}
|
||||
if u.Pinned != nil {
|
||||
sets = append(sets, "pinned = ?")
|
||||
args = append(args, boolToInt(*u.Pinned))
|
||||
}
|
||||
if u.CardType != nil {
|
||||
sets = append(sets, "card_type = ?")
|
||||
args = append(args, string(*u.CardType))
|
||||
}
|
||||
if u.CardData != nil {
|
||||
sets = append(sets, "card_data = ?")
|
||||
args = append(args, *u.CardData)
|
||||
}
|
||||
|
||||
args = append(args, existing.ID)
|
||||
query := fmt.Sprintf("UPDATE entities SET %s WHERE id = ?", strings.Join(sets, ", "))
|
||||
|
||||
if _, err := tx.Exec(query, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if u.Tags != nil {
|
||||
if _, err := tx.Exec("DELETE FROM entity_tags WHERE entity_id = ?", existing.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := insertTags(tx, existing.ID, *u.Tags); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) Promote(id string, cardType CardType, cardData *string) error {
|
||||
e, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.CardType != nil {
|
||||
return ErrAlreadyPromoted
|
||||
}
|
||||
|
||||
dataVal := "{}"
|
||||
if cardData != nil {
|
||||
dataVal = *cardData
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE entities SET card_type = ?, card_data = ?, modified_at = ?
|
||||
WHERE id = ?`,
|
||||
string(cardType), dataVal, time.Now().UTC().Format(time.RFC3339), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Demote(id string) error {
|
||||
e, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.CardType == nil {
|
||||
return ErrAlreadyFluid
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE entities SET card_type = NULL, card_data = NULL,
|
||||
use_count = 0, last_used_at = NULL, modified_at = ?
|
||||
WHERE id = ?`,
|
||||
time.Now().UTC().Format(time.RFC3339), id)
|
||||
return err
|
||||
}
|
||||
|
||||
type DeleteResult int
|
||||
|
||||
const (
|
||||
DeletedSoft DeleteResult = iota
|
||||
DeletedHard
|
||||
)
|
||||
|
||||
func (s *Store) SoftDelete(id string) (DeleteResult, error) {
|
||||
var deletedAt sql.NullString
|
||||
err := s.db.QueryRow("SELECT deleted_at FROM entities WHERE id = ?", id).Scan(&deletedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if deletedAt.Valid {
|
||||
_, err = s.db.Exec("DELETE FROM entities WHERE id = ?", id)
|
||||
return DeletedHard, err
|
||||
}
|
||||
|
||||
_, err = s.db.Exec("UPDATE entities SET deleted_at = ? WHERE id = ?",
|
||||
time.Now().UTC().Format(time.RFC3339), id)
|
||||
return DeletedSoft, err
|
||||
}
|
||||
|
||||
func (s *Store) IncrementUse(id string) error {
|
||||
res, err := s.db.Exec(`
|
||||
UPDATE entities SET use_count = use_count + 1, last_used_at = ?
|
||||
WHERE id = ?`,
|
||||
time.Now().UTC().Format(time.RFC3339), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Resolve(prefix string) (string, error) {
|
||||
rows, err := s.db.Query("SELECT id FROM entities WHERE id LIKE ?", prefix+"%")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
switch len(ids) {
|
||||
case 0:
|
||||
return "", ErrNotFound
|
||||
case 1:
|
||||
return ids[0], nil
|
||||
default:
|
||||
return "", fmt.Errorf("ambiguous id prefix %q matches %d entities", prefix, len(ids))
|
||||
}
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
func (s *Store) loadTags(entityID string) ([]string, error) {
|
||||
rows, err := s.db.Query("SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tags []string
|
||||
for rows.Next() {
|
||||
var tag string
|
||||
if err := rows.Scan(&tag); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func insertTags(tx *sql.Tx, entityID string, tags []string) error {
|
||||
for _, tag := range tags {
|
||||
if _, err := tx.Exec("INSERT OR IGNORE INTO entity_tags (entity_id, tag) VALUES (?, ?)",
|
||||
entityID, tag); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatTimePtr(t *time.Time) interface{} {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func parseTimePtr(ns sql.NullString) *time.Time {
|
||||
if !ns.Valid {
|
||||
return nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, ns.String)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
func nullToPtr(ns sql.NullString) *string {
|
||||
if !ns.Valid {
|
||||
return nil
|
||||
}
|
||||
return &ns.String
|
||||
}
|
||||
|
||||
func nullToCardType(ns sql.NullString) *CardType {
|
||||
if !ns.Valid {
|
||||
return nil
|
||||
}
|
||||
ct := CardType(ns.String)
|
||||
return &ct
|
||||
}
|
||||
|
||||
func cardTypePtr(ct *CardType) interface{} {
|
||||
if ct == nil {
|
||||
return nil
|
||||
}
|
||||
return string(*ct)
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (e *Entity) CardDataJSON() map[string]interface{} {
|
||||
if e.CardData == nil {
|
||||
return nil
|
||||
}
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal([]byte(*e.CardData), &m)
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
func TestCreate_Note(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "hello world", Glyph: GlyphNote}
|
||||
if err := s.Create(e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.ID == "" {
|
||||
t.Fatal("ID not set")
|
||||
}
|
||||
|
||||
got, err := s.Get(e.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Body != "hello world" {
|
||||
t.Errorf("body: got %q", got.Body)
|
||||
}
|
||||
if got.Glyph != GlyphNote {
|
||||
t.Errorf("glyph: got %q", got.Glyph)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_TodoWithTimeAnchor(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "deploy", Glyph: GlyphTodo, TimeAnchor: ptr("14:00")}
|
||||
if err := s.Create(e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.Get(e.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.TimeAnchor == nil || *got.TimeAnchor != "14:00" {
|
||||
t.Errorf("time_anchor: got %v", got.TimeAnchor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithTags(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "deploy nginx", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}}
|
||||
if err := s.Create(e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.Get(e.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Tags) != 2 {
|
||||
t.Fatalf("expected 2 tags, got %d", len(got.Tags))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithCardType(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ct := CardSnippet
|
||||
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
||||
if err := s.Create(e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := s.Get(e.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.CardType == nil || *got.CardType != CardSnippet {
|
||||
t.Errorf("card_type: got %v", got.CardType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGet_NotFound(t *testing.T) {
|
||||
s := testStore(t)
|
||||
_, err := s.Get("01NONEXISTENT0000000000000")
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_DefaultParams(t *testing.T) {
|
||||
s := testStore(t)
|
||||
for i := 0; i < 3; i++ {
|
||||
s.Create(&Entity{Body: "note", Glyph: GlyphNote})
|
||||
}
|
||||
|
||||
entities, err := s.List(DefaultListParams())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entities) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(entities))
|
||||
}
|
||||
// desc order: newest first
|
||||
if entities[0].CreatedAt.Before(entities[2].CreatedAt) {
|
||||
t.Error("expected newest first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_FilterByTag(t *testing.T) {
|
||||
s := testStore(t)
|
||||
s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops"}})
|
||||
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"home"}})
|
||||
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"ops", "home"}})
|
||||
|
||||
p := DefaultListParams()
|
||||
tag := "ops"
|
||||
p.Tag = &tag
|
||||
|
||||
entities, err := s.List(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entities) != 2 {
|
||||
t.Errorf("expected 2 entities with tag ops, got %d", len(entities))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_FilterByDate(t *testing.T) {
|
||||
s := testStore(t)
|
||||
s.Create(&Entity{Body: "today", Glyph: GlyphNote})
|
||||
|
||||
p := DefaultListParams()
|
||||
date := time.Now().UTC().Format("2006-01-02")
|
||||
p.Date = &date
|
||||
|
||||
entities, err := s.List(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entities) != 1 {
|
||||
t.Errorf("expected 1, got %d", len(entities))
|
||||
}
|
||||
|
||||
otherDate := "2020-01-01"
|
||||
p.Date = &otherDate
|
||||
entities, err = s.List(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entities) != 0 {
|
||||
t.Errorf("expected 0 for past date, got %d", len(entities))
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_CardsOnly(t *testing.T) {
|
||||
s := testStore(t)
|
||||
s.Create(&Entity{Body: "fluid", Glyph: GlyphNote})
|
||||
ct := CardSnippet
|
||||
s.Create(&Entity{Body: "card", Glyph: GlyphNote, CardType: &ct})
|
||||
|
||||
p := DefaultListParams()
|
||||
p.CardsOnly = true
|
||||
entities, err := s.List(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entities) != 1 {
|
||||
t.Fatalf("expected 1 card, got %d", len(entities))
|
||||
}
|
||||
if entities[0].Body != "card" {
|
||||
t.Errorf("expected card entity, got %q", entities[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_IncludeDeleted(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
||||
s.Create(e)
|
||||
s.SoftDelete(e.ID)
|
||||
|
||||
p := DefaultListParams()
|
||||
entities, err := s.List(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entities) != 0 {
|
||||
t.Error("deleted entity should be excluded by default")
|
||||
}
|
||||
|
||||
p.IncludeDeleted = true
|
||||
entities, err = s.List(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entities) != 1 {
|
||||
t.Error("deleted entity should appear with include_deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_SortByUseCount(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ct := CardSnippet
|
||||
e1 := &Entity{Body: "low", Glyph: GlyphNote, CardType: &ct}
|
||||
e2 := &Entity{Body: "high", Glyph: GlyphNote, CardType: &ct}
|
||||
s.Create(e1)
|
||||
s.Create(e2)
|
||||
s.IncrementUse(e2.ID)
|
||||
s.IncrementUse(e2.ID)
|
||||
|
||||
p := DefaultListParams()
|
||||
p.Sort = "use_count"
|
||||
entities, err := s.List(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entities[0].Body != "high" {
|
||||
t.Errorf("expected highest use first, got %q", entities[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_Pagination(t *testing.T) {
|
||||
s := testStore(t)
|
||||
for i := 0; i < 10; i++ {
|
||||
s.Create(&Entity{Body: "note", Glyph: GlyphNote})
|
||||
}
|
||||
|
||||
p := DefaultListParams()
|
||||
p.Limit = 3
|
||||
p.Offset = 0
|
||||
page1, err := s.List(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page1) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(page1))
|
||||
}
|
||||
|
||||
p.Offset = 3
|
||||
page2, err := s.List(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(page2) != 3 {
|
||||
t.Fatalf("expected 3, got %d", len(page2))
|
||||
}
|
||||
if page1[0].ID == page2[0].ID {
|
||||
t.Error("pages should not overlap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_Body(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "old", Glyph: GlyphNote}
|
||||
s.Create(e)
|
||||
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
newBody := "new"
|
||||
if err := s.Update(e.ID, &EntityUpdate{Body: &newBody}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, _ := s.Get(e.ID)
|
||||
if got.Body != "new" {
|
||||
t.Errorf("body not updated: %q", got.Body)
|
||||
}
|
||||
if !got.ModifiedAt.After(e.ModifiedAt) {
|
||||
t.Error("modified_at not updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_Tags(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "test", Glyph: GlyphNote, Tags: []string{"old"}}
|
||||
s.Create(e)
|
||||
|
||||
newTags := []string{"new1", "new2"}
|
||||
if err := s.Update(e.ID, &EntityUpdate{Tags: &newTags}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, _ := s.Get(e.ID)
|
||||
if len(got.Tags) != 2 {
|
||||
t.Fatalf("expected 2 tags, got %d: %v", len(got.Tags), got.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromote_Success(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
||||
s.Create(e)
|
||||
|
||||
if err := s.Promote(e.ID, CardSnippet, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, _ := s.Get(e.ID)
|
||||
if got.CardType == nil || *got.CardType != CardSnippet {
|
||||
t.Errorf("expected snippet, got %v", got.CardType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromote_AlreadyPromoted(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ct := CardSnippet
|
||||
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
||||
s.Create(e)
|
||||
|
||||
if err := s.Promote(e.ID, CardTemplate, nil); err != ErrAlreadyPromoted {
|
||||
t.Errorf("expected ErrAlreadyPromoted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDemote_Success(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
||||
s.Create(e)
|
||||
s.Promote(e.ID, CardSnippet, nil)
|
||||
|
||||
if err := s.Demote(e.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, _ := s.Get(e.ID)
|
||||
if got.CardType != nil {
|
||||
t.Errorf("expected nil card_type, got %v", got.CardType)
|
||||
}
|
||||
if got.UseCount != 0 {
|
||||
t.Errorf("expected use_count reset to 0, got %d", got.UseCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDemote_AlreadyFluid(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "trick", Glyph: GlyphNote}
|
||||
s.Create(e)
|
||||
|
||||
if err := s.Demote(e.ID); err != ErrAlreadyFluid {
|
||||
t.Errorf("expected ErrAlreadyFluid, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftDelete_First(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
||||
s.Create(e)
|
||||
|
||||
result, err := s.SoftDelete(e.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != DeletedSoft {
|
||||
t.Errorf("expected DeletedSoft, got %d", result)
|
||||
}
|
||||
|
||||
got, _ := s.Get(e.ID)
|
||||
if got.DeletedAt == nil {
|
||||
t.Error("expected deleted_at to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftDelete_Second(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "doomed", Glyph: GlyphNote}
|
||||
s.Create(e)
|
||||
|
||||
s.SoftDelete(e.ID)
|
||||
result, err := s.SoftDelete(e.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result != DeletedHard {
|
||||
t.Errorf("expected DeletedHard, got %d", result)
|
||||
}
|
||||
|
||||
_, err = s.Get(e.ID)
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound after hard delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoftDelete_NotFound(t *testing.T) {
|
||||
s := testStore(t)
|
||||
_, err := s.SoftDelete("01NONEXISTENT0000000000000")
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrementUse(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ct := CardSnippet
|
||||
e := &Entity{Body: "trick", Glyph: GlyphNote, CardType: &ct}
|
||||
s.Create(e)
|
||||
|
||||
if err := s.IncrementUse(e.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, _ := s.Get(e.ID)
|
||||
if got.UseCount != 1 {
|
||||
t.Errorf("expected use_count=1, got %d", got.UseCount)
|
||||
}
|
||||
if got.LastUsedAt == nil {
|
||||
t.Error("expected last_used_at to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_FullID(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "test", Glyph: GlyphNote}
|
||||
s.Create(e)
|
||||
|
||||
got, err := s.Resolve(e.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != e.ID {
|
||||
t.Errorf("expected %s, got %s", e.ID, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_Prefix(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "test", Glyph: GlyphNote}
|
||||
s.Create(e)
|
||||
|
||||
got, err := s.Resolve(e.ID[:6])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != e.ID {
|
||||
t.Errorf("expected %s, got %s", e.ID, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_NotFound(t *testing.T) {
|
||||
s := testStore(t)
|
||||
_, err := s.Resolve("ZZZZZZZZZ")
|
||||
if err != ErrNotFound {
|
||||
t.Errorf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package db
|
||||
|
||||
type TagCount struct {
|
||||
Tag string
|
||||
Count int
|
||||
}
|
||||
|
||||
func (s *Store) ListTags() ([]TagCount, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT t.tag, COUNT(*) as cnt
|
||||
FROM entity_tags t
|
||||
JOIN entities e ON t.entity_id = e.id
|
||||
WHERE e.deleted_at IS NULL
|
||||
GROUP BY t.tag
|
||||
ORDER BY t.tag`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tags []TagCount
|
||||
for rows.Next() {
|
||||
var tc TagCount
|
||||
if err := rows.Scan(&tc.Tag, &tc.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags = append(tags, tc)
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []TagCount{}
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package db
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestListTags_Empty(t *testing.T) {
|
||||
s := testStore(t)
|
||||
tags, err := s.ListTags()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tags) != 0 {
|
||||
t.Errorf("expected 0 tags, got %d", len(tags))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTags_Counts(t *testing.T) {
|
||||
s := testStore(t)
|
||||
s.Create(&Entity{Body: "a", Glyph: GlyphNote, Tags: []string{"ops", "nginx"}})
|
||||
s.Create(&Entity{Body: "b", Glyph: GlyphNote, Tags: []string{"ops"}})
|
||||
s.Create(&Entity{Body: "c", Glyph: GlyphNote, Tags: []string{"home"}})
|
||||
|
||||
tags, err := s.ListTags()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tags) != 3 {
|
||||
t.Fatalf("expected 3 distinct tags, got %d", len(tags))
|
||||
}
|
||||
|
||||
counts := map[string]int{}
|
||||
for _, tc := range tags {
|
||||
counts[tc.Tag] = tc.Count
|
||||
}
|
||||
if counts["ops"] != 2 {
|
||||
t.Errorf("ops count: expected 2, got %d", counts["ops"])
|
||||
}
|
||||
if counts["nginx"] != 1 {
|
||||
t.Errorf("nginx count: expected 1, got %d", counts["nginx"])
|
||||
}
|
||||
if counts["home"] != 1 {
|
||||
t.Errorf("home count: expected 1, got %d", counts["home"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTags_ExcludesDeleted(t *testing.T) {
|
||||
s := testStore(t)
|
||||
e := &Entity{Body: "doomed", Glyph: GlyphNote, Tags: []string{"gone"}}
|
||||
s.Create(e)
|
||||
s.SoftDelete(e.ID)
|
||||
|
||||
s.Create(&Entity{Body: "alive", Glyph: GlyphNote, Tags: []string{"here"}})
|
||||
|
||||
tags, err := s.ListTags()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tags) != 1 {
|
||||
t.Fatalf("expected 1 tag, got %d", len(tags))
|
||||
}
|
||||
if tags[0].Tag != "here" {
|
||||
t.Errorf("expected 'here', got %q", tags[0].Tag)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user