Generic refactoring almost completed

This commit is contained in:
andrea 2023-11-13 21:01:12 +01:00
parent ce9dd7fd63
commit 45bcf24ecf
26 changed files with 1562 additions and 1089 deletions

1
go.mod
View file

@ -6,6 +6,7 @@ require (
github.com/google/uuid v1.3.1 github.com/google/uuid v1.3.1
github.com/julienschmidt/httprouter v1.3.0 github.com/julienschmidt/httprouter v1.3.0
github.com/sirupsen/logrus v1.8.1 github.com/sirupsen/logrus v1.8.1
gopkg.in/yaml.v2 v2.4.0
gorm.io/gorm v1.25.5 gorm.io/gorm v1.25.5
) )

2
go.sum
View file

@ -39,6 +39,8 @@ golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM= modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=

View file

@ -52,4 +52,3 @@ func (h *Default256Hasher) Calculate(hashes []string) string {
return h.hashFn(strings.Join(orderedHashes, "")) return h.hashFn(strings.Join(orderedHashes, ""))
} }
-

View file

@ -1,6 +1,27 @@
package models package models
import (
"crypto/sha256"
"fmt"
)
type Answer struct { type Answer struct {
ID string `json:"id" gorm:"primaryKey"` ID string `json:"id" gorm:"primaryKey"`
Text string `json:"text"` Text string `json:"text"`
} }
func (a *Answer) String() string {
return a.Text
}
func (a *Answer) GetID() string {
return a.ID
}
func (a *Answer) SetID(id string) {
a.ID = id
}
func (a *Answer) GetHash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(a.Text)))
}

View file

@ -1,10 +1,30 @@
package models package models
type Filter struct {
Tags []*Tag
}
type Collection struct { type Collection struct {
Meta Meta
Name string `json:"name"` Name string `json:"name"`
Query string `json:"query"` Filter *Filter `json:"filter"`
Quizzes []*Quiz `json:"quizzes" gorm:"many2many:collection_quizzes"` Quizzes []*Quiz `json:"quizzes" gorm:"many2many:collection_quizzes"`
} }
func (c *Collection) String() string {
return c.Name
}
func (c *Collection) GetID() string {
return c.ID
}
func (c *Collection) SetID(id string) {
c.ID = id
}
func (c *Collection) GetHash() string {
return ""
}

View file

@ -6,5 +6,4 @@ type Meta struct {
ID string `json:"id" yaml:"id" gorm:"primaryKey"` ID string `json:"id" yaml:"id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at" yaml:"created_at"` CreatedAt time.Time `json:"created_at" yaml:"created_at"`
UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"`
Tags []*Tag `json:"tags" yaml:"-" gorm:"-"`
} }

View file

@ -1,6 +1,8 @@
package models package models
import ( import (
"fmt"
"reflect"
"testing" "testing"
"github.com/remogatto/prettytest" "github.com/remogatto/prettytest"
@ -16,3 +18,59 @@ func TestRunner(t *testing.T) {
new(testSuite), new(testSuite),
) )
} }
func (t *testSuite) TestQuizFromMarkdown() {
markdown := `Question text (1).
Question text (2).
Question text with #tag1 #tag2 (3).
* Answer 1
* Answer 2
* Answer 3
* Answer 4`
expectedQuiz := &Quiz{
Question: &Question{Text: "Question text (1).\n\nQuestion text (2).\n\nQuestion text with #tag1 #tag2 (3)."},
Answers: []*Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
}
quiz, _, err := MarkdownToQuiz(markdown)
t.Nil(err, fmt.Sprintf("Quiz should be parsed without errors: %v", err))
if !t.Failed() {
t.True(reflect.DeepEqual(quiz, expectedQuiz), fmt.Sprintf("Expected %+v, got %+v", expectedQuiz, quiz))
}
}
func (t *testSuite) TestMarkdownFromQuiz() {
quiz := &Quiz{
Question: &Question{Text: "Newly created question text."},
Answers: []*Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
}
md, err := QuizToMarkdown(quiz)
t.Nil(err, fmt.Sprintf("Conversion to markdown should not raise an error: %v", err))
if !t.Failed() {
t.Equal(`Newly created question text.
* Answer 1
* Answer 2
* Answer 3
* Answer 4
`, md)
}
}

View file

@ -1,6 +1,27 @@
package models package models
import (
"crypto/sha256"
"fmt"
)
type Question struct { type Question struct {
Meta Meta
Text string `json:"text"` Text string `json:"text"`
} }
func (q *Question) String() string {
return q.Text
}
func (q *Question) GetID() string {
return q.ID
}
func (q *Question) SetID(id string) {
q.ID = id
}
func (q *Question) GetHash() string {
return fmt.Sprintf("%x", sha256.Sum256([]byte(q.Text)))
}

View file

@ -1,11 +1,196 @@
package models package models
import (
"crypto/sha256"
"errors"
"fmt"
"io"
"sort"
"strings"
"gopkg.in/yaml.v2"
)
type Quiz struct { type Quiz struct {
Meta Meta
Hash string `json:"hash"` Hash string `json:"hash"`
Question *Question `json:"question" gorm:"foreignKey:ID"` Question *Question `json:"question" gorm:"foreignKey:ID"`
Answers []*Answer `json:"answers" gorm:"many2many:quiz_answers"` Answers []*Answer `json:"answers" gorm:"many2many:quiz_answers"`
Tags []*Tag `json:"tags" yaml:"-" gorm:"-"`
Correct *Answer `json:"correct" gorm:"foreignKey:ID"` Correct *Answer `json:"correct" gorm:"foreignKey:ID"`
CorrectPos uint `gorm:"-"` // Position of the correct answer during quiz creation
Type int `json:"type"` Type int `json:"type"`
} }
func MarkdownToQuiz(markdown string) (*Quiz, *Meta, error) {
meta, remainingMarkdown, err := ParseMetaHeaderFromMarkdown(markdown)
if err != nil {
return nil, nil, err
}
lines := strings.Split(remainingMarkdown, "\n")
questionText := ""
answers := []*Answer{}
for _, line := range lines {
if strings.HasPrefix(line, "*") {
answerText := strings.TrimPrefix(line, "* ")
answer := &Answer{Text: answerText}
answers = append(answers, answer)
} else {
if questionText != "" {
questionText += "\n"
}
questionText += line
}
}
questionText = strings.TrimRight(questionText, "\n")
if questionText == "" {
return nil, nil, fmt.Errorf("Question text should not be empty.")
}
if len(answers) < 2 {
return nil, nil, fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers))
}
question := &Question{Text: questionText}
quiz := &Quiz{Question: question, Answers: answers, CorrectPos: 0}
if meta != nil {
quiz.Meta = *meta
}
return quiz, meta, nil
}
func QuizToMarkdown(quiz *Quiz) (string, error) {
if quiz.Question == nil {
return "", errors.New("Quiz should contain a question but it wasn't provided.")
}
if len(quiz.Answers) < 2 {
return "", errors.New("Quiz should contain at least 2 answers but none was provided.")
}
quiz.Correct = quiz.Answers[quiz.CorrectPos]
if quiz.Correct == nil {
return "", errors.New("Quiz should contain a correct answer but not was provided.")
}
correctAnswer := "* " + quiz.Correct.Text
var otherAnswers string
for pos, answer := range quiz.Answers {
if quiz.CorrectPos != uint(pos) {
otherAnswers += "* " + answer.Text + "\n"
}
}
markdown := quiz.Question.Text + "\n\n" + correctAnswer + "\n" + otherAnswers
return markdown, nil
}
func (q *Quiz) GetID() string {
return q.ID
}
func (q *Quiz) SetID(id string) {
q.ID = id
}
func (q *Quiz) GetHash() string {
return q.calculateHash()
}
func (q *Quiz) calculateHash() string {
result := make([]string, 0)
result = append(result, q.Question.GetHash())
for _, a := range q.Answers {
result = append(result, a.GetHash())
}
orderedHashes := make([]string, len(result))
copy(orderedHashes, result)
sort.Strings(orderedHashes)
return fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(orderedHashes, ""))))
}
func ParseMetaHeaderFromMarkdown(markdown string) (*Meta, string, error) {
reader := strings.NewReader(markdown)
var sb strings.Builder
var line string
var err error
for {
line, err = readLine(reader)
if err != nil {
if err == io.EOF {
break
}
return nil, "", err
}
if strings.TrimSpace(line) == "---" {
break
}
}
for {
line, err = readLine(reader)
if err != nil {
if err == io.EOF {
break
}
return nil, "", err
}
if strings.TrimSpace(line) == "---" {
break
}
sb.WriteString(line)
}
if sb.String() == "" {
return nil, markdown, nil
}
var meta Meta
err = yaml.Unmarshal([]byte(sb.String()), &meta)
if err != nil {
return nil, markdown, err
}
remainingMarkdown := markdown[strings.Index(markdown, "---\n"+sb.String()+"---\n")+len("---\n"+sb.String()+"---\n"):]
return &meta, remainingMarkdown, nil
}
func readLine(reader *strings.Reader) (string, error) {
var sb strings.Builder
for {
r, _, err := reader.ReadRune()
if err != nil {
if err == io.EOF {
return sb.String(), io.EOF
}
return "", err
}
sb.WriteRune(r)
if r == '\n' {
break
}
}
return sb.String(), nil
}

