refactor: remove db package and move database logic to service layer

This commit is contained in:
2026-03-29 21:24:09 +02:00
parent 6ff013dd2a
commit 4ebcb88628
16 changed files with 163 additions and 217 deletions

View File

@@ -1,7 +1,6 @@
package cmd
import (
"axolotl/db"
"axolotl/models"
"axolotl/output"
"axolotl/service"
@@ -19,12 +18,6 @@ var cTags, cRels []string
var addCmd = &cobra.Command{
Use: "add <title>", Short: "Create a new node", Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
d, err := db.GetDB()
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
if !slices.ContainsFunc(cTags, func(e string) bool { return strings.HasPrefix(e, "_type::") }) {
cTags = append(cTags, "_type::issue")
}
@@ -49,11 +42,13 @@ var addCmd = &cobra.Command{
rels[models.RelInNamespace] = append(rels[models.RelInNamespace], cfg.GetUser())
}
svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser())
if n, err := svc.Create(args[0], cContent, cDue, cTags, rels); err != nil {
svc, err := service.GetNodeService(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "failed to create:", err)
} else if n, err := svc.Create(args[0], cContent, cDue, cTags, rels); err != nil {
fmt.Fprintln(os.Stderr, "failed to create:", err)
} else {
output.PrintNode(cmd.OutOrStdout(), n, jsonFlag)
output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
}
},
}

View File

