795 lines
25 KiB
Go
795 lines
25 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()})
|
|
}
|
|
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()})
|
|
}
|
|
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})
|
|
}
|
|
|
|
// 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"})
|
|
}
|
|
|
|
conx, err := loadConxDb(strconv.Itoa(int(body.ConxDbID)))
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
|
|
result := services.ExecuteSQL(conx, body.Database, body.SQL)
|
|
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 {
|
|
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"})
|
|
}
|
|
|
|
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)
|
|
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
|
|
|
|
items, total, err := models.GetQueryHistory(uint(conxID), 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)
|
|
if err := models.DeleteQueryHistory(uint(conxID)); 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()})
|
|
}
|
|
|
|
result := services.ExecuteSQL(conx, body.Database, body.SQL)
|
|
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()})
|
|
}
|
|
|
|
result := services.ExecuteSQL(conx, body.Database, body.SQL)
|
|
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)
|
|
|
|
result := services.ExecuteSQL(conx, body.Database, sqlText)
|
|
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")
|
|
switch config.Provider {
|
|
case "anthropic":
|
|
req.Header.Set(authHeader, config.ApiKey)
|
|
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 config.ApiKey != "" && config.ApiKey != "ollama" {
|
|
req.SetBasicAuth("ollama", config.ApiKey)
|
|
}
|
|
default:
|
|
req.Header.Set(authHeader, "Bearer "+config.ApiKey)
|
|
}
|
|
|
|
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
|
|
}
|