All checks were successful
		
		
	
	ci/woodpecker/push/woodpecker Pipeline was successful
				
			Reviewed-on: #17 Co-authored-by: David Ashby <delta.mu.alpha@gmail.com> Co-committed-by: David Ashby <delta.mu.alpha@gmail.com>
		
			
				
	
	
		
			256 lines
		
	
	
		
			7.0 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			256 lines
		
	
	
		
			7.0 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package database
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"database/sql"
 | |
| 	"embed"
 | |
| 	"fmt"
 | |
| 	"io/fs"
 | |
| 	"strconv"
 | |
| 	"strings"
 | |
| 	"time"
 | |
| 
 | |
| 	"git.yetaga.in/alazyreader/library/media"
 | |
| 	_ "github.com/go-sql-driver/mysql"
 | |
| )
 | |
| 
 | |
| //go:embed migrations/mysql
 | |
| var migrationsFS embed.FS
 | |
| 
 | |
| type migration struct {
 | |
| 	id      int
 | |
| 	name    string
 | |
| 	content []byte
 | |
| }
 | |
| 
 | |
| type MySQL struct {
 | |
| 	connection          *sql.DB
 | |
| 	tableName           string
 | |
| 	versionTable        string
 | |
| 	migrationsDirectory string
 | |
| }
 | |
| 
 | |
| func NewMySQLConnection(user, pass, host, port, db string) (*MySQL, error) {
 | |
| 	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", user, pass, host, port, db) // what a strange syntax
 | |
| 	connection, err := sql.Open("mysql", dsn)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	return &MySQL{
 | |
| 		connection:          connection,
 | |
| 		tableName:           "books",
 | |
| 		versionTable:        "migrations",
 | |
| 		migrationsDirectory: "migrations/mysql",
 | |
| 	}, nil
 | |
| }
 | |
| 
 | |
| func (m *MySQL) PrepareDatabase(ctx context.Context) error {
 | |
| 	if m.connection == nil || m.migrationsDirectory == "" || m.versionTable == "" {
 | |
| 		return fmt.Errorf("uninitialized mysql client")
 | |
| 	}
 | |
| 
 | |
| 	tablecheck := fmt.Sprintf(`SELECT count(*) AS count
 | |
| 		FROM information_schema.TABLES
 | |
| 		WHERE TABLE_NAME = '%s'
 | |
| 		AND TABLE_SCHEMA in (SELECT DATABASE());`, m.versionTable)
 | |
| 	tableschema := fmt.Sprintf(`CREATE TABLE %s (
 | |
| 		id INT NOT NULL,
 | |
| 		name VARCHAR(100) NOT NULL,
 | |
| 		datetime DATE,
 | |
| 		PRIMARY KEY (id))`, m.versionTable)
 | |
| 
 | |
| 	var versionTableExists int
 | |
| 	m.connection.QueryRowContext(ctx, tablecheck).Scan(&versionTableExists)
 | |
| 	if versionTableExists != 0 {
 | |
| 		return nil
 | |
| 	}
 | |
| 	_, err := m.connection.ExecContext(ctx, tableschema)
 | |
| 	return err
 | |
| }
 | |
| 
 | |
| func (m *MySQL) GetLatestMigration(ctx context.Context) (int, error) {
 | |
| 	if m.connection == nil || m.migrationsDirectory == "" || m.versionTable == "" {
 | |
| 		return 0, fmt.Errorf("uninitialized mysql client")
 | |
| 	}
 | |
| 
 | |
| 	migrationCheck := fmt.Sprintf("SELECT COALESCE(MAX(id), 0) FROM %s", m.versionTable)
 | |
| 	var latestMigration int
 | |
| 	err := m.connection.QueryRowContext(ctx, migrationCheck).Scan(&latestMigration)
 | |
| 	return latestMigration, err
 | |
| }
 | |
| 
 | |
| func (m *MySQL) RunMigrations(ctx context.Context) (int, int, error) {
 | |
| 	if m.connection == nil || m.migrationsDirectory == "" || m.versionTable == "" {
 | |
| 		return 0, 0, fmt.Errorf("uninitialized mysql client")
 | |
| 	}
 | |
| 
 | |
| 	migrations := map[int]migration{}
 | |
| 	dir, err := migrationsFS.ReadDir(m.migrationsDirectory)
 | |
| 	if err != 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, 0, err
 | |
| 			}
 | |
| 			mig.id, mig.name = id, name
 | |
| 			mig.content, err = fs.ReadFile(migrationsFS, m.migrationsDirectory+"/"+dir[f].Name())
 | |
