This commit is contained in:
Lizandro Guarnizo
2026-06-02 10:35:37 -05:00
parent f8b352eb03
commit ba76754331
4 changed files with 992 additions and 21 deletions
+92
View File
@@ -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)) {
+545 -21
View File
@@ -136,6 +136,20 @@
Limpiar
</button>
<!-- Botón AI -->
<button @click="askAI()" :disabled="aiLoading || !sqlText.trim()"
class="flex items-center gap-1 px-3 py-1.5 text-xs rounded transition disabled:opacity-40"
:class="aiLoading ? 'bg-purple-100 text-purple-700 border border-purple-200' : 'bg-purple-50 text-purple-700 border border-purple-200 hover:bg-purple-100'">
<svg x-show="!aiLoading" class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 007.92 12.446A9 9 0 1112 2.992z"/>
<path d="M17 4a2 2 0 012 2M19 4a2 2 0 012 2M19 8a2 2 0 01-2 2M17 8a2 2 0 01-2-2"/>
</svg>
<svg x-show="aiLoading" class="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span x-text="aiLoading ? 'Pensando…' : 'AI Sugerir'"></span>
</button>
<div class="flex-1"></div>
<!-- Exportar -->
@@ -167,13 +181,41 @@
<div class="flex-1"></div>
<span x-show="selectedDb" class="text-xs text-[#4ade80] font-mono" x-text="selectedDb"></span>
</div>
<textarea id="sql-editor" x-model="sqlText"
@keydown.ctrl.enter.prevent="runQuery()"
@keydown.meta.enter.prevent="runQuery()"
:placeholder="isMongo ? 'db.coleccion.find({})\n// Ctrl+Enter para ejecutar\n// db.coleccion.insertOne({campo: \'valor\'})' : isRedis ? '# Comandos Redis (uno por línea)\nKEYS *\nGET mi_clave\nHGETALL mi_hash\n# Ctrl+Enter para ejecutar' : '-- Escribe tu consulta SQL aquí\n-- Ctrl+Enter para ejecutar\nSELECT * FROM tabla LIMIT 100;'"
class="w-full bg-gray-900 text-gray-100 font-mono text-sm p-4 resize-none outline-none leading-relaxed"
style="height:180px; tab-size:2;"></textarea>
</div>
<div class="relative">
<textarea id="sql-editor" x-model="sqlText"
@input.debounce.150ms="onSqlInput()"
@keydown="onSqlKeydown($event)"
@keydown.ctrl.enter.prevent="runQuery()"
@keydown.meta.enter.prevent="runQuery()"
@scroll="closeAutocomplete()"
@click="closeAutocomplete()"
:placeholder="isMongo ? 'db.coleccion.find({})\n// Ctrl+Enter para ejecutar\n// db.coleccion.insertOne({campo: \'valor\'})' : isRedis ? '# Comandos Redis (uno por línea)\nKEYS *\nGET mi_clave\nHGETALL mi_hash\n# Ctrl+Enter para ejecutar' : '-- Escribe tu consulta SQL aquí\n-- Ctrl+Enter para ejecutar\nSELECT * FROM tabla LIMIT 100;'"
class="w-full bg-gray-900 text-gray-100 font-mono text-sm p-4 resize-none outline-none leading-relaxed"
style="height:180px; tab-size:2;"></textarea>
<!-- Autocomplete dropdown -->
<div x-show="autocompleteVisible" x-cloak
x-ref="autocomplete"
:style="'position:absolute;left:'+autocompletePos.left+'px;top:'+autocompletePos.top+'px;z-index:100;'"
class="bg-white border border-gray-300 rounded-lg shadow-xl max-h-48 overflow-y-auto min-w-[160px]">
<template x-for="(item, i) in autocompleteItems" :key="i">
<div @click="selectAutocomplete(i)"
class="px-3 py-1.5 text-xs font-mono cursor-pointer flex items-center gap-2"
:class="i === autocompleteIdx ? 'bg-[#8eb02f] text-white' : 'hover:bg-gray-100 text-gray-700'">
<span x-text="item.label"></span>
<span class="ml-auto text-[9px] opacity-60" x-text="item.type"></span>
</div>
</template>
</div>
<!-- Typo suggestions -->
<div x-show="typoSuggestions.length > 0" x-cloak class="mt-1 flex flex-wrap gap-1">
<template x-for="(s, i) in typoSuggestions" :key="i">
<button @click="applyTypoFix(s)"
class="text-[10px] px-2 py-0.5 rounded bg-yellow-50 border border-yellow-200 text-yellow-700 hover:bg-yellow-100 transition font-mono">
¿Quisiste decir: <span class="font-semibold" x-text="s"></span>?
</button>
</template>
</div>
</div>
<!-- ─── Helpers SQL (solo MySQL/PostgreSQL, no Redis) ─── -->
<div x-show="!isMongo && !isRedis && selectedConxId" class="mt-1.5">
<!-- Chips rápidos -->
@@ -466,9 +508,25 @@
<tr class="hover:bg-gray-50">
<td class="py-2 px-3 text-gray-400 select-none" x-text="idx+1"></td>
<template x-for="col in columns" :key="col">
<td class="py-2 px-3 font-mono max-w-xs truncate"
<td class="py-2 px-3 font-mono max-w-xs truncate relative"
:title="nullStr(row[col])"
x-text="nullStr(row[col])"></td>
@dblclick="startEdit(idx, col, row, $event)">
<div x-show="!(editCell && editCell.rowIdx === idx && editCell.col === col)" x-text="nullStr(row[col])"></div>
<div x-show="editCell && editCell.rowIdx === idx && editCell.col === col" class="flex items-center gap-1">
<input x-model="editValue" type="text"
@keydown.enter.prevent="saveEdit(idx, col)"
@keydown.escape.prevent="cancelEdit()"
@click.stop
class="w-full border border-[#8eb02f] rounded px-1.5 py-0.5 text-xs font-mono outline-none bg-white"
:class="editSaving ? 'opacity-50' : ''"
:disabled="editSaving"
x-ref="editInput">
<button @click="saveEdit(idx, col)" :disabled="editSaving"
class="shrink-0 px-1 py-0.5 rounded bg-green-500 text-white text-[10px] hover:bg-green-600"></button>
<button @click="cancelEdit()"
class="shrink-0 px-1 py-0.5 rounded bg-gray-300 text-gray-700 text-[10px] hover:bg-gray-400"></button>
</div>
</td>
</template>
</tr>
</template>
@@ -506,6 +564,56 @@
</div>
</div>
<!-- Modal AI Sugerencia -->
<div x-show="aiModalOpen" x-cloak x-transition class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div @click.outside="aiModalOpen = false" class="bg-white rounded-xl shadow-2xl w-full max-w-2xl mx-4 overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b bg-gray-50">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 007.92 12.446A9 9 0 1112 2.992z"/>
</svg>
<span class="text-sm font-semibold text-gray-700">Sugerencia IA</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-600 font-medium">AI</span>
</div>
<button @click="aiModalOpen = false" class="text-gray-400 hover:text-gray-600 text-lg leading-none">&times;</button>
</div>
<div class="p-4">
<div class="mb-3">
<label class="text-[10px] text-gray-500 font-semibold uppercase tracking-wide block mb-1">Acción</label>
<div class="flex gap-1.5">
<template x-for="act in aiActions" :key="act.value">
<button @click="aiAction = act.value"
class="px-3 py-1.5 text-xs rounded border transition"
:class="aiAction === act.value ? 'bg-purple-600 text-white border-purple-600' : 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'"
x-text="act.label"></button>
</template>
</div>
</div>
<div class="border rounded-lg bg-gray-900 p-3 mb-3 max-h-40 overflow-y-auto">
<pre class="text-xs font-mono text-gray-100 whitespace-pre-wrap" x-text="aiModalSql"></pre>
</div>
<button @click="askAI()" :disabled="aiLoading || !sqlText.trim()"
class="w-full py-2 text-xs font-semibold text-white rounded-lg transition disabled:opacity-40 flex items-center justify-center gap-2"
:class="aiLoading ? 'bg-purple-400' : 'bg-purple-600 hover:bg-purple-700'">
<svg x-show="aiLoading" class="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span x-text="aiLoading ? 'Procesando…' : 'Obtener sugerencia'"></span>
</button>
<div x-show="aiSuggestion" class="mt-3 border rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
<span class="text-xs font-semibold text-gray-500">Resultado</span>
<button @click="applyAISuggestion()"
class="text-[10px] px-2 py-1 rounded bg-[#8eb02f] text-white hover:bg-[#6d8c24] transition font-semibold">
Aplicar al editor
</button>
</div>
<pre class="p-3 text-xs font-mono text-gray-800 whitespace-pre-wrap bg-white max-h-48 overflow-y-auto" x-text="aiSuggestion"></pre>
</div>
</div>
</div>
</div>
<!-- Toast -->
<div x-show="toast.show" x-cloak x-transition
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-sm text-white"
@@ -543,6 +651,49 @@ document.addEventListener('alpine:init', () => {
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;
+352
View File
@@ -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) {
+3
View File
@@ -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)