feat: migrate database from SQLite to MySQL with .env configuration

- Replace go-sqlite3 with go-sql-driver/mysql (pure Go, no CGO needed)
- Add joho/godotenv for .env file loading
- Rewrite database/db.go with MySQL-compatible DDL (BIGINT AUTO_INCREMENT, InnoDB, utf8mb4)
- Convert all SQLite-specific SQL: INSERT OR IGNORE → INSERT IGNORE,
  INSERT OR REPLACE → INSERT ... ON DUPLICATE KEY UPDATE,
  datetime('now') → NOW(), date arithmetic → DATE_SUB()
- Add MySQL config fields to config.go (host/port/user/password/database)
- Add .env.example with connection parameter template
- Update Dockerfile: remove CGO/gcc/musl dependency, smaller build
- Update docker-compose.yml: add MySQL service container with healthcheck
- Update CLAUDE.md documentation
This commit is contained in:
2026-06-20 22:40:46 +08:00
parent 6c60b767df
commit 00c1839635
14 changed files with 130 additions and 102 deletions
+40 -78
View File
@@ -4,7 +4,7 @@ import (
"database/sql"
"log"
_ "github.com/mattn/go-sqlite3"
_ "github.com/go-sql-driver/mysql"
"github.com/HoHD/PR-Helper/models"
)
@@ -13,11 +13,14 @@ type DB struct {
conn *sql.DB
}
func New(dbPath string) (*DB, error) {
conn, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
func New(dsn string) (*DB, error) {
conn, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err := conn.Ping(); err != nil {
return nil, err
}
db := &DB{conn: conn}
if err := db.migrate(); err != nil {
return nil, err
@@ -39,111 +42,70 @@ func (db *DB) Conn() *sql.DB {
func (db *DB) migrate() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
key VARCHAR(255) PRIMARY KEY,
value TEXT NOT NULL
)`,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
id BIGINT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
user_id BIGINT NOT NULL,
key VARCHAR(255) NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (user_id, key)
)`,
PRIMARY KEY (user_id, key),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS repositories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
url TEXT NOT NULL,
local_path TEXT NOT NULL,
size_bytes INTEGER DEFAULT 0,
size_bytes BIGINT DEFAULT 0,
cloned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
last_used DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
repo_id INTEGER REFERENCES repositories(id),
type TEXT NOT NULL,
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
repo_id BIGINT,
type VARCHAR(50) NOT NULL,
base_ref TEXT NOT NULL,
head_ref TEXT NOT NULL,
result TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
result LONGTEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (repo_id) REFERENCES repositories(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS review_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
analysis_id INTEGER REFERENCES analyses(id),
scope TEXT NOT NULL,
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT,
analysis_id BIGINT,
scope VARCHAR(50) NOT NULL,
scope_key TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (analysis_id) REFERENCES analyses(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
for _, s := range stmts {
if _, err := db.conn.Exec(s); err != nil {
return err
}
}
// Run incremental migrations for existing databases
if err := db.migrateV2(); err != nil {
return err
}
return nil
}
// migrateV2 adds user_id columns to existing tables and removes the old UNIQUE constraint on url.
func (db *DB) migrateV2() error {
migrations := []string{
`ALTER TABLE repositories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE`,
`ALTER TABLE analyses ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE`,
`ALTER TABLE review_notes ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE`,
}
for _, m := range migrations {
// ALTER TABLE ADD COLUMN will fail if column already exists; ignore the error
db.conn.Exec(m)
}
// Recreate repositories table without the UNIQUE constraint on url (same url can be cloned by different users)
// SQLite doesn't support DROP CONSTRAINT, so we use a workaround
var hasOldUnique int
db.conn.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='repositories_old'`).Scan(&hasOldUnique)
if hasOldUnique == 0 {
// Check if the UNIQUE constraint exists by trying to detect the old schema
// We'll just do the migration idempotently
db.conn.Exec(`CREATE TABLE IF NOT EXISTS repositories_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
url TEXT NOT NULL,
local_path TEXT NOT NULL,
size_bytes INTEGER DEFAULT 0,
cloned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME DEFAULT CURRENT_TIMESTAMP
)`)
db.conn.Exec(`INSERT OR IGNORE INTO repositories_new (id, user_id, url, local_path, size_bytes, cloned_at, last_used)
SELECT id, user_id, url, local_path, size_bytes, cloned_at, last_used FROM repositories`)
// Only swap if the new table has data or old table is empty
var newCount, oldCount int
db.conn.QueryRow(`SELECT COUNT(*) FROM repositories_new`).Scan(&newCount)
db.conn.QueryRow(`SELECT COUNT(*) FROM repositories`).Scan(&oldCount)
if newCount >= oldCount {
db.conn.Exec(`DROP TABLE repositories`)
db.conn.Exec(`ALTER TABLE repositories_new RENAME TO repositories`)
} else {
db.conn.Exec(`DROP TABLE repositories_new`)
}
}
return nil
}
func (db *DB) seedDefaults() error {
for key, val := range models.DefaultSettings {
_, err := db.conn.Exec(
`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, key, val,
`INSERT IGNORE INTO settings (key, value) VALUES (?, ?)`, key, val,
)
if err != nil {
log.Printf("warning: failed to seed setting %s: %v", key, err)