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/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 todas las conexiones DB disponibles (para el selector). func GetConxDbList(c *fiber.Ctx) error { var items []models.ConxDb app.Http.Database.DB.Preload("TipoDb").Preload("Servidor").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) } // 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, "'", "''") + "'" } sqlText := fmt.Sprintf("UPDATE %s SET %s = %s WHERE %s = '%s'", qTable, qCol, valueSQL, qPkCol, strings.ReplaceAll(body.PkValue, "'", "''")) 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) 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: 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" 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") if config.Provider == "anthropic" { req.Header.Set(authHeader, config.ApiKey) req.Header.Set("anthropic-version", "2023-06-01") } else { 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 }