Files
box-manifest-android/server/internal/tree/store.go
T

656 lines
16 KiB
Go

package tree
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base32"
"encoding/hex"
"errors"
"fmt"
"strings"
)
const (
RootID = "root"
UnsortedID = "unsorted"
)
var (
ErrNotFound = errors.New("node not found")
ErrConflict = errors.New("operation conflicts with tree rules")
ErrInvalid = errors.New("invalid node data")
)
type Node struct {
ID string `json:"id"`
LookupCode string `json:"lookupCode"`
ParentID *string `json:"parentId"`
Name string `json:"name"`
Quantity *int64 `json:"quantity"`
}
type TreeNode struct {
Node
Children []*TreeNode `json:"children"`
}
type ImportNode struct {
Name string `json:"name"`
Quantity *int64 `json:"quantity,omitempty"`
Children []ImportNode `json:"children,omitempty"`
}
type ChildHandling string
const (
WithSubtree ChildHandling = "WITH_SUBTREE"
PromoteChildren ChildHandling = "PROMOTE_CHILDREN"
ChildrenToUnsorted ChildHandling = "CHILDREN_TO_UNSORTED"
)
type QuantityUpdate struct {
Set bool
Value *int64
}
type Store struct {
db *sql.DB
}
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
func (s *Store) Initialize(ctx context.Context) error {
// A single connection keeps SQLite's connection-local pragmas predictable.
s.db.SetMaxOpenConns(1)
if _, err := s.db.ExecContext(ctx, `PRAGMA foreign_keys = ON`); err != nil {
return fmt.Errorf("enable foreign keys: %w", err)
}
if _, err := s.db.ExecContext(ctx, `PRAGMA journal_mode = WAL`); err != nil {
return fmt.Errorf("enable WAL: %w", err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
parent_id TEXT REFERENCES nodes(id) ON DELETE RESTRICT,
name TEXT NOT NULL CHECK (length(trim(name)) > 0),
quantity INTEGER CHECK (quantity IS NULL OR quantity >= 0),
system INTEGER NOT NULL DEFAULT 0 CHECK (system IN (0, 1)),
CHECK (id <> parent_id)
)`); err != nil {
return fmt.Errorf("create nodes table: %w", err)
}
if _, err = tx.ExecContext(ctx, `CREATE INDEX IF NOT EXISTS nodes_parent_id ON nodes(parent_id)`); err != nil {
return fmt.Errorf("create parent index: %w", err)
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO nodes (id, parent_id, name, system)
VALUES (?, NULL, 'Root', 1)
ON CONFLICT(id) DO NOTHING`, RootID); err != nil {
return fmt.Errorf("create root: %w", err)
}
if _, err = tx.ExecContext(ctx, `
INSERT INTO nodes (id, parent_id, name, system)
VALUES (?, ?, 'Unsorted', 1)
ON CONFLICT(id) DO NOTHING`, UnsortedID, RootID); err != nil {
return fmt.Errorf("create unsorted: %w", err)
}
columns, err := tableColumns(ctx, tx, "nodes")
if err != nil {
return err
}
if !columns["lookup_code"] {
if _, err = tx.ExecContext(ctx, `ALTER TABLE nodes ADD COLUMN lookup_code TEXT`); err != nil {
return fmt.Errorf("add lookup code: %w", err)
}
}
rows, err := tx.QueryContext(ctx, `SELECT id FROM nodes WHERE lookup_code IS NULL`)
if err != nil {
return err
}
var missing []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
missing = append(missing, id)
}
if err := rows.Close(); err != nil {
return err
}
for _, id := range missing {
code, err := newLookupCode(ctx, tx)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET lookup_code = ? WHERE id = ?`, code, id); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS nodes_lookup_code ON nodes(lookup_code)`); err != nil {
return fmt.Errorf("create lookup code index: %w", err)
}
return tx.Commit()
}
func (s *Store) Create(ctx context.Context, parentID *string, name string, quantity *int64) (Node, error) {
name = strings.TrimSpace(name)
if name == "" || quantity != nil && *quantity < 0 {
return Node{}, ErrInvalid
}
parent := UnsortedID
if parentID != nil {
parent = *parentID
}
if _, err := s.Get(ctx, parent); err != nil {
return Node{}, err
}
id, err := newID()
if err != nil {
return Node{}, fmt.Errorf("generate node id: %w", err)
}
code, err := newLookupCode(ctx, s.db)
if err != nil {
return Node{}, fmt.Errorf("generate lookup code: %w", err)
}
if _, err = s.db.ExecContext(ctx,
`INSERT INTO nodes (id, lookup_code, parent_id, name, quantity) VALUES (?, ?, ?, ?, ?)`,
id, code, parent, name, quantity,
); err != nil {
return Node{}, fmt.Errorf("create node: %w", err)
}
return s.Get(ctx, id)
}
func (s *Store) CreateMany(ctx context.Context, parentID string, names []string) ([]Node, error) {
if len(names) == 0 {
return nil, ErrInvalid
}
cleanNames := make([]string, len(names))
ids := make([]string, len(names))
for index, name := range names {
cleanNames[index] = strings.TrimSpace(name)
if cleanNames[index] == "" {
return nil, ErrInvalid
}
id, err := newID()
if err != nil {
return nil, fmt.Errorf("generate node id: %w", err)
}
ids[index] = id
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err := getWith(ctx, tx, parentID); err != nil {
return nil, err
}
for index := range cleanNames {
code, err := newLookupCode(ctx, tx)
if err != nil {
return nil, err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO nodes (id, lookup_code, parent_id, name) VALUES (?, ?, ?, ?)`,
ids[index], code, parentID, cleanNames[index],
); err != nil {
return nil, fmt.Errorf("create node: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
created := make([]Node, len(cleanNames))
for index := range cleanNames {
created[index], err = s.Get(ctx, ids[index])
if err != nil {
return nil, err
}
}
return created, nil
}
func (s *Store) CreateTree(ctx context.Context, parentID string, nodes []ImportNode) (int, error) {
if len(nodes) == 0 {
return 0, ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
if _, err := getWith(ctx, tx, parentID); err != nil {
return 0, err
}
created := 0
var insert func(string, []ImportNode) error
insert = func(parent string, children []ImportNode) error {
for _, child := range children {
name := strings.TrimSpace(child.Name)
if name == "" || child.Quantity != nil && *child.Quantity < 0 {
return ErrInvalid
}
id, err := newID()
if err != nil {
return err
}
code, err := newLookupCode(ctx, tx)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO nodes (id, lookup_code, parent_id, name, quantity) VALUES (?, ?, ?, ?, ?)`,
id, code, parent, name, child.Quantity,
); err != nil {
return err
}
created++
if err := insert(id, child.Children); err != nil {
return err
}
}
return nil
}
if err := insert(parentID, nodes); err != nil {
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
return created, nil
}
func (s *Store) Get(ctx context.Context, id string) (Node, error) {
return scanNode(s.db.QueryRowContext(ctx,
`SELECT id, lookup_code, parent_id, name, quantity FROM nodes WHERE id = ?`, id,
))
}
func (s *Store) GetByLookupCode(ctx context.Context, code string) (Node, error) {
return scanNode(s.db.QueryRowContext(ctx,
`SELECT id, lookup_code, parent_id, name, quantity FROM nodes WHERE lookup_code = ?`,
strings.ToUpper(strings.TrimSpace(code)),
))
}
func (s *Store) Tree(ctx context.Context) (*TreeNode, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, lookup_code, parent_id, name, quantity FROM nodes ORDER BY rowid`)
if err != nil {
return nil, err
}
defer rows.Close()
byID := make(map[string]*TreeNode)
ordered := make([]*TreeNode, 0)
for rows.Next() {
node, err := scanNode(rows)
if err != nil {
return nil, err
}
treeNode := &TreeNode{Node: node, Children: make([]*TreeNode, 0)}
byID[node.ID] = treeNode
ordered = append(ordered, treeNode)
}
if err := rows.Err(); err != nil {
return nil, err
}
root := byID[RootID]
if root == nil {
return nil, errors.New("root node is missing")
}
for _, node := range ordered {
if node.ID == RootID {
continue
}
if node.ParentID == nil || byID[*node.ParentID] == nil {
return nil, fmt.Errorf("node %q has an invalid parent", node.ID)
}
byID[*node.ParentID].Children = append(byID[*node.ParentID].Children, node)
}
return root, nil
}
func (s *Store) Update(ctx context.Context, id string, name *string, quantity QuantityUpdate) (Node, error) {
if name == nil && !quantity.Set {
return Node{}, ErrInvalid
}
if name != nil {
trimmed := strings.TrimSpace(*name)
if trimmed == "" {
return Node{}, ErrInvalid
}
name = &trimmed
if id == RootID || id == UnsortedID {
return Node{}, ErrConflict
}
}
if quantity.Set && quantity.Value != nil && *quantity.Value < 0 {
return Node{}, ErrInvalid
}
if _, err := s.Get(ctx, id); err != nil {
return Node{}, err
}
if name != nil {
if _, err := s.db.ExecContext(ctx, `UPDATE nodes SET name = ? WHERE id = ?`, *name, id); err != nil {
return Node{}, err
}
}
if quantity.Set {
if _, err := s.db.ExecContext(ctx, `UPDATE nodes SET quantity = ? WHERE id = ?`, quantity.Value, id); err != nil {
return Node{}, err
}
}
return s.Get(ctx, id)
}
func (s *Store) Delete(ctx context.Context, id string) error {
if id == RootID || id == UnsortedID {
return ErrConflict
}
result, err := s.db.ExecContext(ctx, `
DELETE FROM nodes
WHERE id = ?
AND NOT EXISTS (SELECT 1 FROM nodes child WHERE child.parent_id = nodes.id)`, id)
if err != nil {
return err
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count == 1 {
return nil
}
if _, err := s.Get(ctx, id); errors.Is(err, ErrNotFound) {
return ErrNotFound
}
return ErrConflict
}
func (s *Store) DeleteMany(ctx context.Context, ids []string) error {
if len(ids) == 0 || hasDuplicateIDs(ids) {
return ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, id := range ids {
if id == RootID || id == UnsortedID {
return ErrConflict
}
if _, err := getWith(ctx, tx, id); err != nil {
return err
}
var hasChildren int
if err := tx.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM nodes WHERE parent_id = ?)`, id,
).Scan(&hasChildren); err != nil {
return err
}
if hasChildren == 1 {
return ErrConflict
}
}
for _, id := range ids {
if _, err := tx.ExecContext(ctx, `DELETE FROM nodes WHERE id = ?`, id); err != nil {
return err
}
}
return tx.Commit()
}
func (s *Store) Move(ctx context.Context, id, targetParentID string, handling ChildHandling) (Node, error) {
if handling == "" {
handling = WithSubtree
}
if handling != WithSubtree && handling != PromoteChildren && handling != ChildrenToUnsorted {
return Node{}, ErrInvalid
}
if id == RootID || id == UnsortedID || id == targetParentID {
return Node{}, ErrConflict
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Node{}, err
}
defer tx.Rollback()
node, err := getWith(ctx, tx, id)
if err != nil {
return Node{}, err
}
if _, err = getWith(ctx, tx, targetParentID); err != nil {
return Node{}, err
}
cycle, err := isDescendant(ctx, tx, id, targetParentID)
if err != nil {
return Node{}, err
}
if cycle {
return Node{}, ErrConflict
}
switch handling {
case PromoteChildren:
if node.ParentID == nil {
return Node{}, ErrConflict
}
if _, err = tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE parent_id = ?`, *node.ParentID, id); err != nil {
return Node{}, err
}
case ChildrenToUnsorted:
if _, err = tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE parent_id = ?`, UnsortedID, id); err != nil {
return Node{}, err
}
}
if _, err = tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE id = ?`, targetParentID, id); err != nil {
return Node{}, err
}
if err = tx.Commit(); err != nil {
return Node{}, err
}
return s.Get(ctx, id)
}
func (s *Store) MoveMany(
ctx context.Context,
ids []string,
targetParentID string,
handling ChildHandling,
) ([]Node, error) {
if len(ids) == 0 || hasDuplicateIDs(ids) {
return nil, ErrInvalid
}
if handling == "" {
handling = WithSubtree
}
if handling != WithSubtree && handling != PromoteChildren && handling != ChildrenToUnsorted {
return nil, ErrInvalid
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
if _, err := getWith(ctx, tx, targetParentID); err != nil {
return nil, err
}
nodes := make([]Node, len(ids))
var commonParent string
for index, id := range ids {
if id == RootID || id == UnsortedID || id == targetParentID {
return nil, ErrConflict
}
node, err := getWith(ctx, tx, id)
if err != nil {
return nil, err
}
if node.ParentID == nil {
return nil, ErrConflict
}
if index == 0 {
commonParent = *node.ParentID
} else if *node.ParentID != commonParent {
return nil, ErrConflict
}
cycle, err := isDescendant(ctx, tx, id, targetParentID)
if err != nil {
return nil, err
}
if cycle {
return nil, ErrConflict
}
nodes[index] = node
}
for index, id := range ids {
switch handling {
case PromoteChildren:
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE parent_id = ?`, commonParent, id); err != nil {
return nil, err
}
case ChildrenToUnsorted:
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE parent_id = ?`, UnsortedID, id); err != nil {
return nil, err
}
}
if _, err := tx.ExecContext(ctx, `UPDATE nodes SET parent_id = ? WHERE id = ?`, targetParentID, id); err != nil {
return nil, err
}
nodes[index].ParentID = &targetParentID
}
if err := tx.Commit(); err != nil {
return nil, err
}
return nodes, nil
}
type scanner interface {
Scan(dest ...any) error
}
func scanNode(row scanner) (Node, error) {
var node Node
var parent sql.NullString
var quantity sql.NullInt64
if err := row.Scan(&node.ID, &node.LookupCode, &parent, &node.Name, &quantity); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Node{}, ErrNotFound
}
return Node{}, err
}
if parent.Valid {
node.ParentID = &parent.String
}
if quantity.Valid {
node.Quantity = &quantity.Int64
}
return node, nil
}
func getWith(ctx context.Context, tx *sql.Tx, id string) (Node, error) {
return scanNode(tx.QueryRowContext(ctx,
`SELECT id, lookup_code, parent_id, name, quantity FROM nodes WHERE id = ?`, id))
}
func isDescendant(ctx context.Context, tx *sql.Tx, ancestorID, candidateID string) (bool, error) {
var found int
err := tx.QueryRowContext(ctx, `
WITH RECURSIVE descendants(id) AS (
SELECT id FROM nodes WHERE parent_id = ?
UNION ALL
SELECT nodes.id FROM nodes JOIN descendants ON nodes.parent_id = descendants.id
)
SELECT EXISTS(SELECT 1 FROM descendants WHERE id = ?)`, ancestorID, candidateID).Scan(&found)
return found == 1, err
}
func newID() (string, error) {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
return "", err
}
return hex.EncodeToString(value[:]), nil
}
type queryer interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
func newLookupCode(ctx context.Context, db queryer) (string, error) {
for attempt := 0; attempt < 20; attempt++ {
var value [5]byte
if _, err := rand.Read(value[:]); err != nil {
return "", err
}
code := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(value[:])[:6]
var exists int
if err := db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM nodes WHERE lookup_code = ?)`, code).Scan(&exists); err != nil {
return "", err
}
if exists == 0 {
return code, nil
}
}
return "", errors.New("could not generate unique lookup code")
}
func tableColumns(ctx context.Context, tx *sql.Tx, table string) (map[string]bool, error) {
rows, err := tx.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
return nil, err
}
defer rows.Close()
columns := make(map[string]bool)
for rows.Next() {
var cid int
var name, columnType string
var notNull, primaryKey int
var defaultValue any
if err := rows.Scan(&cid, &name, &columnType, &notNull, &defaultValue, &primaryKey); err != nil {
return nil, err
}
columns[name] = true
}
return columns, rows.Err()
}
func hasDuplicateIDs(ids []string) bool {
seen := make(map[string]struct{}, len(ids))
for _, id := range ids {
if strings.TrimSpace(id) == "" {
return true
}
if _, exists := seen[id]; exists {
return true
}
seen[id] = struct{}{}
}
return false
}