up
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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">×</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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user