| 			if err != nil {
 | |
| 				return 0, 0, fmt.Errorf("failure loading migration: %w", err)
 | |
| 			}
 | |
| 			migrations[mig.id] = mig
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	latestMigrationRan, err := m.GetLatestMigration(ctx)
 | |
| 	if err != nil {
 | |
| 		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, 0, nil
 | |
| 	}
 | |
| 
 | |
| 	// loop over and apply migrations if required
 | |
| 	tx, err := m.connection.BeginTx(ctx, nil)
 | |
| 	if err != nil {
 | |
| 		return latestMigrationRan, 0, err
 | |
| 	}
 | |
| 	migrationLogSql := fmt.Sprintf("INSERT INTO %s (id, name, datetime) VALUES (?, ?, ?)", m.versionTable)
 | |
| 	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, migrationsRun, nestederr
 | |
| 			}
 | |
| 			return latestMigrationRan, migrationsRun, err
 | |
| 		}
 | |
| 		_, err = tx.ExecContext(ctx, migrationLogSql, mig.id, mig.name, time.Now())
 | |
| 		if err != nil {
 | |
| 			nestederr := tx.Rollback()
 | |
| 			if nestederr != nil {
 | |
| 				return latestMigrationRan, migrationsRun, nestederr
 | |
| 			}
 | |
| 			return latestMigrationRan, migrationsRun, err
 | |
| 		}
 | |
| 		latestMigrationRan = latestMigrationRan + 1
 | |
| 		migrationsRun = migrationsRun + 1
 | |
| 	}
 | |
| 	err = tx.Commit()
 | |
| 	return latestMigrationRan, migrationsRun, err
 | |
| }
 | |
| 
 | |
| func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) {
 | |
| 	if m.connection == nil {
 | |
| 		return nil, fmt.Errorf("uninitialized mysql client")
 | |
| 	}
 | |
| 
 | |
| 	allBooksQuery := fmt.Sprintf(`SELECT
 | |
| 		id, title, authors, sortauthor, isbn10, isbn13, format, genre, publisher,
 | |
| 		series, volume, year, signed, description, notes, coverurl, childrens
 | |
| 	FROM %s`, m.tableName)
 | |
| 
 | |
| 	books := []media.Book{}
 | |
| 	rows, err := m.connection.QueryContext(ctx, allBooksQuery)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	defer rows.Close()
 | |
| 
 | |
| 	for rows.Next() {
 | |
| 		b := media.Book{}
 | |
| 		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.CoverURL, &b.Childrens)
 | |
| 		if err != nil {
 | |
| 			return nil, err
 | |
| 		}
 | |
| 		b.Authors = strings.Split(authors, ";")
 | |
| 		b.Notes = strings.TrimSpace(b.Notes)
 | |
| 		books = append(books, b)
 | |
| 	}
 | |
| 
 | |
| 	return books, nil
 | |
| }
 | |
| 
 | |
| func (m *MySQL) AddBook(ctx context.Context, b *media.Book) error {
 | |
| 	if m.connection == nil {
 | |
| 		return fmt.Errorf("uninitialized mysql client")
 | |
| 	}
 | |
| 
 | |
| 	res, err := m.connection.ExecContext(ctx, `
 | |
| 		INSERT INTO `+m.tableName+`
 | |
| 		(
 | |
| 			title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, series,
 | |
| 			volume, year, signed, description, notes, coverurl, childrens
 | |
| 		)
 | |
| 		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.CoverURL, b.Childrens,
 | |
| 	)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	i, err := res.RowsAffected()
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	if i != 1 {
 | |
| 		return fmt.Errorf("unexpectedly updated more than one row: %d", i)
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.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 `+m.tableName+`
 | |
| 		SET
 | |
| 			id=? title=? authors=? sortauthor=? isbn10=? isbn13=? format=? genre=? publisher=?
 | |
| 			series=? volume=? year=? signed=? description=? notes=? coverurl=? childrens=?
 | |
| 		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.CoverURL, new.Childrens, old.ID,
 | |
| 	)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	i, err := res.RowsAffected()
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 	if i != 1 {
 | |
| 		return fmt.Errorf("unexpectedly updated more than one row: %d", i)
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (m *MySQL) DeleteBook(_ context.Context, b *media.Book) error {
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func parseMigrationFileName(filename string) (int, string, error) {
 | |
| 	sp := strings.SplitN(filename, "-", 2)
 | |
| 	i, err := strconv.Atoi(sp[0])
 | |
| 	if err != nil {
 | |
| 		return 0, "", err
 | |
| 	}
 | |
| 	tr := strings.TrimSuffix(sp[1], ".sql")
 | |
| 	return i, tr, nil
 | |
| }
 |