get database migrations up and running

This commit is contained in:
David 2021-07-02 18:13:58 -04:00
parent da239cf9ad
commit 04506ed01f
6 changed files with 227 additions and 43 deletions

View File

@ -1,21 +1,21 @@
package book package book
type Book struct { type Book struct {
ID int ID int `json:"-"`
Title string Title string `json:"title"`
Authors []string Authors []string `json:"authors"`
SortAuthor string SortAuthor string `json:"sortAuthor"`
ISBN10 string ISBN10 string `json:"isbn-10"`
ISBN13 string ISBN13 string `json:"isbn-13"`
Format string Format string `json:"format"`
Genre string Genre string `json:"genre"`
Publisher string Publisher string `json:"publisher"`
Series string Series string `json:"series"`
Volume string Volume string `json:"volume"`
Year string Year string `json:"year"`
Signed bool Signed bool `json:"signed"`
Description string Description string `json:"description"`
Notes string Notes string `json:"notes"`
OnLoan string OnLoan string `json:"onLoan"`
CoverURL string CoverURL string `json:"coverURL"`
} }

View File

@ -1,15 +1,80 @@
package main package main
import ( import (
"fmt" "context"
"encoding/json"
"io/fs" "io/fs"
"log"
"net/http" "net/http"
"git.yetaga.in/alazyreader/library/book"
"git.yetaga.in/alazyreader/library/database"
"git.yetaga.in/alazyreader/library/frontend" "git.yetaga.in/alazyreader/library/frontend"
) )
// test 3 type Library interface {
func main() { GetAllBooks(context.Context) ([]book.Book, error)
subfs, _ := fs.Sub(frontend.Static, "files") }
fmt.Println(http.ListenAndServe(":8080", http.FileServer(http.FS(subfs))))
type Router struct {
static fs.FS
lib Library
}
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/api" {
APIHandler(r.lib).ServeHTTP(w, req)
return
}
StaticHandler(r.static).ServeHTTP(w, req)
return
}
func APIHandler(l Library) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
books, err := l.GetAllBooks(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
b, err := json.Marshal(books)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(b)
w.Write([]byte("\n"))
})
}
func StaticHandler(f fs.FS) http.Handler {
return http.FileServer(http.FS(f))
}
func main() {
f, err := frontend.Root()
if err != nil {
log.Fatalln(err)
}
lib, err := database.NewMySQLConnection("root", "KigYBNCT9IU5XyB3ehzMLFWyI", "127.0.0.1", "3306", "library")
if err != nil {
log.Fatalln(err)
}
err = lib.PrepareDatabase(context.Background())
if err != nil {
log.Fatalln(err)
}
latest, run, err := lib.RunMigrations(context.Background())
if err != nil {
log.Fatalln(err)
}
log.Printf("latest migration: %d; migrations run: %d", latest, run)
r := &Router{
static: f,
lib: lib,
}
log.Println("Listening on http://localhost:8080/")
log.Fatalln(http.ListenAndServe(":8080", r))
} }

View File

@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS books(
id INT NOT NULL AUTO_INCREMENT,
title VARCHAR(1024),
authors VARCHAR(1024),
sortauthor VARCHAR(1024),
isbn10 VARCHAR(10),
isbn13 VARCHAR(13),
format VARCHAR(255),
genre VARCHAR(255),
publisher VARCHAR(255),
series VARCHAR(255),
volume VARCHAR(255),
year VARCHAR(10),
signed BOOLEAN,
description TEXT,
notes TEXT,
onloan VARCHAR(255),
coverurl VARCHAR(1024),
PRIMARY KEY (id)
)

View File

