peachy-go/db.go

260 lines
5.7 KiB
Go

package peachy
import (
_ "embed"
"errors"
"fmt"
"log"
"os"
"strings"
"git.codemonkeysoftware.net/b/peachy-go/csexp"
"git.codemonkeysoftware.net/b/peachy-go/csexp/match"
"zombiezen.com/go/sqlite"
"zombiezen.com/go/sqlite/sqlitex"
)
const AppID = '🍑'
var ErrInvalidDB = errors.New("invalid database file")
var ErrFileExists = errors.New("database file already exists")
var ErrFileNotExist = errors.New("database file does not exist")
type DBError struct{ error }
func (dbe DBError) Error() string {
return "database error: " + dbe.error.Error()
}
type DB struct {
conn *sqlite.Conn
}
func (db DB) Close() {
if db.conn != nil {
db.conn.Close()
}
}
var tableNameMatcher = match.MustCompile("(%s%s)")
func parseTableName(ctx sqlite.Context, args []sqlite.Value) (sqlite.Value, error) {
rawName := args[0].Text()
classOrName := args[1].Text()
var class, name string
var result *string
switch classOrName {
case "class":
result = &class
case "name":
result = &name
default:
return sqlite.Value{}, errors.New(`parse_table_name: 2nd arg must be "class" or "name"`)
}
sexp, err := csexp.ParseString(rawName)
if err != nil {
return sqlite.Value{}, nil
}
err = tableNameMatcher.Match(sexp, &class, &name)
if err != nil {
log.Printf("parseTableName: unexpected sexp structure for %s", rawName)
return sqlite.Value{}, nil
}
return sqlite.TextValue(*result), nil
}
func setupConn(conn *sqlite.Conn) error {
return conn.CreateFunction("parse_table_name", &sqlite.FunctionImpl{
NArgs: 2,
Deterministic: true,
AllowIndirect: true,
Scalar: parseTableName,
})
}
func Open(path string) (db DB, err error) {
var conn *sqlite.Conn
defer func() {
if err != nil {
conn.Close()
}
}()
conn, err = sqlite.OpenConn(path, sqlite.OpenReadWrite|sqlite.OpenWAL)
switch sqlite.ErrCode(err) {
case sqlite.ResultOK:
case sqlite.ResultCantOpen:
return db, ErrFileNotExist
case sqlite.ResultNotADB:
return db, ErrInvalidDB
default:
return db, DBError{err}
}
var goodAppID bool
sqlitex.ExecuteTransient(conn, "PRAGMA application_id", &sqlitex.ExecOptions{
ResultFunc: func(stmt *sqlite.Stmt) error {
goodAppID = stmt.ColumnInt32(0) == AppID
return nil
}})
if !goodAppID {
return db, ErrInvalidDB
}
err = setupConn(conn)
if err != nil {
return db, err
}
return DB{conn: conn}, nil
}
//go:embed metadata_schema.sql
var metadataSchema string
func Create(path string) (db DB, err error) {
fmt.Println("metadata schema:\n", metadataSchema)
var conn *sqlite.Conn
defer func() {
if err != nil {
conn.Close()
}
}()
finfo, _ := os.Stat(path)
if finfo != nil {
return db, ErrFileExists
}
conn, err = sqlite.OpenConn(path, sqlite.OpenCreate|sqlite.OpenReadWrite|sqlite.OpenWAL)
if err != nil {
return db, fmt.Errorf("could not create database: %w", err)
}
query := fmt.Sprintf("PRAGMA application_id=%d", AppID)
err = sqlitex.ExecuteTransient(conn, query, nil)
if err != nil {
return db, DBError{err}
}
err = sqlitex.ExecuteScript(conn, metadataSchema, nil)
if err != nil {
return db, DBError{err}
}
err = setupConn(conn)
if err != nil {
return db, err
}
return DB{conn: conn}, nil
}
func quoteName(name string) string {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
type CompositeKind int
const (
Record CompositeKind = iota
Variant
)
type CompositeType struct {
Name string
Kind CompositeKind
}
const addConcreteCompositeTypeQuery = `CREATE TABLE %s (
id INTEGER PRIMARY KEY
);`
const addAbstractCompositeTypeQuery = `CREATE TABLE %s (
id INTEGER PRIMARY KEY,
%s_value_id INTEGER NOT NULL REFERENCES %s(id)
);`
func (db DB) AddCompositeType(name string, kind CompositeKind) error {
var kindStr string
switch kind {
case Record:
kindStr = "record"
case Variant:
kindStr = "variant"
default:
return errors.New("invalid kind")
}
abstractTableName := quoteName(csexp.List{
csexp.Atom("composite-value"),
csexp.Atom(name),
}.String())
concreteTableName := quoteName(csexp.List{
csexp.Atom(kindStr + "-value"),
csexp.Atom(name),
}.String())
err := sqlitex.Execute(db.conn, fmt.Sprintf(addConcreteCompositeTypeQuery, concreteTableName), nil)
if err != nil {
return fmt.Errorf("AddCompositeType: %w", err)
}
err = sqlitex.Execute(db.conn, fmt.Sprintf(addAbstractCompositeTypeQuery, abstractTableName, kindStr, concreteTableName), nil)
if err != nil {
return fmt.Errorf("AddCompositeType: %w", err)
}
return nil
}
const getCompositeTypesQuery = `SELECT
parse_table_name(name, 'name'),
parse_table_name(name, 'class')
FROM sqlite_schema
WHERE parse_table_name(name, 'class') IN ('record-value', 'variant-value')
ORDER BY parse_table_name(name, 'name') ASC;`
func (db DB) GetCompositeTypes() ([]CompositeType, error) {
var results []CompositeType
err := sqlitex.Execute(db.conn, getCompositeTypesQuery, &sqlitex.ExecOptions{
ResultFunc: func(stmt *sqlite.Stmt) error {
result := CompositeType{Name: stmt.ColumnText(0)}
if stmt.ColumnText(1) == "record-value" {
result.Kind = Record
} else {
result.Kind = Variant
}
results = append(results, result)
return nil
},
})
if err != nil {
return nil, fmt.Errorf("GetCompositeTypes: %w", err)
}
return results, nil
}
func (db DB) SiteName() (string, error) {
var name string
err := sqlitex.Execute(db.conn, "SELECT name FROM site_metadata", &sqlitex.ExecOptions{
ResultFunc: func(stmt *sqlite.Stmt) error {
name = stmt.ColumnText(0)
return nil
},
})
if err != nil {
err = fmt.Errorf("SiteName: %w", err)
}
return name, err
}
func (db DB) SetSiteName(name string) error {
err := sqlitex.Execute(db.conn, "UPDATE site_metadata SET name = ?", &sqlitex.ExecOptions{
Args: []any{name},
})
if err != nil {
return fmt.Errorf("SetSiteName: %w", err)
}
return nil
}