up
This commit is contained in:
@@ -300,6 +300,98 @@ func ListTables(conx models.ConxDb, database string) ([]string, error) {
|
|||||||
return tables, nil
|
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.
|
// TestConnection verifica si la conexión es válida.
|
||||||
func TestDBConnection(conx models.ConxDb) error {
|
func TestDBConnection(conx models.ConxDb) error {
|
||||||
if isMongoDriver(strings.ToLower(conx.TipoDb.Nombre)) {
|
if isMongoDriver(strings.ToLower(conx.TipoDb.Nombre)) {
|
||||||
|
|||||||
@@ -136,6 +136,20 @@
|
|||||||
Limpiar
|
Limpiar
|
||||||
</button>
|
</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>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
<!-- Exportar -->
|
<!-- Exportar -->
|
||||||
@@ -167,13 +181,41 @@
|
|||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
<span x-show="selectedDb" class="text-xs text-[#4ade80] font-mono" x-text="selectedDb"></span>
|
<span x-show="selectedDb" class="text-xs text-[#4ade80] font-mono" x-text="selectedDb"></span>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="sql-editor" x-model="sqlText"
|
<div class="relative">
|
||||||
@keydown.ctrl.enter.prevent="runQuery()"
|
<textarea id="sql-editor" x-model="sqlText"
|
||||||
@keydown.meta.enter.prevent="runQuery()"
|
@input.debounce.150ms="onSqlInput()"
|
||||||
: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;'"
|
@keydown="onSqlKeydown($event)"
|
||||||
class="w-full bg-gray-900 text-gray-100 font-mono text-sm p-4 resize-none outline-none leading-relaxed"
|
@keydown.ctrl.enter.prevent="runQuery()"
|
||||||
style="height:180px; tab-size:2;"></textarea>
|
@keydown.meta.enter.prevent="runQuery()"
|
||||||
</div>
|
@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) ─── -->
|
<!-- ─── Helpers SQL (solo MySQL/PostgreSQL, no Redis) ─── -->
|
||||||
<div x-show="!isMongo && !isRedis && selectedConxId" class="mt-1.5">
|
<div x-show="!isMongo && !isRedis && selectedConxId" class="mt-1.5">
|
||||||
<!-- Chips rápidos -->
|
<!-- Chips rápidos -->
|
||||||
@@ -466,9 +508,25 @@
|
|||||||
<tr class="hover:bg-gray-50">
|
<tr class="hover:bg-gray-50">
|
||||||
<td class="py-2 px-3 text-gray-400 select-none" x-text="idx+1"></td>
|
<td class="py-2 px-3 text-gray-400 select-none" x-text="idx+1"></td>
|
||||||
<template x-for="col in columns" :key="col">
|
<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])"
|
: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>
|
</template>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
@@ -506,6 +564,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Toast -->
|
||||||
<div x-show="toast.show" x-cloak x-transition
|
<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"
|
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,
|
showUpdateBuilder: false,
|
||||||
updateBuilder: { collection: '', field: '', value: '', filterField: '_id', filterValue: '""', op: '$set', multi: 'one' },
|
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
|
// Redis helpers
|
||||||
showRedisBuilder: false,
|
showRedisBuilder: false,
|
||||||
redisBuilder: { cmd: 'GET', key: '', value: '', ttl: '3600', field: '' },
|
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: '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;" },
|
{ label: 'SIZE db', cmd: "SELECT pg_size_pretty(pg_database_size(current_database())) AS size;" },
|
||||||
];
|
];
|
||||||
// MySQL / MariaDB
|
|
||||||
return [
|
return [
|
||||||
{ label: 'SHOW DATABASES', cmd: 'SHOW DATABASES;' },
|
{ label: 'SHOW DATABASES', cmd: 'SHOW DATABASES;' },
|
||||||
{ label: 'SHOW TABLES', cmd: 'SHOW TABLES;' },
|
{ 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}}}])' },
|
{ label: 'aggregate()', cmd: 'db.coleccion.aggregate([{"$group": {"_id": "$campo", "total": {"$sum": 1}}}])' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
|
// INIT
|
||||||
|
// ══════════════════════════════════════════════════════════════
|
||||||
async init() {
|
async init() {
|
||||||
// Pre-cargar ID desde URL si viene de conx_db
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const preId = params.get('conx_db_id');
|
const preId = params.get('conx_db_id');
|
||||||
|
|
||||||
const res = await axios.get('/app/query-runner/connections');
|
const res = await axios.get('/app/query-runner/connections');
|
||||||
this.conexiones = res.data.data || [];
|
this.conexiones = res.data.data || [];
|
||||||
|
|
||||||
if (preId) {
|
if (preId) {
|
||||||
this.selectedConxId = parseInt(preId);
|
this.selectedConxId = parseInt(preId);
|
||||||
await this.onConxChange();
|
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() {
|
async onConxChange() {
|
||||||
this.databases = [];
|
this.databases = [];
|
||||||
this.tables = [];
|
this.tables = [];
|
||||||
@@ -624,6 +1144,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.isRedis = false;
|
this.isRedis = false;
|
||||||
this.selectedCollection = '';
|
this.selectedCollection = '';
|
||||||
this.collectionFields = [];
|
this.collectionFields = [];
|
||||||
|
this.columnCache = {};
|
||||||
if (!this.selectedConxId) return;
|
if (!this.selectedConxId) return;
|
||||||
const conx = this.conexiones.find(c => c.ID == this.selectedConxId);
|
const conx = this.conexiones.find(c => c.ID == this.selectedConxId);
|
||||||
const dbType = conx?.tipo_db?.nombre?.toLowerCase() || '';
|
const dbType = conx?.tipo_db?.nombre?.toLowerCase() || '';
|
||||||
@@ -673,6 +1194,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.statusMsg = '';
|
this.statusMsg = '';
|
||||||
this.results = [];
|
this.results = [];
|
||||||
this.columns = [];
|
this.columns = [];
|
||||||
|
this.editCell = null;
|
||||||
|
this.closeAutocomplete();
|
||||||
try {
|
try {
|
||||||
const res = await axios.post('/app/query-runner/run', {
|
const res = await axios.post('/app/query-runner/run', {
|
||||||
conx_db_id: parseInt(this.selectedConxId),
|
conx_db_id: parseInt(this.selectedConxId),
|
||||||
@@ -796,18 +1319,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
document.getElementById('sql-editor')?.focus();
|
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) {
|
toJsonValue(raw) {
|
||||||
const s = (raw || '').trim();
|
const s = (raw || '').trim();
|
||||||
if (s === '') return '""';
|
if (s === '') return '""';
|
||||||
// Ya es string JSON, número, bool, null, objeto o array
|
|
||||||
if (/^".*"$/.test(s)) return s;
|
if (/^".*"$/.test(s)) return s;
|
||||||
if (s === 'true' || s === 'false' || s === 'null') return s;
|
if (s === 'true' || s === 'false' || s === 'null') return s;
|
||||||
if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(s)) return s;
|
if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(s)) return s;
|
||||||
if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) 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, '\\"');
|
const escaped = s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
return `"${escaped}"`;
|
return `"${escaped}"`;
|
||||||
},
|
},
|
||||||
@@ -836,7 +1354,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
let map;
|
let map;
|
||||||
|
|
||||||
if (this.isPostgres) {
|
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 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 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}";`;
|
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,
|
drop_db: dropDb,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// ── MySQL / MariaDB ─────────────────────────────────────────
|
|
||||||
const createDb = `CREATE DATABASE IF NOT EXISTS \`${db}\`\n CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;`;
|
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 createUser = `CREATE USER IF NOT EXISTS '${user}'@'${host}' IDENTIFIED BY '${pass}';`;
|
||||||
const grant = `GRANT ALL PRIVILEGES ON \`${db}\`.* TO '${user}'@'${host}';\nFLUSH PRIVILEGES;`;
|
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();
|
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() {
|
generateRedisCmd() {
|
||||||
const { cmd, key, value, ttl, field } = this.redisBuilder;
|
const { cmd, key, value, ttl, field } = this.redisBuilder;
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import (
|
|||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -222,6 +224,356 @@ func ExportJSON(c *fiber.Ctx) error {
|
|||||||
return c.SendStream(bytes.NewReader(data), len(data))
|
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 ───────────────────────────────────────────────────────────────────
|
// ── helper ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func loadConxDb(idStr string) (models.ConxDb, error) {
|
func loadConxDb(idStr string) (models.ConxDb, error) {
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ func UserRoutes(app fiber.Router) {
|
|||||||
protected.Delete("/query-runner/history", controllers.ClearHistory)
|
protected.Delete("/query-runner/history", controllers.ClearHistory)
|
||||||
protected.Post("/query-runner/export/csv", controllers.ExportCSV)
|
protected.Post("/query-runner/export/csv", controllers.ExportCSV)
|
||||||
protected.Post("/query-runner/export/json", controllers.ExportJSON)
|
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 ────────────────────────────────────────────────
|
// ─── Hostinger API ────────────────────────────────────────────────
|
||||||
protected.Get("/hostinger", middlewares.MenuMiddleware, controllers.HostingerConfigPage)
|
protected.Get("/hostinger", middlewares.MenuMiddleware, controllers.HostingerConfigPage)
|
||||||
protected.Post("/hostinger/config", controllers.SaveHostingerConfig)
|
protected.Post("/hostinger/config", controllers.SaveHostingerConfig)
|
||||||
|
|||||||
Reference in New Issue
Block a user