First commit

This commit is contained in:
2020-02-04 18:46:10 +01:00
commit 190e1eb01d
91 changed files with 13411 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
package database
import (
"github.com/gisle/stufflog/config"
"github.com/gisle/stufflog/database/drivers/bolt"
"github.com/gisle/stufflog/database/repositories"
"github.com/gisle/stufflog/slerrors"
)
// Database is a collections of repositories.
type Database interface {
Users() repositories.UserRepository
UserSessions() repositories.UserSessionRepository
Activities() repositories.ActivityRepository
Periods() repositories.PeriodRepository
}
// Init gets you database based on the configuration provided.
func Init(cfg config.Database) (Database, error) {
switch cfg.Driver {
case "bolt", "boltdb":
return bolt.Init(cfg)
default:
return nil, &slerrors.SLError{Code: 500, Text: "Database driver " + cfg.Driver + " not recognized."}
}
}
+229
View File
@@ -0,0 +1,229 @@
package bolt
import (
"context"
"github.com/gisle/stufflog/database/repositories"
"github.com/gisle/stufflog/models"
"github.com/gisle/stufflog/slerrors"
"github.com/vmihailenco/msgpack/v4"
"go.etcd.io/bbolt"
)
var bnActivities = []byte("Activity")
type activityRepository struct {
db *bbolt.DB
userIdIdx *index
}
func (r *activityRepository) FindID(ctx context.Context, id string) (*models.Activity, error) {
activity := new(models.Activity)
err := r.db.View(func(tx *bbolt.Tx) error {
value := tx.Bucket(bnActivities).Get(unsafeStringToBytes(id))
if value == nil {
return slerrors.NotFound("Activity")
}
err := msgpack.Unmarshal(value, activity)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return activity, nil
}
func (r *activityRepository) List(ctx context.Context) ([]*models.Activity, error) {
activities := make([]*models.Activity, 0, 16)
err := r.db.View(func(tx *bbolt.Tx) error {
cursor := tx.Bucket(bnActivities).Cursor()
for key, value := cursor.First(); key != nil; key, value = cursor.Next() {
activity := new(models.Activity)
err := msgpack.Unmarshal(value, activity)
if err != nil {
return err
}
activities = append(activities, activity)
}
return nil
})
if err != nil {
return nil, err
}
return activities, nil
}
func (r *activityRepository) ListUser(ctx context.Context, user models.User) ([]*models.Activity, error) {
activities := make([]*models.Activity, 0, 16)
err := r.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bnActivities)
ids, err := r.userIdIdx.WithTx(tx).Get(user.ID)
if err != nil {
return err
}
for _, id := range ids {
value := bucket.Get(id)
if value == nil {
continue
}
activity := new(models.Activity)
err := msgpack.Unmarshal(value, activity)
if err != nil {
return err
}
activities = append(activities, activity)
}
return nil
})
if err != nil {
return nil, err
}
return activities, nil
}
func (r *activityRepository) Insert(ctx context.Context, activity models.Activity) error {
value, err := msgpack.Marshal(&activity)
if err != nil {
return err
}
return r.db.Update(func(tx *bbolt.Tx) error {
err := tx.Bucket(bnActivities).Put(unsafeStringToBytes(activity.ID), value)
if err != nil {
return err
}
err = r.userIdIdx.WithTx(tx).Set(unsafeStringToBytes(activity.ID), activity.UserID)
if err != nil {
return err
}
return nil
})
}
func (r *activityRepository) Update(ctx context.Context, activity models.Activity, updates []*models.ActivityUpdate) (*models.Activity, error) {
err := r.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bnActivities)
// Re-Get to guarantee consistency.
value := bucket.Get(unsafeStringToBytes(activity.ID))
if value == nil {
return slerrors.NotFound("Activity")
}
err := msgpack.Unmarshal(value, &activity)
if err != nil {
return err
}
// Perform updates
didChange := false
for _, update := range updates {
changed, err := activity.ApplyUpdate(*update)
if err != nil {
return err
}
if changed {
didChange = true
}
}
// Put back into bucket
if didChange {
value, err := msgpack.Marshal(&activity)
if err != nil {
return err
}
err = tx.Bucket(bnActivities).Put(unsafeStringToBytes(activity.ID), value)
if err != nil {
return err
}
} else {
return errUnchanged
}
return nil
})
if err != nil && err != errUnchanged {
return nil, err
}
return &activity, nil
}
func (r *activityRepository) Remove(ctx context.Context, activity models.Activity) error {
return r.db.Update(func(tx *bbolt.Tx) error {
err := tx.Bucket(bnActivities).Delete(unsafeStringToBytes(activity.ID))
if err != nil {
return err
}
err = r.userIdIdx.WithTx(tx).Set(unsafeStringToBytes(activity.ID))
if err != nil {
return err
}
return nil
})
}
func (r *activityRepository) reindex() error {
return r.db.Update(func(tx *bbolt.Tx) error {
cursor := tx.Bucket(bnActivities).Cursor()
err := r.userIdIdx.Reset(tx)
if err != nil {
return err
}
userIdIdxTx := r.userIdIdx.WithTx(tx)
for key, value := cursor.First(); key != nil; key, value = cursor.Next() {
activity := new(models.Activity)
err := msgpack.Unmarshal(value, activity)
if err != nil {
return err
}
err = userIdIdxTx.Set(unsafeStringToBytes(activity.ID), activity.UserID)
if err != nil {
return err
}
}
return nil
})
}
func newActivityRepository(db *bbolt.DB) (repositories.ActivityRepository, error) {
err := db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(bnActivities)
return err
})
if err != nil {
return nil, err
}
userIdIdx, err := newModelIndex(db, "Activity", "UserID")
if err != nil {
return nil, err
}
return &activityRepository{
db: db,
userIdIdx: userIdIdx,
}, nil
}
+123
View File
@@ -0,0 +1,123 @@
package bolt
import (
"context"
"github.com/gisle/stufflog/models"
"github.com/stretchr/testify/assert"
"testing"
)
func TestActivityRepository(t *testing.T) {
if dbErr != nil {
t.Fatal(dbErr)
}
strPtr := func(s string) *string { return &s }
ctx := context.Background()
testUser1 := models.User{
ID: "U1234",
Name: "Stuffer",
}
testUser2 := models.User{
ID: "U4321",
Name: "Logger",
}
testActivity1 := models.Activity{
ID: "A01",
Name: "3D Modelling",
UserID: testUser1.ID,
Icon: "cube",
SubActivities: []models.SubActivity{
{ID: "S1001", Name: "Practice", UnitName: "minute", Multiplier: 15},
{ID: "S1002", Name: "Tutorial", UnitName: "minute", Multiplier: 20},
{ID: "S1003", Name: "Scene", UnitName: "minute", Multiplier: 30},
},
}
testActivity2 := models.Activity{
ID: "A02",
Name: "Roleplay",
UserID: testUser2.ID,
Icon: "dice-d20",
SubActivities: []models.SubActivity{
{ID: "S2001", Name: "Writing", UnitName: "word", Multiplier: 1},
{ID: "S2002", Name: "Planning", UnitName: "word", Multiplier: 2},
{ID: "S2003", Name: "Publish", UnitName: "word", Multiplier: 1},
{ID: "S2003", Name: "Write & Publish", UnitName: "word", Multiplier: 3},
{ID: "S2004", Name: "IRC RP", UnitName: "minute", Multiplier: 6.6},
},
}
testActivity3 := models.Activity{
ID: "A03",
Name: "Coding",
UserID: testUser1.ID,
Icon: "code",
SubActivities: []models.SubActivity{
{ID: "S3001", Name: "Rust NameGen", UnitName: "minute", Multiplier: 20},
{ID: "S3002", Name: "Learn GoLang", UnitName: "minute", Multiplier: 25},
},
}
testActivities := []*models.Activity{&testActivity1, &testActivity2, &testActivity3}
testActivity3Update := models.ActivityUpdate{
SetName: strPtr("Programming"),
AddSub: &models.SubActivity{Name: "Website Rewrite", UnitName: "minute", Multiplier: 15},
EditSub: &models.SubActivity{ID: "S3001", Name: "Rust SetName Generator", UnitName: "minute", Multiplier: 22},
RemoveSub: strPtr("S3002"),
}
testActivity3Updated := models.Activity{
ID: "A03",
Name: "Programming",
UserID: testUser1.ID,
Icon: "code",
SubActivities: []models.SubActivity{
{ID: "S3001", Name: "Rust SetName Generator", UnitName: "minute", Multiplier: 22},
{Name: "Website Rewrite", UnitName: "minute", Multiplier: 15},
},
}
t.Run("Insert", func(t *testing.T) {
assert.NoError(t, db.Activities().Insert(ctx, testActivity1), "Save testActivity1")
assert.NoError(t, db.Activities().Insert(ctx, testActivity2), "Save testActivity2")
assert.NoError(t, db.Activities().Insert(ctx, testActivity3), "Save testActivity3")
})
t.Run("Find", func(t *testing.T) {
activity, err := db.Activities().FindID(ctx, testActivity1.ID)
assert.NoError(t, err, "Find testActivity1")
assert.Equal(t, &testActivity1, activity, "Find testActivity1")
activity, err = db.Activities().FindID(ctx, testActivity2.ID)
assert.NoError(t, err, "Find testActivity2")
assert.Equal(t, &testActivity2, activity, "Find testActivity2")
activity, err = db.Activities().FindID(ctx, testActivity3.ID)
assert.NoError(t, err, "Find testActivity3")
assert.Equal(t, &testActivity3, activity, "Find testActivity3")
})
t.Run("List", func(t *testing.T) {
activities, err := db.Activities().List(ctx)
assert.Equal(t, testActivities, activities, "List 3 activities")
assert.NoError(t, err, "List 3 activities")
activities, err = db.Activities().ListUser(ctx, testUser1)
assert.Equal(t, []*models.Activity{&testActivity1, &testActivity3}, activities, "List 2 activities for testUser1")
assert.NoError(t, err, "List 2 activities for testUser1")
})
t.Run("Update", func(t *testing.T) {
activity, err := db.Activities().Update(ctx, testActivity3, []*models.ActivityUpdate{&testActivity3Update})
if activity != nil && len(activity.SubActivities) > 1 {
testActivity3Updated.SubActivities[1].ID = activity.SubActivities[1].ID
}
assert.Equal(t, &testActivity3Updated, activity, "Update activity")
assert.NoError(t, err, "Update activity")
})
t.Run("Remove", func(t *testing.T) {
assert.NoError(t, db.Activities().Remove(ctx, testActivity2), "Delete testActivity2")
activities, err := db.Activities().List(ctx)
assert.Equal(t, []*models.Activity{&testActivity1, &testActivity3Updated}, activities, "List 2 remaining activities")
assert.NoError(t, err, "List 2 remaining activities")
})
}
+76
View File
@@ -0,0 +1,76 @@
package bolt
import (
"errors"
"github.com/gisle/stufflog/config"
"github.com/gisle/stufflog/database/repositories"
"go.etcd.io/bbolt"
"time"
"unsafe"
)
// A Database is a database.Database implementation using a bolt backend.
type Database struct {
users repositories.UserRepository
userSessions repositories.UserSessionRepository
activities repositories.ActivityRepository
periods repositories.PeriodRepository
}
func (database *Database) Users() repositories.UserRepository {
return database.users
}
func (database *Database) UserSessions() repositories.UserSessionRepository {
return database.userSessions
}
func (database *Database) Activities() repositories.ActivityRepository {
return database.activities
}
func (database *Database) Periods() repositories.PeriodRepository {
return database.periods
}
func Init(cfg config.Database) (*Database, error) {
opts := *bbolt.DefaultOptions
opts.Timeout = time.Second * 5
db, err := bbolt.Open(cfg.Path, 0700, &opts)
if err != nil {
return nil, err
}
users, err := newUserRepository(db)
if err != nil {
return nil, err
}
userSessions, err := newUserSessionRepository(db)
if err != nil {
return nil, err
}
periods, err := newPeriodRepository(db)
if err != nil {
return nil, err
}
activities, err := newActivityRepository(db)
if err != nil {
return nil, err
}
database := &Database{
users: users,
userSessions: userSessions,
periods: periods,
activities: activities,
}
return database, nil
}
// unsafeStringToBytes makes a byte array, mutation is punishable by segfault.
func unsafeStringToBytes(s string) []byte {
return *(*[]byte)(unsafe.Pointer(&s))
}
var errUnchanged = errors.New("database/bolt: unchanged")
+24
View File
@@ -0,0 +1,24 @@
package bolt
import (
"fmt"
"github.com/gisle/stufflog/config"
"os"
"testing"
"time"
)
var db *Database
var dbErr error
func TestMain(m *testing.M) {
ns := time.Now().Nanosecond()
path := fmt.Sprintf("/tmp/stufflog_repos_test_%s_%d.db", time.Now().Format("20060102150405"), ns)
db, dbErr = Init(config.Database{
Driver: "bolt",
Path: path,
})
os.Exit(m.Run())
}
+339
View File
@@ -0,0 +1,339 @@
package bolt
import (
"bytes"
"errors"
"go.etcd.io/bbolt"
)
import "github.com/vmihailenco/msgpack/v4"
var bnMap = []byte("map")
var bnRev = []byte("rev")
type index struct {
bucketNames [][]byte
}
// newModelIndex creates an index with the naming convention of `_idx.Model.Field`.
func newModelIndex(db *bbolt.DB, model, field string) (*index, error) {
return newIndex(db, "_idx", model, field)
}
// newIndex creates a new index and ensures the bucket chain is in order.
func newIndex(db *bbolt.DB, buckets ...string) (*index, error) {
if len(buckets) == 0 {
panic("no buckets")
}
bucketNames := make([][]byte, len(buckets))
for i := range buckets {
bucketNames[i] = []byte(buckets[i])
}
err := db.Update(func(tx *bbolt.Tx) error {
bucket, err := tx.CreateBucketIfNotExists(bucketNames[0])
if err != nil {
return err
}
for _, bucketName := range bucketNames[1:] {
bucket, err = bucket.CreateBucketIfNotExists(bucketName)
if err != nil {
return err
}
}
_, err = bucket.CreateBucketIfNotExists(bnMap)
if err != nil {
return err
}
_, err = bucket.CreateBucketIfNotExists(bnRev)
if err != nil {
return err
}
return nil
})
return &index{bucketNames: bucketNames}, err
}
func (idx *index) Reset(tx *bbolt.Tx) error {
rootBucket := tx.Bucket(idx.bucketNames[0])
for _, name := range idx.bucketNames[1:] {
rootBucket = rootBucket.Bucket(name)
}
err := rootBucket.DeleteBucket(bnRev)
if err != nil {
return err
}
err = rootBucket.DeleteBucket(bnMap)
if err != nil {
return err
}
_, err = rootBucket.CreateBucket(bnRev)
if err != nil {
return err
}
_, err = rootBucket.CreateBucket(bnMap)
if err != nil {
return err
}
return nil
}
func (idx *index) buckets(tx *bbolt.Tx) (mapBucket, revBucket *bbolt.Bucket) {
rootBucket := tx.Bucket(idx.bucketNames[0])
for _, name := range idx.bucketNames[1:] {
rootBucket = rootBucket.Bucket(name)
}
return rootBucket.Bucket(bnMap), rootBucket.Bucket(bnRev)
}
func (idx *index) WithTx(tx *bbolt.Tx) *indexTx {
mapBucket, revBucket := idx.buckets(tx)
return &indexTx{
tx: tx,
mapBucket: mapBucket,
revBucket: revBucket,
}
}
type indexTx struct {
tx *bbolt.Tx
mapBucket *bbolt.Bucket
revBucket *bbolt.Bucket
}
func (itx *indexTx) Get(value string) (ids [][]byte, err error) {
entry := itx.mapBucket.Get(unsafeStringToBytes(value))
if entry == nil {
return
}
ids = make([][]byte, 0, 8)
err = msgpack.Unmarshal(entry, &ids)
return
}
// Reverse gets all values associated with the ID
func (itx *indexTx) Reverse(id []byte) (values []string, err error) {
value := itx.revBucket.Get(id)
if value == nil {
return
}
values = make([]string, 0, 8)
err = msgpack.Unmarshal(value, &values)
return
}
func (itx *indexTx) Before(value string) (ids [][]byte, err error) {
cursor := itx.mapBucket.Cursor()
ids = make([][]byte, 0, 16)
valueBytes := unsafeStringToBytes(value)
cursor.Seek(valueBytes)
key, entryValue := cursor.Prev()
if key == nil {
return
}
entry := itx.mapBucket.Get([]byte(entryValue))
if entry == nil {
return
}
err = msgpack.Unmarshal(entry, &ids)
return
}
func (itx *indexTx) Between(a, b string) (ids [][]byte, err error) {
cursor := itx.mapBucket.Cursor()
ids = make([][]byte, 0, 16)
aBytes := unsafeStringToBytes(a)
bBytes := unsafeStringToBytes(b)
for key, value := cursor.Seek(aBytes); key != nil && bytes.Compare(key, bBytes) < 1; key, value = cursor.Next() {
entry := itx.mapBucket.Get([]byte(value))
if entry == nil {
return
}
err = msgpack.Unmarshal(entry, &ids)
if err != nil {
return
}
}
return
}
// Set sets the index to the given values. This removes any values not in the given list.
func (itx *indexTx) Set(id []byte, values ...string) error {
oldValues := make([]string, 0, len(values))
newValues := make([]string, 0, len(values))
// Check for duplicates
for i, value := range values {
for _, value2 := range values[i+1:] {
if value == value2 {
return errors.New("Duplicate value for index: " + value)
}
}
}
if value := itx.revBucket.Get(id); value != nil {
// Existing ID
existingValues := make([]string, 0, 16)
err := msgpack.Unmarshal(value, &existingValues)
if err != nil {
return err
}
// Record new and old values if there are any.
if len(values) > 0 {
// Find old values
OldValueLoop:
for _, existingValue := range existingValues {
for _, value := range values {
if value == existingValue {
continue OldValueLoop
}
}
oldValues = append(oldValues, existingValue)
}
// Find new values
NewValueLoop:
for _, value := range values {
if value == "" {
continue
}
for _, existingValue := range existingValues {
if value == existingValue {
continue NewValueLoop
}
}
newValues = append(newValues, value)
}
} else {
// There aren't any values, all values should be considered old.
oldValues = existingValues
newValues = newValues[:0]
}
} else {
// New ID, can be skipped if this is clearing the itx operation
if len(values) == 0 {
return nil
}
// Otherwise, all values are newValues.
for _, value := range values {
if value == "" {
continue
}
newValues = append(newValues, value)
}
}
// Put new reverse lookup entry
if len(values) > 0 {
revData, err := msgpack.Marshal(values)
if err != nil {
return err
}
err = itx.revBucket.Put(id, revData)
if err != nil {
return err
}
}
// Remove old values
for _, oldValue := range oldValues {
ov := []byte(oldValue)
value := itx.mapBucket.Get(ov)
if value == nil {
return errors.New("oldValue expected, but not found. itx probably corrupt")
}
ids := make([][]byte, 0, 8)
err := msgpack.Unmarshal(value, &ids)
if err != nil {
return err
}
for i, existingId := range ids {
if bytes.Equal(existingId, id) {
ids = append(ids[:i], ids[i+1:]...)
break
}
}
if len(ids) == 0 {
err = itx.mapBucket.Delete(ov)
if err != nil {
return err
}
} else {
newValue, err := msgpack.Marshal(ids)
if err != nil {
return err
}
err = itx.mapBucket.Put(ov, newValue)
if err != nil {
return err
}
}
}
// Add new values
for _, newValue := range newValues {
nv := []byte(newValue)
if existing := itx.mapBucket.Get(nv); existing != nil {
ids := make([][]byte, 0, 8)
err := msgpack.Unmarshal(existing, &ids)
if err != nil {
return err
}
newValue, err := msgpack.Marshal(append(ids, id))
if err != nil {
return err
}
err = itx.mapBucket.Put(nv, newValue)
if err != nil {
return err
}
} else {
newValue, err := msgpack.Marshal([][]byte{id})
if err != nil {
return err
}
err = itx.mapBucket.Put(nv, newValue)
if err != nil {
return err
}
}
}
// If this will delete all values, then delete this entry
if len(values) == 0 {
err := itx.revBucket.Delete(id)
if err != nil {
return err
}
}
return nil
}
+215
View File
@@ -0,0 +1,215 @@
package bolt
import (
"fmt"
"github.com/stretchr/testify/assert"
"go.etcd.io/bbolt"
"testing"
"time"
)
func TestIndex(t *testing.T) {
ns := time.Now().Nanosecond()
path := fmt.Sprintf("/tmp/stufflog_idx_test_%s_%d.db", time.Now().Format("20060102150405"), ns)
db, err := bbolt.Open(path, 0744, nil)
if err != nil {
t.Fatal("Failed to open db:", err)
}
idx, err := newIndex(db, "idx", "stuff", "things")
idx2, err := newIndex(db, "idx", "stuff2", "things")
testId := []byte("Test")
test2Id := []byte("Test2")
test3Id := []byte("Test3")
test4Id := []byte("Test4")
t.Run("Insert Initial", func(t *testing.T) {
_ = db.Update(func(tx *bbolt.Tx) error {
itx := idx.WithTx(tx)
itx2 := idx2.WithTx(tx)
assert.NoError(t, itx.Set(testId, "A", "B", "C", "D", "E"))
assert.NoError(t, itx.Set(test2Id, "B", "C"))
assert.NoError(t, itx.Set(test3Id, "A", "C", "D"))
assert.NoError(t, itx.Set(test4Id, "A", "C", "D"))
assert.NoError(t, itx2.Set(testId, "user_0"))
assert.NoError(t, itx2.Set(test2Id, "user_1"))
assert.NoError(t, itx2.Set(test3Id, "user_2"))
assert.NoError(t, itx.Set(testId, "A", "B", "C"))
assert.NoError(t, itx.Set(testId, "A", "C"))
assert.NoError(t, itx.Set(test4Id))
return nil
})
})
if t.Failed() {
return
}
t.Run("Check Initial", func(t *testing.T) {
_ = db.View(func(tx *bbolt.Tx) error {
itx := idx.WithTx(tx)
itx2 := idx2.WithTx(tx)
ids, err := itx.Get("A")
assert.NoError(t, err)
assert.Equal(t, [][]byte{testId, test3Id}, ids)
ids, err = itx.Get("B")
assert.NoError(t, err)
assert.Equal(t, [][]byte{test2Id}, ids)
ids, err = itx.Get("C")
assert.NoError(t, err)
assert.Equal(t, [][]byte{testId, test2Id, test3Id}, ids)
ids, err = itx.Get("D")
assert.NoError(t, err)
assert.Equal(t, [][]byte{test3Id}, ids)
ids, err = itx2.Get("user_0")
assert.NoError(t, err)
assert.Equal(t, [][]byte{testId}, ids)
ids, err = itx2.Get("user_1")
assert.NoError(t, err)
assert.Equal(t, [][]byte{test2Id}, ids)
ids, err = itx2.Get("user_2")
assert.NoError(t, err)
assert.Equal(t, [][]byte{test3Id}, ids)
return nil
})
})
if t.Failed() {
return
}
t.Run("Check Initial Reverse", func(t *testing.T) {
_ = db.View(func(tx *bbolt.Tx) error {
itx := idx.WithTx(tx)
itx2 := idx2.WithTx(tx)
values, err := itx.Reverse(testId)
assert.NoError(t, err)
assert.Equal(t, []string{"A", "C"}, values)
values, err = itx.Reverse(test2Id)
assert.NoError(t, err)
assert.Equal(t, []string{"B", "C"}, values)
values, err = itx.Reverse(test3Id)
assert.NoError(t, err)
assert.Equal(t, []string{"A", "C", "D"}, values)
values, err = itx.Reverse(test4Id)
assert.NoError(t, err)
assert.Empty(t, values)
values, err = itx2.Reverse(testId)
assert.NoError(t, err)
assert.Equal(t, []string{"user_0"}, values)
values, err = itx2.Reverse(test2Id)
assert.NoError(t, err)
assert.Equal(t, []string{"user_1"}, values)
values, err = itx2.Reverse(test3Id)
assert.NoError(t, err)
assert.Equal(t, []string{"user_2"}, values)
return nil
})
})
if t.Failed() {
return
}
t.Run("Nuke 'n Pave", func(t *testing.T) {
_ = db.Update(func(tx *bbolt.Tx) error {
assert.NoError(t, idx.Reset(tx))
assert.NoError(t, idx2.Reset(tx))
itx := idx.WithTx(tx)
itx2 := idx2.WithTx(tx)
assert.NoError(t, itx.Set(testId, "pave"))
assert.NoError(t, itx.Set(test2Id, "pave"))
assert.NoError(t, itx.Set(test3Id, "pave"))
assert.NoError(t, itx2.Set(testId, "pave2"))
assert.NoError(t, itx2.Set(test3Id, "pave2"))
return nil
})
})
if t.Failed() {
return
}
t.Run("Check Nuke 'n Pave", func(t *testing.T) {
_ = db.View(func(tx *bbolt.Tx) error {
itx := idx.WithTx(tx)
itx2 := idx2.WithTx(tx)
ids, err := itx.Get("A")
assert.NoError(t, err)
assert.Empty(t, ids)
ids, err = itx.Get("B")
assert.NoError(t, err)
assert.Empty(t, ids)
ids, err = itx.Get("C")
assert.NoError(t, err)
assert.Empty(t, ids)
ids, err = itx.Get("D")
assert.NoError(t, err)
assert.Empty(t, ids)
ids, err = itx2.Get("user_0")
assert.NoError(t, err)
assert.Empty(t, ids)
ids, err = itx2.Get("user_1")
assert.NoError(t, err)
assert.Empty(t, ids)
ids, err = itx2.Get("user_2")
assert.NoError(t, err)
assert.Empty(t, ids)
ids, err = itx.Get("pave")
assert.NoError(t, err)
assert.Equal(t, [][]byte{testId, test2Id, test3Id}, ids)
ids, err = itx2.Get("pave2")
assert.NoError(t, err)
assert.Equal(t, [][]byte{testId, test3Id}, ids)
return nil
})
})
if t.Failed() {
return
}
t.Run("Check Nuke 'n Pave Reverse", func(t *testing.T) {
_ = db.View(func(tx *bbolt.Tx) error {
itx := idx.WithTx(tx)
itx2 := idx2.WithTx(tx)
values, err := itx.Reverse(testId)
assert.NoError(t, err)
assert.Equal(t, []string{"pave"}, values)
values, err = itx.Reverse(test2Id)
assert.NoError(t, err)
assert.Equal(t, []string{"pave"}, values)
values, err = itx.Reverse(test3Id)
assert.NoError(t, err)
assert.Equal(t, []string{"pave"}, values)
values, err = itx2.Reverse(testId)
assert.NoError(t, err)
assert.Equal(t, []string{"pave2"}, values)
values, err = itx2.Reverse(test2Id)
assert.NoError(t, err)
assert.Empty(t, values)
values, err = itx2.Reverse(test3Id)
assert.NoError(t, err)
assert.Equal(t, []string{"pave2"}, values)
return nil
})
})
if t.Failed() {
return
}
}
+288
View File
@@ -0,0 +1,288 @@
package bolt
import (
"context"
"github.com/gisle/stufflog/database/repositories"
"github.com/gisle/stufflog/models"
"github.com/gisle/stufflog/slerrors"
"github.com/vmihailenco/msgpack/v4"
"go.etcd.io/bbolt"
)
var bnPeriods = []byte("Period")
type periodRepository struct {
db *bbolt.DB
userIdIdx *index
activityIdIdx *index
}
func (r *periodRepository) FindID(ctx context.Context, id string) (*models.Period, error) {
period := new(models.Period)
err := r.db.View(func(tx *bbolt.Tx) error {
value := tx.Bucket(bnPeriods).Get(unsafeStringToBytes(id))
if value == nil {
return slerrors.NotFound("Period")
}
err := msgpack.Unmarshal(value, period)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return period, nil
}
func (r *periodRepository) List(ctx context.Context) ([]*models.Period, error) {
periods := make([]*models.Period, 0, 16)
err := r.db.View(func(tx *bbolt.Tx) error {
cursor := tx.Bucket(bnPeriods).Cursor()
for key, value := cursor.First(); key != nil; key, value = cursor.Next() {
period := new(models.Period)
err := msgpack.Unmarshal(value, period)
if err != nil {
return err
}
periods = append(periods, period)
}
return nil
})
if err != nil {
return nil, err
}
return periods, nil
}
func (r *periodRepository) ListUser(ctx context.Context, user models.User) ([]*models.Period, error) {
periods := make([]*models.Period, 0, 16)
err := r.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bnPeriods)
ids, err := r.userIdIdx.WithTx(tx).Get(user.ID)
if err != nil {
return err
}
for _, id := range ids {
value := bucket.Get(id)
if value == nil {
continue
}
period := new(models.Period)
err := msgpack.Unmarshal(value, period)
if err != nil {
return err
}
periods = append(periods, period)
}
return nil
})
if err != nil {
return nil, err
}
return periods, nil
}
func (r *periodRepository) ListActivity(ctx context.Context, activity models.Activity) ([]*models.Period, error) {
periods := make([]*models.Period, 0, 16)
err := r.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bnPeriods)
ids, err := r.activityIdIdx.WithTx(tx).Get(activity.ID)
if err != nil {
return err
}
for _, id := range ids {
value := bucket.Get(id)
if value == nil {
continue
}
period := new(models.Period)
err := msgpack.Unmarshal(value, period)
if err != nil {
return err
}
periods = append(periods, period)
}
return nil
})
if err != nil {
return nil, err
}
return periods, nil
}
func (r *periodRepository) Insert(ctx context.Context, period models.Period) error {
value, err := msgpack.Marshal(&period)
if err != nil {
return err
}
return r.db.Update(func(tx *bbolt.Tx) error {
err := tx.Bucket(bnPeriods).Put(unsafeStringToBytes(period.ID), value)
if err != nil {
return err
}
err = r.index(tx, &period)
if err != nil {
return err
}
return nil
})
}
func (r *periodRepository) Update(ctx context.Context, period models.Period, updates []*models.PeriodUpdate) (*models.Period, error) {
err := r.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bnPeriods)
// Re-Get to guarantee consistency.
value := bucket.Get(unsafeStringToBytes(period.ID))
if value == nil {
return slerrors.NotFound("Activity")
}
err := msgpack.Unmarshal(value, &period)
if err != nil {
return err
}
// Perform updates
didChange := false
for _, update := range updates {
changed, err := period.ApplyUpdate(*update)
if err != nil {
return err
}
if changed {
didChange = true
}
}
// Put back into bucket
if didChange {
value, err := msgpack.Marshal(&period)
if err != nil {
return err
}
err = bucket.Put(unsafeStringToBytes(period.ID), value)
if err != nil {
return err
}
err = r.index(tx, &period)
if err != nil {
return err
}
} else {
return errUnchanged
}
return nil
})
if err != nil && err != errUnchanged {
return nil, err
}
return &period, nil
}
func (r *periodRepository) Remove(ctx context.Context, period models.Period) error {
return r.db.Update(func(tx *bbolt.Tx) error {
err := tx.Bucket(bnPeriods).Delete(unsafeStringToBytes(period.ID))
if err != nil {
return err
}
err = r.unIndex(tx, &period)
if err != nil {
return err
}
return nil
})
}
func (r *periodRepository) index(tx *bbolt.Tx, period *models.Period) error {
idBytes := unsafeStringToBytes(period.ID)
err := r.userIdIdx.WithTx(tx).Set(idBytes, period.UserID)
if err != nil {
return err
}
activityIDs := make([]string, 0, len(period.Goals))
added := make(map[string]bool)
for _, goal := range period.Goals {
if !added[goal.ActivityID] {
added[goal.ActivityID] = true
activityIDs = append(activityIDs, goal.ActivityID)
}
}
err = r.activityIdIdx.WithTx(tx).Set(idBytes, activityIDs...)
if err != nil {
return err
}
return nil
}
func (r *periodRepository) unIndex(tx *bbolt.Tx, period *models.Period) error {
idBytes := unsafeStringToBytes(period.ID)
err := r.userIdIdx.WithTx(tx).Set(idBytes)
if err != nil {
return err
}
err = r.activityIdIdx.WithTx(tx).Set(idBytes)
if err != nil {
return err
}
return nil
}
func newPeriodRepository(db *bbolt.DB) (repositories.PeriodRepository, error) {
err := db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(bnPeriods)
return err
})
if err != nil {
return nil, err
}
userIdIdx, err := newModelIndex(db, "Period", "UserID")
if err != nil {
return nil, err
}
activityIdIdx, err := newModelIndex(db, "Period", "Goals.ActivityID")
if err != nil {
return nil, err
}
return &periodRepository{
db: db,
userIdIdx: userIdIdx,
activityIdIdx: activityIdIdx,
}, nil
}
+91
View File
@@ -0,0 +1,91 @@
package bolt
import (
"context"
"github.com/gisle/stufflog/database/repositories"
"github.com/gisle/stufflog/models"
"github.com/gisle/stufflog/slerrors"
"github.com/vmihailenco/msgpack/v4"
"go.etcd.io/bbolt"
)
var bnUsers = []byte("User")
type userRepository struct {
db *bbolt.DB
}
func (r *userRepository) FindID(ctx context.Context, id string) (*models.User, error) {
user := new(models.User)
err := r.db.View(func(tx *bbolt.Tx) error {
value := tx.Bucket(bnUsers).Get(unsafeStringToBytes(id))
if value == nil {
return slerrors.NotFound("User")
}
err := msgpack.Unmarshal(value, user)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return user, nil
}
func (r *userRepository) List(ctx context.Context) ([]*models.User, error) {
users := make([]*models.User, 0, 16)
err := r.db.View(func(tx *bbolt.Tx) error {
cursor := tx.Bucket(bnUsers).Cursor()
for key, value := cursor.First(); key != nil; key, value = cursor.Next() {
user := new(models.User)
err := msgpack.Unmarshal(value, user)
if err != nil {
return err
}
users = append(users, user)
}
return nil
})
if err != nil {
return nil, err
}
return users, nil
}
func (r *userRepository) Save(ctx context.Context, user models.User) error {
value, err := msgpack.Marshal(&user)
if err != nil {
return err
}
return r.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(bnUsers).Put(unsafeStringToBytes(user.ID), value)
})
}
func (r *userRepository) Remove(ctx context.Context, user models.User) error {
return r.db.Update(func(tx *bbolt.Tx) error {
return tx.Bucket(bnUsers).Delete(unsafeStringToBytes(user.ID))
})
}
func newUserRepository(db *bbolt.DB) (repositories.UserRepository, error) {
err := db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(bnUsers)
return err
})
if err != nil {
return nil, err
}
return &userRepository{
db: db,
}, nil
}
+80
View File
@@ -0,0 +1,80 @@
package bolt
import (
"context"
"github.com/gisle/stufflog/models"
"github.com/stretchr/testify/assert"
"testing"
)
func TestUserRepository(t *testing.T) {
if dbErr != nil {
t.Fatal(dbErr)
}
ctx := context.Background()
testUser1 := models.User{
ID: "U1234",
Name: "Stuffer",
}
testUser2 := models.User{
ID: "U4321",
Name: "Logger",
}
testUser3 := models.User{
ID: "U2396",
Name: "Temper",
}
testUsers := []*models.User{
&testUser1,
&testUser3,
&testUser2,
}
t.Run("Save", func(t *testing.T) {
assert.NoError(t, db.Users().Save(ctx, testUser1), "Save testUser1")
assert.NoError(t, db.Users().Save(ctx, testUser2), "Save testUser2")
assert.NoError(t, db.Users().Save(ctx, testUser3), "Save testUser3")
})
t.Run("List", func(t *testing.T) {
users, err := db.Users().List(ctx)
assert.Equal(t, testUsers, users, "List 3 users")
assert.NoError(t, err, "List 3 users")
})
t.Run("Find", func(t *testing.T) {
user, err := db.Users().FindID(ctx, testUser1.ID)
assert.Equal(t, &testUser1, user, "Find testUser1")
assert.NoError(t, err, "Find testUser1")
user, err = db.Users().FindID(ctx, testUser2.ID)
assert.Equal(t, &testUser2, user, "Find testUser2")
assert.NoError(t, err, "Find testUser2")
user, err = db.Users().FindID(ctx, testUser3.ID)
assert.Equal(t, &testUser3, user, "Find testUser3")
assert.NoError(t, err, "Find testUser3")
user, err = db.Users().FindID(ctx, "UNonExistent")
assert.Nil(t, user, "Find UNonExistent")
assert.Error(t, err, "Find UNonExistent")
})
t.Run("Remove", func(t *testing.T) {
assert.NoError(t, db.Users().Remove(ctx, testUser3), "Remove testUser3")
user, err := db.Users().FindID(ctx, testUser1.ID)
assert.Equal(t, &testUser1, user, "Find non-deleted testUser1")
assert.NoError(t, err, "Find non-deleted testUser1")
user, err = db.Users().FindID(ctx, testUser2.ID)
assert.Equal(t, &testUser2, user, "Find non-deleted testUser2")
assert.NoError(t, err, "Find non-deleted testUser2")
user, err = db.Users().FindID(ctx, testUser3.ID)
assert.Nil(t, user, "Find deleted testUser3")
assert.Error(t, err, "Find deleted testUser3")
users, err := db.Users().List(ctx)
assert.Equal(t, []*models.User{&testUser1, &testUser2}, users, "List 2 remaining users")
assert.NoError(t, err, "List 2 remaining users")
})
}
+197
View File
@@ -0,0 +1,197 @@
package bolt
import (
"context"
"github.com/gisle/stufflog/database/repositories"
"github.com/gisle/stufflog/models"
"github.com/gisle/stufflog/slerrors"
"github.com/vmihailenco/msgpack/v4"
"go.etcd.io/bbolt"
)
var bnUserSessions = []byte("UserSession")
type userSessionRepository struct {
userIdIdx *index
db *bbolt.DB
}
func (r *userSessionRepository) FindID(ctx context.Context, id string) (*models.UserSession, error) {
session := new(models.UserSession)
err := r.db.View(func(tx *bbolt.Tx) error {
value := tx.Bucket(bnUserSessions).Get(unsafeStringToBytes(id))
if value == nil {
return slerrors.NotFound("Session")
}
err := msgpack.Unmarshal(value, session)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return session, nil
}
func (r *userSessionRepository) List(ctx context.Context) ([]*models.UserSession, error) {
sessions := make([]*models.UserSession, 0, 16)
err := r.db.View(func(tx *bbolt.Tx) error {
cursor := tx.Bucket(bnUserSessions).Cursor()
for key, value := cursor.First(); key != nil; key, value = cursor.Next() {
session := new(models.UserSession)
err := msgpack.Unmarshal(value, session)
if err != nil {
return err
}
sessions = append(sessions, session)
}
return nil
})
if err != nil {
return nil, err
}
return sessions, nil
}
func (r *userSessionRepository) ListUser(ctx context.Context, user models.User) ([]*models.UserSession, error) {
var sessions []*models.UserSession
err := r.db.View(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bnUserSessions)
ids, err := r.userIdIdx.WithTx(tx).Get(user.ID)
if err != nil {
return err
}
sessions = make([]*models.UserSession, len(ids))
for i, id := range ids {
value := bucket.Get(id)
session := new(models.UserSession)
err := msgpack.Unmarshal(value, session)
if err != nil {
return err
}
sessions[i] = session
}
return nil
})
if err != nil {
return nil, err
}
return sessions, nil
}
func (r *userSessionRepository) Save(ctx context.Context, session models.UserSession) error {
value, err := msgpack.Marshal(&session)
if err != nil {
return err
}
return r.db.Update(func(tx *bbolt.Tx) error {
err := tx.Bucket(bnUserSessions).Put(unsafeStringToBytes(session.ID), value)
if err != nil {
return err
}
err = r.userIdIdx.WithTx(tx).Set(unsafeStringToBytes(session.ID), session.UserID)
if err != nil {
return err
}
return nil
})
}
func (r *userSessionRepository) Remove(ctx context.Context, session models.UserSession) error {
return r.db.Update(func(tx *bbolt.Tx) error {
err := tx.Bucket(bnUserSessions).Delete(unsafeStringToBytes(session.ID))
if err != nil {
return err
}
err = r.userIdIdx.WithTx(tx).Set(unsafeStringToBytes(session.ID))
if err != nil {
return err
}
return nil
})
}
func (r *userSessionRepository) RemoveUser(ctx context.Context, user models.User) error {
return r.db.Update(func(tx *bbolt.Tx) error {
bucket := tx.Bucket(bnUserSessions)
ids, err := r.userIdIdx.WithTx(tx).Get(user.ID)
if err != nil {
return err
}
for _, id := range ids {
err := bucket.Delete(id)
if err != nil {
return err
}
}
return nil
})
}
func (r *userSessionRepository) reindex() error {
return r.db.Update(func(tx *bbolt.Tx) error {
cursor := tx.Bucket(bnUserSessions).Cursor()
err := r.userIdIdx.Reset(tx)
if err != nil {
return err
}
userIdIdxTx := r.userIdIdx.WithTx(tx)
for key, value := cursor.First(); key != nil; key, value = cursor.Next() {
session := new(models.UserSession)
err := msgpack.Unmarshal(value, session)
if err != nil {
return err
}
err = userIdIdxTx.Set(unsafeStringToBytes(session.ID), session.UserID)
if err != nil {
return err
}
}
return nil
})
}
func newUserSessionRepository(db *bbolt.DB) (repositories.UserSessionRepository, error) {
err := db.Update(func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists(bnUserSessions)
return err
})
if err != nil {
return nil, err
}
idx, err := newModelIndex(db, "UserSession", "UserID")
if err != nil {
return nil, err
}
return &userSessionRepository{
db: db,
userIdIdx: idx,
}, nil
}
+15
View File
@@ -0,0 +1,15 @@
package repositories
import (
"context"
"github.com/gisle/stufflog/models"
)
type ActivityRepository interface {
FindID(ctx context.Context, id string) (*models.Activity, error)
List(ctx context.Context) ([]*models.Activity, error)
ListUser(ctx context.Context, user models.User) ([]*models.Activity, error)
Insert(ctx context.Context, activity models.Activity) error
Update(ctx context.Context, activity models.Activity, updates []*models.ActivityUpdate) (*models.Activity, error)
Remove(ctx context.Context, activity models.Activity) error
}
+16
View File
@@ -0,0 +1,16 @@
package repositories
import (
"context"
"github.com/gisle/stufflog/models"
)
type PeriodRepository interface {
FindID(ctx context.Context, id string) (*models.Period, error)
List(ctx context.Context) ([]*models.Period, error)
ListUser(ctx context.Context, user models.User) ([]*models.Period, error)
ListActivity(ctx context.Context, activity models.Activity) ([]*models.Period, error)
Insert(ctx context.Context, period models.Period) error
Update(ctx context.Context, period models.Period, updates []*models.PeriodUpdate) (*models.Period, error)
Remove(ctx context.Context, period models.Period) error
}
+14
View File
@@ -0,0 +1,14 @@
package repositories
import (
"context"
"github.com/gisle/stufflog/models"
)
// UserRepository is a repository for database operations for users.
type UserRepository interface {
FindID(ctx context.Context, id string) (*models.User, error)
List(ctx context.Context) ([]*models.User, error)
Save(ctx context.Context, user models.User) error
Remove(ctx context.Context, user models.User) error
}
+16
View File
@@ -0,0 +1,16 @@
package repositories
import (
"context"
"github.com/gisle/stufflog/models"
)
// UserSessionRepository is a repository for database operations for userSessions.
type UserSessionRepository interface {
FindID(ctx context.Context, id string) (*models.UserSession, error)
List(ctx context.Context) ([]*models.UserSession, error)
ListUser(ctx context.Context, user models.User) ([]*models.UserSession, error)
Save(ctx context.Context, session models.UserSession) error
Remove(ctx context.Context, session models.UserSession) error
RemoveUser(ctx context.Context, user models.User) error
}