package migrate import ( "context" "database/sql" "errors" "fmt" "io" "os" "path/filepath" "time" "github.com/pressly/goose/v3" "github.com/spf13/cobra" "git.avsdev.uk/Go/job-search/internal/cli" "git.avsdev.uk/Go/job-search/internal/store" ) // openMigrator loads the configuration and returns a migrator over the // store's embedded migrations for the configured database, opened with the // store's connection settings. The caller must close the returned *sql.DB. // // If the database file doesn't exist, create decides between asking whether // to create it (up) and failing (down, status: nothing to work on). func openMigrator( cmd *cobra.Command, opts *cli.Options, create, createConfirmed bool, ) (*goose.Provider, *sql.DB, error) { conf, err := opts.RunSetup(cmd, true, true) if err != nil { return nil, nil, fmt.Errorf("load configuration: %w", err) } file := conf.Database.File if file == "" { return nil, nil, errors.New( "no database file specified. Update your configuration", ) } if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { if !create { return nil, nil, fmt.Errorf("database file '%s' does not exist", file) } if !createConfirmed { question := fmt.Sprintf( "Database file '%s' does not exist. Would you like to create it?", file, ) if !cli.Confirm(cmd.OutOrStdout(), cmd.InOrStdin(), question) { return nil, nil, errors.New("no database file") } } } else if err != nil { return nil, nil, err } db, err := store.OpenDB(file) if err != nil { return nil, nil, err } migrator, err := store.NewMigrator(db) if err != nil { return nil, nil, errors.Join(err, db.Close()) } return migrator, db, nil } // closeDB closes db when deferred, adding any error to *err. func closeDB(db *sql.DB, err *error) { *err = errors.Join(*err, db.Close()) } // errWriter writes until a write fails, then keeps the error, so a run of // prints needs checking only once. type errWriter struct { out io.Writer err error } // printf writes like fmt.Fprintf, unless an earlier write failed. func (e *errWriter) printf(format string, args ...any) { if e.err == nil { _, e.err = fmt.Fprintf(e.out, format, args...) } } // printResults lists migrations that ran, e.g. // // applied 00003_create_release_tags_table.sql (2ms) func printResults(out io.Writer, results []*goose.MigrationResult) error { output := &errWriter{ out: out, } for _, result := range results { verb := "applied" if result.Direction == "down" { verb = "reverted" } output.printf( " %-9s %s (%s)\n", verb, filepath.Base(result.Source.Path), result.Duration.Round(time.Millisecond), ) } return output.err } // failure prints what ran before a failed migration and describes the // failure. Each migration runs in a transaction, so the failed one has been // rolled back and the database is at the last version that succeeded. func failure(out io.Writer, err error) error { var partialErr *goose.PartialError if !errors.As(err, &partialErr) { return err } printErr := printResults(out, partialErr.Applied) name := "unknown migration" if partialErr.Failed != nil && partialErr.Failed.Source != nil { name = filepath.Base(partialErr.Failed.Source.Path) } return errors.Join( fmt.Errorf( "migration %s failed and was rolled back; the database is at the last "+ "version that succeeded.\nMigration error: %w", name, partialErr.Err, ), printErr, ) } // printVersion reports where the database ended up. func printVersion( ctx context.Context, out io.Writer, migrator *goose.Provider, ) error { dbVersion, err := migrator.GetDBVersion(ctx) if err != nil { return err } if dbVersion == 0 { _, err = fmt.Fprintln(out, "Database cleared") } else { _, err = fmt.Fprintf(out, "Database migrated to version %d\n", dbVersion) } return err }