Skip to content

Commit 9834f25

Browse files
fix(cascade): seed a delete restricted by another query with its keys
A delete restricted by another query, such as `(Subject & (Session & "quality = 0")).delete()`, failed. The cascade applies the seed's compiled conditions again to a fresh copy of the table through `restrict()`, which collects column names from the whole condition text, subquery included, and rejects every name the seed table lacks. The delete raised `Attribute ... is not found in query` before deleting anything. On MySQL the backquoted schema name in every subquery tripped the same check, so even `(Subject & Session).delete()` raised. On PostgreSQL, a seed whose subquery names only the seed's columns got past the check but still did nothing. The cascade deletes children before parents, so by the time it reached the seed, the rows its subquery reads were gone. `(Subject & Session).delete()` matched no subject, returned 0 and rolled back. A seed whose conditions contain a subquery is now replaced by the primary keys it selects, as a `dj.Top` seed already is. The traversal and the delete then work from the rows the restriction selected when the delete started. The new tests delete subjects restricted by a query on their sessions, by its negation, and by the session table itself. They check the preview counts and the rows left afterwards, on both backends. All six cases fail on the previous code. (cherry picked from commit 6863332)
1 parent cf994a9 commit 9834f25

2 files changed

Lines changed: 65 additions & 5 deletions

File tree

‎src/datajoint/diagram.py‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import inspect
2121
import io
2222
import logging
23+
import re
2324

2425
import networkx as nx
2526

@@ -154,16 +155,31 @@ def stroke_rule(light_hex, dark_hex, opacity=None):
154155
return "<style>\n@media (prefers-color-scheme: dark) {\n" + body + "\n}\n</style>"
155156

156157

158+
def _has_subquery(condition):
159+
"""Whether a compiled restriction condition contains a subquery."""
160+
if not isinstance(condition, str):
161+
return False
162+
text = re.sub(r"(\\\")|(\\\')", "", condition) # escaped quotes
163+
text = re.sub(r"'[^']*'|\"[^\"]*\"", "", text) # quoted text and identifiers
164+
return re.search(r"\bselect\b", text, re.IGNORECASE) is not None
165+
166+
157167
def _seed_restriction(table_expr):
158168
"""
159169
Return a traversal seed's restriction and the attributes it names.
160170
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.
171+
Two kinds of seed are replaced by the primary keys they select:
172+
173+
- A ``dj.Top`` restriction is an ORDER BY ... LIMIT, not part of the WHERE
174+
clause. Otherwise the traversal would start from every row that matches
175+
the seed's other conditions.
176+
- A restriction by another query, such as ``Subject & (Session & "quality = 0")``,
177+
compiles to a subquery. The traversal applies the seed's conditions again
178+
to a fresh copy of the table, which rejects a subquery that names other
179+
tables' columns. A delete would also remove the rows the subquery reads,
180+
when they are downstream, before it deletes the seed itself.
165181
"""
166-
if table_expr._top is not None:
182+
if table_expr._top is not None or any(_has_subquery(c) for c in table_expr.restriction):
167183
return AndList([table_expr.keys()]), set(table_expr.primary_key)
168184
return AndList(table_expr.restriction), set(table_expr.restriction_attributes)
169185

‎tests/integration/test_cascade_overdelete.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,47 @@ class Downstream(dj.Manual):
192192
assert Master.to_arrays("master_id").tolist() == [2]
193193
assert len(Master.P()) == 0
194194
assert Downstream.to_arrays("master_id").tolist() == [2]
195+
196+
197+
SUBQUERY_SEEDS = {
198+
# seed -> (subjects deleted, sessions deleted)
199+
"restricted by a query": (lambda S, T: S & (T & "quality = 0"), {1, 3}, {(1, 1), (1, 2), (3, 1)}),
200+
"anti-restricted by a query": (lambda S, T: S - (T & "quality = 0"), {2, 4}, {(2, 1)}),
201+
"restricted by a table": (lambda S, T: S & T, {1, 2, 3}, {(1, 1), (1, 2), (2, 1), (3, 1)}),
202+
}
203+
204+
205+
@pytest.mark.parametrize("seed_kind", list(SUBQUERY_SEEDS))
206+
def test_seed_restricted_by_a_downstream_query(schema_by_backend, seed_kind):
207+
"""A seed restricted by a query on a downstream table deletes the rows that
208+
query selected when the delete started, with all their descendants."""
209+
210+
@schema_by_backend
211+
class Subject(dj.Manual):
212+
definition = """
213+
subject_id : int32
214+
"""
215+
216+
@schema_by_backend
217+
class Session(dj.Manual):
218+
definition = """
219+
-> Subject
220+
session_id : int32
221+
---
222+
quality : int32
223+
"""
224+
225+
Subject.insert([(1,), (2,), (3,), (4,)])
226+
Session.insert([(1, 1, 1), (1, 2, 0), (2, 1, 1), (3, 1, 0)])
227+
all_sessions = {(1, 1), (1, 2), (2, 1), (3, 1)}
228+
229+
make_seed, subjects, sessions = SUBQUERY_SEEDS[seed_kind]
230+
seed = make_seed(Subject, Session)
231+
232+
counts = dj.Diagram.cascade(seed).counts()
233+
assert counts[Subject.full_table_name] == len(subjects)
234+
assert counts[Session.full_table_name] == len(sessions)
235+
236+
seed.delete(prompt=False)
237+
assert set(Subject.to_arrays("subject_id").tolist()) == {1, 2, 3, 4} - subjects
238+
assert set(zip(*Session.to_arrays("subject_id", "session_id"))) == all_sessions - sessions

0 commit comments

Comments
 (0)