summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorxengineering <me@xengineering.eu>2024-05-08 21:53:13 +0200
committerxengineering <me@xengineering.eu>2024-05-08 22:26:00 +0200
commit0ba1a7661a81200db98e40149eef1e39fd22f407 (patch)
tree387b0d130fe444e964629d1a3168cfca050e70ca
parent5396273447260b88e9aea77bb3347ed8ad4b1ae5 (diff)
downloadceres-0ba1a7661a81200db98e40149eef1e39fd22f407.tar
ceres-0ba1a7661a81200db98e40149eef1e39fd22f407.tar.zst
ceres-0ba1a7661a81200db98e40149eef1e39fd22f407.zip
Introduce model.Transaction()
It is a very common pattern that some function needs to access the database and wants to wrap all the actions into one transaction. The advantage of a transaction is that it is ACID: - atomic - consistent - isolated - durable In Go it is required to request a new transaction, execute functionality on it and handle rollback or commit of this transaction based on the success of the operation. All this and the error handling can be written down in the model.Transaction() function exactly once. The full signature of it is: func Transaction(f func(*sql.Tx) error) error It requires a function or closure passed as argument which takes the transaction (*sql.Tx) and returns an error which might be nil. This is very generic. It is applied to: - injecting test data - database migrations - data read requests - data write requests
-rw-r--r--controller/recipe.go6
-rw-r--r--model/database.go62
-rw-r--r--model/object.go15
-rw-r--r--model/recipe_test.go129
-rw-r--r--view/recipe.go2
5 files changed, 100 insertions, 114 deletions
diff --git a/controller/recipe.go b/controller/recipe.go
index 9427b0d..9529b2a 100644
--- a/controller/recipe.go
+++ b/controller/recipe.go
@@ -19,7 +19,7 @@ func RecipeCreate(w http.ResponseWriter, r *http.Request) {
recipe.Created = recipe.LastChanged
var obj model.Object = &recipe
- err := model.SafeCrud(obj.Create)
+ err := model.Transaction(obj.Create)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -50,7 +50,7 @@ func RecipeUpdate(w http.ResponseWriter, r *http.Request) {
recipe.LastChanged = fmt.Sprint(time.Now().Unix())
var obj model.Object = &recipe
- err = model.SafeCrud(obj.Update)
+ err = model.Transaction(obj.Update)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -64,7 +64,7 @@ func RecipeDelete(w http.ResponseWriter, r *http.Request) {
recipe.Id = mux.Vars(r)[`id`]
var obj model.Object = &recipe
- err := model.SafeCrud(obj.Delete)
+ err := model.Transaction(obj.Delete)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
diff --git a/model/database.go b/model/database.go
index d816163..44792fe 100644
--- a/model/database.go
+++ b/model/database.go
@@ -4,6 +4,7 @@ import (
"database/sql"
"embed"
"log"
+ "fmt"
_ "github.com/mattn/go-sqlite3"
)
@@ -35,37 +36,53 @@ func InitDatabase(path string) {
log.Fatal(err)
}
- // FIXME roll back migration on error
query, err := GetSql(`migrate`)
if err != nil {
log.Fatal(err)
}
- _, err = db.Exec(query)
+
+ err = Transaction(func(tx *sql.Tx) error {
+ _, err := tx.Exec(query)
+ if err != nil {
+ return err
+ }
+ return nil
+ })
if err != nil {
- log.Fatal(err)
+ log.Fatalf("Migration failed: %v\n", err)
}
}
-func InjectTestRecipes() {
- recipes := RecipeTestData()
-
- tx, err := NewTx()
+func Transaction(f func(*sql.Tx) error) error {
+ tx, err := db.Begin()
if err != nil {
- log.Fatalf("Failed to inject test recipes: %v\n", err)
+ return err
}
- for _, recipe := range recipes {
- err = recipe.Create(tx)
- if err != nil {
- Rollback(tx)
- log.Fatalf("Failed to inject test recipe: %v\n", err)
+ err = f(tx)
+ if err != nil {
+ rollbackErr := tx.Rollback()
+ if rollbackErr != nil {
+ return fmt.Errorf("Failed rollback '%w' after failed transaction '%w'", rollbackErr, err)
}
}
- err = tx.Commit()
- if err != nil {
- log.Fatalf("Failed to inject test recipe: %v\n", err)
- }
+ return tx.Commit()
+}
+
+func InjectTestRecipes() {
+ Transaction(func(tx *sql.Tx) error {
+ recipes := RecipeTestData()
+
+ for _, recipe := range recipes {
+ err := recipe.Create(tx)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
}
func CloseDatabase() {
@@ -76,14 +93,3 @@ func CloseDatabase() {
log.Println("Closed database")
}
}
-
-func NewTx() (*sql.Tx, error) {
- return db.Begin()
-}
-
-func Rollback(tx *sql.Tx) {
- err := tx.Rollback()
- if err != nil {
- log.Printf("Failed to rollback transaction: %v\n", err)
- }
-}
diff --git a/model/object.go b/model/object.go
index bcbba3e..63ef419 100644
--- a/model/object.go
+++ b/model/object.go
@@ -10,18 +10,3 @@ type Object interface {
Update(tx *sql.Tx) error
Delete(tx *sql.Tx) error
}
-
-func SafeCrud(crud func(tx *sql.Tx) error) error {
- tx, err := NewTx()
- if err != nil {
- return err
- }
-
- err = crud(tx)
- if err != nil {
- Rollback(tx)
- return err
- }
-
- return tx.Commit()
-}
diff --git a/model/recipe_test.go b/model/recipe_test.go
index 7b6c14c..74ccfa2 100644
--- a/model/recipe_test.go
+++ b/model/recipe_test.go
@@ -1,11 +1,12 @@
package model
import (
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
"reflect"
"testing"
- "path/filepath"
- "os"
- "fmt"
)
func TestRecipeCrud(t *testing.T) {
@@ -25,68 +26,62 @@ func TestRecipeCrud(t *testing.T) {
InitDatabase(filepath.Join(storage.Path, "ceres.sqlite3"))
defer CloseDatabase()
- tx, err := NewTx()
- if err != nil {
- t.Fatalf("Failed to inject test recipes: %v\n", err)
- }
-
- var original, readback, update, updated, deleted Recipe
-
- recipes := RecipeTestData()
- original = recipes[0]
- update = recipes[1]
-
- err = original.Create(tx)
- if err != nil {
- t.Fatalf("Failed to create test recipe in DB: %v\n", err)
- }
-
- readback.Id = original.Id
- err = readback.Read(tx)
- if err != nil {
- t.Fatalf("Failed to read test recipe from DB: %v\n", err)
- }
-
- if !reflect.DeepEqual(original, readback) {
- t.Fatalf("Recipes did not match after create / read cycle:\n"+
- "Before: %s\nAfter: %s\n", original, readback)
- }
-
- update.Id = original.Id
-
- err = update.Update(tx)
- if err != nil {
- t.Fatalf("Failed to update recipe: %v\n", err)
- }
-
- updated.Id = original.Id
- err = updated.Read(tx)
- if err != nil {
- t.Fatalf("Failed to read back updated recipe: %v\n", err)
- }
-
- if !reflect.DeepEqual(update, updated) {
- t.Fatalf("Recipes did not match after update / read cycle:\n"+
- "Update: %s\nUpdated: %s\n", update, updated)
- }
-
- if reflect.DeepEqual(updated, original) {
- t.Fatalf("Updated and original recipe match")
- }
-
- err = updated.Delete(tx)
- if err != nil {
- t.Fatalf("Failed to delete updated recipe: %v\n", err)
- }
-
- deleted.Id = updated.Id
- err = deleted.Read(tx)
- if err == nil {
- t.Fatalf("Was able to read back deleted recipe")
- }
-
- err = tx.Commit()
- if err != nil {
- t.Fatalf("Unable to commit test transaction")
- }
+ Transaction(func(tx *sql.Tx) error {
+ var original, readback, update, updated, deleted Recipe
+
+ recipes := RecipeTestData()
+ original = recipes[0]
+ update = recipes[1]
+
+ err = original.Create(tx)
+ if err != nil {
+ t.Fatalf("Failed to create test recipe in DB: %v\n", err)
+ }
+
+ readback.Id = original.Id
+ err = readback.Read(tx)
+ if err != nil {
+ t.Fatalf("Failed to read test recipe from DB: %v\n", err)
+ }
+
+ if !reflect.DeepEqual(original, readback) {
+ t.Fatalf("Recipes did not match after create / read cycle:\n"+
+ "Before: %s\nAfter: %s\n", original, readback)
+ }
+
+ update.Id = original.Id
+
+ err = update.Update(tx)
+ if err != nil {
+ t.Fatalf("Failed to update recipe: %v\n", err)
+ }
+
+ updated.Id = original.Id
+ err = updated.Read(tx)
+ if err != nil {
+ t.Fatalf("Failed to read back updated recipe: %v\n", err)
+ }
+
+ if !reflect.DeepEqual(update, updated) {
+ t.Fatalf("Recipes did not match after update / read cycle:\n"+
+ "Update: %s\nUpdated: %s\n", update, updated)
+ }
+
+ if reflect.DeepEqual(updated, original) {
+ t.Fatalf("Updated and original recipe match")
+ }
+
+ err = updated.Delete(tx)
+ if err != nil {
+ t.Fatalf("Failed to delete updated recipe: %v\n", err)
+ }
+
+ deleted.Id = updated.Id
+ err = deleted.Read(tx)
+ if err == nil {
+ t.Fatalf("Was able to read back deleted recipe")
+ }
+
+ return nil
+ })
}
diff --git a/view/recipe.go b/view/recipe.go
index ba670a2..7b9980d 100644
--- a/view/recipe.go
+++ b/view/recipe.go
@@ -13,7 +13,7 @@ func RecipeRead(w http.ResponseWriter, r *http.Request) {
recipe.Id = mux.Vars(r)[`id`]
var obj model.Object = &recipe
- err := model.SafeCrud(obj.Read)
+ err := model.Transaction(obj.Read)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return