Skip to content

Commit 7c702e9

Browse files
Merge pull request #1593 from datajoint/fix/cascade-overdelete-2.3
fix(cascade): restrict only the rows a cascade reaches (2.3)
2 parents 68fdea5 + 105d81a commit 7c702e9

3 files changed

Lines changed: 327 additions & 14 deletions

File tree

‎src/datajoint/diagram.py‎

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
157171
class 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,21 @@ 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+
# Copy each node's attribute set too: the walk below extends it in place.
1084+
saved = {
1085+
node: (
1086+
copy.copy(restrictions[node]) if node in restrictions else None,
1087+
set(self._restriction_attrs[node]) if node in self._restriction_attrs else None,
1088+
)
1089+
for node in intermediates
1090+
}
1091+
10471092
# Walk real_path in reverse (child → parent direction). For each
10481093
# adjacent (parent, child) pair, look up the FK edge props.
10491094
any_propagated = False
@@ -1059,6 +1104,8 @@ def _propagate_part_to_master(self, part_node, master_name, mode, restrictions):
10591104
child_ft = self._restricted_table(child)
10601105
child_attrs = self._restriction_attrs.get(child, set())
10611106

1107+
# The lift is existential: a master joins the cascade only if one
1108+
# of its part rows did, so the part's condition is never copied.
10621109
self._apply_propagation_rule_upward(
10631110
child_ft,
10641111
child_attrs,
@@ -1067,6 +1114,7 @@ def _propagate_part_to_master(self, part_node, master_name, mode, restrictions):
10671114
aliased,
10681115
mode,
10691116
restrictions,
1117+
copy_allowed=False,
10701118
)
10711119
any_propagated = True
10721120

@@ -1094,6 +1142,16 @@ def _propagate_part_to_master(self, part_node, master_name, mode, restrictions):
10941142
restrictions[master_name] = [False]
10951143
self._restriction_attrs.setdefault(master_name, set())
10961144

1145+
for node, (restriction, attrs) in saved.items():
1146+
if restriction is None:
1147+
restrictions.pop(node, None)
1148+
else:
1149+
restrictions[node] = restriction
1150+
if attrs is None:
1151+
self._restriction_attrs.pop(node, None)
1152+
else:
1153+
self._restriction_attrs[node] = attrs
1154+
10971155
return any_propagated
10981156

10991157
def _edge_props(self, parent, child):

‎src/datajoint/table.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import collections
2+
import copy
23
import csv
34
import inspect
45
import itertools
@@ -1083,6 +1084,12 @@ def delete_quick(self, get_count=False):
10831084
Deletes the table without cascading and without user prompt.
10841085
If this table has populated dependent tables, this will fail.
10851086
"""
1087+
if self._top is not None:
1088+
# dj.Top is an ORDER BY ... LIMIT, not part of the WHERE clause:
1089+
# delete exactly the rows it selects.
1090+
target = copy.copy(self)
1091+
target._top = None
1092+
return (target & self.keys()).delete_quick(get_count=get_count)
10861093
query = "DELETE FROM " + self.full_table_name + self.where_clause()
10871094
cursor = self.connection.query(query)
10881095
# Use cursor.rowcount (DB-API 2.0 standard, works for both MySQL and PostgreSQL)

0 commit comments

Comments
 (0)