Skip to content

Commit 7ceec5d

Browse files
Merge pull request #1468 from datajoint/fix/1429-cascade-part-part-renamed-fk
fix(#1429): cascade through FK chain for part_integrity="cascade"
2 parents ec77b47 + 9ae221a commit 7ceec5d

2 files changed

Lines changed: 392 additions & 21 deletions

File tree

src/datajoint/diagram.py

Lines changed: 205 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,17 @@ def cascade(cls, table_expr, part_integrity="enforce"):
365365
# Propagate downstream
366366
result._propagate_restrictions(node, mode="cascade", part_integrity=part_integrity)
367367

368+
# part_integrity="cascade" may pull in nodes that aren't descendants of
369+
# the seed (e.g. the master of a seed Part, plus the master's other
370+
# Parts). Expand nodes_to_show to include any restricted node and the
371+
# descendants of any newly-restricted ancestor. See #1429.
372+
restricted_nodes = set(result._cascade_restrictions)
373+
expanded = set(result.nodes_to_show) | restricted_nodes
374+
for n in restricted_nodes - result.nodes_to_show:
375+
expanded.update(nx.descendants(result, n))
376+
result.nodes_to_show = expanded & set(result.nodes())
377+
result._expanded_nodes = set(result.nodes_to_show)
378+
368379
# Trim graph to cascade subgraph: only restricted tables
369380
# (seed + descendants) plus alias nodes connecting them.
370381
keep = set(result._cascade_restrictions)
@@ -443,7 +454,6 @@ def _propagate_restrictions(self, start_node, mode, part_integrity="enforce"):
443454
propagation rules at each edge. Only processes descendants of
444455
start_node to avoid duplicate propagation when chaining.
445456
"""
446-
from .table import FreeTable
447457

448458
sorted_nodes = topo_sort(self)
449459
# Only propagate through descendants of start_node
@@ -453,6 +463,18 @@ def _propagate_restrictions(self, start_node, mode, part_integrity="enforce"):
453463

454464
restrictions = self._cascade_restrictions if mode == "cascade" else self._restrict_conditions
455465

466+
# Seed-is-Part case: when the seed itself is a Part and part_integrity="cascade",
467+
# the main loop's part_integrity block (which fires inside `out_edges`)
468+
# cannot trigger from the seed because a leaf Part has no out-edges.
469+
# Trigger the upward propagation explicitly for the seed. See #1429.
470+
if part_integrity == "cascade" and mode == "cascade":
471+
seed_master = extract_master(start_node)
472+
if seed_master and seed_master in self.nodes() and seed_master not in visited_masters:
473+
visited_masters.add(seed_master)
474+
if self._propagate_part_to_master(start_node, seed_master, mode, restrictions):
475+
allowed_nodes.add(seed_master)
476+
allowed_nodes.update(nx.descendants(self, seed_master))
477+
456478
# Multiple passes to handle part_integrity="cascade" upward propagation.
457479
# When a part table triggers its master to join the cascade, the master's
458480
# other descendants need processing in a subsequent pass. The loop
@@ -512,29 +534,19 @@ def _propagate_restrictions(self, start_node, mode, part_integrity="enforce"):
512534
any_new = True
513535

514536
# part_integrity="cascade": propagate up from part to master
537+
# via the actual FK graph path, applying upward propagation
538+
# rules at each edge. Handles Part-of-Part chains and
539+
# renamed FKs (via .proj()), unlike the prior implementation
540+
# which assumed shared PK attribute names. See #1429.
515541
if part_integrity == "cascade" and mode == "cascade":
516542
master_name = extract_master(target)
517-
if (
518-
master_name
519-
and master_name in self.nodes()
520-
and master_name not in restrictions
521-
and master_name not in visited_masters
522-
):
543+
if master_name and master_name in self.nodes() and master_name not in visited_masters:
523544
visited_masters.add(master_name)
524-
child_ft = self._restricted_table(target)
525-
master_ft = FreeTable(self._connection, master_name)
526-
from .condition import make_condition
527-
528-
master_restr = make_condition(
529-
master_ft,
530-
(master_ft.proj() & child_ft.proj()).to_arrays(),
531-
master_ft.restriction_attributes,
532-
)
533-
restrictions[master_name] = [master_restr]
534-
self._restriction_attrs[master_name] = set()
535-
allowed_nodes.add(master_name)
536-
allowed_nodes.update(nx.descendants(self, master_name))
537-
any_new = True
545+
propagated = self._propagate_part_to_master(target, master_name, mode, restrictions)
546+
if propagated:
547+
allowed_nodes.add(master_name)
548+
allowed_nodes.update(nx.descendants(self, master_name))
549+
any_new = True
538550

539551
def _apply_propagation_rule(
540552
self,
@@ -590,6 +602,178 @@ def _apply_propagation_rule(
590602

591603
self._restriction_attrs.setdefault(child_node, set()).update(child_attrs)
592604

605+
def _apply_propagation_rule_upward(self, child_ft, child_attrs, parent_node, attr_map, aliased, mode, restrictions):
606+
"""
607+
Apply the symmetric (upward) propagation rule to a parent←child edge.
608+
609+
Inverts `_apply_propagation_rule`: derives a restriction on the parent
610+
from a restriction on the child, following the FK chain in reverse.
611+
Used by part_integrity="cascade" to propagate a Part's restriction up
612+
to its Master, transparently handling renamed FKs (via .proj()) and
613+
Part-of-Part chains. See #1429.
614+
615+
Edge metadata convention (matches `_apply_propagation_rule`):
616+
- `attr_map`: dict mapping child column → parent (referenced) column.
617+
- `aliased`: True iff any column was renamed across the FK.
618+
619+
Rules (symmetric to the forward rules in `_apply_propagation_rule`):
620+
621+
1. Non-aliased AND child restriction attrs ⊆ parent PK:
622+
Copy child restriction directly (attrs are shared by name).
623+
2. Aliased FK (attr_map renames columns):
624+
``child.proj(**{parent: child for child, parent in attr_map.items()})``
625+
— reverses the renaming so the result has parent's column names.
626+
3. Non-aliased AND child restriction attrs ⊄ parent PK:
627+
``child.proj()`` — project child to parent's PK columns.
628+
"""
629+
parent_pk = self.nodes[parent_node].get("primary_key", set())
630+
631+
if not aliased and child_attrs and child_attrs <= parent_pk:
632+
# Backward Rule 1: copy child restriction directly
633+
child_restr = restrictions.get(
634+
child_ft.full_table_name,
635+
[] if mode == "cascade" else AndList(),
636+
)
637+
if mode == "cascade":
638+
restrictions.setdefault(parent_node, []).extend(child_restr)
639+
else:
640+
restrictions.setdefault(parent_node, AndList()).extend(child_restr)
641+
parent_attrs = set(child_attrs)
642+
elif aliased:
643+
# Backward Rule 2: reverse rename
644+
parent_item = child_ft.proj(**{pk: fk for fk, pk in attr_map.items()})
645+
if mode == "cascade":
646+
restrictions.setdefault(parent_node, []).append(parent_item)
647+
else:
648+
restrictions.setdefault(parent_node, AndList()).append(parent_item)
649+
parent_attrs = set(attr_map.values()) # parent's PK column names
650+
else:
651+
# Backward Rule 3: project child to parent PK
652+
parent_item = child_ft.proj()
653+
if mode == "cascade":
654+
restrictions.setdefault(parent_node, []).append(parent_item)
655+
else:
656+
restrictions.setdefault(parent_node, AndList()).append(parent_item)
657+
parent_attrs = set(attr_map.values())
658+
659+
self._restriction_attrs.setdefault(parent_node, set()).update(parent_attrs)
660+
661+
def _propagate_part_to_master(self, part_node, master_name, mode, restrictions):
662+
"""
663+
Walk the FK graph from `part_node` up to `master_name`, applying
664+
`_apply_propagation_rule_upward` at each real edge along the path.
665+
666+
Returns True if any propagation occurred. Handles Part-of-Part chains
667+
by walking the full path (intermediate Parts get restricted too) and
668+
renamed FKs via the upward rules.
669+
670+
Alias nodes (integer-named graph nodes inserted for aliased edges)
671+
are transparent — both half-edges carry the same `attr_map` props,
672+
so we read props from one and skip the alias node when walking.
673+
674+
After the walk, the master's restriction is **materialized** to a
675+
literal value tuple via ``to_arrays()``. Without materialization, a
676+
subsequent forward cascade from the master back down to its parts
677+
would produce a self-referential subquery (MySQL error 1093, since
678+
the master's restriction depends on the same Part being deleted).
679+
Materializing converts the restriction into a static value set, so
680+
the forward cascade generates ``WHERE ... IN (literal-list)`` rather
681+
than ``WHERE ... IN (SELECT ... FROM <part>)``.
682+
683+
Limitations
684+
-----------
685+
- **Single FK path**: ``nx.shortest_path`` returns *one* path from
686+
``master_name`` to ``part_node``. If a Part is reachable from its
687+
Master through multiple distinct FK chains (e.g. references two
688+
different intermediate Parts), restrictions through the
689+
non-shortest paths are not applied. This pattern is unusual; if a
690+
schema hits it, the user is responsible for restricting the
691+
additional paths explicitly via ``part_integrity="ignore"`` plus
692+
manual ``delete()`` calls.
693+
- **Memory cost of materialization**: ``master_ft.proj().to_arrays()``
694+
pulls the matching master primary keys into Python memory. Cost is
695+
bounded by the count of *distinct* master rows referenced by the
696+
matching parts — typically small for surgical cascades, but can
697+
grow with bulk cascades on tables with many master rows. Cascade
698+
*preview* (``Diagram.cascade(...).counts()``) pays the same cost.
699+
"""
700+
try:
701+
path = nx.shortest_path(self, master_name, part_node)
702+
except (nx.NetworkXNoPath, nx.NodeNotFound):
703+
return False
704+
705+
# Strip alias nodes; what remains is the sequence of real tables.
706+
real_path = [n for n in path if not (isinstance(n, str) and n.isdigit())]
707+
if len(real_path) < 2 or real_path[-1] != part_node or real_path[0] != master_name:
708+
return False
709+
710+
# Walk real_path in reverse (child → parent direction). For each
711+
# adjacent (parent, child) pair, look up the edge props — direct
712+
# edge if non-aliased, via alias node if aliased.
713+
any_propagated = False
714+
for i in range(len(real_path) - 1, 0, -1):
715+
child = real_path[i]
716+
parent = real_path[i - 1]
717+
edge_props = self._find_real_edge_props(parent, child)
718+
if edge_props is None:
719+
return any_propagated # Path broken (shouldn't happen if shortest_path succeeded)
720+
721+
attr_map = edge_props.get("attr_map", {})
722+
aliased = edge_props.get("aliased", False)
723+
child_ft = self._restricted_table(child)
724+
child_attrs = self._restriction_attrs.get(child, set())
725+
726+
self._apply_propagation_rule_upward(
727+
child_ft,
728+
child_attrs,
729+
parent,
730+
attr_map,
731+
aliased,
732+
mode,
733+
restrictions,
734+
)
735+
any_propagated = True
736+
737+
# Materialize the master's restriction so subsequent forward cascade
738+
# doesn't produce self-referential subqueries. Replace the master's
739+
# accumulated query restrictions with a literal value tuple.
740+
if any_propagated and master_name in restrictions:
741+
from .condition import make_condition
742+
from .table import FreeTable
743+
744+
master_ft = self._restricted_table(master_name)
745+
master_pk_values = master_ft.proj().to_arrays()
746+
if mode == "cascade":
747+
bare_master = FreeTable(self._connection, master_name)
748+
if len(master_pk_values) > 0:
749+
materialized = make_condition(
750+
bare_master,
751+
master_pk_values,
752+
bare_master.restriction_attributes,
753+
)
754+
restrictions[master_name] = [materialized]
755+
else:
756+
# No matching master rows — false restriction so master is
757+
# included with zero matches in counts/iter.
758+
restrictions[master_name] = [False]
759+
self._restriction_attrs.setdefault(master_name, set())
760+
761+
return any_propagated
762+
763+
def _find_real_edge_props(self, parent, child):
764+
"""
765+
Return edge props for parent → child, transparently traversing the
766+
integer-named alias node that the graph inserts for aliased FKs.
767+
Returns None if no such edge or alias-mediated edge exists.
768+
"""
769+
if self.has_edge(parent, child):
770+
return self.edges[parent, child]
771+
for _, mid, _ in self.out_edges(parent, data=True):
772+
if isinstance(mid, str) and mid.isdigit() and self.has_edge(mid, child):
773+
# Both half-edges carry the same attr_map / aliased props
774+
return self.edges[parent, mid]
775+
return None
776+
593777
def counts(self):
594778
"""
595779
Return affected row counts per table without modifying data.

0 commit comments

Comments
 (0)