get database migrations up and running
This commit is contained in:
		
							
								
								
									
										34
									
								
								book/book.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"` | ||||
| } | ||||
|   | ||||
| @@ -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 | ||||
| func main() { | ||||
| 	subfs, _ := fs.Sub(frontend.Static, "files") | ||||
| 	fmt.Println(http.ListenAndServe(":8080", http.FileServer(http.FS(subfs)))) | ||||
| 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() { | ||||
| 	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)) | ||||
| } | ||||
|   | ||||
| @@ -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) | ||||
| ) | ||||
| @@ -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,5 +2,7 @@ version: "3.8" | ||||
| services: | ||||
|   mysql: | ||||
|     image: mysql:5.7 | ||||
|     ports: | ||||
|       - 3306:3306 | ||||
|     environment: | ||||
|       - MYSQL_ROOT_PASSWORD=KigYBNCT9IU5XyB3ehzMLFWyI | ||||
|   | ||||
| @@ -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") | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user