From ba7675433189b9676ae638c007e56f816bfcefb0 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:35:37 -0500 Subject: [PATCH] up --- pkg/services/query_runner_service.go | 92 ++++ resources/views/query_runner.html | 566 +++++++++++++++++++- rest/controllers/query_runner_controller.go | 352 ++++++++++++ rest/routes/user.go | 3 + 4 files changed, 992 insertions(+), 21 deletions(-) diff --git a/pkg/services/query_runner_service.go b/pkg/services/query_runner_service.go index cb44614..3ec5bba 100644 --- a/pkg/services/query_runner_service.go +++ b/pkg/services/query_runner_service.go @@ -300,6 +300,98 @@ func ListTables(conx models.ConxDb, database string) ([]string, error) { return tables, nil } +// ColumnInfo describe una columna de tabla. +type ColumnInfo struct { + Name string `json:"name"` + Type string `json:"type"` + Nullable string `json:"nullable"` + Key string `json:"key"` + Default string `json:"default"` +} + +// GetTableColumns devuelve la info de columnas de una tabla. +func GetTableColumns(conx models.ConxDb, database, table string) ([]ColumnInfo, error) { + driver := strings.ToLower(conx.TipoDb.Nombre) + db, err := openDynamicDB(conx) + if err != nil { + return nil, err + } + defer db.Close() + + // Seleccionar base de datos si es necesario + if database != "" { + if strings.Contains(driver, "postgres") { + 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 { + // Intentar de todas formas + } + } + } + + var query string + switch { + case strings.Contains(driver, "postgres"): + query = fmt.Sprintf(`SELECT column_name, data_type, is_nullable, + COALESCE(column_default,'') as column_default, + '' as column_key + FROM information_schema.columns + WHERE table_name = '%s' AND table_schema = 'public' + ORDER BY ordinal_position`, table) + case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"): + query = fmt.Sprintf("SHOW COLUMNS FROM `%s`", table) + case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"): + query = fmt.Sprintf(`SELECT COLUMN_NAME as column_name, DATA_TYPE as data_type, + IS_NULLABLE as is_nullable, COALESCE(COLUMN_DEFAULT,'') as column_default, + '' as column_key + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = '%s' ORDER BY ORDINAL_POSITION`, table) + default: + query = fmt.Sprintf("PRAGMA table_info('%s')", table) + } + + rows, err := db.Query(query) + if err != nil { + return nil, err + } + defer rows.Close() + + var cols []ColumnInfo + driverLower := strings.ToLower(driver) + isMySQL := strings.Contains(driverLower, "mysql") || strings.Contains(driverLower, "mariadb") + + for rows.Next() { + var ci ColumnInfo + if isMySQL { + var field, colType, null, key, extra string + var defaultVal *string + if err := rows.Scan(&field, &colType, &null, &key, &defaultVal, &extra); err != nil { + continue + } + ci.Name = field + ci.Type = colType + ci.Nullable = null + ci.Key = key + if defaultVal != nil { + ci.Default = *defaultVal + } + } else { + if err := rows.Scan(&ci.Name, &ci.Type, &ci.Nullable, &ci.Default, &ci.Key); err != nil { + continue + } + } + cols = append(cols, ci) + } + if cols == nil { + cols = []ColumnInfo{} + } + return cols, nil +} + // TestConnection verifica si la conexión es válida. func TestDBConnection(conx models.ConxDb) error { if isMongoDriver(strings.ToLower(conx.TipoDb.Nombre)) { diff --git a/resources/views/query_runner.html b/resources/views/query_runner.html index 93f13c2..ae5c82b 100644 --- a/resources/views/query_runner.html +++ b/resources/views/query_runner.html @@ -136,6 +136,20 @@ Limpiar + + +
@@ -167,13 +181,41 @@
- - +
+ + +
+ +
+ +
+ +
+
@@ -466,9 +508,25 @@ @@ -506,6 +564,56 @@
+ +
+
+
+
+ + + + Sugerencia IA + AI +
+ +
+
+
+ +
+ +
+
+
+

+                
+ +
+
+ Resultado + +
+

