Prepara el terreno para que el cliente cargue su propia cuenta de IA sin que su clave quede en texto plano ni que el selector le muestre las de los demás. - ClaveEnClaro() descifra con fallback a texto plano: las filas viejas se leen igual y quedan cifradas al primer guardado, sin script ni downtime. utils.Decrypt hace panic (no devuelve error) con entrada que no es un ciphertext válido, así que el fallback va sobre recover — eso mismo es el mecanismo de detección de "todavía está en claro". - Migrados TODOS los lectores: uMind (chat y embeddings), bot de Telegram, Whisper, Query Runner, streaming de IA y Landing Generator. Un lector sin migrar mandaría el ciphertext como API key. - AiConfig gana TenantID (null = global del staff) y GetAiConfigSelectPorTenants para acotar el selector. - Test de los 4 casos del fallback, incluido hex válido que no descifra. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
892 lines
28 KiB
Go
892 lines
28 KiB
Go
package controllers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/auth"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
|
)
|
|
|
|
// QueryRunnerPage renderiza la vista del editor SQL.
|
|
// GET /app/query-runner?conx_db_id=1
|
|
func QueryRunnerPage(c *fiber.Ctx) error {
|
|
conxIDStr := c.Query("conx_db_id", "")
|
|
var conx models.ConxDb
|
|
if conxIDStr != "" {
|
|
id, _ := strconv.ParseUint(conxIDStr, 10, 32)
|
|
app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").First(&conx, id)
|
|
}
|
|
|
|
data := fiber.Map{
|
|
"user": c.Locals("user").(map[string]interface{}),
|
|
"modules": c.Locals("userModules"),
|
|
"conx_db_id": conxIDStr,
|
|
"conx": conx,
|
|
}
|
|
if err := c.Render("query_runner", data, "layouts/main"); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetConxDbList devuelve las conexiones DB disponibles (según el rol del usuario).
|
|
// Para API calls (Hermes) sin sesión retorna todas.
|
|
func GetConxDbList(c *fiber.Ctx) error {
|
|
user, err := auth.User(c)
|
|
if err != nil || user == nil {
|
|
var all []models.ConxDb
|
|
app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").Find(&all)
|
|
return c.JSON(fiber.Map{"data": all})
|
|
}
|
|
|
|
var items []models.ConxDb
|
|
if user.IsAdmin {
|
|
app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").Find(&items)
|
|
} else {
|
|
ids := make([]uint, 0)
|
|
for _, db := range user.Role.ConxDBs {
|
|
ids = append(ids, db.ID)
|
|
}
|
|
if len(ids) == 0 {
|
|
return c.JSON(fiber.Map{"data": []models.ConxDb{}})
|
|
}
|
|
app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").Where("id IN ?", ids).Find(&items)
|
|
}
|
|
return c.JSON(fiber.Map{"data": items})
|
|
}
|
|
|
|
// GetDatabases lista las bases de datos de una conexión.
|
|
// GET /app/query-runner/databases?conx_db_id=1
|
|
func GetDatabases(c *fiber.Ctx) error {
|
|
conx, err := loadConxDb(c.Query("conx_db_id", "0"))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
if !usuarioPuedeUsarConx(c, conx.ID) {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "No tienes acceso a esta conexión"})
|
|
}
|
|
dbs, err := services.ListDatabases(conx)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"data": dbs})
|
|
}
|
|
|
|
// GetTables lista las tablas de una base de datos.
|
|
// GET /app/query-runner/tables?conx_db_id=1&db=mydb
|
|
func GetTables(c *fiber.Ctx) error {
|
|
conx, err := loadConxDb(c.Query("conx_db_id", "0"))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
if !usuarioPuedeUsarConx(c, conx.ID) {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "No tienes acceso a esta conexión"})
|
|
}
|
|
dbName := c.Query("db", "")
|
|
tables, err := services.ListTables(conx, dbName)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"data": tables})
|
|
}
|
|
|
|
// GetRelations lista las llaves foráneas de toda una base de datos, para
|
|
// poder dibujar el diagrama de relaciones entre tablas.
|
|
// GET /app/query-runner/relations?conx_db_id=1&db=mydb
|
|
func GetRelations(c *fiber.Ctx) error {
|
|
conx, err := loadConxDb(c.Query("conx_db_id", "0"))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
if !usuarioPuedeUsarConx(c, conx.ID) {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "No tienes acceso a esta conexión"})
|
|
}
|
|
dbName := c.Query("db", "")
|
|
relaciones, err := services.GetSchemaRelations(conx, dbName)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"data": relaciones})
|
|
}
|
|
|
|
// usuarioPuedeUsarConx verifica que el usuario autenticado pueda ejecutar SQL
|
|
// contra esa conexión: admin ve todas, el resto solo las que su rol tiene
|
|
// asignadas en Role.ConxDBs (el mismo criterio que ya usa GetConxDbList para
|
|
// decidir qué mostrar en el selector — aquí se hace cumplir de verdad).
|
|
//
|
|
// Sin este chequeo, cualquiera con acceso al submódulo Query Runner podía
|
|
// mandar cualquier conx_db_id por API y ejecutar SQL contra una conexión que
|
|
// nunca se le mostró ni se le asignó a su rol.
|
|
func usuarioPuedeUsarConx(c *fiber.Ctx, conxDbID uint) bool {
|
|
user, err := auth.User(c)
|
|
if err != nil || user == nil {
|
|
// Llamada sin sesión (Hermes/API con su propia auth): no restringido aquí.
|
|
return true
|
|
}
|
|
if user.IsAdmin {
|
|
return true
|
|
}
|
|
for _, db := range user.Role.ConxDBs {
|
|
if db.ID == conxDbID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// RunQuery ejecuta una consulta SQL.
|
|
// POST /app/query-runner/run
|
|
// Body: { conx_db_id, database, sql }
|
|
func RunQuery(c *fiber.Ctx) error {
|
|
var body struct {
|
|
ConxDbID uint `json:"conx_db_id"`
|
|
Database string `json:"database"`
|
|
SQL string `json:"sql"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
|
|
}
|
|
if strings.TrimSpace(body.SQL) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "La consulta está vacía"})
|
|
}
|
|
if !usuarioPuedeUsarConx(c, body.ConxDbID) {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "No tienes acceso a esta conexión"})
|
|
}
|
|
|
|
conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
uid := extractUserID(c)
|
|
result := services.ExecuteSQL(conx, body.Database, body.SQL, uid)
|
|
return c.JSON(result)
|
|
}
|
|
|
|
// RunBatchQuery ejecuta múltiples consultas en lote.
|
|
// POST /app/query-runner/run-batch
|
|
// Body: { conx_db_id, database, sqls: ["...", "..."] }
|
|
// También soporta multipart/form-data con file .sql
|
|
func RunBatchQuery(c *fiber.Ctx) error {
|
|
uid := extractUserID(c)
|
|
conxDbIDStr := c.FormValue("conx_db_id", c.Query("conx_db_id"))
|
|
database := c.FormValue("database", c.Query("database"))
|
|
|
|
// Obtener statements del body o del archivo subido
|
|
var statements []string
|
|
|
|
// 1. Intentar leer archivo .sql subido
|
|
if file, err := c.FormFile("file"); err == nil {
|
|
f, err := file.Open()
|
|
if err == nil {
|
|
defer f.Close()
|
|
buf := new(bytes.Buffer)
|
|
buf.ReadFrom(f)
|
|
content := buf.String()
|
|
// Dividir por ;
|
|
for _, stmt := range strings.Split(content, ";") {
|
|
stmt = strings.TrimSpace(stmt)
|
|
if stmt != "" {
|
|
statements = append(statements, stmt)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Si no hay archivo, leer del body JSON
|
|
if len(statements) == 0 {
|
|
var body struct {
|
|
ConxDbID uint `json:"conx_db_id"`
|
|
Database string `json:"database"`
|
|
SQLs []string `json:"sqls"`
|
|
}
|
|
if err := c.BodyParser(&body); err == nil {
|
|
conxDbIDStr = strconv.Itoa(int(body.ConxDbID))
|
|
if body.Database != "" {
|
|
database = body.Database
|
|
}
|
|
for _, s := range body.SQLs {
|
|
s = strings.TrimSpace(s)
|
|
if s != "" {
|
|
statements = append(statements, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(statements) == 0 {
|
|
return c.Status(400).JSON(fiber.Map{"error": "No hay consultas para ejecutar. Envía sqls[] o un archivo .sql"})
|
|
}
|
|
|
|
if conxIDNum, parseErr := strconv.ParseUint(conxDbIDStr, 10, 32); parseErr == nil {
|
|
if !usuarioPuedeUsarConx(c, uint(conxIDNum)) {
|
|
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "No tienes acceso a esta conexión"})
|
|
}
|
|
}
|
|
|
|
conx, err := loadConxDb(conxDbIDStr)
|
|
if err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
type batchResult struct {
|
|
Index int `json:"index"`
|
|
SQL string `json:"sql"`
|
|
Status string `json:"status"`
|
|
Duration string `json:"duration"`
|
|
Rows int `json:"rows"`
|
|
Columns []string `json:"columns,omitempty"`
|
|
Data []fiber.Map `json:"data,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
results := make([]batchResult, 0, len(statements))
|
|
|
|
for i, stmt := range statements {
|
|
r := services.ExecuteSQL(conx, database, stmt, uid)
|
|
br := batchResult{
|
|
Index: i,
|
|
SQL: stmt,
|
|
Status: "ok",
|
|
Duration: fmt.Sprintf("%dms", r.DurationMs),
|
|
Rows: r.RowCount,
|
|
}
|
|
if r.Error != "" {
|
|
br.Status = "error"
|
|
br.Error = r.Error
|
|
} else {
|
|
br.Columns = r.Columns
|
|
br.Data = make([]fiber.Map, len(r.Rows))
|
|
for j, row := range r.Rows {
|
|
br.Data[j] = fiber.Map(row)
|
|
}
|
|
}
|
|
results = append(results, br)
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"results": results, "total": len(results)})
|
|
}
|
|
|
|
// TestConnection verifica que la conexión funciona.
|
|
// GET /app/query-runner/test?conx_db_id=1
|
|
func TestConnection(c *fiber.Ctx) error {
|
|
conx, err := loadConxDb(c.Query("conx_db_id", "0"))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
if err := services.TestDBConnection(conx); err != nil {
|
|
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"ok": false, "error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// GetHistory devuelve el historial de una conexión.
|
|
// GET /app/query-runner/history?conx_db_id=1&page=1
|
|
func GetHistory(c *fiber.Ctx) error {
|
|
conxIDStr := c.Query("conx_db_id", "0")
|
|
conxID, _ := strconv.ParseUint(conxIDStr, 10, 32)
|
|
page, _ := strconv.Atoi(c.Query("page", "1"))
|
|
if page < 1 {
|
|
page = 1
|
|
}
|
|
limit := 50
|
|
offset := (page - 1) * limit
|
|
|
|
user, _ := auth.User(c)
|
|
var items []models.QueryHistory
|
|
var total int64
|
|
var err error
|
|
if user != nil && user.IsAdmin {
|
|
items, total, err = models.GetQueryHistoryAdmin(uint(conxID), limit, offset)
|
|
} else {
|
|
uid := uint(0)
|
|
if user != nil {
|
|
uid = user.ID
|
|
}
|
|
items, total, err = models.GetQueryHistory(uint(conxID), uid, limit, offset)
|
|
}
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
totalPages := int(math.Ceil(float64(total) / float64(limit)))
|
|
return c.JSON(fiber.Map{
|
|
"data": items,
|
|
"total": total,
|
|
"totalPages": totalPages,
|
|
"page": page,
|
|
})
|
|
}
|
|
|
|
// ClearHistory borra el historial de una conexión.
|
|
// DELETE /app/query-runner/history?conx_db_id=1
|
|
func ClearHistory(c *fiber.Ctx) error {
|
|
conxIDStr := c.Query("conx_db_id", "0")
|
|
conxID, _ := strconv.ParseUint(conxIDStr, 10, 32)
|
|
|
|
user, _ := auth.User(c)
|
|
var err error
|
|
if user != nil && user.IsAdmin {
|
|
err = models.DeleteQueryHistoryAdmin(uint(conxID))
|
|
} else {
|
|
uid := uint(0)
|
|
if user != nil {
|
|
uid = user.ID
|
|
}
|
|
err = models.DeleteQueryHistory(uint(conxID), uid)
|
|
}
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true})
|
|
}
|
|
|
|
// ExportCSV exporta el resultado de una consulta como CSV.
|
|
// POST /app/query-runner/export/csv
|
|
func ExportCSV(c *fiber.Ctx) error {
|
|
var body struct {
|
|
ConxDbID uint `json:"conx_db_id"`
|
|
Database string `json:"database"`
|
|
SQL string `json:"sql"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
|
|
}
|
|
conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
uid := extractUserID(c)
|
|
result := services.ExecuteSQL(conx, body.Database, body.SQL, uid)
|
|
if result.Error != "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": result.Error})
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
w := csv.NewWriter(&buf)
|
|
w.Write(result.Columns)
|
|
for _, row := range result.Rows {
|
|
rec := make([]string, len(result.Columns))
|
|
for i, col := range result.Columns {
|
|
v := row[col]
|
|
if v == nil {
|
|
rec[i] = ""
|
|
} else {
|
|
rec[i] = fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
w.Write(rec)
|
|
}
|
|
w.Flush()
|
|
|
|
filename := fmt.Sprintf("query_%s.csv", time.Now().Format("20060102_150405"))
|
|
c.Set("Content-Disposition", "attachment; filename="+filename)
|
|
c.Set("Content-Type", "text/csv; charset=utf-8")
|
|
return c.SendStream(bytes.NewReader(buf.Bytes()), buf.Len())
|
|
}
|
|
|
|
// ExportJSON exporta el resultado como JSON.
|
|
// POST /app/query-runner/export/json
|
|
func ExportJSON(c *fiber.Ctx) error {
|
|
var body struct {
|
|
ConxDbID uint `json:"conx_db_id"`
|
|
Database string `json:"database"`
|
|
SQL string `json:"sql"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
|
|
}
|
|
conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
uid := extractUserID(c)
|
|
result := services.ExecuteSQL(conx, body.Database, body.SQL, uid)
|
|
if result.Error != "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": result.Error})
|
|
}
|
|
|
|
data, _ := json.MarshalIndent(result.Rows, "", " ")
|
|
filename := fmt.Sprintf("query_%s.json", time.Now().Format("20060102_150405"))
|
|
c.Set("Content-Disposition", "attachment; filename="+filename)
|
|
c.Set("Content-Type", "application/json; charset=utf-8")
|
|
return c.SendStream(bytes.NewReader(data), len(data))
|
|
}
|
|
|
|
// GetTableColumnsHandler devuelve info de columnas de una tabla.
|
|
// GET /app/query-runner/columns?conx_db_id=1&db=mydb&table=users
|
|
func GetTableColumnsHandler(c *fiber.Ctx) error {
|
|
conx, err := loadConxDb(c.Query("conx_db_id", "0"))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
dbName := c.Query("db", "")
|
|
table := c.Query("table", "")
|
|
if table == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "table es requerido"})
|
|
}
|
|
cols, err := services.GetTableColumns(conx, dbName, table)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
return c.JSON(fiber.Map{"data": cols})
|
|
}
|
|
|
|
// UpdateCellHandler actualiza una celda específica.
|
|
// POST /app/query-runner/update-cell
|
|
// Body: { conx_db_id, database, table, column, pk_column, pk_value, value }
|
|
func UpdateCellHandler(c *fiber.Ctx) error {
|
|
var body struct {
|
|
ConxDbID uint `json:"conx_db_id"`
|
|
Database string `json:"database"`
|
|
Table string `json:"table"`
|
|
Column string `json:"column"`
|
|
PkColumn string `json:"pk_column"`
|
|
PkValue string `json:"pk_value"`
|
|
Value string `json:"value"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
|
|
}
|
|
if body.Table == "" || body.Column == "" || body.PkColumn == "" || body.PkValue == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "table, column, pk_column y pk_value son requeridos"})
|
|
}
|
|
|
|
conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
driver := strings.ToLower(conx.TipoDb.Nombre)
|
|
if strings.Contains(driver, "mongo") || strings.Contains(driver, "redis") {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Solo soportado para SQL"})
|
|
}
|
|
|
|
// Construir la consulta de actualización
|
|
qCol := quoteIdent(body.Column, driver)
|
|
qTable := quoteIdent(body.Table, driver)
|
|
qPkCol := quoteIdent(body.PkColumn, driver)
|
|
|
|
// Determinar si el valor debe ir como string o literal
|
|
isNumeric := false
|
|
if _, err := strconv.ParseFloat(body.Value, 64); err == nil {
|
|
isNumeric = true
|
|
}
|
|
isBool := strings.ToLower(body.Value) == "true" || strings.ToLower(body.Value) == "false"
|
|
isNull := strings.ToLower(body.Value) == "null"
|
|
|
|
var valueSQL string
|
|
switch {
|
|
case isNull:
|
|
valueSQL = "NULL"
|
|
case isNumeric:
|
|
valueSQL = body.Value
|
|
case isBool:
|
|
valueSQL = body.Value
|
|
default:
|
|
valueSQL = "'" + strings.ReplaceAll(body.Value, "'", "''") + "'"
|
|
}
|
|
|
|
// Determinar si pk_value es numérico para no entrecomillarlo
|
|
pkValueSQL := "'" + strings.ReplaceAll(body.PkValue, "'", "''") + "'"
|
|
if _, err := strconv.ParseFloat(body.PkValue, 64); err == nil {
|
|
pkValueSQL = body.PkValue
|
|
}
|
|
|
|
sqlText := fmt.Sprintf("UPDATE %s SET %s = %s WHERE %s = %s",
|
|
qTable, qCol, valueSQL, qPkCol, pkValueSQL)
|
|
|
|
uid := extractUserID(c)
|
|
result := services.ExecuteSQL(conx, body.Database, sqlText, uid)
|
|
if result.Error != "" {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": result.Error})
|
|
}
|
|
return c.JSON(fiber.Map{"ok": true, "affected": result.AffectedRows})
|
|
}
|
|
|
|
// quoteIdent envuelve un identificador con las comillas adecuadas según el driver.
|
|
func quoteIdent(name, driver string) string {
|
|
d := strings.ToLower(driver)
|
|
if strings.Contains(d, "postgres") {
|
|
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
|
}
|
|
if strings.Contains(d, "sqlserver") || strings.Contains(d, "mssql") {
|
|
return "[" + name + "]"
|
|
}
|
|
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
|
}
|
|
|
|
// NL2SQLHandler convierte lenguaje natural a SQL usando IA.
|
|
// POST /app/query-runner/nl2sql
|
|
// Body: { conx_db_id, database, text, schema }
|
|
// schema es un array de { table, columns: [{name, type}] } para dar contexto a la IA.
|
|
func NL2SQLHandler(c *fiber.Ctx) error {
|
|
var body struct {
|
|
ConxDbID uint `json:"conx_db_id"`
|
|
Database string `json:"database"`
|
|
Text string `json:"text"`
|
|
Schema []struct {
|
|
Table string `json:"table"`
|
|
Columns []struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
} `json:"columns"`
|
|
} `json:"schema"`
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
|
|
}
|
|
if strings.TrimSpace(body.Text) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "El texto está vacío"})
|
|
}
|
|
|
|
// Construir representación del esquema para el prompt
|
|
var schemaLines []string
|
|
for _, t := range body.Schema {
|
|
var cols []string
|
|
for _, col := range t.Columns {
|
|
cols = append(cols, col.Name+" ("+col.Type+")")
|
|
}
|
|
if len(cols) > 0 {
|
|
schemaLines = append(schemaLines, fmt.Sprintf(" %s: %s", t.Table, strings.Join(cols, ", ")))
|
|
} else {
|
|
schemaLines = append(schemaLines, " "+t.Table)
|
|
}
|
|
}
|
|
schemaStr := ""
|
|
if len(schemaLines) > 0 {
|
|
schemaStr = "Esquema disponible:\n" + strings.Join(schemaLines, "\n")
|
|
}
|
|
|
|
aiConfig, err := models.GetAiConfigForService("query_runner")
|
|
if err != nil {
|
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
|
|
"error": "No hay configuración de IA activa. Configura una en /app/ai-config",
|
|
})
|
|
}
|
|
|
|
prompt := fmt.Sprintf(`Eres un experto en SQL. Convierte la siguiente descripción en lenguaje natural a una consulta SQL válida.
|
|
|
|
%s
|
|
|
|
Descripción: %s
|
|
|
|
Reglas:
|
|
- Devuelve SOLO el SQL, sin explicaciones ni bloques de código markdown.
|
|
- Usa los nombres de tablas y columnas exactamente como aparecen en el esquema.
|
|
- Si la base de datos es PostgreSQL, usa comillas dobles para identificadores.
|
|
- Termina siempre con punto y coma.
|
|
- Si no hay suficiente información para generar el SQL, devuelve el SQL más razonable posible.
|
|
|
|
SQL:`, schemaStr, body.Text)
|
|
|
|
result := callAISQLSuggestion(aiConfig, prompt, "nl2sql", body.ConxDbID, body.Database)
|
|
if result == nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "La IA no pudo generar el SQL"})
|
|
}
|
|
return c.JSON(result)
|
|
}
|
|
|
|
// CorrectQueryHandler usa IA o heurística para corregir/sugerir queries.
|
|
// POST /app/query-runner/suggest
|
|
// Body: { conx_db_id, database, sql, action: "correct"|"complete"|"optimize" }
|
|
func CorrectQueryHandler(c *fiber.Ctx) error {
|
|
var body struct {
|
|
ConxDbID uint `json:"conx_db_id"`
|
|
Database string `json:"database"`
|
|
SQL string `json:"sql"`
|
|
Action string `json:"action"` // "correct" | "complete" | "optimize"
|
|
}
|
|
if err := c.BodyParser(&body); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cuerpo inválido"})
|
|
}
|
|
if strings.TrimSpace(body.SQL) == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "La consulta está vacía"})
|
|
}
|
|
if body.Action == "" {
|
|
body.Action = "correct"
|
|
}
|
|
|
|
// 1. Si hay una configuración de IA activa para este servicio, usarla
|
|
aiConfig, aiErr := models.GetAiConfigForService("query_runner")
|
|
if aiErr == nil && aiConfig != nil {
|
|
aiResult := callAISQLSuggestion(aiConfig, body.SQL, body.Action, body.ConxDbID, body.Database)
|
|
if aiResult != nil {
|
|
return c.JSON(aiResult)
|
|
}
|
|
}
|
|
|
|
// 2. Fallback: corrección heurística local
|
|
suggestion := heuristicSQLFix(body.SQL)
|
|
return c.JSON(fiber.Map{
|
|
"suggestion": suggestion,
|
|
"source": "local",
|
|
})
|
|
}
|
|
|
|
// heuristicSQLFix aplica correcciones básicas sin IA.
|
|
func heuristicSQLFix(sql string) string {
|
|
original := sql
|
|
|
|
// Palabras clave de SQL
|
|
// nolint:unused
|
|
keywords := map[string]string{
|
|
"SLECT": "SELECT", "SELCT": "SELECT", "SELCET": "SELECT", "SElECT": "SELECT",
|
|
"SELC": "SELECT", "SELEKT": "SELECT",
|
|
"FORM": "FROM", "FOM": "FROM", "FRO": "FROM",
|
|
"WHER": "WHERE", "WHRE": "WHERE", "WHARE": "WHERE", "WHEERE": "WHERE",
|
|
"UPDTE": "UPDATE", "UPDAT": "UPDATE", "UDATE": "UPDATE", "UPDETA": "UPDATE",
|
|
"DELTE": "DELETE", "DELET": "DELETE", "DELT": "DELETE",
|
|
"INSRT": "INSERT", "INSER": "INSERT", "NSERT": "INSERT",
|
|
"INOT": "INTO", "IINTO": "INTO",
|
|
"VALUS": "VALUES", "VLAUES": "VALUES",
|
|
"GRUP": "GROUP", "GROU": "GROUP", "GRUOP": "GROUP",
|
|
"ORDR": "ORDER", "ORDRE": "ORDER", "ORBER": "ORDER",
|
|
"HAVNG": "HAVING", "HAVIN": "HAVING", "HVING": "HAVING",
|
|
"LIMT": "LIMIT", "LIIMT": "LIMIT",
|
|
"JON": "JOIN", "JOUN": "JOIN",
|
|
"LEF JOIN": "LEFT JOIN", "LEFTJ OIN": "LEFT JOIN",
|
|
"RIGTH": "RIGHT",
|
|
"CRATE": "CREATE", "CREARE": "CREATE",
|
|
"TABEL": "TABLE", "TBALE": "TABLE",
|
|
"ALTR": "ALTER", "ALTE": "ALTER",
|
|
"DRO": "DROP", "DROPP": "DROP",
|
|
"IDNEX": "INDEX", "INEX": "INDEX",
|
|
"PRIMRY": "PRIMARY", "PRIMAR": "PRIMARY", "PRMARY": "PRIMARY",
|
|
"FORIGN": "FOREIGN", "FOREIN": "FOREIGN", "FORIEGN": "FOREIGN",
|
|
"REFERNCES": "REFERENCES", "REFERECES": "REFERENCES", "REFRENCES": "REFERENCES",
|
|
"CONSTRINT": "CONSTRAINT", "CONSTRAIN": "CONSTRAINT",
|
|
"TRUNC": "TRUNCATE", "TRUNCAT": "TRUNCATE",
|
|
"TRIGER": "TRIGGER", "TRGIGER": "TRIGGER",
|
|
"FUNCTON": "FUNCTION", "FUNCTIN": "FUNCTION", "FUNTION": "FUNCTION",
|
|
"PROCEDRE": "PROCEDURE", "PROCDURE": "PROCEDURE",
|
|
"BEGN": "BEGIN", "BEGIIN": "BEGIN",
|
|
"COMIT": "COMMIT", "COMIIT": "COMMIT",
|
|
"ROLLBCK": "ROLLBACK", "ROLLBAK": "ROLLBACK", "ROLBACK": "ROLLBACK",
|
|
}
|
|
|
|
// Reemplazar palabras mal escritas (case-insensitive)
|
|
words := strings.Fields(sql)
|
|
for i, word := range words {
|
|
upperWord := strings.ToUpper(word)
|
|
if corrected, ok := keywords[upperWord]; ok {
|
|
if upperWord != corrected {
|
|
// Mantener case original si empezaba con mayúscula
|
|
if word != "" && word[0] >= 'A' && word[0] <= 'Z' {
|
|
words[i] = corrected
|
|
} else {
|
|
words[i] = strings.ToLower(corrected)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
fixed := strings.Join(words, " ")
|
|
|
|
// Agregar punto y coma si falta
|
|
fixed = strings.TrimSpace(fixed)
|
|
if !strings.HasSuffix(fixed, ";") && !strings.HasSuffix(fixed, "\n") {
|
|
fixed = fixed + ";"
|
|
}
|
|
|
|
if fixed != original {
|
|
return fixed
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// callAISQLSuggestion llama a un LLM para ayudar con SQL.
|
|
func callAISQLSuggestion(config *models.AiConfig, sql, action string, conxDbID uint, database string) *fiber.Map {
|
|
// Construir prompt según la acción
|
|
var prompt string
|
|
switch action {
|
|
case "correct":
|
|
prompt = fmt.Sprintf(`Eres un experto en SQL. Corrige los errores de esta consulta SQL. Devuelve SOLO el SQL corregido, sin explicaciones.
|
|
|
|
SQL original:
|
|
%s
|
|
|
|
SQL corregido:`, sql)
|
|
case "complete":
|
|
prompt = fmt.Sprintf(`Eres un experto en SQL. Completa esta consulta SQL. Devuelve SOLO el SQL completo, sin explicaciones.
|
|
|
|
SQL incompleto:
|
|
%s
|
|
|
|
SQL completo:`, sql)
|
|
case "optimize":
|
|
prompt = fmt.Sprintf(`Eres un experto en optimización de SQL. Optimiza esta consulta añadiendo índices sugeridos, mejorando JOINs y filtrando mejor. Devuelve SOLO el SQL optimizado, sin explicaciones.
|
|
|
|
SQL original:
|
|
%s
|
|
|
|
SQL optimizado:`, sql)
|
|
case "nl2sql":
|
|
// El prompt ya viene construido desde NL2SQLHandler (incluye esquema + descripción)
|
|
prompt = sql
|
|
default:
|
|
prompt = fmt.Sprintf(`Eres un experto en SQL. Ayuda con esta consulta SQL. Devuelve SOLO el SQL resultante, sin explicaciones.
|
|
|
|
%s`, sql)
|
|
}
|
|
|
|
// Hacer la llamada HTTP al proveedor de IA
|
|
client := &http.Client{Timeout: 60 * time.Second}
|
|
|
|
baseURL := config.BaseURL
|
|
if baseURL == "" {
|
|
switch config.Provider {
|
|
case "openai":
|
|
baseURL = "https://api.openai.com/v1"
|
|
case "qwen":
|
|
baseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
|
case "anthropic":
|
|
baseURL = "https://api.anthropic.com/v1"
|
|
default:
|
|
log.Printf("[AI_SQL] Provider '%s' requiere BaseURL configurada", config.Provider)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
modelName := config.ModelName
|
|
if modelName == "" {
|
|
switch config.Provider {
|
|
case "openai":
|
|
modelName = "gpt-4o-mini"
|
|
case "qwen":
|
|
modelName = "qwen2.5-72b-instruct"
|
|
case "anthropic":
|
|
modelName = "claude-3-haiku-20240307"
|
|
case "ollama":
|
|
modelName = "gemma3:1b"
|
|
default:
|
|
modelName = "gpt-4o-mini"
|
|
}
|
|
}
|
|
|
|
requestBody := map[string]interface{}{
|
|
"model": modelName,
|
|
"messages": []map[string]string{
|
|
{"role": "system", "content": "Eres un experto en SQL. Responde SOLO con el SQL, sin explicaciones adicionales."},
|
|
{"role": "user", "content": prompt},
|
|
},
|
|
"temperature": 0.1,
|
|
"max_tokens": 1024,
|
|
}
|
|
|
|
jsonBody, _ := json.Marshal(requestBody)
|
|
|
|
var url string
|
|
var authHeader string
|
|
switch config.Provider {
|
|
case "anthropic":
|
|
url = baseURL + "/messages"
|
|
authHeader = "x-api-key"
|
|
default:
|
|
url = baseURL + "/chat/completions"
|
|
authHeader = "Authorization"
|
|
}
|
|
|
|
req, _ := http.NewRequest("POST", url, bytes.NewReader(jsonBody))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
clave := config.ClaveEnClaro()
|
|
switch config.Provider {
|
|
case "anthropic":
|
|
req.Header.Set(authHeader, clave)
|
|
req.Header.Set("anthropic-version", "2023-06-01")
|
|
case "ollama":
|
|
// Sin auth para red interna; para URL pública el api_key es "usuario:token"
|
|
if clave != "" && clave != "ollama" {
|
|
req.SetBasicAuth("ollama", clave)
|
|
}
|
|
default:
|
|
req.Header.Set(authHeader, "Bearer "+clave)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
log.Printf("[AI_SQL] Error llamando a %s: %v", config.Provider, err)
|
|
return nil
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var apiResult map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&apiResult); err != nil {
|
|
log.Printf("[AI_SQL] Error decodificando respuesta: %v", err)
|
|
return nil
|
|
}
|
|
|
|
// Extraer texto de diferentes formatos de respuesta
|
|
var suggestion string
|
|
if config.Provider == "anthropic" {
|
|
if content, ok := apiResult["content"].([]interface{}); ok && len(content) > 0 {
|
|
if first, ok := content[0].(map[string]interface{}); ok {
|
|
if text, ok := first["text"].(string); ok {
|
|
suggestion = text
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if choices, ok := apiResult["choices"].([]interface{}); ok && len(choices) > 0 {
|
|
if first, ok := choices[0].(map[string]interface{}); ok {
|
|
if msg, ok := first["message"].(map[string]interface{}); ok {
|
|
if content, ok := msg["content"].(string); ok {
|
|
suggestion = content
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Limpiar bloques de código markdown
|
|
suggestion = strings.TrimSpace(suggestion)
|
|
suggestion = strings.TrimPrefix(suggestion, "```sql")
|
|
suggestion = strings.TrimPrefix(suggestion, "```")
|
|
suggestion = strings.TrimSuffix(suggestion, "```")
|
|
suggestion = strings.TrimSpace(suggestion)
|
|
|
|
if suggestion == "" {
|
|
return nil
|
|
}
|
|
|
|
return &fiber.Map{
|
|
"suggestion": suggestion,
|
|
"source": config.Provider,
|
|
}
|
|
}
|
|
|
|
// ── helper ───────────────────────────────────────────────────────────────────
|
|
|
|
func loadConxDb(idStr string) (models.ConxDb, error) {
|
|
id, err := strconv.ParseUint(idStr, 10, 32)
|
|
if err != nil || id == 0 {
|
|
return models.ConxDb{}, fmt.Errorf("conx_db_id inválido")
|
|
}
|
|
var conx models.ConxDb
|
|
if err := app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").First(&conx, id).Error; err != nil {
|
|
return models.ConxDb{}, fmt.Errorf("conexión no encontrada: %w", err)
|
|
}
|
|
return conx, nil
|
|
}
|
|
|
|
func extractUserID(c *fiber.Ctx) uint {
|
|
user, err := auth.User(c)
|
|
if err != nil || user == nil {
|
|
return 0
|
|
}
|
|
return user.ID
|
|
}
|