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:
Lizandro GD
2026-08-08 23:11:48 +00:00
parent b7fba43ea1
commit 21d8cd5260
6 changed files with 3888 additions and 0 deletions
+166
View File
@@ -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)) {