Files
satusehat-service/internal/infrastructure/database/database.go
T
2026-04-14 01:23:34 +00:00

1527 lines
43 KiB
Go

package database
import (
"context"
"crypto/tls"
"database/sql"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"service/internal/infrastructure/config"
"service/pkg/logger"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
// Driver GORM di-import secara eksplisit untuk Migration
_ "github.com/lib/pq" // Driver PostgreSQL
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/driver/sqlserver"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
type DatabaseType string
const (
Postgres DatabaseType = "postgres"
MySQL DatabaseType = "mysql"
SQLServer DatabaseType = "sqlserver"
SQLite DatabaseType = "sqlite"
MongoDB DatabaseType = "mongodb"
)
type Service interface {
Health() map[string]map[string]string
// Metode utama sekarang mengembalikan *gorm.DB
GetGormDB(name string) (*gorm.DB, error)
// Metode untuk mendapatkan koneksi mentah jika diperlukan
GetDB(name string) (*sql.DB, error)
GetSQLXDB(name string) (*sqlx.DB, error)
GetMongoClient(name string) (*mongo.Client, error)
GetReadDB(name string) (*sql.DB, error)
Close() error
ListDBs() []string
GetDBType(name string) (DatabaseType, error)
ListenForChanges(ctx context.Context, dbName string, channels []string, callback func(string, string)) error
NotifyChange(dbName, channel, payload string) error
GetPrimaryDB(name string) (*sql.DB, error)
ExecuteQuery(ctx context.Context, dbName string, query string, args ...interface{}) (*sql.Rows, error)
ExecuteQueryRow(ctx context.Context, dbName string, query string, args ...interface{}) *sql.Row
Exec(ctx context.Context, dbName string, query string, args ...interface{}) (sql.Result, error)
// Method untuk menjalankan migrasi database otomatis
RegisterModel(models ...interface{}) // Method baru untuk registrasi dinamis
Migrate() error
// Method untuk mendapatkan informasi semua database yang terkoneksi
GetAllDatabasesInfo() map[string]interface{}
}
type service struct {
// GORM DB adalah sumber utama
gormDatabases map[string]*gorm.DB
// Map untuk menyimpan koneksi mentah yang diekstrak dari GORM
sqlDatabases map[string]*sql.DB
sqlxDatabases map[string]*sqlx.DB
mongoClients map[string]*mongo.Client
readReplicas map[string][]*sql.DB
configs map[string]config.DatabaseConfig
readConfigs map[string][]config.DatabaseConfig
mu sync.RWMutex
readBalancer map[string]int
listeners map[string]*pq.Listener // Menggunakan sql.Listener dari GORM/driver
listenersMu sync.RWMutex
modelsToMigrate []interface{} // Menyimpan daftar entitas yang akan dimigrasi
}
var (
dbManager *service
once sync.Once
)
// gormLoggerAdapter adalah adapter untuk menghubungkan logger kita dengan GORM
type gormLoggerAdapter struct {
logger.Logger
loggerConfig logger.Config
Config gormlogger.Config
}
// NewGormLoggerAdapter membuat instance baru dari adapter
func NewGormLoggerAdapter(l logger.Logger) gormlogger.Interface {
// Kita tidak perlu menyimpan logger.Config di sini, karena logger kita sudah diinisialisasi.
// Kita hanya perlu menentukan log level untuk GORM.
// Anda bisa membuat ini lebih dinamis dengan membaca dari config jika perlu.
// Untuk sekarang, kita set ke Info agar semua query terlihat.
logLevel := gormlogger.Info
return &gormLoggerAdapter{
Logger: l,
// Konfigurasi logger GORM
Config: gormlogger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logLevel, // Gunakan level yang sudah ditentukan
IgnoreRecordNotFoundError: false, // Jangan abaikan error "not found"
Colorful: false, // Nonaktifkan warna karena kita menggunakan logger terstruktur
},
}
}
// LogMode menetapkan level log dan mengembalikan instance baru
func (l *gormLoggerAdapter) LogMode(level gormlogger.LogLevel) gormlogger.Interface {
// Buat instance adapter baru dengan level yang berbeda
newLogger := *l
newLogger.Config.LogLevel = level
return &newLogger
}
// Info mencatat log info (jarang dipanggil GORM)
func (l *gormLoggerAdapter) Info(ctx context.Context, msg string, data ...interface{}) {
formattedMsg := fmt.Sprintf(msg, data...)
// Mencegah log notifikasi duplikat (dead notif) yang tidak berguna dari internal GORM
if strings.Contains(formattedMsg, "replacing callback") {
return
}
l.Logger.WithContext(ctx).Info(formattedMsg)
}
// Warn mencatat log warning
func (l *gormLoggerAdapter) Warn(ctx context.Context, msg string, data ...interface{}) {
l.Logger.WithContext(ctx).Warn(fmt.Sprintf(msg, data...))
}
// Error mencatat log error
func (l *gormLoggerAdapter) Error(ctx context.Context, msg string, data ...interface{}) {
l.Logger.WithContext(ctx).Error(fmt.Sprintf(msg, data...))
}
// Trace mencatat log trace (SQL query) - ini adalah metode terpenting
func (l *gormLoggerAdapter) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
elapsed := time.Since(begin)
sql, rows := fc()
// Siapkan fields menggunakan helper dari logger kita
fields := []logger.Field{
logger.Duration("duration_ms", elapsed),
logger.Int64("rows", rows),
logger.String("sql", sql),
}
// Tentukan level log dan pesan berdasarkan kondisi
switch {
case err != nil && l.Config.LogLevel >= gormlogger.Error:
// Jika ada error dan log level adalah Error atau lebih tinggi
l.Logger.WithContext(ctx).WithError(err).WithFields(fields...).Error("Database query failed")
case elapsed > l.Config.SlowThreshold && l.Config.LogLevel >= gormlogger.Warn:
// Jika query lambat dan log level adalah Warn atau lebih tinggi
l.Logger.WithContext(ctx).WithFields(fields...).Warn("Slow database query")
case l.Config.LogLevel == gormlogger.Info:
// Jika log level adalah Info, catat semua query sebagai debug
l.Logger.WithContext(ctx).WithFields(fields...).Debug("Database query executed")
}
}
func New(cfg *config.Config) Service {
once.Do(func() {
dbManager = &service{
gormDatabases: make(map[string]*gorm.DB),
sqlDatabases: make(map[string]*sql.DB),
sqlxDatabases: make(map[string]*sqlx.DB),
mongoClients: make(map[string]*mongo.Client),
readReplicas: make(map[string][]*sql.DB),
configs: make(map[string]config.DatabaseConfig),
readConfigs: make(map[string][]config.DatabaseConfig),
readBalancer: make(map[string]int),
listeners: make(map[string]*pq.Listener),
}
logger.Default().Info("Initializing database service with GORM as primary abstraction...")
dbManager.loadFromConfig(cfg)
if _, exists := dbManager.configs["default"]; !exists {
logger.Default().Warn("No 'default' database configured in config")
}
for name, dbConfig := range dbManager.configs {
if err := dbManager.addDatabase(name, dbConfig); err != nil {
logger.Default().Error("Failed to connect to database", logger.String("db_name", name), logger.ErrorField(err))
} else {
logger.Default().Info("Successfully connected to database", logger.String("db_name", name))
}
}
for name, replicaConfigs := range dbManager.readConfigs {
for i, replicaConfig := range replicaConfigs {
if err := dbManager.addReadReplica(name, i, replicaConfig); err != nil {
logger.Default().Error("Failed to connect to read replica", logger.String("db_name", name), logger.Int("replica_index", i), logger.ErrorField(err))
} else {
logger.Default().Info("Successfully connected to read replica", logger.String("db_name", name), logger.Int("replica_index", i))
}
}
}
})
return dbManager
}
func (s *service) loadFromConfig(cfg *config.Config) {
s.mu.Lock()
defer s.mu.Unlock()
for name, dbConfig := range cfg.Databases {
s.configs[name] = dbConfig
}
for name, replicaConfigs := range cfg.ReadReplicas {
s.readConfigs[name] = replicaConfigs
}
}
// GetGormDB sekarang menjadi metode utama dan lebih sederhana
func (s *service) GetGormDB(name string) (*gorm.DB, error) {
s.mu.RLock()
defer s.mu.RUnlock()
db, exists := s.gormDatabases[name]
if !exists {
return nil, fmt.Errorf("database %s not found", name)
}
return db, nil
}
// GetDB mengekstrak *sql.DB dari GORM
func (s *service) GetDB(name string) (*sql.DB, error) {
s.mu.RLock()
defer s.mu.RUnlock()
db, exists := s.sqlDatabases[name]
if !exists {
return nil, fmt.Errorf("database %s not found", name)
}
return db, nil
}
// GetSQLXDB mengekstrak *sqlx.DB dari GORM
func (s *service) GetSQLXDB(name string) (*sqlx.DB, error) {
s.mu.RLock()
defer s.mu.RUnlock()
db, exists := s.sqlxDatabases[name]
if !exists {
return nil, fmt.Errorf("database %s not found", name)
}
return db, nil
}
func (s *service) GetAllDatabasesInfo() map[string]interface{} {
s.mu.RLock()
defer s.mu.RUnlock()
dbInfo := make(map[string]interface{})
for name, gormDB := range s.gormDatabases {
dbConfig, exists := s.configs[name]
if !exists {
continue
}
info := gin.H{
"name": name,
"type": dbConfig.Type,
"host": dbConfig.Host,
"port": dbConfig.Port,
"database": dbConfig.Database,
// "username": dbConfig.Username,
"status": "connected",
}
// Coba dapatkan informasi tambahan dari database
sqlDB, err := gormDB.DB()
if err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := sqlDB.PingContext(ctx); err == nil {
// Coba query spesifik berdasarkan tipe database
switch dbConfig.Type {
case "postgres":
var dbname, version, user string
if err := gormDB.Raw("SELECT current_database(), version(), current_user").Row().Scan(&dbname, &version, &user); err == nil {
// info["current_user"] = user
// info["database_name"] = dbname
info["database_version"] = version
}
case "mysql":
var dbname, version, user string
if err := gormDB.Raw("SELECT DATABASE(), VERSION(), USER()").Row().Scan(&dbname, &version, &user); err == nil {
// info["current_user"] = user
// info["database_name"] = dbname
info["database_version"] = version
}
case "sqlserver":
var dbname, version, user string
if err := gormDB.Raw("SELECT DB_NAME(), @@VERSION, SYSTEM_USER").Row().Scan(&dbname, &version, &user); err == nil {
// info["current_user"] = user
// info["database_name"] = dbname
info["database_version"] = version
}
case "sqlite":
var seq int
var name, file string
if err := gormDB.Raw("PRAGMA database_list").Row().Scan(&seq, &name, &file); err == nil {
// info["database_name"] = name
info["database_file"] = file
}
}
}
}
dbInfo[name] = info
}
return dbInfo
}
func (s *service) addDatabase(name string, dbConfig config.DatabaseConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
var gormDB *gorm.DB
var err error
dbType := DatabaseType(dbConfig.Type)
switch dbType {
case Postgres:
gormDB, err = s.openPostgresGORM(dbConfig)
case MySQL:
gormDB, err = s.openMySQLGORM(dbConfig)
case SQLServer:
gormDB, err = s.openSQLServerGORM(dbConfig)
case SQLite:
gormDB, err = s.openSQLiteGORM(dbConfig)
case MongoDB:
return s.addMongoDB(name, dbConfig)
default:
return fmt.Errorf("unsupported database type: %s", dbConfig.Type)
}
if err != nil {
return fmt.Errorf("failed to connect to GORM for %s: %w", name, err)
}
// Ekstrak koneksi *sql.DB dari GORM
sqlDB, err := gormDB.DB()
if err != nil {
return fmt.Errorf("failed to get underlying sql.DB from GORM for %s: %w", name, err)
}
// Optimasi connection pool
s.optimizeConnectionPool(name, sqlDB, dbConfig)
// Simpan instance GORM dan koneksi mentahnya
s.gormDatabases[name] = gormDB
s.sqlDatabases[name] = sqlDB
// Buat dan simpan instance sqlx.DB
driverName := getDriverName(dbType)
s.sqlxDatabases[name] = sqlx.NewDb(sqlDB, driverName)
logger.Default().Info("Successfully connected and configured GORM for database", logger.String("db_name", name))
return nil
}
// Helper untuk mendapatkan nama driver sqlx
func getDriverName(dbType DatabaseType) string {
switch dbType {
case Postgres:
return "pgx" // Gunakan driver pgx untuk performa lebih baik
case MySQL:
return "mysql"
case SQLServer:
return "sqlserver"
case SQLite:
return "sqlite3"
default:
return string(dbType)
}
}
// --- Metode Koneksi GORM ---
func (s *service) openPostgresGORM(config config.DatabaseConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=%s TimeZone=Asia/Jakarta",
config.Host, config.Username, config.Password, config.Database, config.Port, config.SSLMode)
if config.ConnectTimeout > 0 {
dsn += fmt.Sprintf(" connect_timeout=%d", int(config.ConnectTimeout.Seconds()))
}
if config.StatementTimeout > 0 {
dsn += fmt.Sprintf(" statement_timeout=%d", int(config.StatementTimeout.Milliseconds()))
}
if config.Schema != "" {
dsn += fmt.Sprintf(" search_path=%s", config.Schema)
}
gormConfig := &gorm.Config{
Logger: NewGormLoggerAdapter(logger.Default()), // Atur level log GORM
}
if config.RequireSSL {
// Untuk SSL, kita mungkin perlu mengkonfigurasi tls.Config secara manual
// dan menggunakannya dalam DSN atau melalui pgx driver config
}
return gorm.Open(postgres.Open(dsn), gormConfig)
}
func (s *service) openMySQLGORM(config config.DatabaseConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
config.Username, config.Password, config.Host, config.Port, config.Database)
if config.ConnectTimeout > 0 {
dsn += fmt.Sprintf("&timeout=%s", config.ConnectTimeout.String())
} else if config.Timeout > 0 {
dsn += fmt.Sprintf("&timeout=%s", config.Timeout.String())
}
if config.ReadTimeout > 0 {
dsn += fmt.Sprintf("&readTimeout=%s", config.ReadTimeout.String())
}
if config.WriteTimeout > 0 {
dsn += fmt.Sprintf("&writeTimeout=%s", config.WriteTimeout.String())
}
if config.RequireSSL {
dsn += "&tls=true"
}
gormConfig := &gorm.Config{
Logger: NewGormLoggerAdapter(logger.Default()),
}
return gorm.Open(mysql.Open(dsn), gormConfig)
}
func (s *service) openSQLServerGORM(config config.DatabaseConfig) (*gorm.DB, error) {
dsn := fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s",
config.Username, config.Password, config.Host, config.Port, config.Database)
if config.ConnectTimeout > 0 {
dsn += fmt.Sprintf("&connection+timeout=%d", int(config.ConnectTimeout.Seconds()))
}
if config.RequireSSL {
dsn += "&encrypt=true"
if config.SSLRootCert != "" {
dsn += "&trustServerCertificate=false"
} else {
dsn += "&trustServerCertificate=true"
}
}
gormConfig := &gorm.Config{
Logger: NewGormLoggerAdapter(logger.Default()),
}
return gorm.Open(sqlserver.Open(dsn), gormConfig)
}
func (s *service) openSQLiteGORM(config config.DatabaseConfig) (*gorm.DB, error) {
dsn := config.Path
if config.Timeout > 0 {
dsn += fmt.Sprintf("?_busy_timeout=%d", config.Timeout.Milliseconds())
}
gormConfig := &gorm.Config{
Logger: NewGormLoggerAdapter(logger.Default()),
}
return gorm.Open(sqlite.Open(dsn), gormConfig)
}
// RegisterModel mendaftarkan entitas GORM secara dinamis untuk Automigrate
func (s *service) RegisterModel(models ...interface{}) {
s.mu.Lock()
defer s.mu.Unlock()
s.modelsToMigrate = append(s.modelsToMigrate, models...)
}
// --- PERBAIKAN: Implementasi Migrate Method dengan validasi lebih baik ---
func (s *service) Migrate() error {
s.mu.RLock()
defer s.mu.RUnlock()
log.Println("🔄 Starting database migration process using GORM AutoMigrate...")
if len(s.gormDatabases) == 0 {
return fmt.Errorf("no GORM databases available for migration")
}
// Gunakan model yang sudah didaftarkan
models := s.modelsToMigrate
if len(models) == 0 {
log.Println("⚠️ No models registered for migration.")
// Tetap lanjutkan untuk runSQLMigrationsForDB
}
// Jalankan AutoMigrate untuk setiap database
for name, gormDB := range s.gormDatabases {
dbConfig := s.configs[name]
dbType := DatabaseType(dbConfig.Type)
if dbType == Postgres || dbType == MySQL || dbType == SQLServer || dbType == SQLite {
log.Printf("⚙️ Running migration for [%s] (%s)...", name, dbType)
// Gunakan AutoMigrate untuk model-model yang terdaftar
if len(models) > 0 {
if err := gormDB.AutoMigrate(models...); err != nil {
return fmt.Errorf("auto migration failed for %s: %w", name, err)
}
log.Printf("✅ AutoMigrate successful for [%s]", name)
}
// Jalankan SQL migrations sebagai tambahan
if err := s.runSQLMigrationsForDB(gormDB, dbType); err != nil {
return fmt.Errorf("sql migration failed for %s: %w", name, err)
}
}
}
logger.Default().Info("All database migrations completed successfully")
return nil
}
func (s *service) runSQLMigrationsForDB(gormDB *gorm.DB, dbType DatabaseType) error {
migrationFiles, err := s.readMigrationFiles()
if err != nil {
return fmt.Errorf("failed to read migration files: %w", err)
}
if len(migrationFiles) == 0 {
logger.Default().Info("No migration files found")
return nil
}
logger.Default().Info("Found migration files to execute", logger.Int("count", len(migrationFiles)))
sqlDB, err := gormDB.DB()
if err != nil {
return fmt.Errorf("cannot get underlying sql.DB: %w", err)
}
if err := s.ensureMigrationTable(sqlDB, dbType); err != nil {
return fmt.Errorf("failed to ensure migration table: %w", err)
}
for _, filePath := range migrationFiles {
filename := filepath.Base(filePath)
if s.isMigrationApplied(sqlDB, filename) {
logger.Default().Debug("Skipping already applied migration", logger.String("file", filename))
continue
}
if err := s.executeMigrationFile(sqlDB, filePath); err != nil {
return fmt.Errorf("migration failed at file %s: %w", filePath, err)
}
logger.Default().Info("Successfully executed migration file", logger.String("file", filename))
}
return nil
}
// Fungsi bantu untuk membaca file migrasi
func (s *service) readMigrationFiles() ([]string, error) {
migrationsDir := "internal/infrastructure/database/sql"
// Cek apakah directory migrations ada
if _, err := os.Stat(migrationsDir); os.IsNotExist(err) {
logger.Default().Info("Migrations directory does not exist", logger.String("path", migrationsDir))
return nil, nil
}
var files []string
err := filepath.Walk(migrationsDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Hanya proses file .sql
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".sql") {
files = append(files, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk migrations directory: %w", err)
}
// Sort files by name untuk konsisten
sort.Strings(files)
return files, nil
}
// Fungsi bantu untuk cek apakah tabel sudah ada
func isTableNotExistsError(err error) bool {
if err == nil {
return false
}
errStr := strings.ToLower(err.Error())
return strings.Contains(errStr, "doesn't exist") ||
strings.Contains(errStr, "does not exist") ||
strings.Contains(errStr, "no such table")
}
func (s *service) addReadReplica(name string, index int, config config.DatabaseConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
var db *sql.DB
var err error
dbType := DatabaseType(config.Type)
switch dbType {
case Postgres:
db, err = s.openPostgresConnection(config)
case MySQL:
db, err = s.openMySQLConnection(config)
case SQLServer:
db, err = s.openSQLServerConnection(config)
case SQLite:
db, err = s.openSQLiteConnection(config)
default:
return fmt.Errorf("unsupported database type for read replica: %s", config.Type)
}
if err != nil {
return err
}
// Optimasi juga untuk read replica
s.optimizeConnectionPool(fmt.Sprintf("%s-replica-%d", name, index), db, config)
// Validasi koneksi
if err := s.validateConnection(fmt.Sprintf("%s-replica-%d", name, index), db); err != nil {
db.Close()
return fmt.Errorf("connection validation failed for read replica %s[%d]: %w", name, index, err)
}
if s.readReplicas[name] == nil {
s.readReplicas[name] = make([]*sql.DB, 0)
}
for len(s.readReplicas[name]) <= index {
s.readReplicas[name] = append(s.readReplicas[name], nil)
}
s.readReplicas[name][index] = db
logger.Default().Info("Successfully connected to read replica", logger.String("db_name", name), logger.Int("replica_index", index))
return nil
}
// Helper functions untuk mendeteksi jenis error
func isTableExistsError(err error) bool {
errMsg := err.Error()
return strings.Contains(errMsg, "relation already exists") ||
strings.Contains(errMsg, "table already exists")
}
func isSyntaxError(err error) bool {
errMsg := err.Error()
return strings.Contains(errMsg, "syntax error") ||
strings.Contains(errMsg, "syntax error at") ||
strings.Contains(errMsg, "invalid syntax")
}
func isDuplicateKeyError(err error) bool {
errMsg := err.Error()
return strings.Contains(errMsg, "duplicate key") ||
strings.Contains(errMsg, "unique constraint")
}
func isTableCreationCommand(cmd string) bool {
cmd = strings.ToLower(cmd)
return strings.Contains(cmd, "create table") ||
strings.Contains(cmd, "create index") ||
strings.Contains(cmd, "alter table")
}
func (s *service) openPostgresConnection(config config.DatabaseConfig) (*sql.DB, error) {
connectTimeoutSec := int(config.ConnectTimeout.Seconds())
statementTimeoutSec := int(config.StatementTimeout.Seconds())
// Menggunakan pgx.ParseConfig untuk fleksibilitas lebih tinggi
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s connect_timeout=%d",
config.Host,
config.Port,
config.Username,
config.Password,
config.Database,
config.SSLMode,
connectTimeoutSec,
)
if config.Schema != "" {
dsn += " search_path=" + config.Schema
}
// Jika menggunakan parameter statement_timeout (pg specific)
if statementTimeoutSec > 0 {
// Note: pgx driver handle options differently, but usually passed in DSN or via Config
dsn += fmt.Sprintf(" statement_timeout=%d", statementTimeoutSec*1000)
}
// Setup SSL certificates path if required
if config.RequireSSL {
// Pastikan file path valid
if config.SSLCert != "" && config.SSLKey != "" && config.SSLRootCert != "" {
dsn += fmt.Sprintf(" sslcert=%s sslkey=%s sslrootcert=%s", config.SSLCert, config.SSLKey, config.SSLRootCert)
}
}
// Menggunakan stdlib.OpenDB dari pgx untuk kompatibilitas database/sql
pgxConfig, err := pgx.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("failed to parse pgx config: %w", err)
}
// Apply manual runtime settings if needed here
db := stdlib.OpenDB(*pgxConfig)
// Set timeout yang lebih panjang untuk koneksi awal
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("failed to ping PostgreSQL database: %w", err)
}
return db, nil
}
func (s *service) openMySQLConnection(config config.DatabaseConfig) (*sql.DB, error) {
connStr := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&timeout=%s&readTimeout=%s&writeTimeout=%s",
config.Username,
config.Password,
config.Host,
config.Port,
config.Database,
config.Timeout,
config.ReadTimeout,
config.WriteTimeout,
)
if config.RequireSSL {
connStr += "&tls=true"
// Setup custom TLS config biasanya dilakukan secara global di driver mysql
// atau menggunakan nama config yang sudah didaftarkan
}
db, err := sql.Open("mysql", connStr)
if err != nil {
return nil, fmt.Errorf("failed to open MySQL connection: %w", err)
}
// Set timeout yang lebih panjang untuk koneksi awal
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("failed to ping MySQL database: %w", err)
}
return db, nil
}
func (s *service) openSQLServerConnection(config config.DatabaseConfig) (*sql.DB, error) {
connectTimeoutSec := int(config.ConnectTimeout.Seconds())
connStr := fmt.Sprintf("sqlserver://%s:%s@%s:%d?database=%s&connection timeout=%d",
config.Username,
config.Password,
config.Host,
config.Port,
config.Database,
connectTimeoutSec,
)
if config.RequireSSL {
connStr += "&encrypt=true"
if config.SSLRootCert != "" {
connStr += "&trustServerCertificate=false"
} else {
connStr += "&trustServerCertificate=true"
}
}
db, err := sql.Open("sqlserver", connStr)
if err != nil {
return nil, fmt.Errorf("failed to open SQL Server connection: %w", err)
}
// Set timeout yang lebih panjang untuk koneksi awal
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("failed to ping SQL Server database: %w", err)
}
return db, nil
}
func (s *service) openSQLiteConnection(config config.DatabaseConfig) (*sql.DB, error) {
// Memerlukan import _ "github.com/mattn/go-sqlite3" di tempat lain atau gunakan driver GORM yang membungkusnya
// Namun disini kita gunakan driver name "sqlite3" standar
db, err := sql.Open("sqlite3", config.Path)
if err != nil {
return nil, fmt.Errorf("failed to open SQLite connection: %w", err)
}
// Performance tuning untuk SQLite (WAL mode)
_, err = db.Exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")
if err != nil {
return nil, fmt.Errorf("failed to configure SQLite: %w", err)
}
// Set timeout yang lebih panjang untuk koneksi awal
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("failed to ping SQLite database: %w", err)
}
return db, nil
}
func (s *service) addMongoDB(name string, config config.DatabaseConfig) error {
timeout := config.Timeout
if timeout <= 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
uri := fmt.Sprintf("mongodb://%s:%s@%s:%d/%s",
config.Username,
config.Password,
config.Host,
config.Port,
config.Database,
)
clientOptions := options.Client().ApplyURI(uri)
if config.RequireSSL {
clientOptions.SetTLSConfig(&tls.Config{
InsecureSkipVerify: config.SSLMode == "require" || config.SSLMode == "skip-verify",
MinVersion: tls.VersionTLS12,
})
}
clientOptions.SetConnectTimeout(config.ConnectTimeout)
clientOptions.SetServerSelectionTimeout(config.Timeout)
client, err := mongo.Connect(ctx, clientOptions)
if err != nil {
return fmt.Errorf("failed to connect to MongoDB: %w", err)
}
if err := client.Ping(ctx, nil); err != nil {
return fmt.Errorf("failed to ping MongoDB: %w", err)
}
s.mongoClients[name] = client
logger.Default().Info("Successfully connected to MongoDB", logger.String("db_name", name))
return nil
}
func (s *service) configureSQLDB(name string, db *sql.DB, config config.DatabaseConfig) error {
// Configuration is handled in optimizeConnectionPool, but we can double check or set defaults here
timeout := config.Timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return fmt.Errorf("failed to ping database: %w", err)
}
s.sqlDatabases[name] = db
dbType := DatabaseType(config.Type)
var driverName string
switch dbType {
case Postgres:
driverName = "pgx"
case MySQL:
driverName = "mysql"
case SQLServer:
driverName = "sqlserver"
case SQLite:
driverName = "sqlite3"
default:
// Fallback for sqlx
driverName = string(dbType)
}
sqlxDB := sqlx.NewDb(db, driverName)
s.sqlxDatabases[name] = sqlxDB
logger.Default().Info("Successfully connected to database", logger.String("db_name", name), logger.String("driver", driverName))
return nil
}
func (s *service) Health() map[string]map[string]string {
s.mu.RLock()
defer s.mu.RUnlock()
result := make(map[string]map[string]string)
// Check SQL Databases
for name, db := range s.sqlDatabases {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
stats := make(map[string]string)
err := db.PingContext(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("db down: %v", err)
stats["type"] = "sql"
stats["role"] = "primary"
result[name] = stats
continue
}
stats["status"] = "up"
stats["message"] = "It's healthy"
stats["type"] = "sql"
stats["role"] = "primary"
dbStats := db.Stats()
stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
stats["in_use"] = strconv.Itoa(dbStats.InUse)
stats["idle"] = strconv.Itoa(dbStats.Idle)
stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10)
stats["wait_duration"] = dbStats.WaitDuration.String()
result[name] = stats
}
// Check Read Replicas
for name, replicas := range s.readReplicas {
for i, db := range replicas {
if db == nil {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
replicaName := fmt.Sprintf("%s_replica_%d", name, i)
stats := make(map[string]string)
err := db.PingContext(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("read replica down: %v", err)
stats["role"] = "replica"
result[replicaName] = stats
continue
}
stats["status"] = "up"
stats["role"] = "replica"
dbStats := db.Stats()
stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
result[replicaName] = stats
}
}
// Check MongoDB
for name, client := range s.mongoClients {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
stats := make(map[string]string)
err := client.Ping(ctx, nil)
if err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("mongodb down: %v", err)
stats["type"] = "mongodb"
result[name] = stats
continue
}
stats["status"] = "up"
stats["type"] = "mongodb"
result[name] = stats
}
return result
}
func (s *service) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
var errs []error
for name, listener := range s.listeners {
if err := listener.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close listener for %s: %w", name, err))
}
}
for name, db := range s.sqlDatabases {
if err := db.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close database %s: %w", name, err))
}
}
for name, replicas := range s.readReplicas {
for i, db := range replicas {
if db != nil {
if err := db.Close(); err != nil {
errs = append(errs, fmt.Errorf("failed to close read replica %s[%d]: %w", name, i, err))
}
}
}
}
for name, client := range s.mongoClients {
if err := client.Disconnect(context.Background()); err != nil {
errs = append(errs, fmt.Errorf("failed to disconnect MongoDB client %s: %w", name, err))
}
}
// Clear maps
s.sqlDatabases = make(map[string]*sql.DB)
s.sqlxDatabases = make(map[string]*sqlx.DB)
s.mongoClients = make(map[string]*mongo.Client)
s.listeners = make(map[string]*pq.Listener)
if len(errs) > 0 {
return fmt.Errorf("errors closing databases: %v", errs)
}
return nil
}
func (s *service) GetReadDB(name string) (*sql.DB, error) {
s.mu.RLock()
defer s.mu.RUnlock()
replicas, exists := s.readReplicas[name]
if !exists || len(replicas) == 0 {
// Fallback to primary if no replicas
return s.GetDB(name)
}
// Round-robin selection
s.readBalancer[name] = (s.readBalancer[name] + 1) % len(replicas)
selected := replicas[s.readBalancer[name]]
if selected == nil {
return s.GetDB(name)
}
return selected, nil
}
func (s *service) GetMongoClient(name string) (*mongo.Client, error) {
s.mu.RLock()
defer s.mu.RUnlock()
client, exists := s.mongoClients[name]
if !exists {
return nil, fmt.Errorf("MongoDB client %s not found", name)
}
return client, nil
}
func (s *service) ListDBs() []string {
s.mu.RLock()
defer s.mu.RUnlock()
names := make([]string, 0, len(s.sqlDatabases)+len(s.mongoClients))
for name := range s.sqlDatabases {
names = append(names, name)
}
for name := range s.mongoClients {
names = append(names, name)
}
return names
}
func (s *service) GetDBType(name string) (DatabaseType, error) {
s.mu.RLock()
defer s.mu.RUnlock()
config, exists := s.configs[name]
if !exists {
return "", fmt.Errorf("database %s not found", name)
}
return DatabaseType(config.Type), nil
}
func (s *service) GetPrimaryDB(name string) (*sql.DB, error) {
return s.GetDB(name)
}
func (s *service) ExecuteQuery(ctx context.Context, dbName string, query string, args ...interface{}) (*sql.Rows, error) {
db, err := s.GetDB(dbName)
if err != nil {
return nil, fmt.Errorf("failed to get database %s: %w", dbName, err)
}
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to execute query: %w", err)
}
return rows, nil
}
func (s *service) ExecuteQueryRow(ctx context.Context, dbName string, query string, args ...interface{}) *sql.Row {
db, err := s.GetDB(dbName)
if err != nil {
// Return dummy row that will return error on Scan
// But standard sql.Row doesn't allow easy mocking of error without query execution.
// So we must handle nil db check before calling this or panic might occur if not careful?
// sql.DB methods are safe to call, but we returned error from GetDB.
// Standard pattern: return row created from db, if db error, we can't create row easily.
// Workaround: return a row from a failed query on a dummy DB or panic if critical.
// For now, let's assume GetDB returns error and caller handles it, but since return signature is *sql.Row only...
// Ideally change signature to (*sql.Row, error) or panic.
// Here we just return an empty row which will likely fail on scan.
return &sql.Row{}
}
return db.QueryRowContext(ctx, query, args...)
}
func (s *service) Exec(ctx context.Context, dbName string, query string, args ...interface{}) (sql.Result, error) {
db, err := s.GetDB(dbName)
if err != nil {
return nil, fmt.Errorf("failed to get database %s: %w", dbName, err)
}
result, err := db.ExecContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("failed to execute query: %w", err)
}
return result, nil
}
func (s *service) ListenForChanges(ctx context.Context, dbName string, channels []string, callback func(string, string)) error {
s.mu.RLock()
config, exists := s.configs[dbName]
s.mu.RUnlock()
if !exists {
return fmt.Errorf("database %s not found", dbName)
}
if DatabaseType(config.Type) != Postgres {
return fmt.Errorf("LISTEN/NOTIFY only supported for PostgreSQL databases")
}
// Reconstruct connection string for pq listener
connectTimeoutSec := int(config.ConnectTimeout.Seconds())
connStr := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s&connect_timeout=%d",
config.Username,
config.Password,
config.Host,
config.Port,
config.Database,
config.SSLMode,
connectTimeoutSec,
)
listener := pq.NewListener(
connStr,
10*time.Second,
time.Minute,
func(ev pq.ListenerEventType, err error) {
if err != nil {
log.Printf("Database listener (%s) connection error: %v", dbName, err)
}
},
)
s.listenersMu.Lock()
s.listeners[dbName] = listener
s.listenersMu.Unlock()
for _, channel := range channels {
if err := listener.Listen(channel); err != nil {
listener.Close()
return fmt.Errorf("failed to listen to channel %s: %w", channel, err)
}
log.Printf("Listening to database channel: %s on %s", channel, dbName)
}
go func() {
defer func() {
listener.Close()
s.listenersMu.Lock()
delete(s.listeners, dbName)
s.listenersMu.Unlock()
log.Printf("Database listener for %s stopped", dbName)
}()
// Ticker untuk melakukan Ping manual sebagai pengganti MonitorPing
pingTicker := time.NewTicker(30 * time.Second)
defer pingTicker.Stop()
for {
select {
case n := <-listener.Notify:
if n != nil {
// Panggil callback secara async
go callback(n.Channel, n.Extra)
}
case <-pingTicker.C:
// Manual Ping untuk memastikan koneksi tetap hidup
go func() {
if err := listener.Ping(); err != nil {
log.Printf("⚠️ Listener ping warning for %s: %v", dbName, err)
}
}()
case <-ctx.Done():
return
}
}
}()
return nil
}
func (s *service) NotifyChange(dbName, channel, payload string) error {
db, err := s.GetDB(dbName)
if err != nil {
return fmt.Errorf("failed to get database %s: %w", dbName, err)
}
// Pastikan ini Postgres
s.mu.RLock()
config, exists := s.configs[dbName]
s.mu.RUnlock()
if !exists || DatabaseType(config.Type) != Postgres {
return fmt.Errorf("NOTIFY only supported for PostgreSQL")
}
_, err = db.Exec("SELECT pg_notify($1, $2)", channel, payload)
if err != nil {
return fmt.Errorf("failed to send notification: %w", err)
}
return nil
}
func (s *service) optimizeConnectionPool(name string, db *sql.DB, config config.DatabaseConfig) {
// Jika config menyediakan nilai, gunakan itu. Jika tidak, gunakan formula auto-tuning.
maxOpen := config.MaxOpenConns
maxIdle := config.MaxIdleConns
// Auto-tune jika tidak diset di config (0)
if maxOpen <= 0 {
cpuCores := runtime.NumCPU()
maxOpen = cpuCores*2 + 1
}
if maxIdle <= 0 {
maxIdle = maxOpen / 2
}
db.SetMaxOpenConns(maxOpen)
db.SetMaxIdleConns(maxIdle)
// Gunakan config duration atau default
if config.ConnMaxLifetime > 0 {
db.SetConnMaxLifetime(config.ConnMaxLifetime)
} else {
db.SetConnMaxLifetime(30 * time.Minute)
}
if config.MaxIdleTime > 0 {
db.SetConnMaxIdleTime(config.MaxIdleTime)
} else {
db.SetConnMaxIdleTime(10 * time.Minute)
}
log.Printf("Connection pool configured for %s: MaxOpen=%d, MaxIdle=%d", name, maxOpen, maxIdle)
}
func (s *service) validateConnection(name string, db *sql.DB) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) // Timeout lebih panjang untuk validasi
defer cancel()
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("connection validation failed for %s: %w", name, err)
}
return nil
}
func (s *service) ensureMigrationTable(db *sql.DB, dbType DatabaseType) error {
var createTableSQL string
switch dbType {
case Postgres:
createTableSQL = `
CREATE TABLE IF NOT EXISTS Migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
executed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
`
case MySQL:
createTableSQL = `
CREATE TABLE IF NOT EXISTS Migrations (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`
case SQLServer:
createTableSQL = `
CREATE TABLE IF NOT EXISTS Migrations (
id INT IDENTITY(1,1) PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
executed_at DATETIME DEFAULT GETDATE()
);
`
case SQLite:
createTableSQL = `
CREATE TABLE IF NOT EXISTS Migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL UNIQUE,
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`
default:
return fmt.Errorf("unsupported database type for migration table: %s", dbType)
}
// Eksekusi query dengan penanganan error yang lebih baik
result, err := db.Exec(createTableSQL)
if err != nil {
return fmt.Errorf("failed to create migrations table: %w", err)
}
// Cek apakah tabel berhasil dibuat
rowsAffected, _ := result.RowsAffected()
if rowsAffected > 0 {
logger.Default().Info("Migrations table created successfully")
} else {
logger.Default().Debug("Migrations table already exists")
}
return nil
}
func (s *service) isMigrationApplied(db *sql.DB, filename string) bool {
var count int
// Gunakan query yang sesuai dengan database type
var query string
switch s.getDatabaseTypeFromConnection(db) {
case Postgres:
query = "SELECT COUNT(*) FROM Migrations WHERE filename = $1"
case MySQL, SQLite:
query = "SELECT COUNT(*) FROM Migrations WHERE filename = ?"
case SQLServer:
query = "SELECT COUNT(*) FROM Migrations WHERE filename = @p1"
default:
query = "SELECT COUNT(*) FROM Migrations WHERE filename = ?"
}
err := db.QueryRow(query, filename).Scan(&count)
if err != nil {
// Jika tabel belum ada, anggap migrasi belum dijalankan
if isTableNotExistsError(err) {
return false
}
logger.Default().Error("Error checking migration status", logger.ErrorField(err))
return false
}
return count > 0
}
func (s *service) executeMigrationFile(db *sql.DB, filePath string) error {
// Baca konten file SQL
sqlContent, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read migration file %s: %w", filePath, err)
}
// Jalankan migrasi dalam transaksi
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if err != nil {
tx.Rollback()
}
}()
// Eksekusi setiap perintah SQL
commands := strings.Split(string(sqlContent), ";")
for _, cmd := range commands {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
continue
}
_, err := tx.Exec(cmd)
if err != nil {
return fmt.Errorf("failed to execute SQL command: %s, error: %w", cmd, err)
}
}
// Commit transaksi
if err = tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
// Catat migrasi sebagai telah dijalankan
filename := filepath.Base(filePath)
if err := s.recordMigration(db, filename); err != nil {
return fmt.Errorf("failed to record migration: %w", err)
}
return nil
}
func (s *service) recordMigration(db *sql.DB, filename string) error {
// Gunakan query yang sesuai dengan database type
var query string
switch s.getDatabaseTypeFromConnection(db) {
case Postgres:
query = "INSERT INTO Migrations (filename) VALUES ($1)"
case MySQL, SQLite:
query = "INSERT INTO Migrations (filename) VALUES (?)"
case SQLServer:
query = "INSERT INTO Migrations (filename) VALUES (@p1)"
default:
query = "INSERT INTO Migrations (filename) VALUES (?)"
}
_, err := db.Exec(query, filename)
if err != nil {
return fmt.Errorf("failed to record migration: %w", err)
}
return nil
}
// Fungsi bantu untuk mendeteksi tipe database dari koneksi
func (s *service) getDatabaseTypeFromConnection(db *sql.DB) DatabaseType {
// Ini adalah implementasi sederhana, Anda mungkin perlu menyesuaikan
// dengan cara Anda mengelola koneksi database
// Coba dapatkan driver name dari koneksi
// Note: Ini mungkin perlu disesuaikan dengan driver yang Anda gunakan
if postgresDriver := db.Driver(); postgresDriver != nil {
driverType := fmt.Sprintf("%T", postgresDriver)
if strings.Contains(driverType, "postgres") {
return Postgres
} else if strings.Contains(driverType, "mysql") {
return MySQL
} else if strings.Contains(driverType, "sqlserver") {
return SQLServer
} else if strings.Contains(driverType, "sqlite") {
return SQLite
}
}
// Default fallback
return Postgres
}