75
store/collection_test.go Normal file
View file

@ -0,0 +1,75 @@
package store
import (
"git.andreafazzi.eu/andrea/probo/models"
"github.com/remogatto/prettytest"
)
type collectionTestSuite struct {
prettytest.Suite
}
func (t *collectionTestSuite) TestCreateCollection() {
quizStore := NewQuizStore()
quiz_1, _ := quizStore.Create(
&models.Quiz{
Question: &models.Question{Text: "Question text #tag1 #tag3."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
})
quizStore.Create(
&models.Quiz{
Question: &models.Question{Text: "Question text #tag2."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
})
quiz_2, _ := quizStore.Create(
&models.Quiz{
Question: &models.Question{Text: "Question text #tag3."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
})
collectionStore := NewStore[*models.Collection]()
collection, err := collectionStore.Create(
&models.Collection{
Name: "My Collection",
})
t.Nil(err, "Collection should be created without error")
if !t.Failed() {
quizzes := quizStore.FilterInCollection(collection, &models.Filter{
Tags: []*models.Tag{
{Name: "#tag1"},
{Name: "#tag3"},
},
})
t.Equal(1, len(quizzes))
count := 0
for _, q := range collection.Quizzes {
if quiz_1.ID == q.ID || quiz_2.ID == q.ID {
count++
}
}
t.Equal(1, count)
t.Equal(1, len(collection.Quizzes))
}
}

View file

@ -1,7 +0,0 @@
Newly created question text.
* Answer 1
* Answer 1
* Answer 2
* Answer 3
* Answer 4

View file

@ -2,159 +2,52 @@ package file
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"git.andreafazzi.eu/andrea/probo/client"
"git.andreafazzi.eu/andrea/probo/models" "git.andreafazzi.eu/andrea/probo/models"
"git.andreafazzi.eu/andrea/probo/store"
) )
func (s *FileProboCollectorStore) GetCollectionsDir() string { func NewCollectionFileStore() (*FileStore[*models.Collection, *store.Store[*models.Collection]], error) {
return s.collectionsDir return NewFileStore[*models.Collection](
} store.NewStore[*models.Collection](),
filepath.Join(BaseDir, CollectionsDir),
func (s *FileProboCollectorStore) GetCollectionPath(collection *models.Collection) (string, error) { "collection",
s.lock.RLock() ".json",
defer s.lock.RUnlock() func(s *store.Store[*models.Collection], filepath string, content []byte) (*models.Collection, error) {
collection := new(models.Collection)
path, ok := s.collectionsPaths[collection.ID] err := json.Unmarshal(content, &collection)
if !ok {
return "", errors.New(fmt.Sprintf("Path not found for collection ID %v", collection.ID))
}
return path, nil
}
func (s *FileProboCollectorStore) SetCollectionPath(collection *models.Collection, path string) string {
s.lock.Lock()
defer s.lock.Unlock()
s.collectionsPaths[collection.ID] = path
return path
}
func (s *FileProboCollectorStore) ReadAllCollections() ([]*models.Collection, error) {
return s.memoryStore.ReadAllCollections()
}
func (s *FileProboCollectorStore) CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error) {
collection, err := s.memoryStore.CreateCollection(r)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = s.createOrUpdateCollectionFile(collection) c, err := s.Create(collection)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.memoryStore.ReadCollectionByID(collection.ID) return c, nil
} },
func(s *store.Store[*models.Collection], filePath string, collection *models.Collection) error {
func (s *FileProboCollectorStore) UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, error) { jsonData, err := json.Marshal(collection)
collection, _, err := s.memoryStore.UpdateCollection(r, id)
if err != nil {
return nil, err
}
err = s.createOrUpdateCollectionFile(collection)
if err != nil {
return nil, err
}
return s.memoryStore.ReadCollectionByID(collection.ID)
}
func (s *FileProboCollectorStore) DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error) {
collection, err := s.memoryStore.DeleteCollection(&client.DeleteCollectionRequest{ID: r.ID})
if err != nil {
return nil, err
}
path, err := s.GetCollectionPath(collection)
if err != nil {
return nil, err
}
err = os.Remove(path)
if err != nil {
return nil, err
}
return collection, nil
}
func (s *FileProboCollectorStore) reindexCollections() error {
files, err := os.ReadDir(s.collectionsDir)
if err != nil { if err != nil {
return err return err
} }
jsonFiles := make([]fs.DirEntry, 0) file, err := os.Create(filePath)
for _, file := range files {
filename := file.Name()
if !file.IsDir() && strings.HasSuffix(filename, ".json") {
jsonFiles = append(jsonFiles, file)
}
}
for _, file := range jsonFiles {
filename := file.Name()
fullPath := filepath.Join(s.collectionsDir, filename)
content, err := os.ReadFile(fullPath)
if err != nil { if err != nil {
return err return err
} }
var clientCollection *client.Collection
err = json.Unmarshal(content, &clientCollection)
if err != nil {
return err
}
collection, err := s.memoryStore.CreateCollection(&client.CreateUpdateCollectionRequest{
Collection: clientCollection,
})
if err != nil {
return err
}
s.SetCollectionPath(collection, fullPath)
}
return nil
}
func (s *FileProboCollectorStore) createOrUpdateCollectionFile(collection *models.Collection) error {
json, err := json.Marshal(collection)
if err != nil {
return err
}
fn, _ := s.GetCollectionPath(collection)
if fn == "" {
fn = filepath.Join(s.collectionsDir, fmt.Sprintf("collection_%v.%s", collection.ID, "json"))
}
file, err := os.Create(fn)
if err != nil {
return err
}
defer file.Close() defer file.Close()
_, err = file.Write([]byte(json)) _, err = file.Write(jsonData)
if err != nil { if err != nil {
return err return err
} }
s.SetCollectionPath(collection, fn)
return nil return nil
},
)
} }

View file

