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
 | 
						|
}
 |