Files
soft_usite/pkg/services/query_runner_service.go
T

318 lines
9.2 KiB
Go

package services
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
_ "github.com/microsoft/go-mssqldb"
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
)
// connTimeout es el tiempo máximo para establecer una conexión a la DB externa.
const connTimeout = 8 * time.Second
// QueryResult contiene el resultado de una consulta SQL.
type QueryResult struct {
Columns []string `json:"columns"`
Rows []map[string]any `json:"rows"`
RowCount int `json:"row_count"`
AffectedRows int64 `json:"affected_rows"`
DurationMs int64 `json:"duration_ms"`
IsSelect bool `json:"is_select"`
Error string `json:"error,omitempty"`
}
// openDynamicDB abre una conexión a la base de datos indicada por ConxDb.
func openDynamicDB(c models.ConxDb) (*sql.DB, error) {
driver := strings.ToLower(c.TipoDb.Nombre)
host := c.Servidor.IpServidor
port := c.Puerto
user := c.Usuario
pass := c.Password
var dsn string
var driverName string
switch {
case strings.Contains(driver, "postgres"):
driverName = "postgres"
dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s sslmode=disable connect_timeout=8", host, port, user, pass)
case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
driverName = "mysql"
dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/?timeout=8s&readTimeout=20s&writeTimeout=20s", user, pass, host, port)
case strings.Contains(driver, "sqlite"):
driverName = "sqlite3"
dsn = host // para sqlite el host es la ruta del archivo
case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
driverName = "sqlserver"
dsn = fmt.Sprintf("sqlserver://%s:%s@%s:%s?dial+timeout=8", user, pass, host, port)
default:
return nil, fmt.Errorf("driver no soportado: %s", driver)
}
db, err := sql.Open(driverName, dsn)
if err != nil {
return nil, err
}
db.SetConnMaxLifetime(30 * time.Second)
db.SetMaxOpenConns(2)
// Verificar conectividad inmediatamente para fallar rápido en lugar de bloquear al hacer la primera query
ctx, cancel := context.WithTimeout(context.Background(), connTimeout)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("no se pudo conectar al servidor de base de datos: %w", err)
}
return db, nil
}
// ExecuteSQL ejecuta SQL arbitrario contra la conexión y devuelve QueryResult.
// También guarda en query_history.
func ExecuteSQL(conx models.ConxDb, database, sqlText string) QueryResult {
start := time.Now()
db, err := openDynamicDB(conx)
if err != nil {
saveHistory(conx.ID, sqlText, "error", err.Error(), 0, time.Since(start).Milliseconds())
return QueryResult{Error: err.Error()}
}
defer db.Close()
// Si se especifica una base de datos para seleccionar
if database != "" {
driver := strings.ToLower(conx.TipoDb.Nombre)
if strings.Contains(driver, "postgres") {
// En postgres se cambia con SET search_path o reconectando con dbname en DSN
db2, err2 := openDynamicDBWithName(conx, database)
if err2 == nil {
db.Close()
db = db2
}
} else {
if _, err2 := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err2 != nil {
saveHistory(conx.ID, sqlText, "error", err2.Error(), 0, time.Since(start).Milliseconds())
return QueryResult{Error: err2.Error()}
}
}
}
trimmed := strings.TrimSpace(sqlText)
isSelect := isSelectStatement(trimmed)
var result QueryResult
result.IsSelect = isSelect
if isSelect {
rows, err := db.Query(trimmed)
if err != nil {
elapsed := time.Since(start).Milliseconds()
saveHistory(conx.ID, sqlText, "error", err.Error(), 0, elapsed)
return QueryResult{Error: err.Error(), IsSelect: true}
}
defer rows.Close()
cols, _ := rows.Columns()
result.Columns = cols
for rows.Next() {
vals := make([]any, len(cols))
ptrs := make([]any, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
rows.Scan(ptrs...)
row := make(map[string]any, len(cols))
for i, col := range cols {
v := vals[i]
if b, ok := v.([]byte); ok {
row[col] = string(b)
} else {
row[col] = v
}
}
result.Rows = append(result.Rows, row)
}
result.RowCount = len(result.Rows)
} else {
res, err := db.Exec(trimmed)
elapsed := time.Since(start).Milliseconds()
if err != nil {
saveHistory(conx.ID, sqlText, "error", err.Error(), 0, elapsed)
return QueryResult{Error: err.Error()}
}
affected, _ := res.RowsAffected()
result.AffectedRows = affected
result.DurationMs = elapsed
saveHistory(conx.ID, sqlText, "ok", "", affected, elapsed)
return result
}
result.DurationMs = time.Since(start).Milliseconds()
saveHistory(conx.ID, sqlText, "ok", "", int64(result.RowCount), result.DurationMs)
return result
}
// ListDatabases devuelve la lista de bases de datos del servidor.
func ListDatabases(conx models.ConxDb) ([]string, error) {
db, err := openDynamicDB(conx)
if err != nil {
return nil, err
}
defer db.Close()
driver := strings.ToLower(conx.TipoDb.Nombre)
var query string
switch {
case strings.Contains(driver, "postgres"):
query = "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname"
case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
query = "SHOW DATABASES"
case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
query = "SELECT name FROM sys.databases ORDER BY name"
default:
return []string{"main"}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var dbs []string
for rows.Next() {
var name string
rows.Scan(&name)
dbs = append(dbs, name)
}
return dbs, nil
}
// ListTables devuelve las tablas de una base de datos.
func ListTables(conx models.ConxDb, database string) ([]string, error) {
driver := strings.ToLower(conx.TipoDb.Nombre)
var db *sql.DB
var err error
if strings.Contains(driver, "postgres") {
db, err = openDynamicDBWithName(conx, database)
} else {
db, err = openDynamicDB(conx)
}
if err != nil {
return nil, err
}
defer db.Close()
var query string
switch {
case strings.Contains(driver, "postgres"):
query = "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
if _, err := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err != nil {
return nil, err
}
query = "SHOW TABLES"
case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
query = fmt.Sprintf("USE [%s]; SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE='BASE TABLE' ORDER BY TABLE_NAME", database)
default:
query = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
}
rows, err := db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var tables []string
for rows.Next() {
var name string
rows.Scan(&name)
tables = append(tables, name)
}
return tables, nil
}
// TestConnection verifica si la conexión es válida.
func TestDBConnection(conx models.ConxDb) error {
db, err := openDynamicDB(conx)
if err != nil {
return err
}
defer db.Close()
return db.Ping()
}
// ── helpers ──────────────────────────────────────────────────────────────────
func openDynamicDBWithName(c models.ConxDb, dbName string) (*sql.DB, error) {
host := c.Servidor.IpServidor
port := c.Puerto
user := c.Usuario
pass := c.Password
driver := strings.ToLower(c.TipoDb.Nombre)
var dsn string
switch {
case strings.Contains(driver, "postgres"):
dsn = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable connect_timeout=8", host, port, user, pass, dbName)
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
db.SetConnMaxLifetime(30 * time.Second)
db.SetMaxOpenConns(2)
ctx, cancel := context.WithTimeout(context.Background(), connTimeout)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("no se pudo conectar a la base de datos '%s': %w", dbName, err)
}
return db, nil
default:
return openDynamicDB(c)
}
}
func isSelectStatement(sql string) bool {
upper := strings.ToUpper(strings.TrimSpace(sql))
keywords := []string{"SELECT ", "SHOW ", "DESCRIBE ", "EXPLAIN ", "WITH ", "PRAGMA "}
for _, kw := range keywords {
if strings.HasPrefix(upper, kw) {
return true
}
}
return false
}
func quoteIdentifier(name, driver string) string {
d := strings.ToLower(driver)
if strings.Contains(d, "postgres") {
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
}
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
}
func saveHistory(conxID uint, sqlText, status, errMsg string, rows, durationMs int64) {
models.SaveQueryHistory(models.QueryHistory{
ConxDbID: conxID,
SQL: sqlText,
Status: status,
ErrorMsg: errMsg,
RowsAffect: rows,
DurationMs: durationMs,
ExecutedAt: time.Now(),
})
}