@@ -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 (" " .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
0 commit comments