diff --git a/python/functions/datascience/__init__.py b/python/functions/datascience/__init__.py index cdefab14..4f04f6fd 100644 --- a/python/functions/datascience/__init__.py +++ b/python/functions/datascience/__init__.py @@ -72,8 +72,10 @@ from .profile_datetime import profile_datetime from .resample_timeseries import resample_timeseries from .add_pdf_internal_links import add_pdf_internal_links from .suggest_intratable_fk_candidates import suggest_intratable_fk_candidates +from .draw_join_graph_figure import draw_join_graph_figure __all__ = [ + "draw_join_graph_figure", "suggest_intratable_fk_candidates", "detect_time_column", "extract_timeseries_raw", diff --git a/python/functions/datascience/draw_join_graph_figure.md b/python/functions/datascience/draw_join_graph_figure.md new file mode 100644 index 00000000..e105061d --- /dev/null +++ b/python/functions/datascience/draw_join_graph_figure.md @@ -0,0 +1,103 @@ +--- +id: draw_join_graph_figure_py_datascience +name: draw_join_graph_figure +kind: function +lang: py +domain: datascience +version: "1.0.0" +purity: impure +signature: "def draw_join_graph_figure(join_graph: dict, title: str = None) -> \"matplotlib.figure.Figure\"" +description: "Rasteriza el join graph de una base (relaciones FK inter-tabla, salida de build_join_graph) a un matplotlib.figure.Figure: nodos circulares con el nombre de cada tabla (hubs en color de acento cálido, el resto neutro) y aristas dirigidas etiquetadas from_col→to_col (más la cardinalidad si viene). Es la contrapartida dibujada del string Mermaid para que el capítulo de relaciones del informe AutomaticEDA muestre un diagrama real. Layout networkx spring_layout determinista (seed=42), backend Agg sin abrir ventanas; defensivo: nunca lanza y nunca hace I/O." +tags: [eda, plot, relations, graph, matplotlib, figure, networkx, datascience, impure] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [matplotlib, networkx] +example: | + from draw_join_graph_figure import draw_join_graph_figure + join_graph = { + "nodes": [ + {"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"}, + {"table": "orders", "out_degree": 1, "in_degree": 0, "role": "fact"}, + ], + "edges": [ + {"from_table": "orders", "from_col": "customer_id", + "to_table": "customers", "to_col": "id", "cardinality": "N:1"}, + ], + "hubs": ["orders"], + } + fig = draw_join_graph_figure(join_graph, title="Relaciones FK") + fig.savefig("/tmp/join_graph.png") +tested: true +tests: + - "test_returns_figure_with_axis" + - "test_savefig_produces_nonempty_png" + - "test_empty_dict_does_not_raise_and_savefig_png" + - "test_none_does_not_raise_and_savefig_png" +test_file_path: "python/functions/datascience/draw_join_graph_figure_test.py" +file_path: "python/functions/datascience/draw_join_graph_figure.py" +params: + - name: join_graph + desc: "Dict producido por build_join_graph. Claves: `nodes` (list[dict] con table, out_degree, in_degree, role), `edges` (list[dict] con from_table, from_col, to_table, to_col y opcional cardinality/inclusion) y `hubs` (list[str] de tablas hub a destacar en color cálido). Claves ausentes, items no-dict, None o {} se toleran (devuelve Figure con texto, sin lanzar). Los nombres de nodo se derivan también de las aristas, así que un grafo con edges pero sin nodes explícitos igual se dibuja." + - name: title + desc: "Título dibujado sobre el diagrama. Si se omite (None) se usa \"Join graph\". Default None." +output: "Un matplotlib.figure.Figure (figsize 7x5) con un único Axes que contiene el diagrama node-link dirigido: tablas como nodos circulares etiquetados (hubs en acento cálido #DD8452, resto en azul neutro #4C72B0) y FKs como flechas dirigidas con etiqueta from_col→to_col (+ cardinalidad). Si join_graph no tiene nodos ni aristas (o es None/{}), devuelve igualmente una Figure con el texto centrado \"Sin relaciones FK detectadas.\"; ante cualquier fallo interno devuelve una Figure con un mensaje genérico (nunca lanza). El caller rasteriza/cierra la figura; la función no la muestra ni la guarda." +--- + +## Ejemplo + +```python +from draw_join_graph_figure import draw_join_graph_figure + +# `join_graph` es la salida de build_join_graph (nodes + edges + hubs). +join_graph = { + "nodes": [ + {"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"}, + {"table": "orders", "out_degree": 2, "in_degree": 0, "role": "fact"}, + {"table": "products", "out_degree": 0, "in_degree": 1, "role": "dimension"}, + ], + "edges": [ + {"from_table": "orders", "from_col": "customer_id", + "to_table": "customers", "to_col": "id", "cardinality": "N:1"}, + {"from_table": "orders", "from_col": "product_id", + "to_table": "products", "to_col": "id", "cardinality": "N:1"}, + ], + "hubs": ["orders"], # `orders` se pinta en color de acento (tabla de hechos) +} + +fig = draw_join_graph_figure(join_graph, title="Relaciones FK") + +# El renderer del informe lo rasteriza; aquí solo persistimos para inspección. +fig.savefig("/tmp/join_graph.png") +``` + +## Cuando usarla + +Úsala en el capítulo de relaciones de un informe AutomaticEDA cuando quieras un +diagrama **dibujado** del esquema relacional, no solo el bloque Mermaid pegable. +Pásale directamente la salida de `build_join_graph` (`nodes` + `edges` + `hubs`) +y obtienes una `matplotlib.figure.Figure` lista para que el renderer perezoso la +rasterice. Es la pareja visual del string Mermaid: Mermaid sirve para pegar en +Markdown/docs que lo soporten; esta función produce la imagen real (PNG/PDF) que +va embebida en informes que no renderizan Mermaid. + +## Gotchas + +- **Impura por matplotlib.** Fija el backend `Agg` al importar — no abre + ventanas ni depende de un display. Segura de llamar en lotes desde el + renderer. +- **Layout determinista (`seed=42`).** Usa `nx.spring_layout(G, seed=42)`, así + que la misma entrada produce el mismo diagrama (test reproducible). Para + grafos de 0/1 nodos usa una posición fija centrada en vez del spring layout. +- **No hace I/O.** No llama `plt.show()` ni guarda a disco — solo devuelve la + `Figure`. Quien la consume la rasteriza y la libera (`plt.close(fig)`) para no + acumular memoria en informes con muchas tablas. +- **Devuelve una Figure, NO un dict.** A diferencia de `build_join_graph` (que + devuelve el dict del grafo), esta función devuelve el objeto de figura ya + dibujado. +- **Defensiva, nunca lanza.** `None`, `{}`, claves ausentes o items malformados + se manejan sin error: en el peor caso devuelve una `Figure` con + "Sin relaciones FK detectadas." (vacío) o un mensaje genérico (fallo interno). + No la envuelvas en try/except por miedo a un raise — no lo hay. diff --git a/python/functions/datascience/draw_join_graph_figure.py b/python/functions/datascience/draw_join_graph_figure.py new file mode 100644 index 00000000..aee17622 --- /dev/null +++ b/python/functions/datascience/draw_join_graph_figure.py @@ -0,0 +1,214 @@ +"""Impure EDA helper: rasterize a join graph to a matplotlib Figure (`eda` group). + +Takes the join graph produced by ``build_join_graph`` (inter-table FK relations) +and draws it as a directed node-link diagram on a ready-to-rasterize +``matplotlib.figure.Figure``. Hub tables (the ones with the highest out-degree, +candidate fact tables of a star schema) are highlighted in a warm accent colour; +the rest use a neutral colour. Directed edges carry a ``from_col→to_col`` label +(plus the cardinality when present). + +This is the *drawn* counterpart of the Mermaid string that ``build_join_graph`` +also emits: the relations chapter of an AutomaticEDA report can show a real +picture instead of only the pasteable Mermaid block. + +Impure because it touches matplotlib's rendering machinery. It pins the headless +Agg backend and a deterministic ``spring_layout`` seed so the output is +reproducible. It never raises: on any internal failure (or empty input) it +returns a ``Figure`` carrying a centered message, so the lazy render of the +document is never broken. +""" + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt # noqa: E402 +import networkx as nx # noqa: E402 + +# Warm accent reserved for hub tables (candidate fact tables / star-schema cores). +_HUB_COLOR = "#DD8452" +# Neutral blue for every other table. +_NODE_COLOR = "#4C72B0" +# Muted gray for the empty/error message text. +_MUTED_TEXT = "#5f6b7a" +# Edge colour and label colour. +_EDGE_COLOR = "#7a7a7a" +_EDGE_LABEL_COLOR = "#34495e" +# Constant node size; shared with the edge drawing so arrowheads stop at the +# node boundary instead of being hidden under the marker. +_NODE_SIZE = 2200 + + +def _text_figure(message: str) -> "matplotlib.figure.Figure": + """Return a blank Figure carrying a single centered message. + + Used both for the "no relations" case and as the never-raise fallback. + """ + fig, ax = plt.subplots(figsize=(7, 5)) + ax.axis("off") + ax.text( + 0.5, + 0.5, + message, + ha="center", + va="center", + fontsize=12, + color=_MUTED_TEXT, + transform=ax.transAxes, + ) + fig.tight_layout() + return fig + + +def _edge_label(edge: dict) -> str: + """Build the ``from_col→to_col`` label of an edge, appending cardinality.""" + fc = edge.get("from_col") + tc = edge.get("to_col") + if fc is not None and tc is not None: + label = f"{fc}→{tc}" + elif fc is not None: + label = str(fc) + elif tc is not None: + label = str(tc) + else: + label = "" + card = edge.get("cardinality") + if card: + label = f"{label} ({card})" if label else str(card) + return label + + +def draw_join_graph_figure(join_graph: dict, title: str = None): + """Rasterize a join graph to a matplotlib Figure. + + Builds a ``networkx.DiGraph`` from the graph's nodes and edges, lays it out + with a deterministic ``spring_layout`` (``seed=42``) and draws it on a + ``matplotlib.figure.Figure``: tables as labelled circular nodes (hubs in a + warm accent, the rest neutral) and FK relations as directed arrows labelled + ``from_col→to_col`` (plus cardinality when available). + + The function never raises. On empty/``None`` input it returns a Figure with + a centered "Sin relaciones FK detectadas." message; on any internal failure + it returns a Figure with a generic centered message. It never shows the + figure nor writes it to disk — the document renderer rasterizes it. + + Args: + join_graph: Dict produced by ``build_join_graph`` with keys ``nodes`` + (list of ``{table, out_degree, in_degree, role}``), ``edges`` (list + of ``{from_table, from_col, to_table, to_col, cardinality?, + inclusion?}``) and ``hubs`` (list of hub table names to highlight). + Missing keys, non-dict items, ``None`` or ``{}`` are all tolerated. + title: Optional title drawn above the diagram. When omitted, the title + defaults to "Join graph". + + Returns: + A ``matplotlib.figure.Figure`` (figsize 7x5) with a single Axes holding + the node-link diagram. The caller rasterizes/closes it. + """ + try: + jg = join_graph if isinstance(join_graph, dict) else {} + nodes = jg.get("nodes") or [] + edges = jg.get("edges") or [] + hubs = {h for h in (jg.get("hubs") or []) if h is not None} + + # Collect node names from the declared nodes and, defensively, from the + # edges (so a graph with edges but no explicit nodes still draws). + node_names: list = [] + seen: set = set() + + def _register(name) -> None: + if name is not None and name not in seen: + seen.add(name) + node_names.append(name) + + for n in nodes: + if isinstance(n, dict): + _register(n.get("table")) + for e in edges: + if isinstance(e, dict): + _register(e.get("from_table")) + _register(e.get("to_table")) + + if not node_names: + return _text_figure("Sin relaciones FK detectadas.") + + graph = nx.DiGraph() + for name in node_names: + graph.add_node(name) + + edge_labels: dict = {} + for e in edges: + if not isinstance(e, dict): + continue + ft = e.get("from_table") + tt = e.get("to_table") + if ft is None or tt is None: + continue + graph.add_edge(ft, tt) + edge_labels[(ft, tt)] = _edge_label(e) + + fig, ax = plt.subplots(figsize=(7, 5)) + + # Deterministic layout. Fixed positions for trivial graphs so a single + # node sits centered instead of at an arbitrary spring-layout point. + if graph.number_of_nodes() <= 1: + pos = {name: (0.5, 0.5) for name in graph.nodes()} + else: + pos = nx.spring_layout(graph, seed=42) + + node_colors = [ + _HUB_COLOR if name in hubs else _NODE_COLOR for name in graph.nodes() + ] + nx.draw_networkx_nodes( + graph, + pos, + ax=ax, + node_color=node_colors, + node_size=_NODE_SIZE, + node_shape="o", + edgecolors="white", + linewidths=1.5, + ) + nx.draw_networkx_labels( + graph, + pos, + ax=ax, + font_size=9, + font_color="white", + font_weight="bold", + ) + nx.draw_networkx_edges( + graph, + pos, + ax=ax, + arrows=True, + arrowstyle="-|>", + arrowsize=18, + edge_color=_EDGE_COLOR, + width=1.4, + connectionstyle="arc3,rad=0.06", + node_size=_NODE_SIZE, + ) + if any(lbl for lbl in edge_labels.values()): + nx.draw_networkx_edge_labels( + graph, + pos, + edge_labels=edge_labels, + ax=ax, + font_size=7, + font_color=_EDGE_LABEL_COLOR, + bbox={ + "boxstyle": "round,pad=0.2", + "fc": "white", + "ec": "none", + "alpha": 0.7, + }, + ) + + ax.set_title(title if title else "Join graph", fontsize=13) + ax.axis("off") + fig.tight_layout() + return fig + except Exception: + # Never raise — the document render is lazy and must not be broken. + return _text_figure("No se pudo dibujar el join graph.") diff --git a/python/functions/datascience/draw_join_graph_figure_test.py b/python/functions/datascience/draw_join_graph_figure_test.py new file mode 100644 index 00000000..25d8f360 --- /dev/null +++ b/python/functions/datascience/draw_join_graph_figure_test.py @@ -0,0 +1,84 @@ +"""Tests para draw_join_graph_figure (rasteriza el join graph, grupo eda). + +Usa el backend Agg sin abrir ventanas; cada test cierra la Figure construida +(matplotlib.pyplot.close) para no acumular estado entre tests. Las aserciones de +guardado escriben a tmp_path (fixture de pytest) y comprueban que el PNG no está +vacío. +""" + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt # noqa: E402 +from matplotlib.figure import Figure # noqa: E402 + +from draw_join_graph_figure import draw_join_graph_figure + + +def _make_join_graph(): + """Join graph mínimo: 3 nodos (customers/orders/products) y 2 aristas. + + orders -> customers y orders -> products. `orders` es el hub (out_degree 2). + """ + return { + "nodes": [ + {"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dimension"}, + {"table": "orders", "out_degree": 2, "in_degree": 0, "role": "fact"}, + {"table": "products", "out_degree": 0, "in_degree": 1, "role": "dimension"}, + ], + "edges": [ + { + "from_table": "orders", + "from_col": "customer_id", + "to_table": "customers", + "to_col": "id", + "cardinality": "N:1", + "inclusion": 1.0, + }, + { + "from_table": "orders", + "from_col": "product_id", + "to_table": "products", + "to_col": "id", + "cardinality": "N:1", + "inclusion": 0.98, + }, + ], + "hubs": ["orders"], + } + + +def test_returns_figure_with_axis(): + fig = draw_join_graph_figure(_make_join_graph(), title="Relaciones FK") + assert isinstance(fig, Figure) + # Al menos un eje con el diagrama. + assert len(fig.axes) >= 1 + plt.close(fig) + + +def test_savefig_produces_nonempty_png(tmp_path): + fig = draw_join_graph_figure(_make_join_graph()) + out = tmp_path / "g.png" + fig.savefig(out) + assert out.exists() + assert out.stat().st_size > 0 + plt.close(fig) + + +def test_empty_dict_does_not_raise_and_savefig_png(tmp_path): + fig = draw_join_graph_figure({}) + assert isinstance(fig, Figure) + out = tmp_path / "empty.png" + fig.savefig(out) + assert out.stat().st_size > 0 + plt.close(fig) + + +def test_none_does_not_raise_and_savefig_png(tmp_path): + fig = draw_join_graph_figure(None) + assert isinstance(fig, Figure) + out = tmp_path / "none.png" + fig.savefig(out) + assert out.stat().st_size > 0 + plt.close(fig) diff --git a/python/functions/pipelines/render_automatic_eda_folder.md b/python/functions/pipelines/render_automatic_eda_folder.md index 895f15a2..a79f2a01 100644 --- a/python/functions/pipelines/render_automatic_eda_folder.md +++ b/python/functions/pipelines/render_automatic_eda_folder.md @@ -6,7 +6,7 @@ domain: pipelines purity: impure version: "1.0.0" signature: "def render_automatic_eda_folder(path: str, out_dir: str = \"reports\", basename: str = None, profile_level: str = \"standard\", emit_pdf: bool = True, emit_pptx: bool = True, emit_md: bool = True, per_table_eda: bool = False, min_inclusion: float = 0.9, ctx_extra: dict = None) -> dict" -description: "Informe AutomaticEDA a nivel de BASE one-shot de una CARPETA de archivos tabulares (CSV/Parquet/JSON) o de una DuckDB existente. Carga la carpeta a una DuckDB temporal con load_folder_to_duckdb (o usa la DuckDB dada directa), perfila TODA la base con profile_database (resumen de cada tabla + FK candidatas por containment + join graph con diagrama Mermaid), ENSAMBLA un documento-base por capitulos (portada-base con nombre/n tablas/totales/fecha/fuente, resumen de tablas con una fila por tabla, y relaciones inter-tabla con la tabla de FK candidatas + el diagrama Mermaid) y lo renderiza con el motor AutomaticEDA a PDF (A5 movil), PPTX (16:9) y Markdown autocontenido a la vez. Con per_table_eda=True anexa los capitulos de mini-EDA de cada tabla (build_document por tabla). Es el hermano a nivel de base de render_automatic_eda (que perfila UNA tabla): aqui el informe es de la base y de sus relaciones. Devuelve las rutas de PDF/PPTX/MD, el manifiesto y el DatabaseProfile." +description: "Informe AutomaticEDA a nivel de BASE one-shot de una CARPETA de archivos tabulares (CSV/Parquet/JSON) o de una DuckDB existente. Carga la carpeta a una DuckDB temporal con load_folder_to_duckdb (o usa la DuckDB dada directa), perfila TODA la base con profile_database (resumen de cada tabla + FK candidatas por containment + join graph con diagrama Mermaid), ENSAMBLA un documento-base por capitulos (portada-base con nombre/n tablas/totales/fecha/fuente, resumen de tablas con una fila por tabla, y relaciones inter-tabla con la tabla de FK candidatas + una Figure matplotlib REAL del join graph dibujada con draw_join_graph_figure mas el texto Mermaid) y lo renderiza con el motor AutomaticEDA a PDF (A5 movil), PPTX (16:9) y Markdown autocontenido a la vez. Con per_table_eda=True anexa los capitulos de mini-EDA de cada tabla (build_document por tabla). Es el hermano a nivel de base de render_automatic_eda (que perfila UNA tabla): aqui el informe es de la base y de sus relaciones. Devuelve las rutas de PDF/PPTX/MD, el manifiesto y el DatabaseProfile." tags: [eda, duckdb, database, profiling, relations, pipeline, dataops, report, pdf, pptx, launcher] uses_functions: - load_folder_to_duckdb_py_infra @@ -14,6 +14,7 @@ uses_functions: - render_automatic_eda_pdf_py_datascience - render_automatic_eda_pptx_py_datascience - render_automatic_eda_markdown_py_datascience + - draw_join_graph_figure_py_datascience uses_types: [] returns: [] returns_optional: false @@ -105,8 +106,10 @@ anexado. path inexistente → `{status:'error'}` (no lanza). - El escaneo de la carpeta es **no recursivo** (solo el primer nivel) y por defecto cubre `*.csv,*.parquet,*.json` (ver `load_folder_to_duckdb`). -- El diagrama Mermaid se vuelca como **bloque de código**: en el Markdown queda - como diagrama renderizable; en PDF/PPTX se muestra el **texto** del grafo (no - se rasteriza el diagrama a imagen en esta versión). +- El join graph se rasteriza a una **Figure matplotlib real** (vía + `draw_join_graph_figure`) que aparece dibujada en PDF/PPTX (nodos = tablas, + flechas = FK). Además, el **texto Mermaid** del grafo se incluye como bloque de + código (en el Markdown queda como diagrama renderizable y es útil para pegar a + un LLM). - Carpeta vacía o con 1 sola tabla: funciona igual; el capítulo de relaciones dice "sin FK". dict-no-throw en todos los caminos. diff --git a/python/functions/pipelines/render_automatic_eda_folder.py b/python/functions/pipelines/render_automatic_eda_folder.py index 28793cca..d5b5bbd3 100644 --- a/python/functions/pipelines/render_automatic_eda_folder.py +++ b/python/functions/pipelines/render_automatic_eda_folder.py @@ -38,6 +38,7 @@ import os from datetime import datetime, timezone from datascience import ( + draw_join_graph_figure, render_automatic_eda_markdown, render_automatic_eda_pdf, render_automatic_eda_pptx, @@ -157,14 +158,29 @@ def _relaciones_chapter(db_profile: dict) -> dict: "text": "Sin relaciones FK candidatas detectadas entre las tablas.", }) - mermaid = (db_profile.get("join_graph") or {}).get("mermaid", "") or "" - if mermaid.strip(): + join_graph = db_profile.get("join_graph") or {} + has_edges = bool(join_graph.get("edges")) + if has_edges: blocks.append({"kind": "heading", "text": "Diagrama (join graph)", "level": 3}) - # El Mermaid se vuelca como bloque de código: en MD queda como diagrama - # renderizable; en PDF/PPTX se muestra el texto del grafo (legible). - blocks.append({"kind": "markdown", - "text": "```mermaid\n" + mermaid.strip() + "\n```"}) + # Figure matplotlib REAL del grafo de relaciones (nodos = tablas, + # aristas = FK). Lazy via `make`: el renderer la construye solo al + # paginar, y se rasteriza en PDF/PPTX. draw_join_graph_figure nunca + # lanza (devuelve una Figure de error si algo falla). + blocks.append({ + "kind": "figure", + "make": (lambda jg=join_graph: draw_join_graph_figure( + jg, title="Join graph (relaciones inter-tabla)")), + "caption": "Grafo de relaciones: nodos = tablas, flechas = FK " + "candidatas (etiqueta from_col→to_col).", + "height_in": 4.5, + }) + # Además, el Mermaid en texto: en el Markdown queda como diagrama + # renderizable y es útil para pegar a un LLM. + mermaid = (join_graph.get("mermaid", "") or "").strip() + if mermaid: + blocks.append({"kind": "markdown", + "text": "```mermaid\n" + mermaid + "\n```"}) return {"id": "relaciones", "title": "Relaciones inter-tabla", "version": "1.0.0", "blocks": blocks} diff --git a/python/functions/pipelines/render_automatic_eda_folder_test.py b/python/functions/pipelines/render_automatic_eda_folder_test.py index eb529bdc..85b42cd6 100644 --- a/python/functions/pipelines/render_automatic_eda_folder_test.py +++ b/python/functions/pipelines/render_automatic_eda_folder_test.py @@ -14,7 +14,10 @@ import duckdb sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) -from pipelines.render_automatic_eda_folder import render_automatic_eda_folder +from pipelines.render_automatic_eda_folder import ( + _relaciones_chapter, + render_automatic_eda_folder, +) def _write_demo_folder(folder: str) -> None: @@ -144,3 +147,42 @@ def test_path_does_not_exist(tmp_path): r = render_automatic_eda_folder(str(tmp_path / "nope")) assert r["status"] == "error" assert "no existe" in r["error"].lower() + + +def test_relaciones_chapter_has_real_figure_when_edges(): + """Con edges, el capítulo de relaciones incluye un bloque Figure matplotlib + REAL (no solo el texto Mermaid): su make() devuelve una Figure.""" + db_profile = { + "join_graph": { + "nodes": [ + {"table": "orders", "out_degree": 1, "in_degree": 0, "role": "fact"}, + {"table": "customers", "out_degree": 0, "in_degree": 1, "role": "dim"}, + ], + "edges": [{"from_table": "orders", "from_col": "customer_id", + "to_table": "customers", "to_col": "id", + "cardinality": "N:1"}], + "mermaid": "graph LR orders --> customers", + "hubs": ["orders"], + }, + "fk_candidates": [{"from_table": "orders", "from_col": "customer_id", + "to_table": "customers", "to_col": "id", + "inclusion": 1.0, "cardinality": "N:1"}], + } + ch = _relaciones_chapter(db_profile) + figs = [b for b in ch["blocks"] if b.get("kind") == "figure"] + assert len(figs) == 1, ch["blocks"] + # El make() perezoso produce una matplotlib Figure real. + import matplotlib + matplotlib.use("Agg") + fig = figs[0]["make"]() + from matplotlib.figure import Figure + assert isinstance(fig, Figure) + assert fig.get_axes(), "la Figure del join graph debe tener al menos un eje" + + +def test_relaciones_chapter_no_figure_when_no_edges(): + """Sin edges, no se añade bloque Figure (capítulo dice 'sin FK').""" + db_profile = {"join_graph": {"nodes": [], "edges": [], "mermaid": "", + "hubs": []}, "fk_candidates": []} + ch = _relaciones_chapter(db_profile) + assert not [b for b in ch["blocks"] if b.get("kind") == "figure"]