@@ -154,6 +154,20 @@ def stroke_rule(light_hex, dark_hex, opacity=None):
154154 return "<style>\n @media (prefers-color-scheme: dark) {\n " + body + "\n }\n </style>"
155155
156156
157+ def _seed_restriction (table_expr ):
158+ """
159+ Return a traversal seed's restriction and the attributes it names.
160+
161+ A ``dj.Top`` restriction is an ORDER BY ... LIMIT, not part of the WHERE
162+ clause, so a seed restricted by ``Top`` is replaced by the primary keys
163+ it selects. Otherwise the traversal would start from every row that
164+ matches the seed's other conditions.
165+ """
166+ if table_expr ._top is not None :
167+ return AndList ([table_expr .keys ()]), set (table_expr .primary_key )
168+ return AndList (table_expr .restriction ), set (table_expr .restriction_attributes )
169+
170+
157171class Diagram (nx .MultiDiGraph ): # noqa: C901
158172 """
159173 Schema diagram as a directed acyclic graph (DAG).
@@ -210,9 +224,12 @@ def __init__(self, source, context=None) -> None:
210224 self ._expanded_nodes = set (source ._expanded_nodes )
211225 self .context = source .context
212226 self ._connection = source ._connection
213- self ._cascade_restrictions = copy .deepcopy (source ._cascade_restrictions )
214- self ._restrict_conditions = copy .deepcopy (source ._restrict_conditions )
215- self ._restriction_attrs = copy .deepcopy (source ._restriction_attrs )
227+ # Copy each node's restriction list, not its items: projected
228+ # restrictions are query expressions bound to a live connection,
229+ # which cannot be deep-copied.
230+ self ._cascade_restrictions = {k : copy .copy (v ) for k , v in source ._cascade_restrictions .items ()}
231+ self ._restrict_conditions = {k : copy .copy (v ) for k , v in source ._restrict_conditions .items ()}
232+ self ._restriction_attrs = {k : set (v ) for k , v in source ._restriction_attrs .items ()}
216233 super ().__init__ (source )
217234 return
218235
@@ -471,9 +488,9 @@ def cascade(cls, table_expr, part_integrity="enforce"):
471488 result ._expanded_nodes = set (descendants )
472489
473490 # Seed restriction
474- restriction = AndList (table_expr . restriction )
491+ restriction , restriction_attrs = _seed_restriction (table_expr )
475492 result ._cascade_restrictions [node ] = [restriction ] if restriction else []
476- result ._restriction_attrs [node ] = set ( table_expr . restriction_attributes )
493+ result ._restriction_attrs [node ] = restriction_attrs
477494
478495 # Propagate downstream
479496 result ._propagate_restrictions (node , mode = "cascade" , part_integrity = part_integrity )
@@ -557,9 +574,9 @@ def trace(cls, table_expr):
557574 result ._expanded_nodes = set (ancestors )
558575
559576 # Seed restriction
560- restriction = AndList (table_expr . restriction )
577+ restriction , restriction_attrs = _seed_restriction (table_expr )
561578 result ._cascade_restrictions [node ] = [restriction ] if restriction else []
562- result ._restriction_attrs [node ] = set ( table_expr . restriction_attributes )
579+ result ._restriction_attrs [node ] = restriction_attrs
563580
564581 # Propagate upstream
565582 result ._propagate_restrictions_upstream (node )
@@ -883,16 +900,22 @@ def _apply_propagation_rule(
883900
884901 Rules (from table.py restriction propagation):
885902
886- 1. Non-aliased AND parent restriction attrs ⊆ child PK:
903+ 1. Non-aliased AND parent restriction attrs ⊆ FK attrs ⊆ child PK:
887904 Copy parent restriction directly.
888905 2. Aliased FK (attr_map renames columns):
889906 ``parent.proj(**{fk: pk for fk, pk in attr_map.items()})``
890- 3. Non-aliased AND parent restriction attrs ⊄ child PK:
891- ``parent.proj()``
907+ 3. Otherwise (non-aliased): ``parent.proj()``
908+
909+ Copying is exact only when the restriction names attributes that this
910+ foreign key carries and the foreign key lies in the child's primary
911+ key. A restriction on an attribute that the child inherits through
912+ another foreign key would select child rows by that attribute instead
913+ of by the parent rows they reference. A secondary foreign key may be
914+ NULL, and then the child row references no parent row at all.
892915 """
893916 child_pk = self .nodes [child_node ].get ("primary_key" , set ())
894917
895- if not aliased and parent_attrs and parent_attrs <= child_pk :
918+ if not aliased and parent_attrs and parent_attrs <= set ( attr_map ) <= child_pk :
896919 # Rule 1: copy parent restriction directly
897920 parent_restr = restrictions .get (
898921 parent_ft .full_table_name ,
@@ -912,7 +935,7 @@ def _apply_propagation_rule(
912935 restrictions .setdefault (child_node , AndList ()).append (child_item )
913936 child_attrs = set (attr_map .keys ())
914937 else :
915- # Rule 3: non-aliased, restriction attrs ⊄ child PK — project
938+ # Rule 3: non-aliased, not a pure copy — project
916939 child_item = parent_ft .proj ()
917940 if mode == "cascade" :
918941 restrictions .setdefault (child_node , []).append (child_item )
@@ -922,7 +945,9 @@ def _apply_propagation_rule(
922945
923946 self ._restriction_attrs .setdefault (child_node , set ()).update (child_attrs )
924947
925- def _apply_propagation_rule_upward (self , child_ft , child_attrs , parent_node , attr_map , aliased , mode , restrictions ):
948+ def _apply_propagation_rule_upward (
949+ self , child_ft , child_attrs , parent_node , attr_map , aliased , mode , restrictions , copy_allowed = True
950+ ):
926951 """
927952 Apply the symmetric (upward) propagation rule to a parent←child edge.
928953
@@ -947,10 +972,15 @@ def _apply_propagation_rule_upward(self, child_ft, child_attrs, parent_node, att
947972 ``child.proj(*attr_map.keys())`` — project child onto its FK columns
948973 (which, being non-aliased, share names with parent's PK columns) so
949974 the subsequent restriction on the parent joins on the right columns.
975+
976+ ``copy_allowed=False`` disables Rule 1, so the parent is restricted to
977+ the rows that the restricted child rows actually reference. Copying
978+ the child's condition would also select parent rows that no child row
979+ references.
950980 """
951981 parent_pk = self .nodes [parent_node ].get ("primary_key" , set ())
952982
953- if not aliased and child_attrs and child_attrs <= parent_pk :
983+ if copy_allowed and not aliased and child_attrs and child_attrs <= parent_pk :
954984 # Backward Rule 1: copy child restriction directly
955985 child_restr = restrictions .get (
956986 child_ft .full_table_name ,
@@ -1044,6 +1074,17 @@ def _propagate_part_to_master(self, part_node, master_name, mode, restrictions):
10441074 if len (real_path ) < 2 or real_path [- 1 ] != part_node or real_path [0 ] != master_name :
10451075 return False
10461076
1077+ # The walk restricts the intermediate parts only to carry the
1078+ # restriction up to the master. Those restrictions reference the
1079+ # descendant part, which is deleted first, so they are restored once
1080+ # the master is materialized. The master then cascades back down to
1081+ # all of its parts.
1082+ intermediates = real_path [1 :- 1 ]
1083+ saved = {
1084+ node : (copy .copy (restrictions [node ]) if node in restrictions else None , self ._restriction_attrs .get (node ))
1085+ for node in intermediates
1086+ }
1087+
10471088 # Walk real_path in reverse (child → parent direction). For each
10481089 # adjacent (parent, child) pair, look up the FK edge props.
10491090 any_propagated = False
@@ -1059,6 +1100,8 @@ def _propagate_part_to_master(self, part_node, master_name, mode, restrictions):
10591100 child_ft = self ._restricted_table (child )
10601101 child_attrs = self ._restriction_attrs .get (child , set ())
10611102
1103+ # The lift is existential: a master joins the cascade only if one
1104+ # of its part rows did, so the part's condition is never copied.
10621105 self ._apply_propagation_rule_upward (
10631106 child_ft ,
10641107 child_attrs ,
@@ -1067,6 +1110,7 @@ def _propagate_part_to_master(self, part_node, master_name, mode, restrictions):
10671110 aliased ,
10681111 mode ,
10691112 restrictions ,
1113+ copy_allowed = False ,
10701114 )
10711115 any_propagated = True
10721116
@@ -1094,6 +1138,16 @@ def _propagate_part_to_master(self, part_node, master_name, mode, restrictions):
10941138 restrictions [master_name ] = [False ]
10951139 self ._restriction_attrs .setdefault (master_name , set ())
10961140
1141+ for node , (restriction , attrs ) in saved .items ():
1142+ if restriction is None :
1143+ restrictions .pop (node , None )
1144+ else :
1145+ restrictions [node ] = restriction
1146+ if attrs is None :
1147+ self ._restriction_attrs .pop (node , None )
1148+ else :
1149+ self ._restriction_attrs [node ] = attrs
1150+
10971151 return any_propagated
10981152
10991153 def _edge_props (self , parent , child ):
0 commit comments