Agrega diagrama de relaciones (FK) al Query Runner
- GetSchemaRelations: trae todas las llaves foráneas de la base con consulta específica por motor (Postgres/MySQL/MSSQL/SQLite). No aplica a Redis/Mongo (schemaless). - Endpoint GET /query-runner/relations, con el mismo chequeo de autorización por conexión que ya usa RunQuery — de paso se lo agrego también a GetDatabases/GetTables, que no lo tenían. - UI: botón "Ver relaciones" (diagrama completo) y un ícono por tabla en el árbol lateral (relaciones solo de esa tabla), renderizado con Mermaid.js servido localmente (public/js/mermaid.min.js, sin CDN).
This commit is contained in:
@@ -396,6 +396,172 @@ func GetTableColumns(conx models.ConxDb, database, table string) ([]ColumnInfo,
|
||||
return cols, nil
|
||||
}
|
||||
|
||||
// ForeignKeyInfo describe una relación de llave foránea entre dos tablas.
|
||||
type ForeignKeyInfo struct {
|
||||
ConstraintName string `json:"constraint_name"`
|
||||
FromTable string `json:"from_table"`
|
||||
FromColumn string `json:"from_column"`
|
||||
ToTable string `json:"to_table"`
|
||||
ToColumn string `json:"to_column"`
|
||||
}
|
||||
|
||||
// GetSchemaRelations trae todas las llaves foráneas de una base de datos, para
|
||||
// poder armar un mapa de relaciones (no una tabla puntual: la idea es ver
|
||||
// todo el esquema de una vez). Solo aplica a motores relacionales — Redis y
|
||||
// Mongo son schemaless, no tienen FK que consultar.
|
||||
func GetSchemaRelations(conx models.ConxDb, database string) ([]ForeignKeyInfo, error) {
|
||||
driver := strings.ToLower(conx.TipoDb.Nombre)
|
||||
if isMongoDriver(driver) || isRedisDriver(driver) {
|
||||
return []ForeignKeyInfo{}, nil
|
||||
}
|
||||
|
||||
var db *sql.DB
|
||||
var err error
|
||||
if strings.Contains(driver, "postgres") {
|
||||
db, err = openDynamicDBWithName(conx, database)
|
||||
} else {
|
||||
db, err = openDynamicDB(conx)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
switch {
|
||||
case strings.Contains(driver, "postgres"):
|
||||
return postgresRelations(db)
|
||||
case strings.Contains(driver, "mysql") || strings.Contains(driver, "mariadb"):
|
||||
return mysqlRelations(db, conx, database)
|
||||
case strings.Contains(driver, "sqlserver") || strings.Contains(driver, "mssql"):
|
||||
return mssqlRelations(db, database)
|
||||
default:
|
||||
return sqliteRelations(db)
|
||||
}
|
||||
}
|
||||
|
||||
func postgresRelations(db *sql.DB) ([]ForeignKeyInfo, error) {
|
||||
query := `
|
||||
SELECT con.conname AS constraint_name,
|
||||
tf.relname AS from_table,
|
||||
af.attname AS from_column,
|
||||
tt.relname AS to_table,
|
||||
at.attname AS to_column
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class tf ON tf.oid = con.conrelid
|
||||
JOIN pg_class tt ON tt.oid = con.confrelid
|
||||
JOIN unnest(con.conkey) WITH ORDINALITY AS ck(attnum, ord) ON true
|
||||
JOIN unnest(con.confkey) WITH ORDINALITY AS ct(attnum, ord) ON ct.ord = ck.ord
|
||||
JOIN pg_attribute af ON af.attrelid = con.conrelid AND af.attnum = ck.attnum
|
||||
JOIN pg_attribute at ON at.attrelid = con.confrelid AND at.attnum = ct.attnum
|
||||
WHERE con.contype = 'f'
|
||||
ORDER BY tf.relname, con.conname`
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRelations(rows)
|
||||
}
|
||||
|
||||
func mysqlRelations(db *sql.DB, conx models.ConxDb, database string) ([]ForeignKeyInfo, error) {
|
||||
if _, err := db.Exec("USE " + quoteIdentifier(database, conx.TipoDb.Nombre)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := `
|
||||
SELECT CONSTRAINT_NAME, TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = ? AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||
ORDER BY TABLE_NAME, CONSTRAINT_NAME`
|
||||
rows, err := db.Query(query, database)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRelations(rows)
|
||||
}
|
||||
|
||||
func mssqlRelations(db *sql.DB, database string) ([]ForeignKeyInfo, error) {
|
||||
query := fmt.Sprintf(`
|
||||
USE [%s];
|
||||
SELECT fk.name AS constraint_name,
|
||||
tf.name AS from_table,
|
||||
cf.name AS from_column,
|
||||
tt.name AS to_table,
|
||||
ct.name AS to_column
|
||||
FROM sys.foreign_keys fk
|
||||
JOIN sys.foreign_key_columns fkc ON fkc.constraint_object_id = fk.object_id
|
||||
JOIN sys.tables tf ON tf.object_id = fkc.parent_object_id
|
||||
JOIN sys.tables tt ON tt.object_id = fkc.referenced_object_id
|
||||
JOIN sys.columns cf ON cf.object_id = fkc.parent_object_id AND cf.column_id = fkc.parent_column_id
|
||||
JOIN sys.columns ct ON ct.object_id = fkc.referenced_object_id AND ct.column_id = fkc.referenced_column_id
|
||||
ORDER BY tf.name, fk.name`, database)
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanRelations(rows)
|
||||
}
|
||||
|
||||
// sqliteRelations recorre cada tabla con PRAGMA foreign_key_list, ya que
|
||||
// SQLite no tiene un catálogo único con todas las FK de la base a la vez.
|
||||
func sqliteRelations(db *sql.DB) ([]ForeignKeyInfo, error) {
|
||||
tableRows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tables []string
|
||||
for tableRows.Next() {
|
||||
var name string
|
||||
if err := tableRows.Scan(&name); err == nil {
|
||||
tables = append(tables, name)
|
||||
}
|
||||
}
|
||||
tableRows.Close()
|
||||
|
||||
var out []ForeignKeyInfo
|
||||
for _, table := range tables {
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA foreign_key_list('%s')", table))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, seq int
|
||||
var toTable, fromCol, toCol, onUpdate, onDelete, match string
|
||||
if err := rows.Scan(&id, &seq, &toTable, &fromCol, &toCol, &onUpdate, &onDelete, &match); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, ForeignKeyInfo{
|
||||
ConstraintName: fmt.Sprintf("%s_fk_%d", table, id),
|
||||
FromTable: table,
|
||||
FromColumn: fromCol,
|
||||
ToTable: toTable,
|
||||
ToColumn: toCol,
|
||||
})
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
if out == nil {
|
||||
out = []ForeignKeyInfo{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanRelations(rows *sql.Rows) ([]ForeignKeyInfo, error) {
|
||||
var out []ForeignKeyInfo
|
||||
for rows.Next() {
|
||||
var fk ForeignKeyInfo
|
||||
if err := rows.Scan(&fk.ConstraintName, &fk.FromTable, &fk.FromColumn, &fk.ToTable, &fk.ToColumn); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, fk)
|
||||
}
|
||||
if out == nil {
|
||||
out = []ForeignKeyInfo{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TestConnection verifica si la conexión es válida.
|
||||
func TestDBConnection(conx models.ConxDb) error {
|
||||
if isMongoDriver(strings.ToLower(conx.TipoDb.Nombre)) {
|
||||
|
||||
Vendored
+3587
File diff suppressed because one or more lines are too long
@@ -68,6 +68,14 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Botón ver relaciones de esta tabla (motores SQL) -->
|
||||
<button x-show="!isMongo && !isRedis" @click.stop="verRelaciones(t)"
|
||||
class="shrink-0 px-1.5 py-1 rounded-r hover:bg-[#d4e89c] text-gray-400 opacity-0 group-hover:opacity-100 transition"
|
||||
title="Ver relaciones de esta tabla">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 01-5.656-5.656l1.5-1.5M10.172 13.828a4 4 0 010-5.656l3-3a4 4 0 015.656 5.656l-1.5 1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -149,6 +157,12 @@
|
||||
<input type="file" accept=".sql" @change="uploadSQLFile($event)" class="hidden">
|
||||
</label>
|
||||
|
||||
<!-- Ver relaciones -->
|
||||
<button x-show="!isMongo && !isRedis && selectedDb" @click="verRelaciones()" :disabled="relacionesLoading"
|
||||
class="px-3 py-1.5 text-xs border rounded hover:bg-gray-50 transition disabled:opacity-40">
|
||||
<span x-text="relacionesLoading ? 'Cargando…' : '🔗 Ver relaciones'"></span>
|
||||
</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"
|
||||
@@ -749,6 +763,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: diagrama de relaciones -->
|
||||
<div x-show="relacionesModalOpen" x-cloak x-transition class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4">
|
||||
<div @click.outside="relacionesModalOpen = false" class="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b bg-gray-50 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-[#5a7a1e]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.828 10.172a4 4 0 010 5.656l-3 3a4 4 0 01-5.656-5.656l1.5-1.5M10.172 13.828a4 4 0 010-5.656l3-3a4 4 0 015.656 5.656l-1.5 1.5"/>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-gray-700">Relaciones</span>
|
||||
<span x-show="relacionesFiltro" class="text-[10px] px-1.5 py-0.5 rounded bg-[#e9f0cf] text-[#5a7a1e] font-mono" x-text="relacionesFiltro"></span>
|
||||
<button x-show="relacionesFiltro" @click="relacionesFiltro = ''; renderRelacionesDiagrama()"
|
||||
class="text-[10px] text-gray-400 hover:text-gray-600 underline">ver todas</button>
|
||||
</div>
|
||||
<button @click="relacionesModalOpen = false" class="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-4">
|
||||
<template x-if="relacionesData.length === 0 && !relacionesLoading">
|
||||
<p class="text-sm text-gray-400 text-center py-10">No se encontraron llaves foráneas en esta base de datos.</p>
|
||||
</template>
|
||||
<template x-if="relacionesError">
|
||||
<p class="text-sm text-red-500 text-center py-10" x-text="relacionesError"></p>
|
||||
</template>
|
||||
<div id="relaciones-diagrama" class="flex justify-center"></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"
|
||||
@@ -756,6 +797,13 @@
|
||||
x-text="toast.msg"></div>
|
||||
</div>
|
||||
|
||||
<!-- Mermaid servido localmente (no CDN) — usado para el diagrama de relaciones -->
|
||||
<script src="/js/mermaid.min.js"></script>
|
||||
<script>
|
||||
if (window.mermaid) {
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: 'strict' });
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('queryRunner', () => ({
|
||||
@@ -789,6 +837,14 @@ document.addEventListener('alpine:init', () => {
|
||||
showUpdateBuilder: false,
|
||||
updateBuilder: { collection: '', field: '', value: '', filterField: '_id', filterValue: '""', op: '$set', multi: 'one' },
|
||||
|
||||
// ── Relaciones (diagrama FK) ──
|
||||
relacionesModalOpen: false,
|
||||
relacionesLoading: false,
|
||||
relacionesData: [],
|
||||
relacionesFiltro: '',
|
||||
relacionesError: '',
|
||||
relacionesSeq: 0,
|
||||
|
||||
// ── Autocomplete ──
|
||||
autocompleteVisible: false,
|
||||
autocompleteItems: [],
|
||||
@@ -1428,6 +1484,58 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
},
|
||||
|
||||
async verRelaciones(tabla) {
|
||||
this.relacionesFiltro = tabla || '';
|
||||
this.relacionesModalOpen = true;
|
||||
this.relacionesError = '';
|
||||
if (this.relacionesData.length === 0 || this._relacionesDbCargada !== this.selectedDb) {
|
||||
this.relacionesLoading = true;
|
||||
try {
|
||||
const res = await axios.get('/app/query-runner/relations?conx_db_id=' + this.selectedConxId + '&db=' + encodeURIComponent(this.selectedDb));
|
||||
this.relacionesData = res.data.data || [];
|
||||
this._relacionesDbCargada = this.selectedDb;
|
||||
} catch (e) {
|
||||
this.relacionesError = e.response?.data?.error || 'Error al cargar las relaciones';
|
||||
this.relacionesLoading = false;
|
||||
return;
|
||||
}
|
||||
this.relacionesLoading = false;
|
||||
}
|
||||
await this.renderRelacionesDiagrama();
|
||||
},
|
||||
|
||||
async renderRelacionesDiagrama() {
|
||||
const contenedor = document.getElementById('relaciones-diagrama');
|
||||
if (!contenedor) return;
|
||||
contenedor.innerHTML = '';
|
||||
this.relacionesError = '';
|
||||
|
||||
let relaciones = this.relacionesData;
|
||||
if (this.relacionesFiltro) {
|
||||
relaciones = relaciones.filter(r => r.from_table === this.relacionesFiltro || r.to_table === this.relacionesFiltro);
|
||||
}
|
||||
if (relaciones.length === 0) return;
|
||||
|
||||
const idSeguro = (n) => n.replace(/[^a-zA-Z0-9_]/g, '_');
|
||||
const lineas = ['erDiagram'];
|
||||
relaciones.forEach(r => {
|
||||
lineas.push(` ${idSeguro(r.to_table)} ||--o{ ${idSeguro(r.from_table)} : "${r.from_column}"`);
|
||||
});
|
||||
const definicion = lineas.join('\n');
|
||||
|
||||
if (!window.mermaid) {
|
||||
this.relacionesError = 'No se pudo cargar el motor de diagramas (mermaid.min.js)';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.relacionesSeq++;
|
||||
const { svg } = await mermaid.render('relaciones-svg-' + this.relacionesSeq, definicion);
|
||||
contenedor.innerHTML = svg;
|
||||
} catch (e) {
|
||||
this.relacionesError = 'No se pudo dibujar el diagrama (revisa que los nombres de tabla no tengan caracteres especiales)';
|
||||
}
|
||||
},
|
||||
|
||||
async testConn() {
|
||||
this.testLoading = true;
|
||||
this.testMsg = '';
|
||||
|
||||
@@ -76,6 +76,9 @@ func GetDatabases(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if !usuarioPuedeUsarConx(c, conx.ID) {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "No tienes acceso a esta conexión"})
|
||||
}
|
||||
dbs, err := services.ListDatabases(conx)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
@@ -90,6 +93,9 @@ func GetTables(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if !usuarioPuedeUsarConx(c, conx.ID) {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "No tienes acceso a esta conexión"})
|
||||
}
|
||||
dbName := c.Query("db", "")
|
||||
tables, err := services.ListTables(conx, dbName)
|
||||
if err != nil {
|
||||
@@ -98,6 +104,25 @@ func GetTables(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"data": tables})
|
||||
}
|
||||
|
||||
// GetRelations lista las llaves foráneas de toda una base de datos, para
|
||||
// poder dibujar el diagrama de relaciones entre tablas.
|
||||
// GET /app/query-runner/relations?conx_db_id=1&db=mydb
|
||||
func GetRelations(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()})
|
||||
}
|
||||
if !usuarioPuedeUsarConx(c, conx.ID) {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "No tienes acceso a esta conexión"})
|
||||
}
|
||||
dbName := c.Query("db", "")
|
||||
relaciones, err := services.GetSchemaRelations(conx, dbName)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"data": relaciones})
|
||||
}
|
||||
|
||||
// usuarioPuedeUsarConx verifica que el usuario autenticado pueda ejecutar SQL
|
||||
// contra esa conexión: admin ve todas, el resto solo las que su rol tiene
|
||||
// asignadas en Role.ConxDBs (el mismo criterio que ya usa GetConxDbList para
|
||||
|
||||
@@ -353,6 +353,7 @@ func AdminApiRoutes(api fiber.Router) {
|
||||
h.Get("/query-runner/connections", controllers.GetConxDbList)
|
||||
h.Get("/query-runner/databases", controllers.GetDatabases)
|
||||
h.Get("/query-runner/tables", controllers.GetTables)
|
||||
h.Get("/query-runner/relations", controllers.GetRelations)
|
||||
h.Get("/query-runner/test", controllers.TestConnection)
|
||||
h.Post("/query-runner/run", controllers.RunQuery)
|
||||
h.Post("/query-runner/run-batch", controllers.RunBatchQuery)
|
||||
|
||||
@@ -140,6 +140,7 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Get("/query-runner/connections", controllers.GetConxDbList)
|
||||
protected.Get("/query-runner/databases", controllers.GetDatabases)
|
||||
protected.Get("/query-runner/tables", controllers.GetTables)
|
||||
protected.Get("/query-runner/relations", controllers.GetRelations)
|
||||
protected.Get("/query-runner/test", controllers.TestConnection)
|
||||
// Sin SoloAdmin a propósito: RunQuery/RunBatchQuery ya limitan el alcance
|
||||
// internamente (un no-admin solo ve/consulta las conexiones que su rol
|
||||
|
||||
Reference in New Issue
Block a user