@ -1,13 +1,10 @@
package file package file
import ( import (
"encoding/json"
"fmt"
"os" "os"
"reflect"
"git.andreafazzi.eu/andrea/probo/client"
"git.andreafazzi.eu/andrea/probo/models" "git.andreafazzi.eu/andrea/probo/models"
"git.andreafazzi.eu/andrea/probo/store"
"github.com/remogatto/prettytest" "github.com/remogatto/prettytest"
) )
@ -16,158 +13,60 @@ type collectionTestSuite struct {
} }
func (t *collectionTestSuite) TestCreateCollection() { func (t *collectionTestSuite) TestCreateCollection() {
store, err := NewFileProboCollectorStore(testdataDir) quizStore := store.NewQuizStore()
t.Nil(err, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
if !t.Failed() { quizStore.Create(
quiz_1, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{ &models.Quiz{
Quiz: &client.Quiz{ Question: &models.Question{Text: "Question text #tag1 #tag3."},
Question: &client.Question{Text: "Question text with #tag1."}, Answers: []*models.Answer{
Answers: []*client.Answer{ {Text: "Answer 1"},
{Text: "Answer 1", Correct: true}, {Text: "Answer 2"},
{Text: "Answer 2", Correct: false}, {Text: "Answer 3"},
{Text: "Answer 3", Correct: false}, {Text: "Answer 4"},
{Text: "Answer 4", Correct: false},
},
}, },
}) })
t.Nil(err, "The quiz to be updated should be created without issue") quizStore.Create(
&models.Quiz{
path_1, _ := store.GetQuizPath(quiz_1) Question: &models.Question{Text: "Question text #tag2."},
Answers: []*models.Answer{
if !t.Failed() { {Text: "Answer 1"},
quiz_2, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{ {Text: "Answer 2"},
Quiz: &client.Quiz{ {Text: "Answer 3"},
Question: &client.Question{Text: "Another question text with #tag1."}, {Text: "Answer 4"},
Answers: []*client.Answer{
{Text: "Answer 1", Correct: true},
{Text: "Answer 2", Correct: false},
{Text: "Answer 3", Correct: false},
{Text: "Answer 4", Correct: false},
},
}, },
}) })
t.Nil(err, "The quiz to be updated should be created without issue") quizStore.Create(
&models.Quiz{
path_2, _ := store.GetQuizPath(quiz_2) Question: &models.Question{Text: "Question text #tag3."},
Answers: []*models.Answer{
if !t.Failed() { {Text: "Answer 1"},
{Text: "Answer 2"},
quiz_3, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{ {Text: "Answer 3"},
Quiz: &client.Quiz{ {Text: "Answer 4"},
Question: &client.Question{Text: "Question text without tags."},
Answers: []*client.Answer{
{Text: "Answer 1", Correct: true},
{Text: "Answer 2", Correct: false},
{Text: "Answer 3", Correct: false},
{Text: "Answer 4", Correct: false},
},
}, },
}) })
t.Nil(err, "The quiz to be updated should be created without issue") store, err := NewCollectionFileStore()
t.Nil(err)
path_3, _ := store.GetQuizPath(quiz_3) c := new(models.Collection)
c.Name = "MyCollection"
if !t.Failed() { quizStore.FilterInCollection(c, &models.Filter{
Tags: []*models.Tag{
collection, err := store.CreateCollection( {Name: "#tag3"},
&client.CreateUpdateCollectionRequest{
Collection: &client.Collection{
Name: "MyCollection",
Query: "#tag1",
}, },
}) })
t.Nil(err, "Creating a collection should not return an error") _, err = store.Create(c)
collectionPath, _ := store.GetCollectionPath(collection) exists, err := os.Stat(store.GetPath(c))
if !t.Failed() { t.Nil(err)
t.Equal(2, len(collection.Quizzes)) t.Not(t.Nil(exists))
t.Equal(2, len(c.Quizzes))
os.Remove(path_1) defer os.Remove(store.GetPath(c))
os.Remove(path_2)
os.Remove(path_3)
os.Remove(collectionPath)
}
}
}
}
}
}
func (t *collectionTestSuite) TestUpdateCollection() {
store, err := NewFileProboCollectorStore(testdataDir)
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
if !t.Failed() {
collection, err := createCollectionOnDisk(store, &client.CreateUpdateCollectionRequest{
Collection: &client.Collection{
Name: "Collection name",
Query: "#tag1",
},
})
t.Nil(err, "The collection to be updated should be created without issue")
if !t.Failed() {
clientCollection := &client.Collection{
Name: "Updated collection name",
Query: "#tag2",
}
updatedCollection, err := store.UpdateCollection(
&client.CreateUpdateCollectionRequest{
Collection: clientCollection,
}, collection.ID)
t.Nil(err, fmt.Sprintf("Collection should be updated without errors: %v", err))
t.Equal("#tag2", updatedCollection.Query)
if !t.Failed() {
path, err := store.GetCollectionPath(updatedCollection)
if !t.Failed() {
t.Nil(err, "GetPath should not raise an error.")
if !t.Failed() {
collectionFromDisk, err := readCollectionFromDisk(path)
t.Nil(err, fmt.Sprintf("Collection should be read from disk without errors but an issue was reported: %v", err))
if !t.Failed() {
t.True(reflect.DeepEqual(clientCollection, collectionFromDisk), "Collection should be updated.")
err := os.Remove(path)
t.Nil(err, "Stat should not return an error")
}
}
}
}
}
}
}
func createCollectionOnDisk(store *FileProboCollectorStore, req *client.CreateUpdateCollectionRequest) (*models.Collection, error) {
return store.CreateCollection(req)
}
func readCollectionFromDisk(path string) (*client.Collection, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
collection := new(client.Collection)
err = json.Unmarshal(content, &collection)
if err != nil {
return nil, err
}
return collection, nil
} }

View file

@ -2,68 +2,176 @@ package file
import ( import (
"errors" "errors"
"fmt"
"io/fs"
"os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"git.andreafazzi.eu/andrea/probo/hasher/sha256" "git.andreafazzi.eu/andrea/probo/store"
"git.andreafazzi.eu/andrea/probo/store/memory"
) )
var ( var (
ErrorMetaHeaderIsNotPresent = errors.New("Meta header was not found in file.") ErrorMetaHeaderIsNotPresent = errors.New("Meta header was not found in file.")
DefaultQuizzesDir = "quizzes" BaseDir = "data"
DefaultCollectionsDir = "collections" QuizzesDir = "quizzes"
CollectionsDir = "collections"
) )
type FileProboCollectorStore struct { type Storer[T store.Storable] interface {
Dir string store.Storer[T]
// store.FilterStorer[T]
memoryStore *memory.MemoryProboCollectorStore
quizzesPaths map[string]string
collectionsPaths map[string]string
quizzesDir string
collectionsDir string
// A mutex is used to synchronize read/write access to the map
lock sync.RWMutex
} }
func NewFileProboCollectorStore(dirname string) (*FileProboCollectorStore, error) { type FileStore[T store.Storable, K Storer[T]] struct {
s := new(FileProboCollectorStore) Storer K
s.Dir = dirname Dir string
FilePrefix string
FileSuffix string
s.quizzesDir = filepath.Join(s.Dir, DefaultQuizzesDir) MarshalFunc func(K, string, []byte) (T, error)
s.collectionsDir = filepath.Join(s.Dir, DefaultCollectionsDir) UnmarshalFunc func(K, string, T) error
err := s.Reindex() lock sync.RWMutex
paths map[string]string
}
func NewFileStore[T store.Storable, K Storer[T]](
storer K,
dir string,
prefix string,
suffix string,
marshalFunc func(K, string, []byte) (T, error),
unmarshalFunc func(K, string, T) error,
) (*FileStore[T, K], error) {
store := &FileStore[T, K]{
Storer: storer,
Dir: dir,
FilePrefix: prefix,
FileSuffix: suffix,
MarshalFunc: marshalFunc,
UnmarshalFunc: unmarshalFunc,
paths: make(map[string]string, 0),
}
err := store.IndexDir()
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s, nil return store, nil
} }
func (s *FileProboCollectorStore) Reindex() error { func (s *FileStore[T, K]) Create(entity T) (T, error) {
s.memoryStore = memory.NewMemoryProboCollectorStore( e, err := s.Storer.Create(entity)
sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), if err != nil {
) return e, err
}
s.quizzesPaths = make(map[string]string) filePath := filepath.Join(s.Dir, fmt.Sprintf("%s_%v%s", s.FilePrefix, e.GetID(), s.FileSuffix))
s.collectionsPaths = make(map[string]string)
err := s.reindexQuizzes() err = s.UnmarshalFunc(s.Storer, filePath, e)
if err != nil {
return e, err
}
s.SetPath(e, filePath)
return e, nil
}
func (s *FileStore[T, K]) Update(entity T, id string) (T, error) {
e, err := s.Storer.Update(entity, id)
if err != nil {
return e, err
}
filePath := filepath.Join(s.Dir, fmt.Sprintf("%s_%v%s", s.FilePrefix, e.GetID(), s.FileSuffix))
err = s.UnmarshalFunc(s.Storer, filePath, e)
if err != nil {
return e, err
}
s.SetPath(e, filePath)
return e, nil
}
func (s *FileStore[T, K]) Read(id string) (T, error) {
return s.Storer.Read(id)
}
func (s *FileStore[T, K]) ReadAll() []T {
return s.Storer.ReadAll()
}
func (s *FileStore[T, K]) Delete(id string) (T, error) {
e, err := s.Storer.Delete(id)
if err != nil {
return e, err
}
err = os.Remove(s.GetPath(e))
if err != nil {
return e, err
}
return e, nil
}
func (s *FileStore[T, K]) IndexDir() error {
files, err := os.ReadDir(s.Dir)
if err != nil { if err != nil {
return err return err
} }
err = s.reindexCollections() entityFiles := make([]fs.DirEntry, 0)
for _, file := range files {
filename := file.Name()
if !file.IsDir() && strings.HasSuffix(filename, s.FileSuffix) {
entityFiles = append(entityFiles, file)
}
}
for _, file := range entityFiles {
filename := file.Name()
fullPath := filepath.Join(s.Dir, filename)
content, err := os.ReadFile(fullPath)
if err != nil { if err != nil {
return err return err
} }
entity, err := s.MarshalFunc(s.Storer, fullPath, content)
if err != nil {
return err
}
s.SetPath(entity, fullPath)
}
return nil return nil
}
func (s *FileStore[T, K]) GetPath(entity T) string {
s.lock.RLock()
defer s.lock.RUnlock()
return s.paths[entity.GetID()]
}
func (s *FileStore[T, K]) SetPath(entity T, path string) string {
s.lock.Lock()
defer s.lock.Unlock()
s.paths[entity.GetID()] = path
return path
} }

View file

