Browse Source

get database migrations up and running

pull/1/head
David Ashby 3 months ago
parent
commit
04506ed01f
  1. 34
      book/book.go
  2. 73
      cmd/serve/main.go
  3. 20
      database/migrations/mysql/01-init.sql
  4. 128
      database/mysql.go
  5. 2
      docker-compose.yml
  6. 11
      frontend/frontend.go

34
book/book.go

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

73
cmd/serve/main.go

@ -1,15 +1,80 @@
package main
import (
"fmt"
"context"
"encoding/json"
"io/fs"
"log"
"net/http"
"git.yetaga.in/alazyreader/library/book"
"git.yetaga.in/alazyreader/library/database"
"git.yetaga.in/alazyreader/library/frontend"
)
// test 3
type Library interface {
GetAllBooks(context.Context) ([]book.Book, error)
}
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() {
subfs, _ := fs.Sub(frontend.Static, "files")
fmt.Println(http.ListenAndServe(":8080", http.FileServer(http.FS(subfs))))
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))
}

20
database/migrations/mysql/01-init.sql

@ -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)
)

128
database/mysql.go

@ -25,6 +25,7 @@ type migration struct {
type MySQL struct {
connection *sql.DB
tableName string
versionTable string
migrationsDirectory string
}
@ -37,8 +38,9 @@ func NewMySQLConnection(user, pass, host, port, db string) (*MySQL, error) {
}
return &MySQL{
connection: connection,
tableName: "books",
versionTable: "migrations",
migrationsDirectory: "/migrations/mysql",
migrationsDirectory: "migrations/mysql",
}, nil
}
@ -72,26 +74,26 @@ func (m *MySQL) GetLatestMigration(ctx context.Context) (int, error) {
}
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
}
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 == "" {
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)
if err != nil {
return 0, nil
return 0, 0, err
}
for f := range dir {
if dir[f].Type().IsRegular() {
mig := migration{}
id, name, err := parseMigrationFileName(dir[f].Name())
if err != nil {
return 0, err
return 0, 0, err
}
mig.id, mig.name = id, 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)
if err != nil {
return 0, err
return 0, 0, err
}
// exit if nothing to do (that is, there's no greater migration ID)
if _, ok := migrations[latestMigrationRan+1]; !ok {
return latestMigrationRan, nil
return latestMigrationRan, 0, nil
}
// loop over and apply migrations if required
tx, err := m.connection.BeginTx(ctx, nil)
if err != nil {
return latestMigrationRan, err
return latestMigrationRan, 0, err
}
migrationsRun := 0
for migrationsToRun := true; migrationsToRun; _, migrationsToRun = migrations[latestMigrationRan+1] {
mig := migrations[latestMigrationRan+1]
_, err := tx.ExecContext(ctx, string(mig.content))
if err != nil {
nestederr := tx.Rollback()
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())
if err != nil {
nestederr := tx.Rollback()
if nestederr != nil {
return latestMigrationRan, nestederr
return latestMigrationRan, migrationsRun, nestederr
}
return latestMigrationRan, err
return latestMigrationRan, migrationsRun, err
}
latestMigrationRan = latestMigrationRan + 1
migrationsRun = migrationsRun + 1
}
err = tx.Commit()
return latestMigrationRan, err
return latestMigrationRan, migrationsRun, err
}
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{}
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 {
return nil, err
}
@ -152,10 +174,18 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]book.Book, error) {
for rows.Next() {
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 {
return nil, err
}
b.Authors = strings.Split(authors, ";")
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")
}
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 {
return err
}
@ -185,8 +236,47 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *book.Book) error {
if m.connection == nil {
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 {
return err
}

2
docker-compose.yml

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

11
frontend/frontend.go

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

Loading…
Cancel
Save