Skip to content

Commit 5906776

Browse files
feat(#1532): master-part entity clustering + drop ERD naming (content)
1 parent dba2f0d commit 5906776

2 files changed

Lines changed: 96 additions & 23 deletions

File tree

‎src/datajoint/diagram.py‎

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,7 @@ def __getitem__(self, key):
558558
>>> trace["my_schema.Session"].to_dicts() # string index → FreeTable
559559
"""
560560
# Non-trace diagrams: defer to networkx adjacency lookup so existing
561-
# `diagram[node_name]` patterns (used in diagram algebra, ERD tests)
561+
# `diagram[node_name]` patterns (used in diagram algebra, diagram tests)
562562
# keep working.
563563
if getattr(self, "_mode", None) != "trace":
564564
return super().__getitem__(key)
@@ -1095,15 +1095,17 @@ def _make_graph(self) -> nx.MultiDiGraph:
10951095
nx.MultiDiGraph
10961096
Graph with nodes relabeled to class names.
10971097
"""
1098-
# mark "distinguished" tables, i.e. those that introduce new primary key
1099-
# attributes
1098+
# Mark tables that introduce a new schema dimension, i.e. that add a
1099+
# primary-key attribute of their own beyond what they inherit through
1100+
# foreign keys. These are drawn with an underlined label. ("Schema
1101+
# dimension" / "axis" is the documented term for such a table.)
11001102
# Filter nodes_to_show to only include nodes that exist in the graph
11011103
valid_nodes = self.nodes_to_show.intersection(set(self.nodes()))
11021104
for name in valid_nodes:
11031105
foreign_attributes = set(
11041106
attr for p in self.in_edges(name, data=True) for attr in p[2]["attr_map"] if p[2]["primary"]
11051107
)
1106-
self.nodes[name]["distinguished"] = (
1108+
self.nodes[name]["introduces_dimension"] = (
11071109
"primary_key" in self.nodes[name] and foreign_attributes < self.nodes[name]["primary_key"]
11081110
)
11091111
# construct subgraph and rename nodes to class names. A MultiDiGraph is
@@ -1513,6 +1515,22 @@ def make_dot(self):
15131515
self._encapsulate_edge_attributes(graph)
15141516
dot = nx.drawing.nx_pydot.to_pydot(graph)
15151517
dot.set_rankdir(direction)
1518+
1519+
# Master↔part grouping (#1532): map each part (class name "Master.Part")
1520+
# to its master ("Master"), and record which parts depend on a sibling
1521+
# part so an intra-group chain can descend rather than share a rank.
1522+
part_master = {}
1523+
for gname, gdata in graph.nodes(data=True):
1524+
if gdata.get("node_type") is Part:
1525+
pn = gname.strip('"')
1526+
part_master[pn] = pn.rsplit(".", 1)[0]
1527+
part_names = set(part_master)
1528+
depends_on_sibling = set()
1529+
for pn, mn in part_master.items():
1530+
for pred in graph.predecessors(f'"{pn}"'):
1531+
if pred.strip('"') in part_names and part_master.get(pred.strip('"')) == mn:
1532+
depends_on_sibling.add(pn)
1533+
15161534
for node in dot.get_nodes():
15171535
node.set_shape("circle")
15181536
name = node.get_name().strip('"')
@@ -1545,12 +1563,17 @@ def make_dot(self):
15451563
node.set_tooltip("&#13;".join(description))
15461564
# Strip module prefix from label if it matches the cluster label
15471565
display_name = name
1548-
schema_name = schema_map.get(name)
1549-
if schema_name and "." in name:
1550-
cluster_label = cluster_labels.get(schema_name)
1551-
if cluster_label and name.startswith(cluster_label + "."):
1552-
display_name = name[len(cluster_label) + 1 :]
1553-
node.set_label("<<u>" + display_name + "</u>>" if node.get("distinguished") == "True" else display_name)
1566+
if name in part_names:
1567+
# The entity cluster carries master membership, so a part
1568+
# shows only its own name (`Scan`, not `Acquisition.Scan`).
1569+
display_name = name.rsplit(".", 1)[-1]
1570+
else:
1571+
schema_name = schema_map.get(name)
1572+
if schema_name and "." in name:
1573+
cluster_label = cluster_labels.get(schema_name)
1574+
if cluster_label and name.startswith(cluster_label + "."):
1575+
display_name = name[len(cluster_label) + 1 :]
1576+
node.set_label("<<u>" + display_name + "</u>>" if node.get("introduces_dimension") == "True" else display_name)
15541577
node.set_fillcolor(props["fill"])
15551578
node.set_color(props["stroke"])
15561579
node.set_style("rounded,filled" if props.get("rounded") else "filled")
@@ -1595,8 +1618,12 @@ def make_dot(self):
15951618
schemas[schema_name] = []
15961619
schemas[schema_name].append(node)
15971620

1598-
# Create clusters for each schema
1599-
# Use Python module name if 1:1 mapping, otherwise database schema name
1621+
# Create clusters for each schema. Within a schema, a master and its
1622+
# parts are enclosed together in a nested, unlabeled entity cluster
1623+
# (#1532); master and its parts share a rank so horizontal reads as
1624+
# derivation and vertical as containment, except a part that depends
1625+
# on a sibling part, which is left off the rank so the intra-group
1626+
# chain descends.
16001627
for schema_name, nodes in schemas.items():
16011628
label = cluster_labels.get(schema_name, schema_name)
16021629
cluster = pydot.Cluster(
@@ -1606,8 +1633,40 @@ def make_dot(self):
16061633
color="gray",
16071634
fontcolor="gray",
16081635
)
1609-
for node in nodes:
1610-
cluster.add_node(node)
1636+
node_by_name = {n.get_name().strip('"'): n for n in nodes}
1637+
# masters in this schema that have at least one part present
1638+
masters_here = {}
1639+
for pn in part_names:
1640+
mn = part_master[pn]
1641+
if pn in node_by_name and mn in node_by_name:
1642+
masters_here.setdefault(mn, []).append(pn)
1643+
1644+
grouped = set()
1645+
for master_name, parts in masters_here.items():
1646+
entity = pydot.Cluster(
1647+
"cluster_entity_" + master_name.replace(".", "_"),
1648+
label="",
1649+
style="dashed",
1650+
color="#C7CDD6",
1651+
)
1652+
entity.add_node(node_by_name[master_name])
1653+
grouped.add(master_name)
1654+
same_rank = [node_by_name[master_name].get_name()]
1655+
for pn in parts:
1656+
entity.add_node(node_by_name[pn])
1657+
grouped.add(pn)
1658+
if pn not in depends_on_sibling:
1659+
same_rank.append(node_by_name[pn].get_name())
1660+
if len(same_rank) > 1:
1661+
rank = pydot.Subgraph(rank="same")
1662+
for nm in same_rank:
1663+
rank.add_node(pydot.Node(nm))
1664+
entity.add_subgraph(rank)
1665+
cluster.add_subgraph(entity)
1666+
1667+
for name, node in node_by_name.items():
1668+
if name not in grouped:
1669+
cluster.add_node(node)
16111670
dot.add_subgraph(cluster)
16121671

16131672
return dot

‎tests/integration/test_diagram.py‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
1+
import re
2+
13
import pytest as _pytest
24

35
import datajoint as dj
46

57
from tests.schema_simple import LOCALS_SIMPLE, A, B, D, E, G, L, Profile, Website
68

79

10+
def test_master_part_entity_cluster(schema_simp):
11+
"""A master and its parts render inside a nested entity cluster, and part
12+
labels drop the master prefix (#1532): `B.C` shows as `C`."""
13+
if not dj.diagram.diagram_active:
14+
_pytest.skip("networkx/pydot not available")
15+
svg = dj.Diagram(schema_simp, context=LOCALS_SIMPLE).make_dot().create_svg().decode()
16+
assert "cluster_entity_" in svg, "a master with parts should get a nested entity cluster"
17+
texts = re.findall(r"<text[^>]*>([^<]+)</text>", svg)
18+
assert "C" in texts, "part B.C should display as 'C' (master prefix dropped)"
19+
assert "B.C" not in texts, "the part label must not include the master prefix"
20+
21+
822
def test_decorator(schema_simp):
923
assert issubclass(A, dj.Lookup)
1024
assert not issubclass(A, dj.Part)
@@ -24,10 +38,10 @@ def test_dependencies(schema_simp):
2438
assert set(deps.descendants(L.full_table_name)).issubset(cls.full_table_name for cls in (L, D, E, E.F, E.G, E.H, E.M, G))
2539

2640

27-
def test_erd(schema_simp):
41+
def test_diagram(schema_simp):
2842
assert dj.diagram.diagram_active, "Failed to import networkx and pydot"
29-
erd = dj.Diagram(schema_simp, context=LOCALS_SIMPLE)
30-
graph = erd._make_graph()
43+
diagram = dj.Diagram(schema_simp, context=LOCALS_SIMPLE)
44+
graph = diagram._make_graph()
3145
assert set(cls.__name__ for cls in (A, B, D, E, L)).issubset(graph.nodes())
3246

3347

@@ -46,21 +60,21 @@ def test_diagram_algebra(schema_simp):
4660

4761

4862
def test_repr_svg(schema_adv):
49-
erd = dj.Diagram(schema_adv, context=dict())
50-
svg = erd._repr_svg_()
63+
diagram = dj.Diagram(schema_adv, context=dict())
64+
svg = diagram._repr_svg_()
5165
assert svg.startswith("<svg") and svg.endswith("svg>")
5266

5367

5468
def test_make_image(schema_simp):
55-
erd = dj.Diagram(schema_simp, context=dict())
56-
img = erd.make_image()
69+
diagram = dj.Diagram(schema_simp, context=dict())
70+
img = diagram.make_image()
5771
assert img.ndim == 3 and img.shape[2] in (3, 4)
5872

5973

6074
def test_part_table_parsing(schema_simp):
6175
# https://github.com/datajoint/datajoint-python/issues/882
62-
erd = dj.Diagram(schema_simp, context=LOCALS_SIMPLE)
63-
graph = erd._make_graph()
76+
diagram = dj.Diagram(schema_simp, context=LOCALS_SIMPLE)
77+
graph = diagram._make_graph()
6478
assert "OutfitLaunch" in graph.nodes()
6579
assert "OutfitLaunch.OutfitPiece" in graph.nodes()
6680

0 commit comments

Comments
 (0)