Files
st-pierre/sqlite.go
T

419 lines
9.6 KiB
Go

package main
import (
"context"
"database/sql"
"embed"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/pressly/goose/v3"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var embedMigrations embed.FS
type sqliteStore struct {
db *sql.DB
}
func newSqliteStore(path string) (*sqliteStore, error) {
dir := path
if idx := strings.LastIndexByte(path, '/'); idx > 0 {
dir = path[:idx]
if fi, err := os.Stat(dir); err != nil {
return nil, fmt.Errorf("sqlite dir %s: %w", dir, err)
} else if fi.Mode().Perm()&0200 == 0 {
return nil, fmt.Errorf("sqlite dir %s is not writable (mode %o)", dir, fi.Mode().Perm())
}
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
if _, err := db.Exec("SELECT 1"); err != nil {
db.Close()
return nil, fmt.Errorf("sqlite ping: %w", err)
}
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
db.Close()
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
goose.SetBaseFS(embedMigrations)
if err := goose.SetDialect("sqlite3"); err != nil {
db.Close()
return nil, fmt.Errorf("set dialect: %w", err)
}
if err := goose.Up(db, "migrations"); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return &sqliteStore{db: db}, nil
}
func (s *sqliteStore) save(ctx context.Context, note, host, user, context_ string, repos, tags []string, publicID int64) (int64, error) {
reposJSON := marshalStringSlice(repos)
tagsJSON := marshalStringSlice(tags)
result, err := s.db.ExecContext(ctx,
`INSERT INTO flashcard (public_id, version, note, repos, host, tags, "user", context)
VALUES (?, 1, ?, ?, ?, ?, ?, ?)`,
publicID, note, reposJSON, host, tagsJSON, user, context_,
)
if err != nil {
return 0, fmt.Errorf("insert: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("last insert id: %w", err)
}
if publicID == 0 {
_, err = s.db.ExecContext(ctx, `UPDATE flashcard SET public_id = ? WHERE _id = ?`, id, id)
if err != nil {
return 0, fmt.Errorf("set public_id: %w", err)
}
}
return id, nil
}
func (s *sqliteStore) getByID(ctx context.Context, id int64) (*entry, error) {
row := s.db.QueryRowContext(ctx,
`SELECT _id, public_id, version, note, repos, host, tags, "user", context, delete_timestamp, created
FROM flashcard WHERE _id = ?`, id,
)
var e entry
var reposJSON, tagsJSON sql.NullString
var host, user, context_, deleteTimestamp sql.NullString
err := row.Scan(&e.ID, &e.PublicID, &e.Version, &e.Note, &reposJSON, &host, &tagsJSON, &user, &context_, &deleteTimestamp, &e.Created)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query: %w", err)
}
if reposJSON.Valid {
e.Repos = unmarshalStringSlice(reposJSON.String)
}
if tagsJSON.Valid {
e.Tags = unmarshalStringSlice(tagsJSON.String)
}
if host.Valid {
e.Host = host.String
}
if user.Valid {
e.User = user.String
}
if context_.Valid {
e.Context = context_.String
}
if deleteTimestamp.Valid {
e.DeleteTimestamp = deleteTimestamp.String
}
return &e, nil
}
func (s *sqliteStore) getLatestByPublicID(ctx context.Context, publicID int64) (*entry, error) {
row := s.db.QueryRowContext(ctx,
`SELECT _id, public_id, version, note, repos, host, tags, "user", context, delete_timestamp, created
FROM flashcard WHERE public_id = ? ORDER BY version DESC LIMIT 1`, publicID,
)
var e entry
var reposJSON, tagsJSON sql.NullString
var host, user, context_, deleteTimestamp sql.NullString
err := row.Scan(&e.ID, &e.PublicID, &e.Version, &e.Note, &reposJSON, &host, &tagsJSON, &user, &context_, &deleteTimestamp, &e.Created)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query: %w", err)
}
if reposJSON.Valid {
e.Repos = unmarshalStringSlice(reposJSON.String)
}
if tagsJSON.Valid {
e.Tags = unmarshalStringSlice(tagsJSON.String)
}
if host.Valid {
e.Host = host.String
}
if user.Valid {
e.User = user.String
}
if context_.Valid {
e.Context = context_.String
}
if deleteTimestamp.Valid {
e.DeleteTimestamp = deleteTimestamp.String
}
return &e, nil
}
func (s *sqliteStore) listAllPublicIDs(ctx context.Context) ([]int64, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT f.public_id FROM flashcard f
WHERE f.version = (SELECT MAX(f2.version) FROM flashcard f2 WHERE f2.public_id = f.public_id)
AND f.delete_timestamp IS NULL
AND f.public_id != 0
ORDER BY f.public_id`,
)
if err != nil {
return nil, fmt.Errorf("list ids: %w", err)
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("scan id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows: %w", err)
}
return ids, nil
}
func (s *sqliteStore) getByPublicIDAndVersion(ctx context.Context, publicID int64, version int) (*entry, error) {
row := s.db.QueryRowContext(ctx,
`SELECT _id, public_id, version, note, repos, host, tags, "user", context, delete_timestamp, created
FROM flashcard WHERE public_id = ? AND version = ?`, publicID, version,
)
var e entry
var reposJSON, tagsJSON sql.NullString
var host, user, context_, deleteTimestamp sql.NullString
err := row.Scan(&e.ID, &e.PublicID, &e.Version, &e.Note, &reposJSON, &host, &tagsJSON, &user, &context_, &deleteTimestamp, &e.Created)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("query: %w", err)
}
if reposJSON.Valid {
e.Repos = unmarshalStringSlice(reposJSON.String)
}
if tagsJSON.Valid {
e.Tags = unmarshalStringSlice(tagsJSON.String)
}
if host.Valid {
e.Host = host.String
}
if user.Valid {
e.User = user.String
}
if context_.Valid {
e.Context = context_.String
}
if deleteTimestamp.Valid {
e.DeleteTimestamp = deleteTimestamp.String
}
return &e, nil
}
func (s *sqliteStore) saveVote(ctx context.Context, flashcardID int64, value int, user, repo, message string) (*vote, error) {
_, err := s.db.ExecContext(ctx,
`INSERT INTO vote (flashcard_id, value, "user", repo, message)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(flashcard_id, "user", repo)
DO UPDATE SET value = excluded.value, message = excluded.message`,
flashcardID, value, user, repo, message,
)
if err != nil {
return nil, fmt.Errorf("upsert vote: %w", err)
}
row := s.db.QueryRowContext(ctx,
`SELECT _id, flashcard_id, value, "user", repo, message, created
FROM vote WHERE flashcard_id = ? AND "user" = ? AND repo = ?`,
flashcardID, user, repo,
)
var v vote
err = row.Scan(&v.ID, &v.FlashcardID, &v.Value, &v.User, &v.Repo, &v.Message, &v.Created)
if err != nil {
return nil, fmt.Errorf("read vote: %w", err)
}
return &v, nil
}
func (s *sqliteStore) tombstone(ctx context.Context, publicID int64, version int) (int64, error) {
result, err := s.db.ExecContext(ctx,
`INSERT INTO flashcard (public_id, version, note, delete_timestamp)
VALUES (?, ?, '', datetime('now'))`,
publicID, version,
)
if err != nil {
return 0, fmt.Errorf("tombstone: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("last insert id: %w", err)
}
return id, nil
}
func (s *sqliteStore) indexFTS(ctx context.Context, id int64, note string) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO flashcard_fts(rowid, note) VALUES (?, ?)`,
id, note,
)
if err != nil {
return fmt.Errorf("index fts: %w", err)
}
return nil
}
func (s *sqliteStore) deleteFTS(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM flashcard_fts WHERE rowid = ?`,
id,
)
if err != nil {
return fmt.Errorf("delete fts: %w", err)
}
return nil
}
func (s *sqliteStore) searchFTS(ctx context.Context, query string, limit uint64) ([]searchResult, error) {
if limit == 0 {
limit = 5
}
rows, err := s.db.QueryContext(ctx,
`SELECT rowid, rank FROM flashcard_fts WHERE note MATCH ? ORDER BY rank LIMIT ?`,
query, limit,
)
if err != nil {
return nil, fmt.Errorf("fts query: %w", err)
}
type hit struct {
id int64
rank float64
}
var hits []hit
for rows.Next() {
var h hit
if err := rows.Scan(&h.id, &h.rank); err != nil {
rows.Close()
return nil, fmt.Errorf("scan: %w", err)
}
hits = append(hits, h)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows: %w", err)
}
results := make([]searchResult, 0, len(hits))
for _, h := range hits {
entry, err := s.getByID(ctx, h.id)
if err != nil {
return nil, fmt.Errorf("get entry: %w", err)
}
if entry == nil {
continue
}
results = append(results, searchResult{
Entry: *entry,
Score: float32(-h.rank),
})
}
return results, nil
}
func (s *sqliteStore) close() error {
return s.db.Close()
}
func marshalStringSlice(ss []string) string {
if len(ss) == 0 {
return "[]"
}
b, _ := json.Marshal(ss)
return string(b)
}
func unmarshalStringSlice(raw string) []string {
if raw == "" || raw == "[]" || raw == "null" {
return nil
}
var ss []string
if err := json.Unmarshal([]byte(raw), &ss); err != nil {
return nil
}
return ss
}
func parseStringSlice(raw any) []string {
if raw == nil {
return nil
}
arr, ok := raw.([]any)
if !ok {
return nil
}
result := make([]string, 0, len(arr))
for _, v := range arr {
if s, ok := v.(string); ok {
result = append(result, s)
}
}
return result
}
func parseOptionalString(raw any) string {
if s, ok := raw.(string); ok && strings.TrimSpace(s) != "" {
return s
}
return ""
}
func parseInt64(raw any) int64 {
if raw == nil {
return 0
}
switch v := raw.(type) {
case float64:
return int64(v)
case int64:
return v
case int:
return int64(v)
}
return 0
}