@ -1,24 +1,13 @@
package file package file
import ( import (
"fmt"
"os"
"reflect"
"testing" "testing"
"git.andreafazzi.eu/andrea/probo/client"
"git.andreafazzi.eu/andrea/probo/hasher/sha256"
"git.andreafazzi.eu/andrea/probo/models"
"git.andreafazzi.eu/andrea/probo/store/memory"
"github.com/remogatto/prettytest" "github.com/remogatto/prettytest"
) )
var testdataDir = "./testdata" var testdataDir = "./testdata"
type quizTestSuite struct {
prettytest.Suite
}
func TestRunner(t *testing.T) { func TestRunner(t *testing.T) {
prettytest.Run( prettytest.Run(
t, t,
@ -26,273 +15,3 @@ func TestRunner(t *testing.T) {
new(collectionTestSuite), new(collectionTestSuite),
) )
} }
func (t *quizTestSuite) TestQuizFromMarkdown() {
markdown := `Question text (1).
Question text (2).
Question text with #tag1 #tag2 (3).
* Answer 1
* Answer 2
* Answer 3
* Answer 4`
expectedQuiz := &client.Quiz{
Question: &client.Question{Text: "Question text (1).\n\nQuestion text (2).\n\nQuestion text with #tag1 #tag2 (3)."},
Answers: []*client.Answer{
{Text: "Answer 1", Correct: true},
{Text: "Answer 2", Correct: false},
{Text: "Answer 3", Correct: false},
{Text: "Answer 4", Correct: false},
},
}
quiz, _, err := QuizFromMarkdown(markdown)
t.Nil(err, fmt.Sprintf("Quiz should be parsed without errors: %v", err))
if !t.Failed() {
t.True(reflect.DeepEqual(quiz, expectedQuiz), fmt.Sprintf("Expected %+v, got %+v", expectedQuiz, quiz))
}
}
func (t *quizTestSuite) TestReadAllQuizzes() {
store, err := NewFileProboCollectorStore("./testdata/")
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
if !t.Failed() {
result, err := store.ReadAllQuizzes()
t.True(err == nil, fmt.Sprintf("Quizzes should be returned without errors: %v", err))
if !t.Failed() {
t.Equal(
4,
len(result),
fmt.Sprintf("The store contains 5 files but only 4 should be parsed (duplicated quiz). Total of parsed quizzes are instead %v", len(result)),
)
}
}
}
func (t *quizTestSuite) TestMarkdownFromQuiz() {
store := memory.NewMemoryProboCollectorStore(sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn))
quiz, err := store.CreateQuiz(
&client.CreateUpdateQuizRequest{
Quiz: &client.Quiz{
Question: &client.Question{Text: "Newly created question text."},
Answers: []*client.Answer{
{Text: "Answer 1", Correct: true},
{Text: "Answer 2", Correct: false},
{Text: "Answer 3", Correct: false},
{Text: "Answer 4", Correct: false},
},
},
})
md, err := MarkdownFromQuiz(quiz)
t.Nil(err, "Conversion to markdown should not raise an error")
if !t.Failed() {
t.Equal(`Newly created question text.
* Answer 1
* Answer 2
* Answer 3
* Answer 4
`, md)
}
}
func (t *quizTestSuite) TestCreateQuiz() {
store, err := NewFileProboCollectorStore(testdataDir)
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
if !t.Failed() {
clientQuiz := &client.Quiz{
Question: &client.Question{Text: "Newly created question text."},
Answers: []*client.Answer{
{Text: "Answer 1", Correct: true},
{Text: "Answer 2", Correct: false},
{Text: "Answer 3", Correct: false},
{Text: "Answer 4", Correct: false},
},
}
quiz, err := store.CreateQuiz(
&client.CreateUpdateQuizRequest{
Quiz: clientQuiz,
},
)
t.Nil(err, fmt.Sprintf("An error was raised when saving the quiz on disk: %v", err))
if !t.Failed() {
path, err := store.GetQuizPath(quiz)
t.Nil(err, "GetPath should not raise an error.")
if !t.Failed() {
exists, err := os.Stat(path)
t.Nil(err, "Stat should not return an error")
if !t.Failed() {
t.True(exists != nil, "The new quiz file was not created.")
if !t.Failed() {
quizFromDisk, _, err := readQuizFromDisk(path)
t.Nil(err, "Quiz should be read from disk without errors.")
if !t.Failed() {
t.True(reflect.DeepEqual(quizFromDisk, clientQuiz), "Quiz read from disk and stored in memory should be equal.")
err := os.Remove(path)
t.Nil(err, "Test file should be removed without errors.")
}
}
}
}
}
}
}
func (t *quizTestSuite) TestDeleteQuiz() {
store, err := NewFileProboCollectorStore(testdataDir)
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
if !t.Failed() {
quiz, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{
Quiz: &client.Quiz{
Question: &client.Question{Text: "This quiz should be deleted."},
Answers: []*client.Answer{
{Text: "Answer 1", Correct: true},
{Text: "Answer 2", Correct: false},
{Text: "Answer 3", Correct: false},
{Text: "Answer 4", Correct: false},
},
},
})
t.Nil(err, "The quiz to be deleted should be created without issue")
path, err := store.GetQuizPath(quiz)
t.True(path != "", "Quiz path should be obtained without errors")
if !t.Failed() {
deletedQuiz, err := store.DeleteQuiz(&client.DeleteQuizRequest{ID: quiz.ID})
t.Nil(err, fmt.Sprintf("Quiz should be deleted without errors: %v", err))
t.True(reflect.DeepEqual(quiz, deletedQuiz), "Quiz should be updateEd.")
}
}
}
func (t *quizTestSuite) TestUpdateQuiz() {
store, err := NewFileProboCollectorStore(testdataDir)
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
if !t.Failed() {
quiz, err := createQuizOnDisk(store, &client.CreateUpdateQuizRequest{
Quiz: &client.Quiz{
Question: &client.Question{Text: "Newly created question text."},
Answers: []*client.Answer{
{Text: "Answer 1", Correct: true},
{Text: "Answer 2", Correct: false},
{Text: "Answer 3", Correct: false},
{Text: "Answer 4", Correct: false},
},
},
})
t.Nil(err, "The quiz to be updated should be created without issue")
if !t.Failed() {
clientQuiz := &client.Quiz{
Question: &client.Question{Text: "Updated question text with #tag."},
Answers: []*client.Answer{
{Text: "Answer 1", Correct: true},
{Text: "Answer 2", Correct: false},
{Text: "Answer 3", Correct: false},
{Text: "Answer 4", Correct: false},
},
}
updatedQuiz, err := store.UpdateQuiz(
&client.CreateUpdateQuizRequest{
Quiz: clientQuiz,
}, quiz.ID)
t.Nil(err, fmt.Sprintf("Quiz should be updated without errors: %v", err))
t.Equal(updatedQuiz.ID, quiz.ID, fmt.Sprintf("IDs should remain the same after an update: updated ID %v original ID %v", updatedQuiz.ID, quiz.ID))
t.True(len(updatedQuiz.Tags) == 1, "Length of tags array should be 1")
if !t.Failed() {
path, err := store.GetQuizPath(updatedQuiz)
if !t.Failed() {
t.Nil(err, "GetPath should not raise an error.")
if !t.Failed() {
quizFromDisk, _, err := readQuizFromDisk(path)
t.Nil(err, "Quiz should be read from disk without errors.")
if !t.Failed() {
t.True(reflect.DeepEqual(clientQuiz, quizFromDisk), "Quiz should be updated.")
err := os.Remove(path)
t.Nil(err, "Stat should not return an error")
}
}
}
}
}
}
}
func (t *quizTestSuite) TestReadMetaHeaderFromFile() {
store, err := NewFileProboCollectorStore(testdataDir)
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
meta, err := store.ReadMetaHeaderFromFile("quiz_4.md")
t.True(err == nil, fmt.Sprintf("An error occurred: %v", err))
if !t.Failed() {
t.True(meta.ID != "")
t.True(meta.CreatedAt.String() != "")
}
}
func (t *quizTestSuite) TestWriteMetaHeaderToFile() {
store, err := NewFileProboCollectorStore(testdataDir)
t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
if !t.Failed() {
meta, err := store.ReadMetaHeaderFromFile("quiz_5.md")
t.True(err == nil, fmt.Sprintf("Reading the header returns the following error: %v", err))
if !t.Failed() {
t.True(meta != nil, "Meta header should not be nil")
if !t.Failed() {
t.True(meta.ID != "", "ID should not be empty")
if !t.Failed() {
_, err = store.removeMetaFromFile("quiz_5.md")
t.True(err == nil)
}
}
}
}
}
func createQuizOnDisk(store *FileProboCollectorStore, req *client.CreateUpdateQuizRequest) (*models.Quiz, error) {
return store.CreateQuiz(req)
}
func readQuizFromDisk(path string) (*client.Quiz, *models.Meta, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, nil, err
}
return QuizFromMarkdown(string(content))
}
func testsAreEqual(got, want []*models.Quiz) bool {
return reflect.DeepEqual(got, want)
}

View file

