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
+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;