Commit e640352d authored by Lysander Trischler's avatar Lysander Trischler
Browse files

Implement weights in storages

parent a796021a
package models
import (
"time"
)
type WeightID int
type Weight struct {
ID WeightID
Timestamp time.Time
Grams int
}
......@@ -5,7 +5,10 @@ import (
"golang.org/x/crypto/bcrypt"
"kraftwerk/models"
"kraftwerk/storage"
"kraftwerk/utils"
"sort"
"sync"
"time"
)
// Storage implements a storage.Storage in RAM. Data are not persisted anywhere
......@@ -13,14 +16,22 @@ import (
type Storage struct {
mutex sync.RWMutex
sequenceGenerator utils.SequenceGenerator
// users maps the username to a user object. Using User values over
// pointers helps with the modification and stripping of irrelevant
// information for any operation.
users map[string]models.User
// weight maps the username to all their weights in chronological order.
weights map[string][]models.Weight
}
func NewStorage() *Storage {
return &Storage{users: make(map[string]models.User)}
return &Storage{
users: make(map[string]models.User),
weights: make(map[string][]models.Weight),
}
}
func (s *Storage) Close() error {
......@@ -115,3 +126,106 @@ func (s *Storage) ChangePassword(username, password string) error {
}
return storage.ErrNotFound
}
func (s *Storage) ListWeights(username string, from, until time.Time) ([]*models.Weight, error) {
var include func(t time.Time) bool
if from.IsZero() {
if until.IsZero() {
include = func(t time.Time) bool { return true }
} else {
include = func(t time.Time) bool { return !t.After(until) }
}
} else {
if until.IsZero() {
include = func(t time.Time) bool { return !t.Before(from) }
} else {
include = func(t time.Time) bool { return !t.After(until) && !t.Before(from) }
}
}
s.mutex.RLock()
userWeights, ok := s.weights[username]
s.mutex.RUnlock()
if !ok {
if _, ok := s.users[username]; !ok {
return nil, storage.ErrNotFound
}
}
var weights []*models.Weight
for i := range userWeights {
weight := userWeights[i]
if include(weight.Timestamp) {
weights = append(weights, &weight)
}
}
sort.Slice(weights, func(i, j int) bool {
return weights[i].Timestamp.Before(weights[j].Timestamp)
})
return weights, nil
}
func (s *Storage) GetWeight(username string, weightID models.WeightID) (*models.Weight, error) {
s.mutex.RLock()
userWeights, ok := s.weights[username]
s.mutex.RUnlock()
if !ok {
return nil, storage.ErrNotFound
}
for _, weight := range userWeights {
if weight.ID == weightID {
return &weight, nil
}
}
return nil, storage.ErrNotFound
}
func (s *Storage) AddWeight(username string, weight *models.Weight) error {
weight.ID = models.WeightID(s.sequenceGenerator.Next())
s.mutex.Lock()
defer s.mutex.Unlock()
s.weights[username] = append(s.weights[username], *weight)
return nil
}
func (s *Storage) UpdateWeight(username string, weight *models.Weight) error {
s.mutex.Lock()
defer s.mutex.Unlock()
weights, ok := s.weights[username]
if !ok {
return storage.ErrNotFound
}
for i, w := range weights {
if w.ID == weight.ID {
s.weights[username][i] = *weight
return nil
}
}
return storage.ErrNotFound
}
func (s *Storage) RemoveWeight(username string, weightID models.WeightID) error {
s.mutex.Lock()
defer s.mutex.Unlock()
weights, ok := s.weights[username]
if !ok {
return nil
}
for i, w := range weights {
if w.ID == weightID {
s.weights[username] = append(weights[:i], weights[i+1:]...)
return nil
}
}
return nil
}
......@@ -5,6 +5,7 @@ import (
"kraftwerk/models"
"kraftwerk/storage/memory"
"sync"
"time"
)
// Storage implements a storage.Storage with mocking capabilities for testing
......@@ -18,6 +19,8 @@ type Storage struct {
// modification will have no effect.
Users []*models.User
Weights map[string][]*models.Weight
MockClose func() error
MockListUsers func() ([]*models.User, error)
......@@ -27,6 +30,12 @@ type Storage struct {
MockRemoveUser func(username string) error
MockAuthenticateUser func(username, password string) (*models.User, error)
MockChangePassword func(username, password string) error
MockListWeights func(username string, from, until time.Time) ([]*models.Weight, error)
MockGetWeight func(username string, weightID models.WeightID) (*models.Weight, error)
MockAddWeight func(username string, weight *models.Weight) error
MockUpdateWeight func(username string, weight *models.Weight) error
MockRemoveWeight func(username string, weightID models.WeightID) error
}
func (s *Storage) init() {
......@@ -38,6 +47,15 @@ func (s *Storage) init() {
}
}
s.Users = nil
for username, weights := range s.Weights {
for _, weight := range weights {
if err := s.mem.AddWeight(username, weight); err != nil {
panic(fmt.Errorf("adding weight failed: %w", err))
}
}
}
s.Weights = nil
})
}
......@@ -104,3 +122,43 @@ func (s *Storage) ChangePassword(username, password string) error {
}
return s.mem.ChangePassword(username, password)
}
func (s *Storage) ListWeights(username string, from, until time.Time) ([]*models.Weight, error) {
s.init()
if s.MockListWeights != nil {
return s.MockListWeights(username, from, until)
}
return s.mem.ListWeights(username, from, until)
}
func (s *Storage) GetWeight(username string, weightID models.WeightID) (*models.Weight, error) {
s.init()
if s.MockGetWeight != nil {
return s.MockGetWeight(username, weightID)
}
return s.mem.GetWeight(username, weightID)
}
func (s *Storage) AddWeight(username string, weight *models.Weight) error {
s.init()
if s.MockAddWeight != nil {
return s.MockAddWeight(username, weight)
}
return s.mem.AddWeight(username, weight)
}
func (s *Storage) UpdateWeight(username string, weight *models.Weight) error {
s.init()
if s.MockUpdateWeight != nil {
return s.MockUpdateWeight(username, weight)
}
return s.mem.UpdateWeight(username, weight)
}
func (s *Storage) RemoveWeight(username string, weightID models.WeightID) error {
s.init()
if s.MockRemoveWeight != nil {
return s.MockRemoveWeight(username, weightID)
}
return s.mem.RemoveWeight(username, weightID)
}
......@@ -8,6 +8,7 @@ import (
"kraftwerk/storage"
"reflect"
"testing"
"time"
)
var Eugen = testusers.Eugen
......@@ -24,6 +25,15 @@ var methods map[string]method = map[string]method{
"RemoveUser": func(store storage.Storage) { _ = store.RemoveUser("username") },
"AuthenticateUser": func(store storage.Storage) { _, _ = store.AuthenticateUser("username", "password") },
"ChangePassword": func(store storage.Storage) { _ = store.ChangePassword("username", "password") },
"ListWeights": func(store storage.Storage) { _, _ = store.ListWeights("username", time.Time{}, time.Time{}) },
"GetWeight": func(store storage.Storage) { _, _ = store.GetWeight("username", models.WeightID(1)) },
"AddWeight": func(store storage.Storage) {
_ = store.AddWeight("username", &models.Weight{Timestamp: time.Now(), Grams: 71_000})
},
"UpdateWeight": func(store storage.Storage) {
_ = store.UpdateWeight("username", &models.Weight{ID: models.WeightID(1), Timestamp: time.Now(), Grams: 72_000})
},
"RemoveWeight": func(store storage.Storage) { _ = store.RemoveWeight("usernama", models.WeightID(1)) },
}
func TestTestSetup(t *testing.T) {
......
......@@ -11,6 +11,7 @@ import (
kraftwerkVersion "kraftwerk/version"
sqlite3 "modernc.org/sqlite"
sqlite3Lib "modernc.org/sqlite/lib"
"time"
)
var ErrNotImplemented = errors.New("not implemented")
......@@ -69,6 +70,22 @@ var schemaPatches []schemaPatch = []schemaPatch{
VALUES(0, 1)`,
},
},
{
schemaVersion{0, 2},
[]string{
` CREATE TABLE weights (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
username VARCHAR(256) NOT NULL,
timestamp TIMESTAMP NOT NULL,
grams INTEGER NOT NULL,
FOREIGN KEY (username)
REFERENCES users (username)
)`,
` UPDATE metadata
SET db_schema_version_major = 0, db_schema_version_minor = 2`,
},
},
// Schema changes must use the version number of the release that
// introduced them. Also the schema patches have to be sorted by schema
......@@ -77,11 +94,15 @@ var schemaPatches []schemaPatch = []schemaPatch{
}
func NewStorage(cfg Config, logger logrus.FieldLogger) (*Storage, error) {
db, err := sql.Open("sqlite", cfg.Filename)
db, err := sql.Open("sqlite", cfg.Filename+"?_time_format=sqlite")
if err != nil {
return nil, err
}
if _, err := db.Exec(`PRAGMA foreign_keys = ON`); err != nil {
return nil, fmt.Errorf("enabling foreign keys failed: %w", err)
}
store := &Storage{db: db}
dbVersion := store.getSchemaVersion()
......@@ -247,3 +268,109 @@ func (s *Storage) ChangePassword(username, password string) error {
}
return nil
}
func (s *Storage) ListWeights(username string, from, until time.Time) ([]*models.Weight, error) {
args := []interface{}{username}
query := `
SELECT id, timestamp, grams
FROM weights
WHERE username = ?`
if !from.IsZero() {
query += ` AND ? <= timestamp`
args = append(args, from)
}
if !until.IsZero() {
query += ` AND timestamp <= ?`
args = append(args, until)
}
query += ` ORDER BY timestamp`
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var weights []*models.Weight
for rows.Next() {
weight := &models.Weight{}
if err := rows.Scan(&weight.ID, &weight.Timestamp, &weight.Grams); err != nil {
return nil, err
}
weights = append(weights, weight)
}
if len(weights) == 0 {
row := s.db.QueryRow(`
SELECT 1
FROM users
WHERE username = ?`, username)
var ignored int
if err := row.Scan(&ignored); err != nil {
if err == sql.ErrNoRows {
return nil, storage.ErrNotFound
}
return nil, err
}
}
return weights, nil
}
func (s *Storage) GetWeight(username string, weightID models.WeightID) (*models.Weight, error) {
row := s.db.QueryRow(`
SELECT timestamp, grams
FROM weights
WHERE username = ? AND id = ?`, username, weightID)
weight := &models.Weight{ID: weightID}
if err := row.Scan(&weight.Timestamp, &weight.Grams); err != nil {
if err == sql.ErrNoRows {
return nil, storage.ErrNotFound
}
return nil, err
}
return weight, nil
}
func (s *Storage) AddWeight(username string, weight *models.Weight) error {
result, err := s.db.Exec(`
INSERT INTO weights (username, timestamp, grams)
VALUES (?, ?, ?)`, username, weight.Timestamp, weight.Grams)
if err != nil {
return err
}
id, err := result.LastInsertId()
if err != nil {
return err
}
weight.ID = models.WeightID(id)
return nil
}
func (s *Storage) UpdateWeight(username string, weight *models.Weight) error {
result, err := s.db.Exec(`
UPDATE weights
SET timestamp = ?, grams = ?
WHERE username = ? AND id = ?`, weight.Timestamp, weight.Grams, username, weight.ID)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected != 1 {
return storage.ErrNotFound
}
return nil
}
func (s *Storage) RemoveWeight(username string, weightID models.WeightID) error {
_, err := s.db.Exec(`
DELETE FROM weights
WHERE username = ? AND id = ?`, username, weightID)
if err != nil {
return err
}
return nil
}
......@@ -250,7 +250,7 @@ func assertTablesExist(t *testing.T, store *Storage, additionalTableNames ...str
actualTableNames = append(actualTableNames, name)
}
expectedTableNames := append(additionalTableNames, "users", "metadata")
expectedTableNames := append(additionalTableNames, "users", "metadata", "weights")
assert.ElementsMatch(t, expectedTableNames, actualTableNames,
"tables names do not match")
}
......
......@@ -6,6 +6,7 @@ import (
"github.com/mitchellh/mapstructure"
"gopkg.in/yaml.v3"
"kraftwerk/models"
"time"
)
var (
......@@ -52,6 +53,19 @@ type Storage interface {
// user does not exist, storage.ErrNotFound is returned. Other errors are
// of technical reason.
ChangePassword(username, password string) error
ListWeights(username string, from, until time.Time) ([]*models.Weight, error)
GetWeight(username string, weightID models.WeightID) (*models.Weight, error)
// AddWeight adds a weight for a user. The weight's ID must be empty and
// will be set by the storage implemenation, so the caller then knows the
// generated ID.
AddWeight(username string, weight *models.Weight) error
UpdateWeight(username string, weight *models.Weight) error
RemoveWeight(username string, weightID models.WeightID) error
}
type Config struct {
......
......@@ -7,6 +7,7 @@ import (
"fmt"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"kraftwerk/models"
"kraftwerk/models/testusers"
"kraftwerk/storage"
......@@ -15,6 +16,7 @@ import (
"kraftwerk/storage/sqlite"
"os"
"testing"
"time"
)
var (
......@@ -22,6 +24,17 @@ var (
EugenWithPassword = testusers.EugenWithPassword
Karla = testusers.Karla
KarlaWithPassword = testusers.KarlaWithPassword
CEST, _ = time.LoadLocation("Europe/Berlin")
Aug_02_12_54_20_297_CEST = time.Date(2022, time.August, 2, 12, 54, 20, 297, CEST)
Aug_03_22_21_03_654_CEST = time.Date(2022, time.August, 3, 22, 21, 3, 654, CEST)
Aug_03_22_21_03_655_CEST = time.Date(2022, time.August, 3, 22, 21, 3, 655, CEST)
Aug_03_22_21_03_656_CEST = time.Date(2022, time.August, 3, 22, 21, 3, 656, CEST)
Aug_04_08_07_00_937_CEST = time.Date(2022, time.August, 4, 8, 7, 0, 937, CEST)
Aug_04_08_07_00_938_CEST = time.Date(2022, time.August, 4, 8, 7, 0, 938, CEST)
Aug_04_08_07_00_939_CEST = time.Date(2022, time.August, 4, 8, 7, 0, 939, CEST)
Aug_05_17_20_00_000_CEST = time.Date(2022, time.August, 5, 17, 0, 0, 0, CEST)
)
type storageImplementation struct {
......@@ -428,3 +441,467 @@ func TestChangePassword(t *testing.T) {
}
}
}
func TestListWeights(t *testing.T) {
testCases := []struct {
name string
setup func(t *testing.T, store storage.Storage)
username string
from, until time.Time
expectedError error
expectedWeights []*models.Weight
}{
{
name: "when user does not exist then return ErrNotFound",
username: "eugen",
expectedError: storage.ErrNotFound,
},
{
name: "when user exists but has no weights then return nil slice",
username: "eugen",
setup: func(t *testing.T, store storage.Storage) {
require.NoError(t, store.AddUser(EugenWithPassword()))
},
expectedWeights: nil,
},
{
name: "when from and until are zero then return all weights sorted by ascending timestamp",
username: "eugen",
setup: func(t *testing.T, store storage.Storage) {
require.NoError(t, store.AddUser(EugenWithPassword()))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_03_22_21_03_654_CEST, Grams: 72_000}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_02_12_54_20_297_CEST, Grams: 73_500}))
},
expectedWeights: []*models.Weight{
{Timestamp: Aug_02_12_54_20_297_CEST, Grams: 73_500},
{Timestamp: Aug_03_22_21_03_654_CEST, Grams: 72_000},
},
},
{
name: "when from is zero and until is non-zero then return all weights up to until sorted by ascending timestamp",
username: "eugen",
until: Aug_03_22_21_03_655_CEST,
setup: func(t *testing.T, store storage.Storage) {
require.NoError(t, store.AddUser(EugenWithPassword()))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_03_22_21_03_656_CEST, Grams: 72_550}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_03_22_21_03_655_CEST, Grams: 72_540}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_03_22_21_03_654_CEST, Grams: 72_000}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_02_12_54_20_297_CEST, Grams: 73_500}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_05_17_20_00_000_CEST, Grams: 74_700}))
},
expectedWeights: []*models.Weight{
{Timestamp: Aug_02_12_54_20_297_CEST, Grams: 73_500},
{Timestamp: Aug_03_22_21_03_654_CEST, Grams: 72_000},
{Timestamp: Aug_03_22_21_03_655_CEST, Grams: 72_540},
},
},
{
name: "when from is non-zero and until is zero then return all weights from from on sorted by ascending timestamp",
username: "eugen",
from: Aug_03_22_21_03_655_CEST,
setup: func(t *testing.T, store storage.Storage) {
require.NoError(t, store.AddUser(EugenWithPassword()))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_03_22_21_03_656_CEST, Grams: 72_550}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_03_22_21_03_655_CEST, Grams: 72_540}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_03_22_21_03_654_CEST, Grams: 72_000}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_02_12_54_20_297_CEST, Grams: 73_500}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_05_17_20_00_000_CEST, Grams: 74_700}))
},
expectedWeights: []*models.Weight{
{Timestamp: Aug_03_22_21_03_655_CEST, Grams: 72_540},
{Timestamp: Aug_03_22_21_03_656_CEST, Grams: 72_550},
{Timestamp: Aug_05_17_20_00_000_CEST, Grams: 74_700},
},
},
{
name: "when from and until are non-zero then return all weights from from on up to until sorted by ascending timestamp",
username: "eugen",
from: Aug_03_22_21_03_655_CEST,
until: Aug_04_08_07_00_938_CEST,
setup: func(t *testing.T, store storage.Storage) {
require.NoError(t, store.AddUser(EugenWithPassword()))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_03_22_21_03_656_CEST, Grams: 72_550}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_03_22_21_03_655_CEST, Grams: 72_540}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_03_22_21_03_654_CEST, Grams: 72_000}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_02_12_54_20_297_CEST, Grams: 73_500}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_04_08_07_00_939_CEST, Grams: 74_900}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_04_08_07_00_938_CEST, Grams: 74_800}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_04_08_07_00_937_CEST, Grams: 74_600}))
require.NoError(t, store.AddWeight("eugen", &models.Weight{Timestamp: Aug_05_17_20_00_000_CEST, Grams: 74_700}))
},
expectedWeights: []*models.Weight{
{Timestamp: Aug_03_22_21_03_655_CEST, Grams: 72_540},
{Timestamp: Aug_03_22_21_03_656_CEST, Grams: 72_550},
{Timestamp: Aug_04_08_07_00_937_CEST, Grams: 74_600},
{Timestamp: Aug_04_08_07_00_938_CEST, Grams: 74_800},
},
},
}
for _, impl := range storageImplementations {
for _, testCase := range testCases {
t.Run(fmt.Sprintf("%s - %s", impl.name, testCase.name), func(t *testing.T) {
store := impl.setupStorage(t)