feat: Query Runner — editor SQL con historial, exportar CSV/JSON, árbol de tablas
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// QueryHistory almacena el historial de consultas ejecutadas por los usuarios.
|
||||
type QueryHistory struct {
|
||||
gorm.Model
|
||||
ConxDbID uint `json:"conx_db_id" gorm:"column:conx_db_id;index"`
|
||||
ConxDb ConxDb `json:"conx_db" gorm:"foreignKey:ConxDbID"`
|
||||
SQL string `json:"sql" gorm:"column:sql;type:text"`
|
||||
Status string `json:"status" gorm:"column:status"` // ok | error
|
||||
ErrorMsg string `json:"error_msg" gorm:"column:error_msg;type:text"`
|
||||
RowsAffect int64 `json:"rows_affect" gorm:"column:rows_affect"`
|
||||
DurationMs int64 `json:"duration_ms" gorm:"column:duration_ms"`
|
||||
ExecutedAt time.Time `json:"executed_at" gorm:"column:executed_at;autoCreateTime"`
|
||||
}
|
||||
|
||||
func (QueryHistory) TableName() string {
|
||||
return "query_history"
|
||||
}
|
||||
|
||||
// SaveQueryHistory guarda una entrada en el historial.
|
||||
func SaveQueryHistory(h QueryHistory) error {
|
||||
return app.Http.Database.DB.Create(&h).Error
|
||||
}
|
||||
|
||||
// GetQueryHistory devuelve el historial de una conexión con paginación.
|
||||
func GetQueryHistory(conxDbID uint, limit, offset int) ([]QueryHistory, int64, error) {
|
||||
var items []QueryHistory
|
||||
var total int64
|
||||
db := app.Http.Database.DB.Model(&QueryHistory{}).Where("conx_db_id = ?", conxDbID)
|
||||
db.Count(&total)
|
||||
err := db.Order("executed_at DESC").Limit(limit).Offset(offset).Find(&items).Error
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
// DeleteQueryHistory elimina todo el historial de una conexión.
|
||||
func DeleteQueryHistory(conxDbID uint) error {
|
||||
return app.Http.Database.DB.Where("conx_db_id = ?", conxDbID).Delete(&QueryHistory{}).Error
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
_ "github.com/microsoft/go-mssqldb"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
// 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", host, port, user, pass)
|
||||
case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
|
||||
driverName = "mysql"
|
||||
dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/", 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", 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)
|
||||
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
|
||||
}
|
||||
|
||||
rows, err := db.Query(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", 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)
|
||||
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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user