@ -4,263 +4,151 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"errors" "errors"
"fmt"
"io" "io"
"io/fs"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"git.andreafazzi.eu/andrea/probo/client"
"git.andreafazzi.eu/andrea/probo/models" "git.andreafazzi.eu/andrea/probo/models"
"github.com/go-yaml/yaml" "git.andreafazzi.eu/andrea/probo/store"
"gopkg.in/yaml.v2"
) )
func (s *FileProboCollectorStore) GetQuizzesDir() string { func NewQuizFileStore() (*FileStore[*models.Quiz, *store.QuizStore], error) {
return s.quizzesDir return NewFileStore[*models.Quiz, *store.QuizStore](
} store.NewQuizStore(),
filepath.Join(BaseDir, QuizzesDir),
func (s *FileProboCollectorStore) SetQuizPath(quiz *models.Quiz, path string) string { "quiz",
s.lock.Lock() ".md",
defer s.lock.Unlock() func(s *store.QuizStore, filepath string, content []byte) (*models.Quiz, error) {
quiz, meta, err := models.MarkdownToQuiz(string(content))
s.quizzesPaths[quiz.ID] = path
return path
}
func (s *FileProboCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) {
return s.memoryStore.ReadAllQuizzes()
}
func (s *FileProboCollectorStore) CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error) {
quiz, err := s.memoryStore.CreateQuiz(r)
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = s.createOrUpdateMarkdownFile(quiz) var errQuizAlreadyPresent *store.ErrQuizAlreadyPresent
if err != nil {
q, err := s.Create(quiz)
if err != nil && !errors.As(err, &errQuizAlreadyPresent) {
return nil, err return nil, err
} }
err = s.Reindex()
if err != nil {
return nil, err
}
return s.memoryStore.ReadQuizByHash(quiz.Hash)
}
func (s *FileProboCollectorStore) UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, error) {
quiz, updated, err := s.memoryStore.UpdateQuiz(r, id)
if err != nil {
return nil, err
}
if updated { // Update and re-index only if quiz hash is changed
err = s.createOrUpdateMarkdownFile(quiz)
if err != nil {
return nil, err
}
err = s.Reindex()
if err != nil {
return nil, err
}
}
return s.memoryStore.ReadQuizByHash(quiz.Hash)
}
func (s *FileProboCollectorStore) DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error) {
quiz, err := s.memoryStore.DeleteQuiz(&client.DeleteQuizRequest{ID: r.ID})
if err != nil {
return nil, err
}
path, err := s.GetQuizPath(quiz)
if err != nil {
return nil, err
}
err = os.Remove(path)
if err != nil {
return nil, err
}
err = s.Reindex()
if err != nil {
return nil, err
}
return quiz, nil
}
func (s *FileProboCollectorStore) GetQuizPath(quiz *models.Quiz) (string, error) {
if quiz == nil {
return "", errors.New("Quiz object passed as argument is nil!")
}
s.lock.RLock()
defer s.lock.RUnlock()
path, ok := s.quizzesPaths[quiz.ID]
if !ok {
return "", errors.New(fmt.Sprintf("Path not found for quiz ID %v", quiz.ID))
}
return path, nil
}
func MarkdownFromQuiz(quiz *models.Quiz) (string, error) {
if quiz.Question == nil {
return "", errors.New("Quiz should contain a question but it wasn't provided.")
}
if len(quiz.Answers) < 2 {
return "", errors.New("Quiz should contain at least 2 answers but none was provided.")
}
if quiz.Correct == nil {
return "", errors.New("Quiz should contain a correct answer but not was provided.")
}
correctAnswer := "* " + quiz.Correct.Text
var otherAnswers string
for _, answer := range quiz.Answers {
if quiz.Correct.ID != answer.ID {
otherAnswers += "* " + answer.Text + "\n"
}
}
markdown := quiz.Question.Text + "\n\n" + correctAnswer + "\n" + otherAnswers
return markdown, nil
}
func QuizFromMarkdown(markdown string) (*client.Quiz, *models.Meta, error) {
meta, remainingMarkdown, err := parseMetaHeaderFromMarkdown(markdown)
if err != nil {
return nil, nil, err
}
lines := strings.Split(remainingMarkdown, "\n")
questionText := ""
answers := []*client.Answer{}
for _, line := range lines {
if strings.HasPrefix(line, "*") {
answerText := strings.TrimPrefix(line, "* ")
correct := len(answers) == 0
answer := &client.Answer{Text: answerText, Correct: correct}
answers = append(answers, answer)
} else {
if questionText != "" {
questionText += "\n"
}
questionText += line
}
}
questionText = strings.TrimRight(questionText, "\n")
if questionText == "" {
return nil, nil, fmt.Errorf("Question text should not be empty.")
}
if len(answers) < 2 {
return nil, nil, fmt.Errorf("Number of answers should be at least 2 but parsed answers are %d.", len(answers))
}
question := &client.Question{Text: questionText}
quiz := &client.Quiz{Question: question, Answers: answers}
return quiz, meta, nil
}
func (s *FileProboCollectorStore) ReadMetaHeaderFromFile(filename string) (*models.Meta, error) {
data, err := os.ReadFile(path.Join(s.quizzesDir, filename))
if err != nil {
return nil, err
}
meta, _, err := parseMetaHeaderFromMarkdown(string(data))
if err != nil {
return nil, err
}
return meta, nil
}
func (s *FileProboCollectorStore) WriteMetaHeaderToFile(filename string, meta *models.Meta) (*models.Meta, error) {
readMeta, err := s.ReadMetaHeaderFromFile(filename)
if err != nil {
return nil, err
}
if readMeta == nil {
_, err := writeMetaHeader(path.Join(s.quizzesDir, filename), meta)
if err != nil {
return nil, err
}
}
return meta, nil
}
func (s *FileProboCollectorStore) reindexQuizzes() error {
files, err := os.ReadDir(s.quizzesDir)
if err != nil {
return err
}
markdownFiles := make([]fs.DirEntry, 0)
for _, file := range files {
filename := file.Name()
if !file.IsDir() && strings.HasSuffix(filename, ".md") {
markdownFiles = append(markdownFiles, file)
}
}
if len(markdownFiles) == 0 {
return fmt.Errorf("The directory is empty.")
}
for _, file := range markdownFiles {
filename := file.Name()
fullPath := filepath.Join(s.quizzesDir, filename)
content, err := os.ReadFile(fullPath)
if err != nil {
return err
}
quiz, meta, err := QuizFromMarkdown(string(content))
if err != nil {
return err
}
q, err := s.memoryStore.CreateQuiz(&client.CreateUpdateQuizRequest{
Quiz: quiz,
Meta: meta,
})
if err != nil {
return err
}
if meta == nil { if meta == nil {
s.WriteMetaHeaderToFile(filename, &models.Meta{ writeQuizHeader(filepath, &models.Meta{
ID: q.ID, ID: q.ID,
CreatedAt: time.Now(), CreatedAt: time.Now(),
}) })
} }
s.SetQuizPath(q, fullPath)
return q, nil
},
func(s *store.QuizStore, filePath string, quiz *models.Quiz) error {
markdown, err := models.QuizToMarkdown(quiz)
if err != nil {
return err
}
file, err := os.Create(filePath)
if err != nil {
return err
}
defer file.Close()
markdownWithMetaHeader, err := addMetaHeaderToMarkdown(markdown, &quiz.Meta)
if err != nil {
return err
}
_, err = file.Write([]byte(markdownWithMetaHeader))
if err != nil {
return err
} }
return nil return nil
},
)
} }
func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.Meta, error) { func writeQuizHeader(path string, meta *models.Meta) (*models.Meta, error) {
file, err := os.Open(path.Join(s.quizzesDir, filename)) readMeta, err := readQuizHeader(path)
if err != nil {
return nil, err
}
if readMeta == nil {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
var buffer bytes.Buffer
header, err := yaml.Marshal(meta)
if err != nil {
return nil, err
}
_, err = buffer.WriteString("---\n" + string(header) + "---\n")
if err != nil {
return nil, err
}
_, err = io.Copy(&buffer, file)
if err != nil {
return nil, err
}
file, err = os.Create(path)
if err != nil {
return nil, err
}
defer file.Close()
_, err = io.Copy(file, &buffer)
if err != nil {
return nil, err
}
}
return meta, nil
}
func readQuizHeader(path string) (*models.Meta, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
meta, _, err := models.ParseMetaHeaderFromMarkdown(string(data))
if err != nil {
return nil, err
}
return meta, nil
}
func addMetaHeaderToMarkdown(content string, meta *models.Meta) (string, error) {
var buffer bytes.Buffer
header, err := yaml.Marshal(meta)
if err != nil {
return "", err
}
_, err = buffer.WriteString("---\n" + string(header) + "---\n")
if err != nil {
return "", err
}
_, err = buffer.WriteString(content)
if err != nil {
return "", err
}
return buffer.String(), nil
}
func removeQuizHeader(path string) (*models.Meta, error) {
file, err := os.Open(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -313,7 +201,7 @@ func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.M
return nil, err return nil, err
} }
file, err = os.Create(path.Join(s.quizzesDir, filename)) file, err = os.Create(path)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -326,169 +214,3 @@ func (s *FileProboCollectorStore) removeMetaFromFile(filename string) (*models.M
return &meta, nil return &meta, nil
} }
func (s *FileProboCollectorStore) createOrUpdateMarkdownFile(quiz *models.Quiz) error {
markdown, err := MarkdownFromQuiz(quiz)
if err != nil {
return err
}
fn, _ := s.GetQuizPath(quiz)
if fn == "" {
fn = filepath.Join(s.quizzesDir, fmt.Sprintf("quiz_%v.%s", quiz.ID, "md"))
}
file, err := os.Create(fn)
if err != nil {
return err
}
defer file.Close()
markdownWithMetaHeader, err := addMetaHeaderToMarkdown(markdown, &quiz.Meta)
if err != nil {
return err
}
_, err = file.Write([]byte(markdownWithMetaHeader))
if err != nil {
return err
}
s.SetQuizPath(quiz, fn)
return nil
}
func addMetaHeaderToMarkdown(content string, meta *models.Meta) (string, error) {
var buffer bytes.Buffer
header, err := yaml.Marshal(meta)
if err != nil {
return "", err
}
_, err = buffer.WriteString("---\n" + string(header) + "---\n")
if err != nil {
return "", err
}
_, err = buffer.WriteString(content)
if err != nil {
return "", err
}
return buffer.String(), nil
}
func parseMetaHeaderFromMarkdown(markdown string) (*models.Meta, string, error) {
reader := strings.NewReader(markdown)
var sb strings.Builder
var line string
var err error
for {
line, err = readLine(reader)
if err != nil {
if err == io.EOF {
break
}
return nil, "", err
}
if strings.TrimSpace(line) == "---" {
break
}
}
for {
line, err = readLine(reader)
if err != nil {
if err == io.EOF {
break
}
return nil, "", err
}
if strings.TrimSpace(line) == "---" {
break
}
sb.WriteString(line)
}
if sb.String() == "" {
return nil, markdown, nil
}
var meta models.Meta
err = yaml.Unmarshal([]byte(sb.String()), &meta)
if err != nil {
return nil, markdown, err
}
remainingMarkdown := markdown[strings.Index(markdown, "---\n"+sb.String()+"---\n")+len("---\n"+sb.String()+"---\n"):]
return &meta, remainingMarkdown, nil
}
func readLine(reader *strings.Reader) (string, error) {
var sb strings.Builder
for {
r, _, err := reader.ReadRune()
if err != nil {
if err == io.EOF {
return sb.String(), io.EOF
}
return "", err
}
sb.WriteRune(r)
if r == '\n' {
break
}
}
return sb.String(), nil
}
func writeMetaHeader(filename string, meta *models.Meta) (*models.Meta, error) {
// Apri il file in lettura
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
// Crea un buffer in memoria
var buffer bytes.Buffer
// Scrivi l'intestazione YAML nel buffer
header, err := yaml.Marshal(meta)
if err != nil {
return nil, err
}
_, err = buffer.WriteString("---\n" + string(header) + "---\n")
if err != nil {
return nil, err
}
// Copia il contenuto del file originale nel buffer
_, err = io.Copy(&buffer, file)
if err != nil {
return nil, err
}
// Riapri il file in scrittura
file, err = os.Create(filename)
if err != nil {
return nil, err
}
defer file.Close()
// Scrivi il contenuto del buffer nel file
_, err = io.Copy(file, &buffer)
if err != nil {
return nil, err
}
return meta, nil
}

