Repository navigation
fix(cascade): restrict only the rows a cascade reaches - #1592
dimitri-yatsenko wants to merge 2 commits into
Conversation
Three cases deleted rows outside the cascade: - `(T & dj.Top(n)).delete()` and `delete_quick()` ignored the Top and deleted every row matching the other conditions. A Top seed is now replaced by the primary keys it selects. - A parent restriction was copied to a child whenever the restricted attributes were in the child's primary key. Through a nullable foreign key that shares a primary-key column, the copy also deleted child rows whose foreign key is NULL; in a diamond it missed rows. The copy now applies only when the restricted attributes are among the foreign key's attributes and the foreign key lies in the child's primary key. Otherwise the child is restricted by the projected parent. - With part_integrity="cascade", the part-to-master lift copied the part's condition to the master, which also deleted masters with no matching part rows. The master is now restricted by the restricted part, so a master joins the cascade only if one of its part rows did. The walk's restrictions on intermediate parts are restored once the master is materialized; the master cascades back to all of its parts. The Diagram copy constructor now copies each node's restriction list instead of deep-copying projected query expressions, which hold a live connection.
lum-agilyti
left a comment
There was a problem hiding this comment.
Read through the change against 1589, 1590 and 1591. Each fix seems to close its issue, and the narrower copy rule looks like it leaves the common primary-key chains on the same path as before. A few notes and questions:
- No CI checks appear to have run on this PR or on 1593. Could they be re-triggered before merge?
Diagram.restrict()(diagram.py:778) still builds its seed fromtable_expr.restriction, soDiagram(schema).restrict(T & dj.Top(2))looks like it would keep every row matching the other conditions. It isn't a delete, but it seems to be the same seed issue as 1589. Is it meant to be in scope here, or a follow-up?- In
_propagate_part_to_master,savedkeepsself._restriction_attrs.get(node), which is the same set object the walk then extends in place viasetdefault(...).update(...). So restoring the attributes seems to be a no-op whenever the intermediate part already had attributes. As far as I can tell the extra attributes only make the copy rules stricter, so the effect looks limited to an extra subquery. The code comment says they are restored, though. Is that intended? - Two cases don't seem to be covered by the new tests:
Diagram.trace(T & dj.Top(n)), although the seed change also applies totrace().- A
Topthat selects no rows. The seed is then[[]]and the delete correctly removes nothing. If the seed were ever empty instead,[restriction] if restriction else []looks like it would leave it unrestricted.
- Outside this diff, a question about
trace(): its upward Rule 1 (diagram.py:983) still copies the child's condition to the parent whenever the restricted attributes are in the parent's primary key. The newcopy_alloweddocstring notes that copying "would also select parent rows that no child row references", and that seems to apply here too. For example, with a table keyed bysubject_idthat has a secondary FK toSurgery,self.upstream[Surgery]looks like it would return every surgery of that subject, not just the one the row references. I can see the copy may be deliberate, since insidemake()the traced row hasn't been inserted yet. Is this intended? - 1593 appears to match this PR line for line.
MilagrosMarin
left a comment
There was a problem hiding this comment.
Thanks for the thorough write-up @dimitri-yatsenko — the three root causes and the "restrict only what the cascade reaches" framing hold up when I read the diff. I haven't run the suite locally; these notes come from reading the code.
Requested change
✅ Add a regression test for an empty seed. _seed_restriction replaces a Top seed with AndList([table_expr.keys()]). When keys() returns [], this relies on AndList([[]]) being truthy and compiling to FALSE. From condition.py that looks right, but if it were ever treated as "no restriction" the cascade would delete every row, so it's worth pinning down. For example: (Item & dj.Top(2)).delete() on an empty table, with an assertion that the preview counts are 0 and nothing is deleted.
Merge ordering
#1525 (also in milestone v2.3.5) rewrites _propagate_part_to_master, and a trial merge of the two conflicts in diagram.py. Which should land first? Whichever goes second will need the saved/restored intermediate restrictions in this PR re-checked against the all-paths walk.
Questions
table_expr.keys()pulls every selected key into Python, and the seed becomes a list of dicts that compiles to an OR of conditions. For a largeTop(or, after #1611, a large subquery seed), could that exceedmax_allowed_packet? Is there a practical bound you'd want to document or test?- On head
18e5649I don't see any workflow runs or check-runs through the API. Could you point me to where CI ran?
Everything else reads correct to me: the nullable-FK and diamond rule (parent_attrs <= set(attr_map) <= child_pk), the existential part→master lift, and the copy-constructor change. Let me know if anything needs adjusting!
…aced Top seeds The part-to-master walk saves the restrictions of the parts it walks through and restores them once the master is materialized. It saved each part's restricted-attribute set by reference, and the walk then extends that same set in place, so the restore changed nothing. The extra attributes only made later copies stricter, so the effect was an extra subquery, but the code did not do what its comment says. It now saves a copy of the set. Two tests pin down seeds this PR changes: a `Top` over a restriction that matches nothing previews no rows and deletes nothing, and `Diagram.trace` starts from the rows a `Top` seed selects.
|
Thanks, @lum-agilyti and @MilagrosMarin. Pushed f14d68b here; the same commit is on #1593. Done in this PR
Left for 2.4
Other questions
|
Closes #1589, closes #1590, closes #1591.
A cascade delete reached rows outside the cascade in three cases:
dj.Topseed (delete()ignores adj.Toprestriction and deletes every matching row #1589).Diagram.cascadeandDiagram.tracebuilt the seed from the WHERE restriction only, anddelete_quickusedwhere_clause()only. A seed restricted byTopis now replaced by the primary keys it selects.part_integrity="cascade"deletes masters that have no matching part rows #1591). Withpart_integrity="cascade", the lift copied the part's condition to the master, which selected masters with no matching part rows. The master is now restricted by the restricted part, so a master joins the cascade only if one of its part rows did.Two follow-on changes:
Diagramcopy constructor copies each node's restriction list instead of deep-copying it. Projected restrictions are query expressions bound to a live connection and cannot be deep-copied. This affectedprune()and the diagram operators afterrestrict().Not changed:
Diagram.tracekeeps its upward copy for now.self.upstreamtraces from a row thatmake()has not inserted yet and relies on it. The traversal redesign for 2.4 defines that case explicitly.Tests:
tests/integration/test_cascade_overdelete.pyadds four tests on MySQL and PostgreSQL. All fail on v2.3.4 and pass here. The full suite passes locally (1166 passed, 14 skipped).The same commit is cherry-picked onto
maint/2.3for a v2.3.5 patch release.