@@ -1,7 +1,6 @@
package cmd
import (
"axolotl/db"
"axolotl/output"
"axolotl/service"
"bufio"
@@ -16,12 +15,11 @@ var dForce bool
var delCmd = &cobra.Command{
Use: "del <id>", Short: "Delete a node", Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
d, err := db.GetDB()
svc, err := service.GetNodeService(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, err)
fmt.Fprintln(os.Stderr, "failed to create service: %v", err)
return
}
svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser())
n, err := svc.GetByID(args[0])
if err != nil {
fmt.Fprintln(os.Stderr, " node not found:", args[0])

View File

@@ -1,7 +1,6 @@
package cmd
import (
"axolotl/db"
"axolotl/output"
"axolotl/service"
"fmt"
@@ -14,12 +13,11 @@ import (
var editCmd = &cobra.Command{
Use: "edit <id>", Short: "Edit node content in $EDITOR", Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
d, err := db.GetDB()
svc, err := service.GetNodeService(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser())
n, err := svc.GetByID(args[0])
if err != nil {
fmt.Fprintln(os.Stderr, "node not found:", args[0])
@@ -53,7 +51,7 @@ var editCmd = &cobra.Command{
return
}
n, _ = svc.GetByID(args[0])
output.PrintNode(cmd.OutOrStdout(), n, jsonFlag)
output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
} else {
fmt.Fprintln(os.Stderr, "failed to read temp file:", err)
}

View File

@@ -1,8 +1,8 @@
package cmd
import (
"axolotl/db"
"axolotl/output"
"axolotl/service"
"fmt"
"os"
"path/filepath"
@@ -22,7 +22,7 @@ var initCmd = &cobra.Command{
fmt.Fprintln(os.Stderr, "database already exists:", dbPath)
os.Exit(1)
}
if err := db.Init(dbPath); err != nil {
if err := service.InitNodeService(dbPath); err != nil {
fmt.Fprintln(os.Stderr, "failed to initialize:", err)
os.Exit(1)
}

View File

@@ -1,7 +1,6 @@
package cmd
import (
"axolotl/db"
"axolotl/output"
"axolotl/service"
"fmt"
@@ -17,12 +16,11 @@ var lMention string
var listCmd = &cobra.Command{
Use: "list", Short: "List nodes",
Run: func(cmd *cobra.Command, args []string) {
d, err := db.GetDB()
svc, err := service.GetNodeService(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser())
opts := []service.ListOption{}
if len(lTags) > 0 {
opts = append(opts, service.WithTags(lTags...))
@@ -35,7 +33,7 @@ var listCmd = &cobra.Command{
}
if nodes, err := svc.List(opts...); err == nil {
output.PrintNodes(cmd.OutOrStdout(), nodes, jsonFlag)
output.PrintNodes(cmd.OutOrStdout(), svc, nodes, jsonFlag)
} else {
fmt.Fprintf(os.Stderr, "err: %v\n", err)
}

View File

@@ -15,7 +15,7 @@ var rootCmd = &cobra.Command{Use: "ax", Short: "The axolotl issue tracker"}
func Execute() {
var err error
cfg, err = service.LoadConfig()
cfg, err = service.LoadConfigFile()
if err != nil {
fmt.Fprintln(os.Stderr, "failed to load config:", err)
os.Exit(1)
@@ -27,18 +27,48 @@ func Execute() {
}
}
func expandAlias(alias *service.Alias, args []string, currentUser string) []string {
cmd := alias.Command
cmd = strings.ReplaceAll(cmd, "$me", currentUser)
parts := strings.Fields(cmd)
var result []string
for _, part := range parts {
if part == "$@" {
result = append(result, args...)
continue
}
hasCatchAll := strings.Contains(part, "$@")
replaced := part
if hasCatchAll {
replaced = strings.ReplaceAll(replaced, "$@", strings.Join(args, " "))
}
for i := len(args) - 1; i >= 0; i-- {
placeholder := fmt.Sprintf("$%d", i+1)
replaced = strings.ReplaceAll(replaced, placeholder, args[i])
}
result = append(result, replaced)
}
return result
}
func registerAliasCommands() {
rootCmd.AddGroup(&cobra.Group{ID: "aliases", Title: "Aliases:"})
aliases, _ := cfg.ListAliases()
for _, a := range aliases {
a := a
rootCmd.AddCommand(&cobra.Command{
Use: a.Name,
Short: a.Description,
GroupID: "aliases",
DisableFlagParsing: true,
Run: func(cmd *cobra.Command, args []string) {
expanded := service.ExpandAlias(a, args, cfg.GetUser())
expanded := expandAlias(a, args, cfg.GetUser())
rootCmd.SetArgs(transformArgs(expanded))
if err := rootCmd.Execute(); err != nil {
os.Exit(1)

View File

@@ -1,7 +1,6 @@
package cmd
import (
"axolotl/db"
"axolotl/output"
"axolotl/service"
"fmt"
@@ -13,14 +12,13 @@ import (
var showCmd = &cobra.Command{
Use: "show <id>", Short: "Show node details", Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
d, err := db.GetDB()
svc, err := service.GetNodeService(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser())
if n, err := svc.GetByID(args[0]); err == nil {
output.PrintNode(cmd.OutOrStdout(), n, jsonFlag)
output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
} else {
fmt.Fprintln(os.Stderr, "node not found:", args[0])
}

View File

@@ -1,7 +1,6 @@
package cmd
import (
"axolotl/db"
"axolotl/models"
"axolotl/output"
"axolotl/service"
@@ -22,12 +21,11 @@ var (
var updateCmd = &cobra.Command{
Use: "update <id>", Short: "Update a node", Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
d, err := db.GetDB()
svc, err := service.GetNodeService(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
svc := service.NewSQLiteNodeService(d.DB, cfg.GetUser())
node, err := svc.GetByID(args[0])
if err != nil {
@@ -76,7 +74,6 @@ var updateCmd = &cobra.Command{
}
}
if cmd.Flags().Changed("title") {
node.Title = uTitle
}
@@ -117,7 +114,7 @@ var updateCmd = &cobra.Command{
return
}
if n, err := svc.GetByID(args[0]); err == nil {
output.PrintNode(cmd.OutOrStdout(), n, jsonFlag)
output.PrintNode(cmd.OutOrStdout(), svc, n, jsonFlag)
} else {
fmt.Fprintln(os.Stderr, "failed to fetch node:", err)
}

View File

@@ -1,72 +0,0 @@
package db
import (
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
type DB struct {
*sql.DB
path string
}
var (
database *DB
migrations = []string{
`CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT, due_date TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
`CREATE TABLE IF NOT EXISTS tags (node_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY (node_id, tag), FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS rels (from_id TEXT NOT NULL, to_id TEXT NOT NULL, rel_type TEXT NOT NULL, PRIMARY KEY (from_id, to_id, rel_type), FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE, FOREIGN KEY (to_id) REFERENCES nodes(id) ON DELETE CASCADE)`,
`CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag)`, `CREATE INDEX IF NOT EXISTS idx_rels_from ON rels(from_id)`, `CREATE INDEX IF NOT EXISTS idx_rels_to ON rels(to_id)`,
}
)
func GetDB() (*DB, error) {
if database != nil {
return database, nil
}
dir, err := filepath.Abs(".")
if err != nil {
return nil, err
}
for {
if _, err := os.Stat(filepath.Join(dir, ".ax.db")); err == nil {
if database, err = Open(filepath.Join(dir, ".ax.db")); err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
return database, nil
}
if parent := filepath.Dir(dir); parent == dir {
return nil, errors.New("no .ax.db found (run 'ax init' first)")
} else {
dir = parent
}
}
}
func Init(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
var err error
database, err = Open(path)
return err
}
func Open(path string) (*DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
for _, q := range append([]string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"}, migrations...) {
if _, err := db.Exec(q); err != nil {
return nil, err
}
}
return &DB{DB: db, path: path}, nil
}

View File

@@ -1,19 +0,0 @@
package db
import "axolotl/models"
func (db *DB) GetRelNames(n *models.Node, r models.RelType) ([]string, error) {
ids := n.Relations[string(r)]
if len(ids) == 0 {
return nil, nil
}
result := make([]string, 0, len(ids))
for _, id := range ids {
var title string
if err := db.QueryRow("SELECT title FROM nodes WHERE id = ?", id).Scan(&title); err != nil {
return nil, err
}
result = append(result, title)
}
return result, nil
}

View File

@@ -13,18 +13,6 @@ type Node struct {
Relations map[string][]string `json:"relations,omitempty"`
}
type RelType string
const (
RelBlocks RelType = "blocks"
RelSubtask RelType = "subtask"
RelRelated RelType = "related"
RelCreated RelType = "created"
RelAssignee RelType = "assignee"
RelInNamespace RelType = "in_namespace"
RelMentions RelType = "mentions"
)
func (n *Node) GetProperty(k string) string {
for _, t := range n.Tags {
if strings.HasPrefix(t, "_") {

13
models/rel_type.go Normal file
View File

@@ -0,0 +1,13 @@
package models
type RelType string
const (
RelBlocks RelType = "blocks"
RelSubtask RelType = "subtask"
RelRelated RelType = "related"
RelCreated RelType = "created"
RelAssignee RelType = "assignee"
RelInNamespace RelType = "in_namespace"
RelMentions RelType = "mentions"
)

View File

@@ -1,7 +1,6 @@
package output
import (
"axolotl/db"
"axolotl/models"
"axolotl/service"
"encoding/json"
@@ -59,21 +58,7 @@ const (
iconNamespace = "\uf07b"
)
func render(rm RenderMap, key string, short bool) string {
v, ok := rm[key]
if !ok {
v, ok = rm[""]
if !ok {
return ""
}
}
if short {
return v.c.Sprint(v.s)
}
return v.c.Sprint(v.l)
}
func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error {
func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, jsonOut bool) error {
if jsonOut {
return json.NewEncoder(w).Encode(nodes)
}
@@ -82,11 +67,6 @@ func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error {
return nil
}
d, err := db.GetDB()
if err != nil {
return err
}
fmt.Fprintln(w)
sort.Slice(nodes, func(i, j int) bool {
si, sj := nodes[i].GetProperty("status"), nodes[j].GetProperty("status")
@@ -97,9 +77,14 @@ func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error {
})
for _, n := range nodes {
ns_rels, err := d.GetRelNames(n, models.RelInNamespace)
ns_rel_node_ids := n.Relations[string(models.RelInNamespace)]
ns_rel_node_titles := make([]string, 0, len(ns_rel_node_ids))
for _, id := range ns_rel_node_ids {
ns_rel_node, err := svc.GetByID(id)
if err != nil {
return err
fmt.Fprintf(w, "err: %v", err)
}
ns_rel_node_titles = append(ns_rel_node_titles, ns_rel_node.Title)
}
fmt.Fprintf(w, " %s %s %s %s %s %s",
cDim.Sprint(n.ID),
@@ -107,7 +92,7 @@ func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error {
render(statusRM, n.GetProperty("status"), true),
render(typeRM, n.GetProperty("type"), true),
cTitle.Sprint(truncate(n.Title, 80)),
cDim.Sprint("["+strings.Join(ns_rels, ",")+"]"),
cDim.Sprint("["+strings.Join(ns_rel_node_titles, ",")+"]"),
)
tags := n.GetDisplayTags()
if len(tags) > 0 {
@@ -119,7 +104,7 @@ func PrintNodes(w io.Writer, nodes []*models.Node, jsonOut bool) error {
return nil
}
func PrintNode(w io.Writer, n *models.Node, jsonOut bool) error {
func PrintNode(w io.Writer, svc service.NodeService, n *models.Node, jsonOut bool) error {
if jsonOut {
return json.NewEncoder(w).Encode(n)
}
@@ -138,20 +123,17 @@ func PrintNode(w io.Writer, n *models.Node, jsonOut bool) error {
fmt.Fprintf(w, "\n tags: %s\n", cPrimary.Sprint(strings.Join(tags, " • ")))
}
if db, err := db.GetDB(); err != nil {
fmt.Fprintf(w, "failed to attach to db: %v", err)
} else {
for relType := range n.Relations {
names, err := db.GetRelNames(n, models.RelType(relType))
rel_node_ids := n.Relations[string(relType)]
if len(rel_node_ids) > 0 {
fmt.Fprintf(w, "\n %s\n", string(relType))
}
for _, id := range rel_node_ids {
rel_node, err := svc.GetByID(id)
if err != nil {
fmt.Fprintf(w, "err: %v", err)
}
if len(names) > 0 {
fmt.Fprintf(w, "\n %s\n", string(relType))
}
for _, name := range names {
fmt.Fprintf(w, " %s %s\n", relIcons[relType], name)
}
fmt.Fprintf(w, " %s %s\n", relIcons[relType], rel_node.Title)
}
}
@@ -201,6 +183,20 @@ func PrintAction(w io.Writer, action, detail string, isError bool) {
fmt.Fprintln(w, cGood.Sprint(icon+" "+action+" ")+cDim.Sprint(detail))
}
func render(rm RenderMap, key string, short bool) string {
v, ok := rm[key]
if !ok {
v, ok = rm[""]
if !ok {
return ""
}
}
if short {
return v.c.Sprint(v.s)
}
return v.c.Sprint(v.l)
}
func truncate(s string, max int) string {
if len(s) <= max {
return s

View File

@@ -3,12 +3,10 @@ package service
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"slices"
"strings"
)
type fileConfig struct {
@@ -18,13 +16,12 @@ type fileConfig struct {
}
var defaultAliases = []*Alias{
{Name: "mine", Command: "list --assignee $me --tag _status::open", Description: "Show open tasks assigned to you"},
{Name: "due", Command: "list --tag _status::open --tag _due", Description: "Show open tasks with due dates"},
{Name: "new", Command: "add $@", Description: "Create a new task"},
{Name: "mine", Command: "list --assignee $me --tag _type::issue --tag _status::open", Description: "Show open issues assigned to you"},
{Name: "due", Command: "list --tag _type::issue --tag _status::open", Description: "Show open issues"},
{Name: "inbox", Command: "list --mention $me", Description: "Show your inbox"},
}
func LoadConfig() (Config, error) {
func LoadConfigFile() (Config, error) {
path, err := findConfigPath()
if err != nil {
return nil, err
@@ -153,34 +150,3 @@ func (c *fileConfig) Save() error {
}
return os.WriteFile(c.path, data, 0644)
}
func ExpandAlias(alias *Alias, args []string, currentUser string) []string {
cmd := alias.Command
cmd = strings.ReplaceAll(cmd, "$me", currentUser)
parts := strings.Fields(cmd)
var result []string
for _, part := range parts {
if part == "$@" {
result = append(result, args...)
continue
}
hasCatchAll := strings.Contains(part, "$@")
replaced := part
if hasCatchAll {
replaced = strings.ReplaceAll(replaced, "$@", strings.Join(args, " "))
}
for i := len(args) - 1; i >= 0; i-- {
placeholder := fmt.Sprintf("$%d", i+1)
replaced = strings.ReplaceAll(replaced, placeholder, args[i])
}
result = append(result, replaced)
}
return result
}

View File

@@ -12,6 +12,18 @@ type NodeService interface {
CanClose(id string) (bool, []string, error)
}
func InitNodeService(path string) error {
return InitSqliteDB(path)
}
func GetNodeService(cfg Config) (NodeService, error) {
db, err := GetSqliteDB(cfg)
if err != nil {
return nil, err
}
return &sqliteNodeService{db: db, userID: cfg.GetUser()}, nil
}
type listFilter struct {
tagPrefixes []string
assignee string

View File

@@ -3,10 +3,16 @@ package service
import (
"axolotl/models"
"database/sql"
"errors"
"fmt"
"math/rand"
"os"
"path/filepath"
"slices"
"strings"
"time"
_ "modernc.org/sqlite"
)
type sqliteNodeService struct {
@@ -14,8 +20,50 @@ type sqliteNodeService struct {
userID string
}
func NewSQLiteNodeService(db *sql.DB, userID string) NodeService {
return &sqliteNodeService{db: db, userID: userID}
var migrations = []string{
`CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT, due_date TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
`CREATE TABLE IF NOT EXISTS tags (node_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY (node_id, tag), FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE)`,
`CREATE TABLE IF NOT EXISTS rels (from_id TEXT NOT NULL, to_id TEXT NOT NULL, rel_type TEXT NOT NULL, PRIMARY KEY (from_id, to_id, rel_type), FOREIGN KEY (from_id) REFERENCES nodes(id) ON DELETE CASCADE, FOREIGN KEY (to_id) REFERENCES nodes(id) ON DELETE CASCADE)`,
`CREATE INDEX IF NOT EXISTS idx_tags_tag ON tags(tag)`, `CREATE INDEX IF NOT EXISTS idx_rels_from ON rels(from_id)`, `CREATE INDEX IF NOT EXISTS idx_rels_to ON rels(to_id)`,
}
func InitSqliteDB(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
var err error
db, err := sql.Open("sqlite", path)
if err != nil {
return err
}
for _, q := range append([]string{"PRAGMA journal_mode=WAL", "PRAGMA busy_timeout=5000", "PRAGMA foreign_keys=ON"}, migrations...) {
if _, err := db.Exec(q); err != nil {
return err
}
}
return err
}
func GetSqliteDB(cfg Config) (*sql.DB, error) {
dir, err := filepath.Abs(".")
if err != nil {
return nil, err
}
for {
dbpath := filepath.Join(dir, ".ax.db")
if _, err := os.Stat(dbpath); err == nil {
db, err := sql.Open("sqlite", dbpath)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
return db, nil
}
if parent := filepath.Dir(dir); parent == dir {
return nil, errors.New("no .ax.db found (run 'ax init' first)")
} else {
dir = parent
}
}
}
func (s *sqliteNodeService) GetByID(id string) (*models.Node, error) {