232
store/file/quiz_test.go Normal file
View file

@ -0,0 +1,232 @@
package file
import (
"fmt"
"os"
"path/filepath"
"git.andreafazzi.eu/andrea/probo/models"
"github.com/remogatto/prettytest"
)
type quizTestSuite struct {
prettytest.Suite
}
func (t *quizTestSuite) BeforeAll() {
BaseDir = "testdata"
}
func (t *quizTestSuite) TestReadAllQuizzes() {
store, err := NewQuizFileStore()
t.Nil(err)
if !t.Failed() {
result := store.ReadAll()
t.Equal(
4,
len(result),
fmt.Sprintf(
"The store contains 5 files but only 4 should be parsed (duplicated quiz). Total of parsed quizzes are instead %v",
len(result),
),
)
}
}
func (t *quizTestSuite) TestCreateQuiz() {
store, err := NewQuizFileStore()
t.Nil(err)
if !t.Failed() {
quiz, err := store.Create(
&models.Quiz{
Question: &models.Question{Text: "Newly created question text with #tag1 #tag2."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
t.Nil(err)
t.Equal(2, len(quiz.Tags))
if !t.Failed() {
path := store.GetPath(quiz)
t.True(path != "", "Path should not be empty.")
exists, err := os.Stat(path)
t.Nil(err)
if !t.Failed() {
t.True(exists != nil, "The new quiz file was not created.")
if !t.Failed() {
quizFromDisk, _, err := readQuizFromDisk(path)
defer os.Remove(path)
quizFromDisk.Correct = quiz.Answers[0]
quizFromDisk.Tags = quiz.Tags
t.Nil(err)
if !t.Failed() {
t.Equal(quizFromDisk.Question.Text, quiz.Question.Text)
for i, a := range quizFromDisk.Answers {
t.Equal(a.Text, quiz.Answers[i].Text)
}
for i, tag := range quizFromDisk.Tags {
t.Equal(tag.Name, quiz.Tags[i].Name)
}
}
}
}
}
}
}
func (t *quizTestSuite) TestDeleteQuiz() {
store, err := NewQuizFileStore()
t.Nil(err)
if !t.Failed() {
quiz, err := store.Create(
&models.Quiz{
Question: &models.Question{Text: "This quiz should be deleted."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
t.Nil(err)
if !t.Failed() {
path := store.GetPath(quiz)
_, err := store.Delete(quiz.ID)
t.Nil(err, fmt.Sprintf("Quiz should be deleted without errors: %v", err))
if !t.Failed() {
_, err := os.Stat(path)
t.Not(t.Nil(err))
}
}
}
}
func (t *quizTestSuite) TestUpdateQuiz() {
store, err := NewQuizFileStore()
t.Nil(err)
if !t.Failed() {
quiz, err := store.Create(
&models.Quiz{
Question: &models.Question{Text: "Newly created question text with #tag1 #tag2."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
t.Nil(err)
_, err = store.Update(&models.Quiz{
Question: &models.Question{Text: "Newly created question text with #tag1 #tag2 #tag3."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 1,
}, quiz.ID)
t.Nil(err)
updatedQuizFromMemory, err := store.Read(quiz.ID)
t.Equal(len(updatedQuizFromMemory.Tags), 3)
t.Equal("Answer 2", updatedQuizFromMemory.Correct.Text)
defer os.Remove(store.GetPath(quiz))
}
}
func (t *quizTestSuite) TestAutowriteHeader() {
store, err := NewQuizFileStore()
t.Nil(err)
if !t.Failed() {
meta, err := readQuizHeader(filepath.Join(store.Dir, "quiz_5.md"))
t.Nil(err)
if !t.Failed() {
t.Not(t.Nil(meta))
if !t.Failed() {
t.True(meta.ID != "", "ID should not be empty")
if !t.Failed() {
_, err = removeQuizHeader(filepath.Join(store.Dir, "quiz_5.md"))
t.True(err == nil)
}
}
}
}
}
// func (t *quizTestSuite) TestReadMetaHeaderFromFile() {
// store, err := NewFileProboCollectorStore(testdataDir)
// t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
// meta, err := store.ReadMetaHeaderFromFile("quiz_4.md")
// t.True(err == nil, fmt.Sprintf("An error occurred: %v", err))
// if !t.Failed() {
// t.True(meta.ID != "")
// t.True(meta.CreatedAt.String() != "")
// }
// }
// func (t *quizTestSuite) TestWriteMetaHeaderToFile() {
// store, err := NewFileProboCollectorStore(testdataDir)
// t.True(err == nil, fmt.Sprintf("A file store should be initialized without problems but an error occurred: %v", err))
// if !t.Failed() {
// meta, err := store.ReadMetaHeaderFromFile("quiz_5.md")
// t.True(err == nil, fmt.Sprintf("Reading the header returns the following error: %v", err))
// if !t.Failed() {
// t.True(meta != nil, "Meta header should not be nil")
// if !t.Failed() {
// t.True(meta.ID != "", "ID should not be empty")
// if !t.Failed() {
// _, err = store.removeMetaFromFile("quiz_5.md")
// t.True(err == nil)
// }
// }
// }
// }
// }
// func createQuizOnDisk(store *FileProboCollectorStore, req *client.CreateUpdateQuizRequest) (*models.Quiz, error) {
// return store.CreateQuiz(req)
// }
func readQuizFromDisk(path string) (*models.Quiz, *models.Meta, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, nil, err
}
return models.MarkdownToQuiz(string(content))
}

View file

@ -1 +0,0 @@
{"id":"0386ac85-0701-48e7-a5a7-bf9713f63dac","name":"Updated collection name","query":"#tag2","ids":[]}

View file

@ -1 +0,0 @@
{"id":"b30c4392-52f3-46c5-8865-319ae7d1fbe0","name":"Updated collection name","query":"#tag2","ids":[]}

View file

@ -1,6 +1,7 @@
--- ---
id: a09045c3-af87-4a83-a2bb-7283a2ac67d6 id: a09045c3-af87-4a83-a2bb-7283a2ac67d6
created_at: !!timestamp 2023-09-22T09:08:50.366639817+02:00 created_at: 2023-09-22T09:08:50.366639817+02:00
updated_at: 0001-01-01T00:00:00Z
--- ---
Question text 2. Question text 2.
@ -8,4 +9,3 @@ Question text 2.
* Answer 2 * Answer 2
* Answer 3 * Answer 3
* Answer 4 * Answer 4

View file

@ -1,7 +1,7 @@
--- ---
id: b7ec3eb9-55e1-47c6-8652-3a27fe90bc0f id: 5ebab010-8e96-41c9-905d-a092da037194
created_at: !!timestamp 2023-10-31T10:02:02.869395215+01:00 created_at: 2023-11-13T20:58:30.651064703+01:00
updated_at: !!timestamp 0001-01-01T00:00:00Z updated_at: 0001-01-01T00:00:00Z
--- ---
This quiz is initially without metadata. This quiz is initially without metadata.

View file

@ -1,31 +1,36 @@
package memory package memory
import ( import (
"errors"
"sync" "sync"
"git.andreafazzi.eu/andrea/probo/hasher" "git.andreafazzi.eu/andrea/probo/hasher"
"git.andreafazzi.eu/andrea/probo/models" "git.andreafazzi.eu/andrea/probo/models"
"git.andreafazzi.eu/andrea/probo/store"
) )
type MemoryProboCollectorStore struct { type Store[T Storable] struct {
ids map[string]T
// IDs maps hashes map[string]T
quizzes map[string]*models.Quiz
collections map[string]*models.Collection
participants map[string]*models.Participant
// Hashes maps
questionsHashes map[string]*models.Question
answersHashes map[string]*models.Answer
quizzesHashes map[string]*models.Quiz
hasher hasher.Hasher
// A mutex is used to synchronize read/write access to the map // A mutex is used to synchronize read/write access to the map
lock sync.RWMutex lock sync.RWMutex
} }
func NewStore[T store.Storable]() *Store[T] {
store := new(Store[T])
store.ids = make(map[string]T)
return store
}
type QuizStore struct {
*Store[*Quiz]
questions *Store[*Question]
answers *Store[*Answer]
}
func NewMemoryProboCollectorStore(hasher hasher.Hasher) *MemoryProboCollectorStore { func NewMemoryProboCollectorStore(hasher hasher.Hasher) *MemoryProboCollectorStore {
s := new(MemoryProboCollectorStore) s := new(MemoryProboCollectorStore)
@ -41,19 +46,3 @@ func NewMemoryProboCollectorStore(hasher hasher.Hasher) *MemoryProboCollectorSto
return s return s
} }
func Create[T any](s *MemoryProboCollectorStore, elem *T) (*T, error) {
if elem == nil {
return nil, errors.New("A creation request was made passing a nil element")
}
// Check for duplicates
hashes := s.hasher.QuizHashes(r.Quiz)
quizHash := hashes[len(hashes)-1]
quiz := s.getQuizFromHash(quizHash)
if quiz != nil { // Quiz is already present in the store
return quiz, false, nil
}
}

173
store/quiz.go Normal file
View file

@ -0,0 +1,173 @@
package store
import (
"fmt"
"strings"
"git.andreafazzi.eu/andrea/probo/models"
)
type ErrQuizAlreadyPresent struct {
hash string
}
func (e *ErrQuizAlreadyPresent) Error() string {
return fmt.Sprintf("Quiz with hash %v is already present in the store.", e.hash)
}
type QuizStore struct {
// Memory store for quizzes. It satisfies FilterStorer
// interface.
*FilterStore[*models.Quiz]
questions *Store[*models.Question]
answers *Store[*models.Answer]
}
func NewQuizStore() *QuizStore {
store := new(QuizStore)
store.questions = NewStore[*models.Question]()
store.answers = NewStore[*models.Answer]()
store.FilterStore = NewFilterStore[*models.Quiz]()
return store
}
func (s *QuizStore) Create(quiz *models.Quiz) (*models.Quiz, error) {
if hash := quiz.GetHash(); hash != "" {
q, ok := s.hashes[hash]
if ok {
return q, &ErrQuizAlreadyPresent{hash}
}
}
question, err := s.questions.Create(quiz.Question)
if err != nil {
return nil, err
}
answers := make([]*models.Answer, 0)
for _, a := range quiz.Answers {
storedAnswer, err := s.answers.Create(a)
if err != nil {
return nil, err
}
answers = append(answers, storedAnswer)
}
tags := make([]*models.Tag, 0)
q, err := s.Store.Create(&models.Quiz{
Meta: quiz.Meta,
Question: parseTags[*models.Question](&tags, question)[0],
Answers: parseTags[*models.Answer](&tags, answers...),
Correct: answers[quiz.CorrectPos],
CorrectPos: quiz.CorrectPos,
Tags: tags,
})
if err != nil {
return nil, err
}
return q, nil
}
func (s *QuizStore) Update(quiz *models.Quiz, id string) (*models.Quiz, error) {
_, err := s.Read(id)
if err != nil {
return quiz, err
}
question, err := s.questions.Create(quiz.Question)
if err != nil {
return nil, err
}
answers := make([]*models.Answer, 0)
for _, a := range quiz.Answers {
storedAnswer, err := s.answers.Create(a)
if err != nil {
return nil, err
}
answers = append(answers, storedAnswer)
}
tags := make([]*models.Tag, 0)
q, err := s.Store.Update(&models.Quiz{
Question: parseTags[*models.Question](&tags, question)[0],
Answers: parseTags[*models.Answer](&tags, answers...),
Correct: answers[quiz.CorrectPos],
CorrectPos: quiz.CorrectPos,
Tags: tags,
}, id)
if err != nil {
return nil, err
}
return q, nil
}
func (s *QuizStore) FilterInCollection(collection *models.Collection, filter *models.Filter) []*models.Quiz {
quizzes := s.ReadAll()
filteredQuizzes := s.Filter(quizzes, func(q *models.Quiz) bool {
count := 0
for _, qTag := range q.Tags {
if s.isTagInFilter(qTag, filter) {
count++
}
}
if count == len(filter.Tags) {
return true
}
return false
})
collection.Quizzes = filteredQuizzes
return collection.Quizzes
}
func (s *QuizStore) isTagInFilter(tag *models.Tag, filter *models.Filter) bool {
for _, fTag := range filter.Tags {
if tag.Name == fTag.Name {
return true
}
}
return false
}
func parseTags[T fmt.Stringer](tags *[]*models.Tag, entities ...T) []T {
for _, entity := range entities {
// Trim the following chars
trimChars := "*:.,/\\@()[]{}<>"
// Split the text into words
words := strings.Fields(entity.String())
for _, word := range words {
// If the word starts with '#', it is considered as a tag
if strings.HasPrefix(word, "#") {
// Check if the tag already exists in the tags slice
exists := false
for _, tag := range *tags {
if tag.Name == word {
exists = true
break
}
}
// If the tag does not exist in the tags slice, add it
if !exists {
*tags = append(*tags, &models.Tag{Name: strings.TrimRight(word, trimChars)})
}
}
}
}
return entities
}

252
store/quiz_test.go Normal file
View file

@ -0,0 +1,252 @@
package store
import (
"reflect"
"git.andreafazzi.eu/andrea/probo/models"
"github.com/remogatto/prettytest"
)
type quizTestSuite struct {
prettytest.Suite
}
func (t *quizTestSuite) TestCreateQuiz() {
store := NewQuizStore()
quiz, err := store.Create(
&models.Quiz{
Question: &models.Question{Text: "Newly created question text with #tag."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
t.Nil(err, "Quiz should be created without error")
if !t.Failed() {
quizFromMemory, err := store.Read(quiz.GetID())
t.Nil(err, "Quiz should be found in the store")
if !t.Failed() {
t.True(quizFromMemory.GetID() != "")
t.Equal(len(quizFromMemory.Tags), 1)
t.Equal(len(store.questions.ids), 1)
t.Equal(len(store.answers.ids), 4)
if !t.Failed() {
t.True(reflect.DeepEqual(quizFromMemory, quiz), "Quiz should be equal")
}
}
}
}
func (t *quizTestSuite) TestDuplicateQuiz() {
store := NewQuizStore()
_, err := store.Create(
&models.Quiz{
Question: &models.Question{Text: "Newly created question text."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
t.Nil(err, "Quiz 1 should be created without error")
_, err = store.Create(
&models.Quiz{
Question: &models.Question{Text: "Newly created question text."},
Answers: []*models.Answer{
{Text: "Answer 2"},
{Text: "Answer 1"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 1,
})
t.Not(t.Nil(err), "Quiz 2 should not be created")
}
func (t *quizTestSuite) TestReadQuiz() {
store := NewQuizStore()
quiz, err := store.Create(
&models.Quiz{
Question: &models.Question{Text: "Newly created question text."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
t.Nil(err, "Quiz should be created without error")
if !t.Failed() {
quizzes := store.ReadAll()
t.Equal(1, len(quizzes))
storedQuiz, err := store.Read(quiz.GetID())
t.Nil(err, "Quiz should be read without error")
if !t.Failed() {
t.Equal(quiz.ID, storedQuiz.ID)
}
}
}
func (t *quizTestSuite) TestUpdateQuiz() {
store := NewQuizStore()
quiz, err := store.Create(
&models.Quiz{
Question: &models.Question{Text: "Newly created question text."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
t.Nil(err, "Quiz should be created without error")
if !t.Failed() {
updatedQuiz, err := store.Update(
&models.Quiz{
Question: &models.Question{Text: "Updated question text with #tag."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
}, quiz.GetID())
updatedQuizFromMemory, err := store.Read(quiz.GetID())
t.Nil(err, "Quiz should be found in the store")
if !t.Failed() {
t.Equal(updatedQuizFromMemory.GetID(), updatedQuiz.GetID())
t.Equal(updatedQuizFromMemory.Question.Text, updatedQuiz.Question.Text)
t.Equal(len(updatedQuizFromMemory.Tags), 1)
}
}
}
func (t *quizTestSuite) TestDeleteQuiz() {
store := NewQuizStore()
_, err := store.Create(
&models.Quiz{
Question: &models.Question{Text: "Newly created question text."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
t.Nil(err, "Quiz should be created without error")
if !t.Failed() {
quiz_2, err := store.Create(
&models.Quiz{
Question: &models.Question{Text: "Newly created question text 2."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
t.Nil(err, "Quiz should be created without error")
if !t.Failed() {
_, err := store.Delete(quiz_2.GetID())
t.Nil(err, "Quiz should be deleted without error")
t.Equal(1, len(store.ids))
t.Equal(1, len(store.hashes))
}
}
}
func (t *quizTestSuite) TestFilter() {
store := NewQuizStore()
quiz_1, _ := store.Create(
&models.Quiz{
Question: &models.Question{Text: "Question text with #tag1."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2 with #tag2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
quiz_2, _ := store.Create(
&models.Quiz{
Question: &models.Question{Text: "Question text with #tag3."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2 with #tag4"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
quizzes := store.Filter([]*models.Quiz{quiz_1, quiz_2}, func(q *models.Quiz) bool {
for _, t := range q.Tags {
if t.Name == "#tag1" {
return true
}
}
return false
})
t.Equal(1, len(quizzes))
}
func (t *quizTestSuite) TestParseTextForTags() {
store := NewQuizStore()
quiz, err := store.Create(
&models.Quiz{
Question: &models.Question{Text: "Question text with #tag1."},
Answers: []*models.Answer{
{Text: "Answer 1"},
{Text: "Answer 2 with #tag2"},
{Text: "Answer 3"},
{Text: "Answer 4"},
},
CorrectPos: 0,
})
t.Nil(err, "Quiz should be created without errors.")
if !t.Failed() {
storedQuiz, err := store.Read(quiz.GetID())
t.Nil(err, "Quiz should be found in the store.")
t.Equal(2, len(storedQuiz.Tags))
if !t.Failed() {
t.Equal("#tag1", storedQuiz.Tags[0].Name)
t.Equal("#tag2", storedQuiz.Tags[1].Name)
}
}
}

View file

@ -1,60 +1,159 @@
package store package store
import ( import (
"git.andreafazzi.eu/andrea/probo/client" "fmt"
"git.andreafazzi.eu/andrea/probo/models" "sync"
"github.com/google/uuid"
) )
type QuizReader interface { type IDer interface {
ReadAllQuizzes() ([]*models.Quiz, error) GetID() string
ReadQuizByID(id string) (*models.Quiz, error) SetID(string)
ReadQuizByHash(hash string) (*models.Quiz, error)
} }
type QuizWriter interface { type Hasher interface {
CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error) GetHash() string
UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error)
DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error)
} }
type CollectionReader interface { type Storable interface {
ReadAllCollections() ([]*models.Collection, error) IDer
ReadCollectionByID(id string) (*models.Collection, error) Hasher
} }
type CollectionWriter interface { type Storer[T Storable] interface {
CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error) Create(T) (T, error)
UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error) ReadAll() []T
DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error) Read(string) (T, error)
Update(T, string) (T, error)
Delete(string) (T, error)
} }
type ParticipantReader interface { type FilterStorer[T Storable] interface {
ReadAllParticipants() ([]*models.Participant, error) Storer[T]
ReadParticipantByID(id string) (*models.Participant, error)
Filter([]T, func(T) bool) []T
} }
type ParticipantWriter interface { type Store[T Storable] struct {
CreateParticipant(r *client.CreateUpdateParticipantRequest) (*models.Participant, error) ids map[string]T
UpdateParticipant(r *client.CreateUpdateParticipantRequest, id string) (*models.Participant, bool, error) hashes map[string]T
DeleteParticipant(r *client.DeleteParticipantRequest) (*models.Participant, error)
lock sync.RWMutex
} }
type ExamReader interface { type FilterStore[T Storable] struct {
ReadAllExams() ([]*models.Exam, error) *Store[T]
ReadExamByID(id string) (*models.Exam, error)
} }
type ExamWriter interface { func NewFilterStore[T Storable]() *FilterStore[T] {
CreateExam(r *client.CreateUpdateExamRequest) (*models.Exam, error) return &FilterStore[T]{NewStore[T]()}
UpdateExam(r *client.CreateUpdateExamRequest, id string) (*models.Exam, bool, error)
DeleteExam(r *client.DeleteExamRequest) (*models.Exam, error)
} }
type ProboCollectorStore interface { func (fs *FilterStore[T]) Filter(slice []T, f func(T) bool) []T {
QuizReader result := make([]T, 0)
QuizWriter
CollectionReader for _, item := range slice {
CollectionWriter if f(item) {
ParticipantReader result = append(result, item)
ParticipantWriter }
}
return result
}
func NewStore[T Storable]() *Store[T] {
store := new(Store[T])
store.ids = make(map[string]T)
store.hashes = make(map[string]T)
return store
}
func (s *Store[T]) Create(entity T) (T, error) {
s.lock.Lock()
defer s.lock.Unlock()
if hash := entity.GetHash(); hash != "" {
storedEntity, ok := s.hashes[hash]
if ok {
return storedEntity, nil
}
s.hashes[hash] = entity
}
id := entity.GetID()
if id == "" {
id = uuid.New().String()
}
entity.SetID(id)
s.ids[id] = entity
return entity, nil
}
func (s *Store[T]) ReadAll() []T {
s.lock.Lock()
defer s.lock.Unlock()
result := make([]T, 0)
for _, v := range s.ids {
result = append(result, v)
}
return result
}
func (s *Store[T]) Read(id string) (T, error) {
s.lock.RLock()
defer s.lock.RUnlock()
entity, ok := s.ids[id]
if !ok {
return entity, fmt.Errorf("Entity with ID %s was not found in the store.", id)
}
return entity, nil
}
func (s *Store[T]) Update(entity T, id string) (T, error) {
sEntity, err := s.Read(id)
if err != nil {
return sEntity, err
}
s.lock.Lock()
defer s.lock.Unlock()
entity.SetID(id)
s.ids[id] = entity
if hash := entity.GetHash(); hash != "" {
s.hashes[hash] = entity
}
return entity, nil
}
func (s *Store[T]) Delete(id string) (T, error) {
sEntity, err := s.Read(id)
if err != nil {
return sEntity, err
}
s.lock.Lock()
defer s.lock.Unlock()
delete(s.ids, id)
if hash := sEntity.GetHash(); hash != "" {
delete(s.hashes, hash)
}
return sEntity, nil
} }

15
store/store_test.go Normal file
View file

@ -0,0 +1,15 @@
package store
import (
"testing"
"github.com/remogatto/prettytest"
)
func TestRunner(t *testing.T) {
prettytest.Run(
t,
new(quizTestSuite),
new(collectionTestSuite),
)
}