---
id: build_join_graph_py_datascience
name: build_join_graph
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: pure
signature: "def build_join_graph(fk_candidates: list, tables: list = None) -> dict"
description: "Construye un grafo de relaciones inter-tabla a partir de FK candidatas (salida fk_candidates de infer_fk_containment_duckdb): nodos con grados y rol (fact/dimension/bridge/standalone), aristas por FK, hubs (candidatas a tabla de hechos) y un diagrama Mermaid graph LR pegable. Funcion pura, sin deps externas, no muta el input."
tags: [eda, relations, join, schema, graph, mermaid, star-schema, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
example: |
  from datascience import build_join_graph
  fks = [
    {"from_table": "orders", "from_col": "customer_id",
     "to_table": "customers", "to_col": "id",
     "inclusion": 1.0, "cardinality": "many-to-one"},
    {"from_table": "orders", "from_col": "product_id",
     "to_table": "products", "to_col": "id",
     "inclusion": 0.98, "cardinality": "many-to-one"},
  ]
  g = build_join_graph(fks)
  # g["hubs"] == ["orders"]; orders -> role "fact", customers/products -> "dimension"
  print(g["mermaid"])
tested: true
tests:
  - "test_star_schema_roles_and_hub"
  - "test_two_edges_built"
  - "test_mermaid_contains_tables_and_arrows"
  - "test_bridge_role"
  - "test_standalone_node_from_tables_list"
  - "test_empty_list_does_not_crash"
  - "test_none_input_does_not_crash"
  - "test_malformed_entries_skipped"
  - "test_does_not_mutate_input"
test_file_path: "python/functions/datascience/build_join_graph_test.py"
file_path: "python/functions/datascience/build_join_graph.py"
params:
  - name: fk_candidates
    desc: >
      lista de dicts, cada uno una FK candidata con al menos las claves
      from_table, from_col, to_table, to_col, inclusion, cardinality. Suele ser
      la salida `fk_candidates` de infer_fk_containment_duckdb. Las claves se
      leen de forma defensiva con .get(...); entradas que no son dict o que no
      tienen from_table/to_table se ignoran sin fallar. None se trata como [].
  - name: tables
    desc: >
      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.
output: >
  dict con nodes (list[dict] con table, out_degree, in_degree, role donde role
  es "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 `from_col->to_col`) y hubs (list[str]
  de tablas con out_degree>0 ordenadas por out_degree descendente, candidatas a
  tabla de hechos / star schema).
---

## Ejemplo

```python
from datascience import build_join_graph

# fk_candidates concreto: orders apunta a customers y a products (estrella).
fks = [
    {"from_table": "orders", "from_col": "customer_id",
     "to_table": "customers", "to_col": "id",
     "inclusion": 1.0, "cardinality": "many-to-one"},
    {"from_table": "orders", "from_col": "product_id",
     "to_table": "products", "to_col": "id",
     "inclusion": 0.98, "cardinality": "many-to-one"},
]

g = build_join_graph(fks)

g["hubs"]   # ["orders"]
# nodes: orders -> role "fact" (out_degree 2, in_degree 0),
#        customers/products -> role "dimension" (in_degree 1, out_degree 0)
print(g["mermaid"])
```

El campo `mermaid` se pega tal cual en un bloque ```mermaid:

```mermaid
graph LR
    orders["orders"] -->|customer_id->id| customers["customers"]
    orders["orders"] -->|product_id->id| products["products"]
```

## Cuando usarla

Cuando hayas inferido las foreign keys de una base de datos con
`infer_fk_containment_duckdb` (grupo `eda`) y necesites **visualizar el esquema
relacional**: ver de un vistazo que tabla es la de hechos (hub/star schema),
cuales son dimensiones y cuales quedan sueltas. Devuelve un diagrama Mermaid
pegable en docs, un report o un dashboard, mas el grafo en dict para razonar
sobre los grados (priorizar joins, detectar tablas puente, planear el modelo
dimensional). Es la capa de grafo sobre las FK crudas: lee las candidatas, no
toca la base de datos.

## Notas

Funcion pura, sin I/O ni dependencias externas (solo stdlib), no muta
`fk_candidates`. Tolera lista vacia o `None` (devuelve grafo vacio con un
mermaid minimo `graph LR` con nota `empty`) y entradas malformadas (no-dict o
sin from_table/to_table se ignoran).

Heuristica de `role` por nodo, basada solo en grados:

- **fact** — `out_degree > 0` y `in_degree == 0`: apunta a otras tablas y nadie
  le apunta. Es la candidata a tabla de hechos.
- **dimension** — `in_degree > 0` y `out_degree == 0`: solo recibe referencias
  (tabla maestra / catalogo).
- **bridge** — `out_degree > 0` e `in_degree > 0`: apunta y recibe (tabla puente
  o asociativa de una relacion many-to-many).
- **standalone** — sin aristas (solo aparece si se paso en `tables`).

`hubs` ordena por `out_degree` descendente las tablas con `out_degree > 0`. Para
un star schema limpio, `hubs[0]` es la tabla de hechos. Los IDs de nodo en el
Mermaid se sanean (no-alfanumerico -> `_`) pero la etiqueta visible conserva el
nombre original de la tabla.
```