@ -25,6 +25,7 @@ type migration struct {
type MySQL struct { type MySQL struct {
connection *sql.DB connection *sql.DB
tableName string
versionTable string versionTable string
migrationsDirectory string migrationsDirectory string
} }
@ -37,8 +38,9 @@ func NewMySQLConnection(user, pass, host, port, db string) (*MySQL, error) {
} }
return &MySQL{ return &MySQL{
connection: connection, connection: connection,
tableName: "books",
versionTable: "migrations", versionTable: "migrations",
migrationsDirectory: "/migrations/mysql", migrationsDirectory: "migrations/mysql",
}, nil }, nil
} }
@ -72,26 +74,26 @@ func (m *MySQL) GetLatestMigration(ctx context.Context) (int, error) {
} }
var latestMigration int var latestMigration int
err := m.connection.QueryRowContext(ctx, "SELECT MAX(id) FROM "+m.versionTable).Scan(&latestMigration) err := m.connection.QueryRowContext(ctx, "SELECT COALESCE(MAX(id), 0) FROM "+m.versionTable).Scan(&latestMigration)
return latestMigration, err return latestMigration, err
} }
func (m *MySQL) RunMigrations(ctx context.Context) (int, error) { func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) {
if m.connection == nil || m.migrationsDirectory == "" || m.versionTable == "" { if m.connection == nil || m.migrationsDirectory == "" || m.versionTable == "" {
return 0, fmt.Errorf("uninitialized mysql client") return 0, 0, fmt.Errorf("uninitialized mysql client")
} }
var migrations map[int]migration migrations := map[int]migration{}
dir, err := migrationsFS.ReadDir(m.migrationsDirectory) dir, err := migrationsFS.ReadDir(m.migrationsDirectory)
if err != nil { if err != nil {
return 0, nil return 0, 0, err
} }
for f := range dir { for f := range dir {
if dir[f].Type().IsRegular() { if dir[f].Type().IsRegular() {
mig := migration{} mig := migration{}
id, name, err := parseMigrationFileName(dir[f].Name()) id, name, err := parseMigrationFileName(dir[f].Name())
if err != nil { if err != nil {
return 0, err return 0, 0, err
} }
mig.id, mig.name = id, name mig.id, mig.name = id, name
mig.content, err = fs.ReadFile(migrationsFS, m.migrationsDirectory+"/"+dir[f].Name()) mig.content, err = fs.ReadFile(migrationsFS, m.migrationsDirectory+"/"+dir[f].Name())
@ -101,41 +103,43 @@ func (m *MySQL) RunMigrations(ctx context.Context) (int, error) {
latestMigrationRan, err := m.GetLatestMigration(ctx) latestMigrationRan, err := m.GetLatestMigration(ctx)
if err != nil { if err != nil {
return 0, err return 0, 0, err
} }
// exit if nothing to do (that is, there's no greater migration ID) // exit if nothing to do (that is, there's no greater migration ID)
if _, ok := migrations[latestMigrationRan+1]; !ok { if _, ok := migrations[latestMigrationRan+1]; !ok {
return latestMigrationRan, nil return latestMigrationRan, 0, nil
} }
// loop over and apply migrations if required // loop over and apply migrations if required
tx, err := m.connection.BeginTx(ctx, nil) tx, err := m.connection.BeginTx(ctx, nil)
if err != nil { if err != nil {
return latestMigrationRan, err return latestMigrationRan, 0, err
} }
migrationsRun := 0
for migrationsToRun := true; migrationsToRun; _, migrationsToRun = migrations[latestMigrationRan+1] { for migrationsToRun := true; migrationsToRun; _, migrationsToRun = migrations[latestMigrationRan+1] {
mig := migrations[latestMigrationRan+1] mig := migrations[latestMigrationRan+1]
_, err := tx.ExecContext(ctx, string(mig.content)) _, err := tx.ExecContext(ctx, string(mig.content))
if err != nil { if err != nil {
nestederr := tx.Rollback() nestederr := tx.Rollback()
if nestederr != nil { if nestederr != nil {
return latestMigrationRan, nestederr return latestMigrationRan, migrationsRun, nestederr
} }
return latestMigrationRan, err return latestMigrationRan, migrationsRun, err
} }
_, err = tx.ExecContext(ctx, "INSERT INTO "+m.versionTable+" (id, name, datetime) VALUES (?, ?, ?)", mig.id, mig.name, time.Now()) _, err = tx.ExecContext(ctx, "INSERT INTO "+m.versionTable+" (id, name, datetime) VALUES (?, ?, ?)", mig.id, mig.name, time.Now())
if err != nil { if err != nil {
nestederr := tx.Rollback() nestederr := tx.Rollback()
if nestederr != nil { if nestederr != nil {
return latestMigrationRan, nestederr return latestMigrationRan, migrationsRun, nestederr
} }
return latestMigrationRan, err return latestMigrationRan, migrationsRun, err
} }
latestMigrationRan = latestMigrationRan + 1 latestMigrationRan = latestMigrationRan + 1
migrationsRun = migrationsRun + 1
} }
err = tx.Commit() err = tx.Commit()
return latestMigrationRan, err return latestMigrationRan, migrationsRun, err
} }
func (m *MySQL) GetAllBooks(ctx context.Context) ([]book.Book, error) { func (m *MySQL) GetAllBooks(ctx context.Context) ([]book.Book, error) {
@ -144,7 +148,25 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]book.Book, error) {
} }
books := []book.Book{} books := []book.Book{}
rows, err := m.connection.QueryContext(ctx, "SELECT id, title FROM books") rows, err := m.connection.QueryContext(ctx, `
SELECT id,
title,
authors,
sortauthor,
isbn10,
isbn13,
format,
genre,
publisher,
series,
volume,
year,
signed,
description,
notes,
onloan,
coverurl
FROM `+m.tableName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -152,10 +174,18 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]book.Book, error) {
for rows.Next() { for rows.Next() {
b := book.Book{} b := book.Book{}
err := rows.Scan(&b.ID, &b.Title) var authors string
err := rows.Scan(
&b.ID, &b.Title, &authors,
&b.SortAuthor, &b.ISBN10, &b.ISBN13,
&b.Format, &b.Genre, &b.Publisher,
&b.Series, &b.Volume, &b.Year,
&b.Signed, &b.Description, &b.Notes,
&b.OnLoan, &b.CoverURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
b.Authors = strings.Split(authors, ";")
books = append(books, b) books = append(books, b)
} }
@ -167,7 +197,28 @@ func (m *MySQL) AddBook(ctx context.Context, b *book.Book) error {
return fmt.Errorf("uninitialized mysql client") return fmt.Errorf("uninitialized mysql client")
} }
res, err := m.connection.ExecContext(ctx, "INSERT INTO books (title) VALUES (?)", b.Title) res, err := m.connection.ExecContext(ctx, `
INSERT INTO `+m.tableName+`
(title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, series, volume, year, signed, description, notes, onloan, coverurl)
VALUES
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, )`,
b.Title,
strings.Join(b.Authors, ";"),
b.SortAuthor,
b.ISBN10,
b.ISBN13,
b.Format,
b.Genre,
b.Publisher,
b.Series,
b.Volume,
b.Year,
b.Signed,
b.Description,
b.Notes,
b.OnLoan,
b.CoverURL,
)
if err != nil { if err != nil {
return err return err
} }
@ -185,8 +236,47 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *book.Book) error {
if m.connection == nil { if m.connection == nil {
return fmt.Errorf("uninitialized mysql client") return fmt.Errorf("uninitialized mysql client")
} }
if old.ID != new.ID {
return fmt.Errorf("cannot change book ID")
}
res, err := m.connection.ExecContext(ctx, "UPDATE books SET title=? WHERE id=?", new.Title, old.ID) res, err := m.connection.ExecContext(ctx, `
UPDATE `+m.tableName+`
SET id=?
title=?
authors=?
sortauthor=?
isbn10=?
isbn13=?
format=?
genre=?
publisher=?
series=?
volume=?
year=?
signed=?
description=?
notes=?
onloan=?
coverurl=?
WHERE id=?`,
new.Title,
strings.Join(new.Authors, ";"),
new.SortAuthor,
new.ISBN10,
new.ISBN13,
new.Format,
new.Genre,
new.Publisher,
new.Series,
new.Volume,
new.Year,
new.Signed,
new.Description,
new.Notes,
new.OnLoan,
new.CoverURL,
old.ID)
if err != nil { if err != nil {
return err return err
} }

View File

@ -2,5 +2,7 @@ version: "3.8"
services: services:
mysql: mysql:
image: mysql:5.7 image: mysql:5.7
ports:
- 3306:3306
environment: environment:
- MYSQL_ROOT_PASSWORD=KigYBNCT9IU5XyB3ehzMLFWyI - MYSQL_ROOT_PASSWORD=KigYBNCT9IU5XyB3ehzMLFWyI

View File

@ -1,6 +1,13 @@
package frontend package frontend
import "embed" import (
"embed"
"io/fs"
)
//go:embed files //go:embed files
var Static embed.FS var static embed.FS
func Root() (fs.FS, error) {
return fs.Sub(static, "files")
}