refactor: create BoltItem interface

Create a BoltItem interface which is used to make
the database fucntions more generic.

As part of this change, the Status and Card types
have migrated back into the board package.
This commit is contained in:
Dan Anglin 2021-09-23 21:14:35 +01:00
parent 3e5cd598d0
commit 0e186be66b
Signed by: dananglin
GPG key ID: 0C1D44CFBEE68638
8 changed files with 224 additions and 111 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
/test/databases/*.db /test/databases/*.db
/pelican

View file

@ -1,12 +1,12 @@
package board package board
import ( import (
"bytes"
"encoding/gob"
"fmt" "fmt"
"sort" "sort"
"forge.dananglin.me.uk/code/dananglin/pelican/internal/card"
"forge.dananglin.me.uk/code/dananglin/pelican/internal/database" "forge.dananglin.me.uk/code/dananglin/pelican/internal/database"
"forge.dananglin.me.uk/code/dananglin/pelican/internal/status"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
) )
@ -23,8 +23,15 @@ func LoadBoard(path string) (*bolt.DB, error) {
} }
if len(statusList) == 0 { if len(statusList) == 0 {
newStatusList := status.NewDefaultStatusList() newStatusList := defaultStatusList()
if err := database.WriteStatusList(db, newStatusList); err != nil {
boltItems := make([]database.BoltItem, len(newStatusList))
for i := range newStatusList {
boltItems[i] = &newStatusList[i]
}
if err := database.WriteMany(db, database.StatusBucket, boltItems); err != nil {
return nil, fmt.Errorf("unable to save the default status list to the database, %w", err) return nil, fmt.Errorf("unable to save the default status list to the database, %w", err)
} }
} }
@ -32,21 +39,37 @@ func LoadBoard(path string) (*bolt.DB, error) {
return db, nil return db, nil
} }
// ReadStatusList returns an ordered list of statuses from the database. // ReadStatusList returns the ordered list of statuses from the database.
func ReadStatusList(db *bolt.DB) ([]status.Status, error) { func ReadStatusList(db *bolt.DB) ([]Status, error) {
statuses, err := database.ReadStatusList(db) data, err := database.ReadAll(db, database.StatusBucket)
if err != nil { if err != nil {
return statuses, fmt.Errorf("unable to read the status list, %w", err) return []Status{}, fmt.Errorf("unable to read the status list, %w", err)
} }
sort.Sort(status.ByStatusOrder(statuses)) statuses := make([]Status, len(data))
for i, d := range data {
buf := bytes.NewBuffer(d)
decoder := gob.NewDecoder(buf)
var s Status
if err := decoder.Decode(&s); err != nil {
return []Status{}, fmt.Errorf("unable to decode data, %w", err)
}
statuses[i] = s
}
sort.Sort(ByStatusOrder(statuses))
return statuses, nil return statuses, nil
} }
// TODO: Finish implementation. // TODO: Finish implementation.
func ReadStatus(db *bolt.DB) (status.Status, error) { func ReadStatus(db *bolt.DB) (Status, error) {
return status.Status{}, nil return Status{}, nil
} }
// TODO: Finish implementation. // TODO: Finish implementation.
@ -66,7 +89,7 @@ func DeleteStatus(db *bolt.DB) error {
// CreateCard creates a card in the database. // CreateCard creates a card in the database.
func CreateCard(db *bolt.DB, title, content string) error { func CreateCard(db *bolt.DB, title, content string) error {
statusList, err := database.ReadStatusList(db) statusList, err := ReadStatusList(db)
if err != nil { if err != nil {
return fmt.Errorf("unable to read the status list, %w", err) return fmt.Errorf("unable to read the status list, %w", err)
} }
@ -75,52 +98,64 @@ func CreateCard(db *bolt.DB, title, content string) error {
return statusListEmptyError{} return statusListEmptyError{}
} }
card := card.Card{ card := Card{
ID: -1, ID: -1,
Title: title, Title: title,
Content: content, Content: content,
} }
cardID, err := database.WriteCard(db, card) cardID, err := database.Write(db, database.CardBucket, &card)
if err != nil { if err != nil {
return fmt.Errorf("unable to write card to the database, %w", err) return fmt.Errorf("unable to write card to the database, %w", err)
} }
cardIDs := statusList[0].CardIds initialStatus := statusList[0]
cardIDs = append(cardIDs, cardID) initialStatus.AddCardID(cardID)
statusList[0].CardIds = cardIDs if _, err := database.Write(db, database.StatusBucket, &initialStatus); err != nil {
return fmt.Errorf("unable to write the %s status to the database, %w", initialStatus.Name, err)
// TODO: change the below to save a single status
if err := database.WriteStatusList(db, statusList); err != nil {
return fmt.Errorf("unable to write status list to the database, %w", err)
} }
return nil return nil
} }
// ReadCard returns a Card value from the database. // ReadCard returns a Card value from the database.
func ReadCard(db *bolt.DB, id int) (card.Card, error) { func ReadCard(db *bolt.DB, id int) (Card, error) {
c, err := database.ReadCard(db, id) data, err := database.Read(db, database.CardBucket, id)
if err != nil { if err != nil {
return card.Card{}, fmt.Errorf("unable to read card [%d] from the database, %w", id, err) return Card{}, fmt.Errorf("unable to read card [%d] from the database, %w", id, err)
} }
return c, nil var card Card
buf := bytes.NewBuffer(data)
decoder := gob.NewDecoder(buf)
if err := decoder.Decode(&card); err != nil {
return Card{}, fmt.Errorf("unable to decode data, %w", err)
}
return card, nil
} }
// UpdateCard modifies an existing card and saves the modification to the database. // UpdateCard modifies an existing card and saves the modification to the database.
func UpdateCard(db *bolt.DB, id int, title, content string) error { func UpdateCard(db *bolt.DB, id int, title, content string) error {
c, err := ReadCard(db, id) card, err := ReadCard(db, id)
if err != nil { if err != nil {
return err return err
} }
c.Title = title if len(title) > 0 {
c.Content = content card.Title = title
}
if _, err := database.WriteCard(db, c); err != nil { if len(content) > 0 {
card.Content = content
}
if _, err := database.Write(db, database.CardBucket, &card); err != nil {
return fmt.Errorf("unable to write card to the database, %w", err) return fmt.Errorf("unable to write card to the database, %w", err)
} }

View file

@ -8,7 +8,6 @@ import (
"testing" "testing"
"forge.dananglin.me.uk/code/dananglin/pelican/internal/board" "forge.dananglin.me.uk/code/dananglin/pelican/internal/board"
"forge.dananglin.me.uk/code/dananglin/pelican/internal/card"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
) )
@ -32,17 +31,21 @@ func TestCardLifecycle(t *testing.T) {
_ = db.Close() _ = db.Close()
}() }()
cardTitle := "A test card." initialCardTitle := "A test card."
cardContent := "Ensure that this card is safely stored in the database." initialCardContent := "Ensure that this card is safely stored in the database."
expectedCardID := 1 expectedCardID := 1
testCreateCard(t, db, cardTitle, cardContent, expectedCardID) testCreateCard(t, db, initialCardTitle, initialCardContent, expectedCardID)
testReadCard(t, db, expectedCardID, cardTitle, cardContent) testReadCard(t, db, expectedCardID, initialCardTitle, initialCardContent)
newCardTitle := "Test card updated." modifiedCardTitle := "Test card updated."
newCardContent := "Ensure that this card is safely updated in the database." modifiedCardContent1 := "Ensure that this card is safely updated in the database."
testUpdateCard(t, db, expectedCardID, newCardTitle, newCardContent) testUpdateCard(t, db, expectedCardID, modifiedCardTitle, modifiedCardContent1)
modifiedCardContent2 := "Updated card content only."
testUpdateCardContent(t, db, expectedCardID, modifiedCardTitle, modifiedCardContent2)
} }
func testCreateCard(t *testing.T, db *bolt.DB, title, content string, wantID int) { func testCreateCard(t *testing.T, db *bolt.DB, title, content string, wantID int) {
@ -107,7 +110,7 @@ func testUpdateCard(t *testing.T, db *bolt.DB, cardID int, newTitle, newContent
t.Fatalf("Unable to read the modified test card, %s", err) t.Fatalf("Unable to read the modified test card, %s", err)
} }
want := card.Card{ want := board.Card{
ID: cardID, ID: cardID,
Title: newTitle, Title: newTitle,
Content: newContent, Content: newContent,
@ -120,6 +123,31 @@ func testUpdateCard(t *testing.T, db *bolt.DB, cardID int, newTitle, newContent
} }
} }
func testUpdateCardContent(t *testing.T, db *bolt.DB, cardID int, expectedTitle, newContent string) {
t.Helper()
if err := board.UpdateCard(db, cardID, "", newContent); err != nil {
t.Fatalf("Unable to update the test card, %s", err)
}
got, err := board.ReadCard(db, cardID)
if err != nil {
t.Fatalf("Unable to read the modified test card, %s", err)
}
want := board.Card{
ID: cardID,
Title: expectedTitle,
Content: newContent,
}
if !reflect.DeepEqual(got, want) {
t.Errorf("Unexpected card read from the database, want: %+v, got: %+v", want, got)
} else {
t.Logf("Expected card read from the database, got: %+v", got)
}
}
func projectRoot() (string, error) { func projectRoot() (string, error) {
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err != nil { if err != nil {

18
internal/board/card.go Normal file
View file

@ -0,0 +1,18 @@
package board
// Card represents a card on a Kanban board.
type Card struct {
ID int
Title string
Content string
}
// UpdateId updates the ID of the Card value.
func (c *Card) UpdateId(id int) {
c.ID = id
}
// Id returns the ID of the Card value.
func (c *Card) Id() int {
return c.ID
}

View file

@ -1,4 +1,4 @@
package status package board
// Status represents the status of the Kanban board. // Status represents the status of the Kanban board.
type Status struct { type Status struct {
@ -8,6 +8,20 @@ type Status struct {
Order int Order int
} }
// UpdateId updates the ID of the Status value.
func (s *Status) UpdateId(id int) {
s.ID = id
}
// Id returns the ID of the Status value.
func (s *Status) Id() int {
return s.ID
}
func (s *Status) AddCardID(id int) {
s.CardIds = append(s.CardIds, id)
}
// ByStatusOrder implements sort.Interface for []Status based on the Order field. // ByStatusOrder implements sort.Interface for []Status based on the Order field.
type ByStatusOrder []Status type ByStatusOrder []Status
@ -23,8 +37,8 @@ func (s ByStatusOrder) Less(i, j int) bool {
return s[i].Order < s[j].Order return s[i].Order < s[j].Order
} }
// NewDefaultStatusList creates the default list of statuses and saves the statuses to the database. // defaultStatusList returns the default list of statuses.
func NewDefaultStatusList() []Status { func defaultStatusList() []Status {
return []Status{ return []Status{
{ {
ID: -1, ID: -1,

View file

@ -1,8 +0,0 @@
package card
// Card represents a card on a Kanban board.
type Card struct {
ID int
Title string
Content string
}

View file

@ -10,16 +10,19 @@ import (
"strconv" "strconv"
"time" "time"
"forge.dananglin.me.uk/code/dananglin/pelican/internal/card"
"forge.dananglin.me.uk/code/dananglin/pelican/internal/status"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
) )
const ( const (
statusBucket string = "status" StatusBucket string = "status"
cardBucket string = "card" CardBucket string = "card"
) )
type BoltItem interface {
UpdateId(int)
Id() int
}
// OpenDatabase opens the database, at a given path, for reading and writing. // OpenDatabase opens the database, at a given path, for reading and writing.
// If the file does not exist it will be created. // If the file does not exist it will be created.
func OpenDatabase(path string) (*bolt.DB, error) { func OpenDatabase(path string) (*bolt.DB, error) {
@ -48,13 +51,13 @@ func OpenDatabase(path string) (*bolt.DB, error) {
return db, nil return db, nil
} }
// WriteStatusList saves one or more statuses to the status bucket. // WriteMany saves one or more statuses to the status bucket.
func WriteStatusList(db *bolt.DB, statuses []status.Status) error { func WriteMany(db *bolt.DB, bucketName string, items []BoltItem) error {
if len(statuses) == 0 { if len(items) == 0 {
return nil return nil
} }
bucket := []byte(statusBucket) bucket := []byte(bucketName)
err := db.Update(func(tx *bolt.Tx) error { err := db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucket) b := tx.Bucket(bucket)
@ -63,24 +66,24 @@ func WriteStatusList(db *bolt.DB, statuses []status.Status) error {
return bucketNotExistError{bucket: string(bucket)} return bucketNotExistError{bucket: string(bucket)}
} }
for _, v := range statuses { for _, i := range items {
var err error var err error
if v.ID < 1 { if i.Id() < 1 {
var id uint64 var id uint64
if id, err = b.NextSequence(); err != nil { if id, err = b.NextSequence(); err != nil {
return fmt.Errorf("unable to generate ID, %w", err) return fmt.Errorf("unable to generate ID, %w", err)
} }
v.ID = int(id) i.UpdateId(int(id))
} }
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
encoder := gob.NewEncoder(buf) encoder := gob.NewEncoder(buf)
if err = encoder.Encode(v); err != nil { if err = encoder.Encode(i); err != nil {
return fmt.Errorf("unable to encode data, %w", err) return fmt.Errorf("unable to encode data, %w", err)
} }
if err = b.Put([]byte(strconv.Itoa(v.ID)), buf.Bytes()); err != nil { if err = b.Put([]byte(strconv.Itoa(i.Id())), buf.Bytes()); err != nil {
return fmt.Errorf("unable to add the status to the bucket, %w", err) return fmt.Errorf("unable to add the status to the bucket, %w", err)
} }
} }
@ -94,11 +97,13 @@ func WriteStatusList(db *bolt.DB, statuses []status.Status) error {
return nil return nil
} }
// ReadStatusList retrieves all the statuses from the status bucket. // TODO: Create ReadMany
func ReadStatusList(db *bolt.DB) ([]status.Status, error) {
var statuses []status.Status
bucket := []byte(statusBucket) // ReadAll retrieves all the statuses from the status bucket.
func ReadAll(db *bolt.DB, bucketName string) ([][]byte, error) {
bucket := []byte(bucketName)
var output [][]byte
err := db.View(func(tx *bolt.Tx) error { err := db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucket) b := tx.Bucket(bucket)
@ -108,14 +113,7 @@ func ReadStatusList(db *bolt.DB) ([]status.Status, error) {
} }
if err := b.ForEach(func(_, v []byte) error { if err := b.ForEach(func(_, v []byte) error {
var s status.Status output = append(output, v)
buf := bytes.NewBuffer(v)
decoder := gob.NewDecoder(buf)
if err := decoder.Decode(&s); err != nil {
return fmt.Errorf("unable to decode data, %w", err)
}
statuses = append(statuses, s)
return nil return nil
}); err != nil { }); err != nil {
@ -125,15 +123,15 @@ func ReadStatusList(db *bolt.DB) ([]status.Status, error) {
return nil return nil
}) })
if err != nil { if err != nil {
return statuses, fmt.Errorf("error while loading statuses from the database, %w", err) return output, fmt.Errorf("error while loading statuses from the database, %w", err)
} }
return statuses, nil return output, nil
} }
// WriteCard creates or updates a card to the card bucket in BoltDB. // Write creates or updates a Bolt item to a specified bucket.
func WriteCard(db *bolt.DB, card card.Card) (int, error) { func Write(db *bolt.DB, bucketName string, item BoltItem) (int, error) {
bucket := []byte(cardBucket) bucket := []byte(bucketName)
err := db.Update(func(tx *bolt.Tx) error { err := db.Update(func(tx *bolt.Tx) error {
var err error var err error
@ -143,60 +141,54 @@ func WriteCard(db *bolt.DB, card card.Card) (int, error) {
return bucketNotExistError{bucket: string(bucket)} return bucketNotExistError{bucket: string(bucket)}
} }
if card.ID < 1 { if item.Id() < 1 {
var id uint64 var id uint64
if id, err = b.NextSequence(); err != nil { if id, err = b.NextSequence(); err != nil {
return fmt.Errorf("unable to generate an ID for the card, %w", err) return fmt.Errorf("unable to generate an ID for the card, %w", err)
} }
card.ID = int(id) item.UpdateId(int(id))
} }
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
encoder := gob.NewEncoder(buf) encoder := gob.NewEncoder(buf)
if err = encoder.Encode(card); err != nil { if err = encoder.Encode(item); err != nil {
return fmt.Errorf("unable to encode data, %w", err) return fmt.Errorf("unable to encode data, %w", err)
} }
if err = b.Put([]byte(strconv.Itoa(card.ID)), buf.Bytes()); err != nil { if err = b.Put([]byte(strconv.Itoa(item.Id())), buf.Bytes()); err != nil {
return fmt.Errorf("unable to write the card to the bucket, %w", err) return fmt.Errorf("unable to write the card to the bucket, %w", err)
} }
return nil return nil
}) })
if err != nil { if err != nil {
return card.ID, fmt.Errorf("error while saving the card to the database, %w", err) return 0, fmt.Errorf("error while saving the card to the database, %w", err)
} }
return card.ID, nil return item.Id(), nil
} }
// ReadCard reads a card from the cards bucket in BoltDB. // Read retrieves a Bolt item from a specified bucket and returns the data in bytes.
func ReadCard(db *bolt.DB, id int) (card.Card, error) { func Read(db *bolt.DB, bucketName string, id int) ([]byte, error) {
var card card.Card bucket := []byte(bucketName)
bucket := []byte(cardBucket) var data []byte
err := db.View(func(tx *bolt.Tx) error { if err := db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucket) b := tx.Bucket(bucket)
if b == nil { if b == nil {
return bucketNotExistError{bucket: string(bucket)} return bucketNotExistError{bucket: string(bucket)}
} }
buf := bytes.NewBuffer(b.Get([]byte(strconv.Itoa(id)))) data = b.Get([]byte(strconv.Itoa(id)))
decoder := gob.NewDecoder(buf)
if err := decoder.Decode(&card); err != nil {
return fmt.Errorf("unable to decode card data, %w", err)
}
return nil return nil
}) }); err != nil {
if err != nil { return []byte{}, fmt.Errorf("error while reading the Bolt item from the database, %w", err)
return card, fmt.Errorf("error while loading the card from the database, %w", err)
} }
return card, nil return data, nil
} }
// dbPath returns the path to the database file. If a path is given then that is returned. Otherwise the default path is returned. // dbPath returns the path to the database file. If a path is given then that is returned. Otherwise the default path is returned.
@ -246,7 +238,7 @@ func mkDataDir(path string) error {
// ensureBuckets ensures that the required buckets are created in the database. // ensureBuckets ensures that the required buckets are created in the database.
func ensureBuckets(db *bolt.DB) error { func ensureBuckets(db *bolt.DB) error {
buckets := []string{statusBucket, cardBucket} buckets := []string{StatusBucket, CardBucket}
err := db.Update(func(tx *bolt.Tx) error { err := db.Update(func(tx *bolt.Tx) error {
for _, v := range buckets { for _, v := range buckets {

View file

@ -1,15 +1,16 @@
package database_test package database_test
import ( import (
"bytes"
"encoding/gob"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"testing" "testing"
"forge.dananglin.me.uk/code/dananglin/pelican/internal/card" "forge.dananglin.me.uk/code/dananglin/pelican/internal/board"
"forge.dananglin.me.uk/code/dananglin/pelican/internal/database" "forge.dananglin.me.uk/code/dananglin/pelican/internal/database"
"forge.dananglin.me.uk/code/dananglin/pelican/internal/status"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
) )
@ -78,7 +79,7 @@ func TestWriteAndReadStatusList(t *testing.T) {
func testWriteStatusList(t *testing.T, db *bolt.DB) { func testWriteStatusList(t *testing.T, db *bolt.DB) {
t.Helper() t.Helper()
newStatusList := []status.Status{ newStatusList := []board.Status{
{ {
ID: -1, ID: -1,
Name: "Backlog", Name: "Backlog",
@ -105,7 +106,13 @@ func testWriteStatusList(t *testing.T, db *bolt.DB) {
}, },
} }
if err := database.WriteStatusList(db, newStatusList); err != nil { boltItems := make([]database.BoltItem, len(newStatusList))
for i := range newStatusList {
boltItems[i] = &newStatusList[i]
}
if err := database.WriteMany(db, database.StatusBucket, boltItems); err != nil {
t.Fatalf("An error occurred whilst writing the initial status list to the database, %s", err) t.Fatalf("An error occurred whilst writing the initial status list to the database, %s", err)
} }
} }
@ -113,12 +120,28 @@ func testWriteStatusList(t *testing.T, db *bolt.DB) {
func testReadStatusList(t *testing.T, db *bolt.DB) { func testReadStatusList(t *testing.T, db *bolt.DB) {
t.Helper() t.Helper()
got, err := database.ReadStatusList(db) data, err := database.ReadAll(db, database.StatusBucket)
if err != nil { if err != nil {
t.Fatalf("An error occurred whilst reading the modified status list from the database, %s", err) t.Fatalf("An error occurred whilst reading the modified status list from the database, %s", err)
} }
want := []status.Status{ got := make([]board.Status, len(data))
for i, d := range data {
buf := bytes.NewBuffer(d)
decoder := gob.NewDecoder(buf)
var s board.Status
if err := decoder.Decode(&s); err != nil {
t.Fatalf("An error occurred whilst decoding data, %s", err)
}
got[i] = s
}
want := []board.Status{
{ {
ID: 1, ID: 1,
Name: "Backlog", Name: "Backlog",
@ -182,13 +205,13 @@ func TestReadAndWriteCard(t *testing.T) {
func testWriteCard(t *testing.T, db *bolt.DB) int { func testWriteCard(t *testing.T, db *bolt.DB) int {
t.Helper() t.Helper()
newCard := card.Card{ newCard := board.Card{
ID: -1, ID: -1,
Title: "A test task.", Title: "A test task.",
Content: "This task should be completed.", Content: "This task should be completed.",
} }
cardID, err := database.WriteCard(db, newCard) cardID, err := database.Write(db, database.CardBucket, &newCard)
if err != nil { if err != nil {
t.Fatalf("An error occurred whilst writing the card to the database, %s", err) t.Fatalf("An error occurred whilst writing the card to the database, %s", err)
} }
@ -199,12 +222,22 @@ func testWriteCard(t *testing.T, db *bolt.DB) int {
func testReadCard(t *testing.T, db *bolt.DB, cardID int) { func testReadCard(t *testing.T, db *bolt.DB, cardID int) {
t.Helper() t.Helper()
got, err := database.ReadCard(db, cardID) data, err := database.Read(db, database.CardBucket, cardID)
if err != nil { if err != nil {
t.Fatalf("An error occurred whilst loading the modified from the database, %s", err) t.Fatalf("An error occurred whilst loading the modified from the database, %s", err)
} }
want := card.Card{ var got board.Card
buf := bytes.NewBuffer(data)
decoder := gob.NewDecoder(buf)
if err := decoder.Decode(&got); err != nil {
t.Fatalf("Unable to decode data, %s", err)
}
want := board.Card{
ID: 1, ID: 1,
Title: "A test task.", Title: "A test task.",
Content: "This task should be completed.", Content: "This task should be completed.",