First commit
This commit is contained in:
@@ -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."}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user