+                
+
+
+
+
{ showUpdateBuilder: false, updateBuilder: { collection: '', field: '', value: '', filterField: '_id', filterValue: '""', op: '$set', multi: 'one' }, + // ── Autocomplete ── + autocompleteVisible: false, + autocompleteItems: [], + autocompleteIdx: 0, + autocompletePos: { left: 0, top: 0 }, + columnCache: {}, // table name -> columns array + sqlKeywords: [ + 'SELECT', 'FROM', 'WHERE', 'AND', 'OR', 'IN', 'NOT', 'NULL', 'AS', 'ON', + 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'CROSS', 'FULL', + 'GROUP', 'BY', 'ORDER', 'ASC', 'DESC', 'HAVING', + 'LIMIT', 'OFFSET', 'INSERT', 'INTO', 'VALUES', + 'UPDATE', 'SET', 'DELETE', 'CREATE', 'TABLE', 'ALTER', 'DROP', 'INDEX', + 'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'CONSTRAINT', 'DEFAULT', 'UNIQUE', + 'CHECK', 'CASCADE', 'TRUNCATE', 'DISTINCT', 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', + 'BETWEEN', 'LIKE', 'ILIKE', 'EXISTS', 'UNION', 'ALL', 'INTERSECT', 'EXCEPT', + 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'CAST', 'COALESCE', 'NULLIF', + 'TRUE', 'FALSE', 'GRANT', 'REVOKE', 'COMMIT', 'ROLLBACK', 'BEGIN', 'TRANSACTION', + 'IS', 'ANY', 'SOME', 'DATABASE', 'SCHEMA', 'USER', 'GRANT', 'REVOKE', + 'PRIVILEGES', 'IDENTIFIED', 'PASSWORD', 'FLUSH', + 'SHOW', 'DESCRIBE', 'EXPLAIN', 'WITH', + 'IF', 'EXISTS', 'NOT', 'FULL', 'OUTER', 'CROSS', 'NATURAL', 'USING', + ], + + // ── Editable cells ── + editCell: null, + editValue: '', + editSaving: false, + + // ── Typo detection ── + typoSuggestions: [], + + // ── AI ── + aiLoading: false, + aiModalOpen: false, + aiModalSql: '', + aiSuggestion: '', + aiAction: 'correct', + aiActions: [ + { value: 'correct', label: 'Corregir errores' }, + { value: 'complete', label: 'Completar consulta' }, + { value: 'optimize', label: 'Optimizar SQL' }, + ], + // Redis helpers showRedisBuilder: false, redisBuilder: { cmd: 'GET', key: '', value: '', ttl: '3600', field: '' }, @@ -571,7 +722,6 @@ document.addEventListener('alpine:init', () => { { label: 'SHOW INDEXES', cmd: "SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'nombre_tabla';" }, { label: 'SIZE db', cmd: "SELECT pg_size_pretty(pg_database_size(current_database())) AS size;" }, ]; - // MySQL / MariaDB return [ { label: 'SHOW DATABASES', cmd: 'SHOW DATABASES;' }, { label: 'SHOW TABLES', cmd: 'SHOW TABLES;' }, @@ -598,20 +748,390 @@ document.addEventListener('alpine:init', () => { { label: 'aggregate()', cmd: 'db.coleccion.aggregate([{"$group": {"_id": "$campo", "total": {"$sum": 1}}}])' }, ], + // ══════════════════════════════════════════════════════════════ + // INIT + // ══════════════════════════════════════════════════════════════ async init() { - // Pre-cargar ID desde URL si viene de conx_db const params = new URLSearchParams(window.location.search); const preId = params.get('conx_db_id'); - const res = await axios.get('/app/query-runner/connections'); this.conexiones = res.data.data || []; - if (preId) { this.selectedConxId = parseInt(preId); await this.onConxChange(); } }, + // ══════════════════════════════════════════════════════════════ + // AUTOCOMPLETE + // ══════════════════════════════════════════════════════════════ + async onSqlInput() { + if (!this.sqlText || this.isMongo || this.isRedis) { + this.closeAutocomplete(); + this.typoSuggestions = []; + return; + } + const ta = document.getElementById('sql-editor'); + if (!ta) return; + const cursorPos = ta.selectionStart; + const text = this.sqlText.substring(0, cursorPos); + const words = text.split(/[\s,;()\n]+/); + const currentWord = words[words.length - 1] || ''; + const fullText = this.sqlText; + + // ── Autocomplete ── + if (currentWord.length >= 1) { + const upper = currentWord.toUpperCase(); + const suggestions = []; + + // Keywords + for (const kw of this.sqlKeywords) { + if (kw.startsWith(upper) && kw !== upper) { + suggestions.push({ label: kw, type: 'keyword' }); + } + } + + // Table names + for (const t of this.tables) { + const tLower = t.toLowerCase(); + const cLower = currentWord.toLowerCase(); + if (tLower.startsWith(cLower) && t !== currentWord) { + suggestions.push({ label: t, type: 'table' }); + } + } + + // Column names from loaded columns + const tableContext = this.detectTableContext(fullText, cursorPos); + if (tableContext && this.columnCache[tableContext]) { + for (const col of this.columnCache[tableContext]) { + const colLower = col.name.toLowerCase(); + const cLower2 = currentWord.toLowerCase(); + if (colLower.startsWith(cLower2) && col.name !== currentWord) { + suggestions.push({ label: col.name, type: 'col·' + (col.type || '').substring(0, 12) }); + } + } + } + + if (suggestions.length > 0) { + this.autocompleteItems = suggestions.slice(0, 12); + this.autocompleteIdx = 0; + this.autocompleteVisible = true; + // Position below cursor + const lineHeight = 24; + const charWidth = 8.4; + const lines = text.split('\n'); + const currentLine = lines.length; + const currentCol = lines[lines.length - 1].length; + this.autocompletePos = { + left: Math.min(currentCol * charWidth, 600), + top: currentLine * lineHeight + 4 + }; + } else { + this.closeAutocomplete(); + } + + // ── Typo detection ── + this.detectTypos(currentWord); + } else { + this.closeAutocomplete(); + this.typoSuggestions = []; + } + + // Fetch columns for tables mentioned in the query + this.prefetchTableColumns(fullText); + }, + + onSqlKeydown(e) { + if (!this.autocompleteVisible) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.autocompleteIdx = Math.min(this.autocompleteIdx + 1, this.autocompleteItems.length - 1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + this.autocompleteIdx = Math.max(this.autocompleteIdx - 1, 0); + } else if (e.key === 'Enter' || e.key === 'Tab') { + if (this.autocompleteItems[this.autocompleteIdx]) { + e.preventDefault(); + this.selectAutocomplete(this.autocompleteIdx); + } + } else if (e.key === 'Escape') { + this.closeAutocomplete(); + } + }, + + selectAutocomplete(idx) { + const item = this.autocompleteItems[idx]; + if (!item) return; + const ta = document.getElementById('sql-editor'); + if (!ta) return; + const cursorPos = ta.selectionStart; + const text = this.sqlText.substring(0, cursorPos); + const words = text.split(/[\s,;()\n]+/); + const currentWord = words[words.length - 1] || ''; + const before = this.sqlText.substring(0, cursorPos - currentWord.length); + const after = this.sqlText.substring(cursorPos); + this.sqlText = before + item.label.toLowerCase() + after; + this.closeAutocomplete(); + // Move cursor after inserted word + const newPos = before.length + item.label.length; + requestAnimationFrame(() => { + ta.focus(); + ta.setSelectionRange(newPos, newPos); + }); + }, + + closeAutocomplete() { + this.autocompleteVisible = false; + this.autocompleteItems = []; + this.autocompleteIdx = -1; + }, + + // Detecta qué tabla se está consultando basado en el contexto del cursor + detectTableContext(sql, cursorPos) { + const textBefore = sql.substring(0, cursorPos); + const upper = textBefore.toUpperCase(); + // Buscar patrones: FROM tabla, JOIN tabla, UPDATE tabla, INSERT INTO tabla + const fromMatch = upper.match(/(?:FROM|JOIN|UPDATE|INTO)\s+([A-Z_][A-Z0-9_]*)\s*$/); + if (fromMatch) { + const table = fromMatch[1].toLowerCase(); + // Verificar que existe en tables + for (const t of this.tables) { + if (t.toLowerCase() === table) return t; + } + } + // También buscar cualquier tabla mencionada antes del cursor + for (const t of this.tables) { + const idx = textBefore.toLowerCase().lastIndexOf(t.toLowerCase()); + if (idx >= 0 && idx < cursorPos) return t; + } + return null; + }, + + // Precarga columnas de tablas mencionadas en la query + async prefetchTableColumns(sql) { + if (!this.selectedConxId || !this.selectedDb) return; + const tableRegex = /(?:FROM|JOIN|UPDATE|INTO|TABLE)\s+`?(\w[\w\d_]*)`?/gi; + let match; + const tablesToFetch = new Set(); + while ((match = tableRegex.exec(sql)) !== null) { + tablesToFetch.add(match[1]); + } + for (const table of tablesToFetch) { + if (!this.columnCache[table] && this.tables.includes(table)) { + try { + const res = await axios.get('/app/query-runner/columns', { + params: { conx_db_id: this.selectedConxId, db: this.selectedDb, table } + }); + this.columnCache[table] = res.data.data || []; + } catch (_) {} + } + } + }, + + // ══════════════════════════════════════════════════════════════ + // TYPO DETECTION + // ══════════════════════════════════════════════════════════════ + detectTypos(word) { + if (!word || word.length < 2) { + this.typoSuggestions = []; + return; + } + const upper = word.toUpperCase(); + const suggestions = []; + // Levenshtein distance check against keywords + for (const kw of this.sqlKeywords) { + if (kw === upper) continue; + const dist = this.levenshtein(upper, kw); + if (dist === 1 || (dist === 2 && kw.length > 4)) { + suggestions.push(kw); + } + if (suggestions.length >= 3) break; + } + // Check against table names + for (const t of this.tables) { + const tUpper = t.toUpperCase(); + if (tUpper === upper) continue; + const dist = this.levenshtein(upper, tUpper); + if (dist <= 2) { + suggestions.push(t); + } + if (suggestions.length >= 5) break; + } + this.typoSuggestions = suggestions; + }, + + levenshtein(a, b) { + const m = a.length, n = b.length; + const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost); + } + } + return dp[m][n]; + }, + + applyTypoFix(correctWord) { + const ta = document.getElementById('sql-editor'); + if (!ta) return; + const cursorPos = ta.selectionStart; + const text = this.sqlText.substring(0, cursorPos); + const words = text.split(/[\s,;()\n]+/); + const currentWord = words[words.length - 1] || ''; + const before = this.sqlText.substring(0, cursorPos - currentWord.length); + const after = this.sqlText.substring(cursorPos); + this.sqlText = before + correctWord.toLowerCase() + after; + this.typoSuggestions = []; + const newPos = before.length + correctWord.length; + requestAnimationFrame(() => { + ta.focus(); + ta.setSelectionRange(newPos, newPos); + }); + }, + + // ══════════════════════════════════════════════════════════════ + // EDITABLE CELLS + // ══════════════════════════════════════════════════════════════ + startEdit(rowIdx, col, row, event) { + if (this.isMongo || this.isRedis) return; + // Extract table name from the query + const table = this.extractTableFromQuery(); + if (!table) { + this.showToast('No se pudo detectar la tabla para editar. La consulta debe incluir FROM.', 'error'); + return; + } + // Find primary key column (try 'id', 'ID', or first column) + const pkCol = this.detectPKColumn(); + if (!pkCol || row[pkCol] === undefined || row[pkCol] === null) { + this.showToast('No se pudo detectar la llave primaria para editar.', 'error'); + return; + } + this.editCell = { rowIdx, col, table, pkCol, pkValue: String(row[pkCol]) }; + this.editValue = row[col] === null || row[col] === undefined ? '' : String(row[col]); + requestAnimationFrame(() => { + const input = this.$refs?.editInput; + if (input) input.focus(); + else { + // Fallback: find the input element + const inputs = document.querySelectorAll('[x-ref="editInput"]'); + if (inputs.length > 0) inputs[inputs.length - 1].focus(); + } + }); + }, + + async saveEdit(rowIdx, col) { + if (!this.editCell || this.editSaving) return; + this.editSaving = true; + try { + const res = await axios.post('/app/query-runner/update-cell', { + conx_db_id: parseInt(this.selectedConxId), + database: this.selectedDb, + table: this.editCell.table, + column: col, + pk_column: this.editCell.pkCol, + pk_value: this.editCell.pkValue, + value: this.editValue + }); + if (res.data.error) { + this.showToast('Error: ' + res.data.error, 'error'); + } else { + this.showToast('✓ Celda actualizada (' + res.data.affected + ' fila(s))'); + // Update local result + if (this.results[rowIdx]) { + this.results[rowIdx][col] = this.editValue; + } + } + } catch (e) { + this.showToast('Error al actualizar: ' + (e.response?.data?.error || e.message), 'error'); + } + this.editSaving = false; + this.editCell = null; + this.editValue = ''; + }, + + cancelEdit() { + this.editCell = null; + this.editValue = ''; + }, + + extractTableFromQuery() { + const upper = this.sqlText.toUpperCase(); + // FROM tabla, JOIN tabla, UPDATE tabla + const match = upper.match(/(?:FROM|JOIN|UPDATE|INTO)\s+`?(\w[\w\d_]*)`?/); + if (match) { + const tableName = match[1].toLowerCase(); + // Check against known tables + for (const t of this.tables) { + if (t.toLowerCase() === tableName) return t; + } + return match[1]; + } + return null; + }, + + detectPKColumn() { + // Common primary key names + const pkCandidates = ['id', 'ID', 'Id', '_id', 'codigo', 'code', 'uuid', 'ID_']; + for (const col of this.columns) { + for (const pk of pkCandidates) { + if (col === pk) return col; + } + } + // Fallback: first column + return this.columns[0] || null; + }, + + // ══════════════════════════════════════════════════════════════ + // AI SUGGESTION + // ══════════════════════════════════════════════════════════════ + openAI() { + this.aiModalOpen = true; + this.aiModalSql = this.sqlText; + this.aiSuggestion = ''; + }, + + async askAI() { + if (!this.sqlText.trim()) return; + if (!this.aiModalOpen) { + this.aiModalOpen = true; + this.aiModalSql = this.sqlText; + this.aiSuggestion = ''; + return; + } + this.aiLoading = true; + this.aiSuggestion = ''; + try { + const res = await axios.post('/app/query-runner/suggest', { + conx_db_id: parseInt(this.selectedConxId) || 0, + database: this.selectedDb, + sql: this.aiModalSql || this.sqlText, + action: this.aiAction + }); + if (res.data.suggestion) { + this.aiSuggestion = res.data.suggestion; + } else { + this.showToast('No se pudo generar sugerencia', 'error'); + } + } catch (e) { + this.showToast('Error IA: ' + (e.response?.data?.error || e.message), 'error'); + } + this.aiLoading = false; + }, + + applyAISuggestion() { + if (this.aiSuggestion) { + this.sqlText = this.aiSuggestion; + this.aiModalOpen = false; + this.showToast('✓ Sugerencia aplicada al editor'); + } + }, + + // ══════════════════════════════════════════════════════════════ + // CORE QUERY RUNNER + // ══════════════════════════════════════════════════════════════ async onConxChange() { this.databases = []; this.tables = []; @@ -624,6 +1144,7 @@ document.addEventListener('alpine:init', () => { this.isRedis = false; this.selectedCollection = ''; this.collectionFields = []; + this.columnCache = {}; if (!this.selectedConxId) return; const conx = this.conexiones.find(c => c.ID == this.selectedConxId); const dbType = conx?.tipo_db?.nombre?.toLowerCase() || ''; @@ -673,6 +1194,8 @@ document.addEventListener('alpine:init', () => { this.statusMsg = ''; this.results = []; this.columns = []; + this.editCell = null; + this.closeAutocomplete(); try { const res = await axios.post('/app/query-runner/run', { conx_db_id: parseInt(this.selectedConxId), @@ -796,18 +1319,13 @@ document.addEventListener('alpine:init', () => { document.getElementById('sql-editor')?.focus(); }, - // Convierte un valor crudo ingresado por el usuario a JSON válido. - // Si ya es un literal JSON (num, bool, null, string entre comillas, objeto/array) - // lo deja intacto; de lo contrario lo trata como string y lo envuelve en comillas. toJsonValue(raw) { const s = (raw || '').trim(); if (s === '') return '""'; - // Ya es string JSON, número, bool, null, objeto o array if (/^".*"$/.test(s)) return s; if (s === 'true' || s === 'false' || s === 'null') return s; if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(s)) return s; if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) return s; - // Bare string → escapar y envolver en comillas const escaped = s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); return `"${escaped}"`; }, @@ -836,7 +1354,6 @@ document.addEventListener('alpine:init', () => { let map; if (this.isPostgres) { - // ── PostgreSQL ────────────────────────────────────────────── const createDb = `CREATE DATABASE "${db}"\n WITH ENCODING 'UTF8'\n LC_COLLATE = 'en_US.UTF-8'\n LC_CTYPE = 'en_US.UTF-8'\n TEMPLATE = template0;`; const createUser = `DO $$\nBEGIN\n IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '${user}') THEN\n CREATE USER "${user}" WITH PASSWORD '${pass}';\n END IF;\nEND\n$$;`; const grant = `GRANT ALL PRIVILEGES ON DATABASE "${db}" TO "${user}";\nGRANT ALL ON SCHEMA public TO "${user}";\nALTER DEFAULT PRIVILEGES IN SCHEMA public\n GRANT ALL ON TABLES TO "${user}";\nALTER DEFAULT PRIVILEGES IN SCHEMA public\n GRANT ALL ON SEQUENCES TO "${user}";`; @@ -855,7 +1372,6 @@ document.addEventListener('alpine:init', () => { drop_db: dropDb, }; } else { - // ── MySQL / MariaDB ───────────────────────────────────────── const createDb = `CREATE DATABASE IF NOT EXISTS \`${db}\`\n CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`; const createUser = `CREATE USER IF NOT EXISTS '${user}'@'${host}' IDENTIFIED BY '${pass}';`; const grant = `GRANT ALL PRIVILEGES ON \`${db}\`.* TO '${user}'@'${host}';\nFLUSH PRIVILEGES;`; @@ -879,7 +1395,15 @@ document.addEventListener('alpine:init', () => { document.getElementById('sql-editor')?.focus(); }, - clearEditor() { this.sqlText = ''; this.results = []; this.columns = []; this.statusMsg = ''; }, + clearEditor() { + this.sqlText = ''; + this.results = []; + this.columns = []; + this.statusMsg = ''; + this.closeAutocomplete(); + this.typoSuggestions = []; + this.editCell = null; + }, generateRedisCmd() { const { cmd, key, value, ttl, field } = this.redisBuilder; diff --git a/rest/controllers/query_runner_controller.go b/rest/controllers/query_runner_controller.go index 7fd0a23..56235a6 100644 --- a/rest/controllers/query_runner_controller.go +++ b/rest/controllers/query_runner_controller.go @@ -5,7 +5,9 @@ import ( "encoding/csv" "encoding/json" "fmt" + "log" "math" + "net/http" "strconv" "strings" "time" @@ -222,6 +224,356 @@ func ExportJSON(c *fiber.Ctx) error { 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, "`", "``") + "`" +} + +// 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, usarla + aiConfig, aiErr := models.GetActiveAiConfig("") + 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) { diff --git a/rest/routes/user.go b/rest/routes/user.go index 6ec69f0..9228f23 100755 --- a/rest/routes/user.go +++ b/rest/routes/user.go @@ -120,6 +120,9 @@ func UserRoutes(app fiber.Router) { protected.Delete("/query-runner/history", controllers.ClearHistory) protected.Post("/query-runner/export/csv", controllers.ExportCSV) protected.Post("/query-runner/export/json", controllers.ExportJSON) + protected.Get("/query-runner/columns", controllers.GetTableColumnsHandler) + protected.Post("/query-runner/update-cell", controllers.UpdateCellHandler) + protected.Post("/query-runner/suggest", controllers.CorrectQueryHandler) // ─── Hostinger API ──────────────────────────────────────────────── protected.Get("/hostinger", middlewares.MenuMiddleware, controllers.HostingerConfigPage) protected.Post("/hostinger/config", controllers.SaveHostingerConfig)