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 @@
|
- |
+ @dblclick="startEdit(idx, col, row, $event)">
+
+
+
+
+
+
+
@@ -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)