From 578b4e2079b3baef06e11d990e89aad357ea4723 Mon Sep 17 00:00:00 2001 From: andrea Date: Sat, 28 Oct 2023 20:50:06 +0200 Subject: [PATCH] First implementation of SQlite store --- client/client.go | 39 ++ go.mod | 16 +- go.sum | 29 ++ models/answer.go | 2 +- models/collection.go | 5 +- models/exam.go | 20 +- models/meta.go | 5 +- models/participant.go | 2 +- models/question.go | 5 +- models/quiz.go | 7 +- models/tag.go | 11 +- store/db/db.go | 74 +++- store/db/db_test.go | 56 ++- store/file/collection_test.go | 2 +- store/file/file.go | 615 -------------------------- store/file/testdata/quizzes/quiz_5.md | 5 +- store/memory/memory.go | 60 ++- store/memory/memory_test.go | 64 +-- store/store.go | 50 ++- 19 files changed, 345 insertions(+), 722 deletions(-) diff --git a/client/client.go b/client/client.go index acdafdb..23238a0 100644 --- a/client/client.go +++ b/client/client.go @@ -21,6 +21,21 @@ type Collection struct { Query string `json:"query"` } +type Participant struct { + Firstname string `json:"firstname"` + Lastname string `json:"lastname"` + Class string `json:"class"` + Token uint `json:"token"` +} + +type Exam struct { + Name string `json:"name"` + Description string `json:"description"` + + ParticipantID string `json:"participant_id"` + CollectionID string `json:"collection_id"` +} + type BaseResponse struct { Status string `json:"status"` Message string `json:"message"` @@ -65,3 +80,27 @@ type CreateUpdateCollectionRequest struct { type DeleteCollectionRequest struct { ID string } + +type CreateUpdateExamRequest struct { + *Exam +} + +type DeleteExamRequest struct { + ID string +} + +type ReadExamByIDRequest struct { + ID string +} + +type CreateUpdateParticipantRequest struct { + *Participant +} + +type DeleteParticipantRequest struct { + ID string +} + +type ReadParticipantByIDRequest struct { + ID string +} diff --git a/go.mod b/go.mod index 3c67a7b..fc7a31d 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,24 @@ go 1.17 require github.com/sirupsen/logrus v1.8.1 require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/glebarez/sqlite v1.9.0 // indirect github.com/go-yaml/yaml v2.1.0+incompatible // indirect - github.com/google/uuid v1.3.0 // indirect + github.com/google/uuid v1.3.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/kr/pretty v0.2.1 // indirect github.com/kr/text v0.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8 // indirect - golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.13.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gorm.io/gorm v1.25.5 // indirect + modernc.org/libc v1.24.1 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.7.2 // indirect + modernc.org/sqlite v1.26.0 // indirect ) diff --git a/go.sum b/go.sum index 08d7fd2..0cc21d6 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,20 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs= +github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw= github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o= github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= @@ -10,13 +22,30 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8 h1:nRDwTcxV9B3elxMt+1xINX0bwaPdpouqp5fbynexY8U= github.com/remogatto/prettytest v0.0.0-20200211072524-6d385e11dcb8/go.mod h1:jOEnp79oIHy5cvQSHeLcgVJk1GHOOHJHQWps/d1N5Yo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +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/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E= +modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= +modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw= +modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU= diff --git a/models/answer.go b/models/answer.go index 931c818..46c8613 100644 --- a/models/answer.go +++ b/models/answer.go @@ -1,6 +1,6 @@ package models type Answer struct { - ID string `json:"id"` + ID string `json:"id" gorm:"primaryKey"` Text string `json:"text"` } diff --git a/models/collection.go b/models/collection.go index e33f9c8..647a983 100644 --- a/models/collection.go +++ b/models/collection.go @@ -1,9 +1,10 @@ package models type Collection struct { - ID string `json:"id"` + Meta + Name string `json:"name"` Query string `json:"query"` - IDs []string `json:"ids"` + Quizzes []*Quiz `json:"quizzes" gorm:"many2many:collection_quizzes"` } diff --git a/models/exam.go b/models/exam.go index 0913abb..37cb0b4 100644 --- a/models/exam.go +++ b/models/exam.go @@ -1,13 +1,23 @@ package models -import "time" +import ( + "time" + + "gorm.io/gorm" +) type Exam struct { - ID string `gorm:"primaryKey"` + ID string `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` - Quizzes []*Quiz - Participant *Participant + Name string + Description string - Responses []string + Collection *Collection `gorm:"foreignKey:ID"` + Participant []*Participant `gorm:"many2many:exam_participants"` + + // Responses []string } diff --git a/models/meta.go b/models/meta.go index 0ba0790..abdd51b 100644 --- a/models/meta.go +++ b/models/meta.go @@ -3,7 +3,8 @@ package models import "time" type Meta struct { - ID string `json:"id" yaml:"id"` + ID string `json:"id" yaml:"id" gorm:"primaryKey"` CreatedAt time.Time `json:"created_at" yaml:"created_at"` - Tags []*Tag `json:"tags" yaml:"-"` + UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` + Tags []*Tag `json:"tags" yaml:"-" gorm:"-"` } diff --git a/models/participant.go b/models/participant.go index d2e2238..0ca90b4 100644 --- a/models/participant.go +++ b/models/participant.go @@ -1,7 +1,7 @@ package models type Participant struct { - ID string + ID string `gorm:"primaryKey"` Firstname string Lastname string diff --git a/models/question.go b/models/question.go index 0a25e9d..68d297c 100644 --- a/models/question.go +++ b/models/question.go @@ -1,7 +1,6 @@ package models type Question struct { - ID string `json:"id"` - Text string `json:"text"` - AnswerIDs []string `json:"answer_ids"` + Meta + Text string `json:"text"` } diff --git a/models/quiz.go b/models/quiz.go index 749b2d4..a12b812 100644 --- a/models/quiz.go +++ b/models/quiz.go @@ -2,9 +2,10 @@ package models type Quiz struct { Meta + Hash string `json:"hash"` - Question *Question `json:"question"` - Answers []*Answer `json:"answers"` - Correct *Answer `json:"correct"` + Question *Question `json:"question" gorm:"foreignKey:ID"` + Answers []*Answer `json:"answers" gorm:"many2many:quiz_answers"` + Correct *Answer `json:"correct" gorm:"foreignKey:ID"` Type int `json:"type"` } diff --git a/models/tag.go b/models/tag.go index d2c8e5c..d87e349 100644 --- a/models/tag.go +++ b/models/tag.go @@ -1,5 +1,14 @@ package models +import ( + "time" + + "gorm.io/gorm" +) + type Tag struct { - Name string `json:"name"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Name string `json:"name" gorm:"primaryKey"` } diff --git a/store/db/db.go b/store/db/db.go index e2fdefc..0247063 100644 --- a/store/db/db.go +++ b/store/db/db.go @@ -1,9 +1,79 @@ package db +import ( + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/models" + "git.andreafazzi.eu/andrea/probo/store" + "github.com/glebarez/sqlite" + "github.com/google/uuid" + "gorm.io/gorm" +) + type DBProboCollectorStore struct { Path string + + db *gorm.DB + si store.ProboCollectorStore } -func NewDBProboCollectorStore(path string) (*DBProboCollectorStore, error) { - return nil, nil +func NewDBProboCollectorStore(path string, s store.ProboCollectorStore) (*DBProboCollectorStore, error) { + var err error + + store := new(DBProboCollectorStore) + + store.db, err = gorm.Open(sqlite.Open(path), &gorm.Config{}) + if err != nil { + return nil, err + } + + err = store.db.AutoMigrate( + &models.Question{}, + &models.Answer{}, + &models.Quiz{}, + &models.Collection{}, + &models.Exam{}, + &models.Participant{}, + ) + if err != nil { + return nil, err + } + + store.si = s + + return store, nil +} + +func (s *DBProboCollectorStore) CreateExam(r *client.CreateUpdateExamRequest) (*models.Exam, error) { + exam := new(models.Exam) + + exam.ID = uuid.New().String() + exam.Name = r.Name + exam.Description = r.Description + + collection, err := s.si.ReadCollectionByID(r.CollectionID) + if err != nil { + return nil, err + } + + exam.Collection = collection + + result := s.db.Create(exam) + if result.Error != nil { + return nil, result.Error + } + + return exam, nil +} + +func (s *DBProboCollectorStore) ReadExamByID(r *client.ReadExamByIDRequest) (*models.Exam, error) { + exam := &models.Exam{ + ID: r.ID, + } + + s.db.First(&exam) + if err := s.db.Error; err != nil { + return nil, err + } + + return exam, nil } diff --git a/store/db/db_test.go b/store/db/db_test.go index 8793b1f..e64ffbd 100644 --- a/store/db/db_test.go +++ b/store/db/db_test.go @@ -1,8 +1,12 @@ package db import ( + "os" "testing" + "git.andreafazzi.eu/andrea/probo/client" + "git.andreafazzi.eu/andrea/probo/hasher/sha256" + "git.andreafazzi.eu/andrea/probo/store/memory" "github.com/remogatto/prettytest" ) @@ -18,7 +22,55 @@ func TestRunner(t *testing.T) { } func (t *dbTestSuite) TestCreateExam() { - store, err := NewDBProboCollectorStore("testdata/test.sqlite") + memStore := memory.NewMemoryProboCollectorStore( + sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), + ) + memStore.CreateQuiz( + &client.CreateUpdateQuizRequest{ + Quiz: &client.Quiz{ + Question: &client.Question{Text: "Newly created question text with #tag1."}, + Answers: []*client.Answer{ + {Text: "Answer 1", Correct: true}, + {Text: "Answer 2", Correct: false}, + {Text: "Answer 3", Correct: false}, + {Text: "Answer 4", Correct: false}, + }, + }, + }) + + store, err := NewDBProboCollectorStore("testdata/test.sqlite", memStore) + defer os.Remove("testdata/test.sqlite") + t.Nil(err) - t.Not(t.Nil(store)) + + if !t.Failed() { + collection, err := memStore.CreateCollection(&client.CreateUpdateCollectionRequest{ + Collection: &client.Collection{ + Name: "Collection name", + Query: "#tag1", + }, + }) + + t.Nil(err) + + if !t.Failed() { + exam, err := store.CreateExam(&client.CreateUpdateExamRequest{ + Exam: &client.Exam{ + Name: "Exam", + Description: "Exam description", + CollectionID: collection.ID, + }, + }) + + t.Nil(err) + if !t.Failed() { + t.Not(t.Nil(exam)) + examFromDb, err := store.ReadExamByID(&client.ReadExamByIDRequest{ID: exam.ID}) + t.Nil(err) + if !t.Failed() { + t.Equal(examFromDb.ID, exam.ID) + } + } + } + } } diff --git a/store/file/collection_test.go b/store/file/collection_test.go index 3e02678..1e6f3e1 100644 --- a/store/file/collection_test.go +++ b/store/file/collection_test.go @@ -86,7 +86,7 @@ func (t *collectionTestSuite) TestCreateCollection() { collectionPath, _ := store.GetCollectionPath(collection) if !t.Failed() { - t.Equal(2, len(collection.IDs)) + t.Equal(2, len(collection.Quizzes)) os.Remove(path_1) os.Remove(path_2) diff --git a/store/file/file.go b/store/file/file.go index fbeb23f..4c6264c 100644 --- a/store/file/file.go +++ b/store/file/file.go @@ -1,26 +1,12 @@ package file import ( - "bufio" - "bytes" - "encoding/json" "errors" - "fmt" - "io" - "io/fs" - "io/ioutil" - "os" - "path" "path/filepath" - "strings" "sync" - "time" - "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/go-yaml/yaml" ) var ( @@ -61,109 +47,6 @@ func NewFileProboCollectorStore(dirname string) (*FileProboCollectorStore, error return s, nil } -func (s *FileProboCollectorStore) GetQuizzesDir() string { - return s.quizzesDir -} - -func (s *FileProboCollectorStore) GetCollectionsDir() string { - return s.collectionsDir -} - -func (s *FileProboCollectorStore) reindexQuizzes() error { - files, err := ioutil.ReadDir(s.quizzesDir) - if err != nil { - return err - } - - markdownFiles := make([]fs.FileInfo, 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 { - s.WriteMetaHeaderToFile(filename, &models.Meta{ - ID: q.ID, - CreatedAt: time.Now(), - }) - } - s.SetQuizPath(q, fullPath) - } - - return nil -} - -func (s *FileProboCollectorStore) reindexCollections() error { - files, err := ioutil.ReadDir(s.collectionsDir) - if err != nil { - return err - } - - jsonFiles := make([]fs.FileInfo, 0) - - 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 { - 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) Reindex() error { s.memoryStore = memory.NewMemoryProboCollectorStore( sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), @@ -184,501 +67,3 @@ func (s *FileProboCollectorStore) Reindex() error { return nil } - -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 { - return nil, err - } - - 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) 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(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 (s *FileProboCollectorStore) GetCollectionPath(collection *models.Collection) (string, error) { - s.lock.RLock() - defer s.lock.RUnlock() - - path, ok := s.collectionsPaths[collection.ID] - if !ok { - return "", errors.New(fmt.Sprintf("Path not found for collection ID %v", collection.ID)) - } - return path, nil -} - -func (s *FileProboCollectorStore) SetQuizPath(quiz *models.Quiz, path string) string { - s.lock.Lock() - defer s.lock.Unlock() - - s.quizzesPaths[quiz.ID] = path - - return path -} - -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 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 := ioutil.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) 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 { - return nil, err - } - - err = s.createOrUpdateCollectionFile(collection) - if err != nil { - return nil, err - } - - return s.memoryStore.ReadCollectionByID(collection.ID) -} - -func (s *FileProboCollectorStore) UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, error) { - 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) removeMetaFromFile(filename string) (*models.Meta, error) { - file, err := os.Open(path.Join(s.quizzesDir, filename)) - if err != nil { - return nil, err - } - defer file.Close() - - var buffer bytes.Buffer - - reader := bufio.NewReader(file) - - var meta models.Meta - var line string - var sb strings.Builder - for { - line, err = reader.ReadString('\n') - if err != nil { - if err == io.EOF { - break - } - return nil, err - } - - if strings.TrimSpace(line) == "---" { - break - } - } - - for { - line, err = reader.ReadString('\n') - if err != nil { - if err == io.EOF { - break - } - return nil, err - } - - if strings.TrimSpace(line) == "---" { - break - } - - sb.WriteString(line) - } - - err = yaml.Unmarshal([]byte(sb.String()), &meta) - if err != nil { - return nil, err - } - - _, err = io.Copy(&buffer, reader) - if err != nil { - return nil, err - } - - file, err = os.Create(path.Join(s.quizzesDir, filename)) - if err != nil { - return nil, err - } - defer file.Close() - - _, err = io.Copy(file, &buffer) - if err != nil { - return nil, err - } - - 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 (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() - - _, err = file.Write([]byte(json)) - if err != nil { - return err - } - - s.SetCollectionPath(collection, 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 -} diff --git a/store/file/testdata/quizzes/quiz_5.md b/store/file/testdata/quizzes/quiz_5.md index 6518986..5a30fad 100644 --- a/store/file/testdata/quizzes/quiz_5.md +++ b/store/file/testdata/quizzes/quiz_5.md @@ -1,6 +1,7 @@ --- -id: e4ee7eb2-5608-4709-ad85-1381b457de35 -created_at: !!timestamp 2023-10-16T12:47:06.100160075+02:00 +id: 9243c924-5a91-4fa1-9cf3-db9ce1e19ca4 +created_at: !!timestamp 2023-10-28T20:49:22.688075744+02:00 +updated_at: !!timestamp 0001-01-01T00:00:00Z --- This quiz is initially without metadata. diff --git a/store/memory/memory.go b/store/memory/memory.go index e42cfea..5672f3f 100644 --- a/store/memory/memory.go +++ b/store/memory/memory.go @@ -140,21 +140,30 @@ func (s *MemoryProboCollectorStore) deleteQuiz(id string) (*models.Quiz, error) return nil, fmt.Errorf("Trying to delete a quiz that doesn't exist in memory (ID: %v)", id) } - s.quizzes[id] = nil - s.quizzesHashes[quiz.Hash] = nil + delete(s.quizzes, id) + delete(s.quizzesHashes, quiz.Hash) return quiz, nil - } func (s *MemoryProboCollectorStore) ReadAllQuizzes() ([]*models.Quiz, error) { result := make([]*models.Quiz, 0) for id := range s.quizzes { - result = append(result, s.getQuizFromID(id)) + if quiz := s.getQuizFromID(id); quiz != nil { + result = append(result, quiz) + } } return result, nil } +func (s *MemoryProboCollectorStore) ReadQuizByID(id string) (*models.Quiz, error) { + quiz := s.getQuizFromID(id) + if quiz == nil { + return nil, fmt.Errorf("Quiz with ID %s was not found in the store.", id) + } + return quiz, nil +} + func (s *MemoryProboCollectorStore) ReadQuizByHash(hash string) (*models.Quiz, error) { quiz := s.getQuizFromHash(hash) if quiz == nil { @@ -236,7 +245,7 @@ func (s *MemoryProboCollectorStore) createOrUpdateQuiz(r *client.CreateUpdateQui q := s.getQuestionFromHash(questionHash) if q == nil { // if the question is not in the store then we should add it q = s.createQuestionFromHash(questionHash, &models.Question{ - ID: uuid.New().String(), + Meta: models.Meta{ID: uuid.New().String()}, Text: s.parseTextForTags(r.Quiz.Question.Text, &quiz.Tags), }) } @@ -274,14 +283,16 @@ func (s *MemoryProboCollectorStore) UpdateQuiz(r *client.CreateUpdateQuizRequest return s.createOrUpdateQuiz(r, id) } -func (s *MemoryProboCollectorStore) DeleteQuiz(id string) (*models.Quiz, error) { - return s.deleteQuiz(id) +func (s *MemoryProboCollectorStore) DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error) { + return s.deleteQuiz(r.ID) } func (s *MemoryProboCollectorStore) ReadAllCollections() ([]*models.Collection, error) { result := make([]*models.Collection, 0) for id := range s.collections { - result = append(result, s.getCollectionFromID(id)) + if collection := s.getCollectionFromID(id); collection != nil { + result = append(result, collection) + } } return result, nil } @@ -296,13 +307,34 @@ func (s *MemoryProboCollectorStore) UpdateCollection(r *client.CreateUpdateColle } func (s *MemoryProboCollectorStore) ReadCollectionByID(id string) (*models.Collection, error) { + if id == "" { + return nil, errors.New("ID should not be an empty string!") + } collection := s.getCollectionFromID(id) if collection == nil { - return nil, fmt.Errorf("Collection ID %v not found in the store", collection.ID) + return nil, fmt.Errorf("Collection ID %v not found in the store", id) } return collection, nil } +func (s *MemoryProboCollectorStore) DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error) { + return s.deleteCollection(r.ID) +} + +func (s *MemoryProboCollectorStore) deleteCollection(id string) (*models.Collection, error) { + s.lock.Lock() + defer s.lock.Unlock() + + collection := s.collections[id] + if collection == nil { + return nil, fmt.Errorf("Trying to delete a collection that doesn't exist in memory (ID: %v)", id) + } + + delete(s.collections, id) + + return collection, nil +} + func (s *MemoryProboCollectorStore) createOrUpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error) { var collection *models.Collection @@ -323,21 +355,21 @@ func (s *MemoryProboCollectorStore) createOrUpdateCollection(r *client.CreateUpd collection.Name = r.Collection.Name collection.Query = r.Collection.Query - collection.IDs = s.query(collection.Query) + collection.Quizzes = s.query(collection.Query) return s.createCollectionFromID(id, collection), true, nil } -func (s *MemoryProboCollectorStore) query(query string) []string { +func (s *MemoryProboCollectorStore) query(query string) []*models.Quiz { s.lock.Lock() defer s.lock.Unlock() - result := make([]string, 0) + result := make([]*models.Quiz, 0) - for id, quiz := range s.quizzes { + for _, quiz := range s.quizzes { for _, tag := range quiz.Tags { if query == tag.Name { - result = append(result, id) + result = append(result, quiz) break } } diff --git a/store/memory/memory_test.go b/store/memory/memory_test.go index 35b3e19..201d8ad 100644 --- a/store/memory/memory_test.go +++ b/store/memory/memory_test.go @@ -18,6 +18,7 @@ func TestRunner(t *testing.T) { prettytest.Run( t, new(testSuite), + new(collectionTestSuite), ) } @@ -131,70 +132,11 @@ func (t *testSuite) TestDeleteQuiz() { }, }) - deletedQuiz, err := store.DeleteQuiz(quiz.ID) + deletedQuiz, err := store.DeleteQuiz(&client.DeleteQuizRequest{ID: quiz.ID}) t.Equal(quiz.ID, deletedQuiz.ID, "Returned deleted quiz ID should be equal to the request") t.Nil(err, fmt.Sprintf("The update returned an error: %v", err)) - _, err = store.ReadQuizByHash(quiz.Hash) + _, err = store.ReadQuizByHash(deletedQuiz.Hash) t.True(err != nil, "Reading a non existent quiz should return an error") } - -func (t *testSuite) TestUpdateCollection() { - store := NewMemoryProboCollectorStore( - sha256.NewDefault256Hasher(sha256.DefaultSHA256HashingFn), - ) - - quiz_1, _ := store.CreateQuiz( - &client.CreateUpdateQuizRequest{ - Quiz: &client.Quiz{ - Question: &client.Question{Text: "Question text with #tag1."}, - Answers: []*client.Answer{ - {Text: "Answer 1", Correct: true}, - {Text: "Answer 2", Correct: false}, - {Text: "Answer 3", Correct: false}, - {Text: "Answer 4", Correct: false}, - }, - }, - }) - - quiz_2, _ := store.CreateQuiz( - &client.CreateUpdateQuizRequest{ - Quiz: &client.Quiz{ - Question: &client.Question{Text: "Another question text with #tag1."}, - Answers: []*client.Answer{ - {Text: "Answer 1", Correct: true}, - {Text: "Answer 2", Correct: false}, - {Text: "Answer 3", Correct: false}, - {Text: "Answer 4", Correct: false}, - }, - }, - }) - - collection, _ := store.CreateCollection( - &client.CreateUpdateCollectionRequest{ - Collection: &client.Collection{ - Name: "MyCollection", - }, - }) - - updatedCollection, updated, err := store.UpdateCollection( - &client.CreateUpdateCollectionRequest{ - Collection: &client.Collection{ - Name: "MyUpdatedCollection", - Query: "#tag1", - }, - }, collection.ID) - - t.Nil(err, fmt.Sprintf("The update returned an error: %v", err)) - - if !t.Failed() { - t.True(updated) - t.Equal("MyUpdatedCollection", updatedCollection.Name) - t.True(len(updatedCollection.IDs) == 2) - if !t.Failed() { - t.Equal(quiz_1.ID, updatedCollection.IDs[0]) - t.Equal(quiz_2.ID, updatedCollection.IDs[1]) - } - } -} diff --git a/store/store.go b/store/store.go index 2a621eb..e7253d5 100644 --- a/store/store.go +++ b/store/store.go @@ -5,16 +5,56 @@ import ( "git.andreafazzi.eu/andrea/probo/models" ) -type ProboCollectorStore interface { +type QuizReader interface { ReadAllQuizzes() ([]*models.Quiz, error) + ReadQuizByID(id string) (*models.Quiz, error) ReadQuizByHash(hash string) (*models.Quiz, error) - CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error) - UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, error) - DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error) +} +type QuizWriter interface { + CreateQuiz(r *client.CreateUpdateQuizRequest) (*models.Quiz, error) + UpdateQuiz(r *client.CreateUpdateQuizRequest, id string) (*models.Quiz, bool, error) + DeleteQuiz(r *client.DeleteQuizRequest) (*models.Quiz, error) +} + +type CollectionReader interface { ReadAllCollections() ([]*models.Collection, error) ReadCollectionByID(id string) (*models.Collection, error) +} + +type CollectionWriter interface { CreateCollection(r *client.CreateUpdateCollectionRequest) (*models.Collection, error) - UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, error) + UpdateCollection(r *client.CreateUpdateCollectionRequest, id string) (*models.Collection, bool, error) DeleteCollection(r *client.DeleteCollectionRequest) (*models.Collection, error) } + +type ParticipantReader interface { + ReadAllParticipants() ([]*models.Participant, error) + ReadParticipantByID(id string) (*models.Participant, error) +} + +type ParticipantWriter interface { + CreateParticipant(r *client.CreateUpdateParticipantRequest) (*models.Participant, error) + UpdateParticipant(r *client.CreateUpdateParticipantRequest, id string) (*models.Participant, bool, error) + DeleteParticipant(r *client.DeleteParticipantRequest) (*models.Participant, error) +} + +type ExamReader interface { + ReadAllExams() ([]*models.Exam, error) + ReadExamByID(id string) (*models.Exam, error) +} + +type ExamWriter interface { + CreateExam(r *client.CreateUpdateExamRequest) (*models.Exam, error) + UpdateExam(r *client.CreateUpdateExamRequest, id string) (*models.Exam, bool, error) + DeleteExam(r *client.DeleteExamRequest) (*models.Exam, error) +} + +type ProboCollectorStore interface { + QuizReader + QuizWriter + CollectionReader + CollectionWriter + ParticipantReader + ParticipantWriter +}