up
This commit is contained in:
+52
-15
@@ -29,8 +29,12 @@ func (c MySQLConfig) dsn() string {
|
||||
port = 3306
|
||||
}
|
||||
// parseTime + UTC so DATETIME columns scan into time.Time; utf8mb4 for full
|
||||
// Unicode (names, comments…).
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4",
|
||||
// Unicode (names, comments…). timeout bounds the TCP dial so an unreachable
|
||||
// or wrong-port server fails fast with a real error instead of hanging
|
||||
// startup (which would surface only as "db not initialized"); read/write
|
||||
// timeouts cap a stuck statement (generous, so normal migrations/imports
|
||||
// never trip them).
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&loc=UTC&charset=utf8mb4&timeout=10s&readTimeout=120s&writeTimeout=120s",
|
||||
c.User, c.Password, c.Host, port, c.Database)
|
||||
}
|
||||
|
||||
@@ -50,8 +54,12 @@ func validDBIdent(s string) bool {
|
||||
|
||||
// PingMySQL verifies a shared-database connection and creates the logbook
|
||||
// database if it doesn't exist yet. It connects at server level first (no
|
||||
// database selected) so a not-yet-created DB isn't an error, then runs
|
||||
// CREATE DATABASE IF NOT EXISTS. Backs the settings "Test connection" button.
|
||||
// database selected), tries CREATE DATABASE IF NOT EXISTS, then confirms the
|
||||
// database is actually usable. A restricted user (common on shared hosting)
|
||||
// may lack the CREATE DATABASE privilege but still have full rights on a
|
||||
// pre-created database — so a denied CREATE is not fatal as long as the
|
||||
// database already exists and we can connect to it. Backs the "Test
|
||||
// connection" button.
|
||||
func PingMySQL(c MySQLConfig) error {
|
||||
if strings.TrimSpace(c.Host) == "" {
|
||||
return fmt.Errorf("host is required")
|
||||
@@ -63,17 +71,37 @@ func PingMySQL(c MySQLConfig) error {
|
||||
return fmt.Errorf("open mysql: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
conn.SetConnMaxLifetime(5 * time.Second)
|
||||
conn.SetConnMaxLifetime(15 * time.Second)
|
||||
if err := conn.Ping(); err != nil {
|
||||
return fmt.Errorf("connect to %s:%d: %w", c.Host, c.Port, err)
|
||||
}
|
||||
if name := strings.TrimSpace(c.Database); name != "" {
|
||||
if !validDBIdent(name) {
|
||||
return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
|
||||
}
|
||||
if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
|
||||
name := strings.TrimSpace(c.Database)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
if !validDBIdent(name) {
|
||||
return fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
|
||||
}
|
||||
createErr := error(nil)
|
||||
if _, err := conn.Exec("CREATE DATABASE IF NOT EXISTS `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
|
||||
if !isAccessDenied(err) {
|
||||
return fmt.Errorf("create database %q: %w", name, err)
|
||||
}
|
||||
createErr = err // remember it in case the DB also turns out to be unreachable
|
||||
}
|
||||
// Confirm the database is usable (it may have been pre-created by an admin
|
||||
// even though this user can't CREATE one).
|
||||
dbConn, err := sql.Open("mysql", c.dsn())
|
||||
if err != nil {
|
||||
return fmt.Errorf("open mysql db: %w", err)
|
||||
}
|
||||
defer dbConn.Close()
|
||||
dbConn.SetConnMaxLifetime(15 * time.Second)
|
||||
if err := dbConn.Ping(); err != nil {
|
||||
if createErr != nil {
|
||||
return fmt.Errorf("database %q does not exist and user %q cannot create it — ask your MySQL admin to create the database and grant access (%v)", name, c.User, createErr)
|
||||
}
|
||||
return fmt.Errorf("connect to database %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -81,6 +109,13 @@ func PingMySQL(c MySQLConfig) error {
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// schemaMigrationsDDL tracks which migrations have run. Authored in SQLite
|
||||
// dialect; run through the dialect translator for MySQL.
|
||||
const schemaMigrationsDDL = `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
)`
|
||||
|
||||
// Dialect names the backend opened at startup. Repos consult it for the few
|
||||
// places where SQLite and MySQL SQL genuinely differ (upserts, JSON extraction).
|
||||
// Timestamps are generated in Go (NowISO) for both, so most queries are shared.
|
||||
@@ -138,13 +173,15 @@ func Open(path string) (*sql.DB, error) {
|
||||
// (no external dependency). translate, when non-nil, rewrites each statement
|
||||
// for a non-SQLite backend (see mysqlDDL); nil means run the SQLite DDL as-is.
|
||||
func migrate(conn *sql.DB, translate func(string) string) error {
|
||||
// A non-nil translator means this is the MySQL connection (use the
|
||||
// per-statement, FK-aware path); nil means a SQLite connection. This is
|
||||
// determined by the caller's argument, NOT the global Dialect, so the
|
||||
// in-memory SQLite used to build the MySQL baseline still migrates as SQLite.
|
||||
mysqlPath := translate != nil
|
||||
if translate == nil {
|
||||
translate = func(s string) string { return s }
|
||||
}
|
||||
if _, err := conn.Exec(translate(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
)`)); err != nil {
|
||||
if _, err := conn.Exec(translate(schemaMigrationsDDL)); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
@@ -182,7 +219,7 @@ func migrate(conn *sql.DB, translate func(string) string) error {
|
||||
// "duplicate column". Instead apply statement-by-statement and tolerate
|
||||
// "already exists" errors, making migrations idempotent (and self-healing
|
||||
// for a previously half-applied database).
|
||||
if IsMySQL() {
|
||||
if mysqlPath {
|
||||
if err := applyMySQLMigration(conn, sqlText); err != nil {
|
||||
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user