260 lines
5.7 KiB
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
|
|
}
|