"""Construye un grafo de relaciones inter-tabla a partir de FK candidatas. Toma la lista `fk_candidates` (salida de infer_fk_containment_duckdb) y produce un grafo de relaciones: nodos (tablas) con grados y rol inferido (fact/dimension/ bridge/standalone), aristas (una por FK), un diagrama Mermaid pegable y la lista de hubs (tablas con mayor out_degree, candidatas a tabla de hechos / star schema). Funcion pura: lista de dicts -> dict de grafo. Sin I/O ni dependencias externas. """ def _mermaid_id(name: str) -> str: """Sanea un nombre de tabla para usarlo como identificador Mermaid. Mermaid no admite espacios, guiones ni puntos en los IDs de nodo. Se sustituyen por guion bajo. El nombre original se conserva como etiqueta visible del nodo. """ safe = [] for ch in str(name): safe.append(ch if (ch.isalnum() or ch == "_") else "_") out = "".join(safe) if not out: out = "node" if out[0].isdigit(): out = "t_" + out return out def build_join_graph(fk_candidates: list, tables: list = None) -> dict: """Construye un grafo de relaciones inter-tabla desde FK candidatas. Args: fk_candidates: lista de dicts, cada uno una FK candidata con al menos las claves from_table, from_col, to_table, to_col, inclusion, cardinality. Claves ausentes se toleran con .get(...). Suele ser la salida `fk_candidates` de infer_fk_containment_duckdb. tables: lista opcional de nombres de TODAS las tablas. Sirve para incluir como nodos aislados (role "standalone") las tablas que no aparecen en ninguna FK. Si es None, los nodos se derivan solo de las aristas. Returns: dict con: - nodes: list[dict] con table, out_degree, in_degree, role (role: "fact" | "dimension" | "bridge" | "standalone"). - edges: list[dict] con from_table, from_col, to_table, to_col, inclusion, cardinality (una por FK valida). - mermaid: str con un diagrama `graph LR` pegable en un bloque ```mermaid, una arista por FK etiquetada con las columnas. - hubs: list[str] de tablas con mayor out_degree (>0), ordenadas por out_degree descendente. Candidatas a tabla de hechos. """ fk_candidates = fk_candidates or [] out_degree: dict = {} in_degree: dict = {} node_order: list = [] def _ensure(name) -> None: if name is None: return if name not in out_degree: out_degree[name] = 0 in_degree[name] = 0 node_order.append(name) # Sembrar nodos aislados si se pasaron todas las tablas. for t in tables or []: _ensure(t) edges: list = [] for fk in fk_candidates: if not isinstance(fk, dict): continue ft = fk.get("from_table") tt = fk.get("to_table") if ft is None or tt is None: continue _ensure(ft) _ensure(tt) out_degree[ft] += 1 in_degree[tt] += 1 edges.append( { "from_table": ft, "from_col": fk.get("from_col"), "to_table": tt, "to_col": fk.get("to_col"), "inclusion": fk.get("inclusion"), "cardinality": fk.get("cardinality"), } ) nodes: list = [] for name in node_order: od = out_degree[name] ind = in_degree[name] if od == 0 and ind == 0: role = "standalone" elif od > 0 and ind == 0: # Apunta a otras tablas pero nadie le apunta: tabla de hechos. role = "fact" elif od == 0 and ind > 0: # Solo recibe referencias: tabla de dimension / maestra. role = "dimension" else: # Apunta y recibe: tabla puente / asociativa. role = "bridge" nodes.append( { "table": name, "out_degree": od, "in_degree": ind, "role": role, } ) max_out = max((n["out_degree"] for n in nodes), default=0) hubs: list = [] if max_out > 0: hubs = [ n["table"] for n in sorted( (n for n in nodes if n["out_degree"] > 0), key=lambda n: n["out_degree"], reverse=True, ) ] mermaid = _build_mermaid(nodes, edges) return {"nodes": nodes, "edges": edges, "mermaid": mermaid, "hubs": hubs} def _build_mermaid(nodes: list, edges: list) -> str: """Renderiza el grafo como un diagrama Mermaid `graph LR` pegable. Una arista por FK, etiquetada con `from_col->to_col`. Los nodos aislados se declaran sueltos para que aparezcan en el diagrama. Si no hay nodos ni aristas, devuelve un diagrama minimo valido con una nota. """ lines = ["graph LR"] if not nodes and not edges: lines.append(" empty[No relations]") return "\n".join(lines) # Declarar nodos aislados (sin ninguna arista) para que se rendericen. connected = set() for e in edges: connected.add(e["from_table"]) connected.add(e["to_table"]) for n in nodes: name = n["table"] if name not in connected: nid = _mermaid_id(name) lines.append(f' {nid}["{name}"]') for e in edges: ft = e["from_table"] tt = e["to_table"] fc = e.get("from_col") tc = e.get("to_col") label = f"{fc}->{tc}" if (fc is not None and tc is not None) else "" fid = _mermaid_id(ft) tid = _mermaid_id(tt) if label: lines.append(f' {fid}["{ft}"] -->|{label}| {tid}["{tt}"]') else: lines.append(f' {fid}["{ft}"] --> {tid}["{tt}"]') return "\n".join(lines)