Skip to content

Commit 2b148a4

Browse files
test(strict,cascade): pin documented enforcement limits and audit coverage gaps
Locks the behaviors the post-2.3 audit verified, so regressions in either direction — a documented bypass silently closing, or a blocking path silently opening — become visible. test_strict_provenance_limits.py: len/bool and query-expression 'in' bypass (documented); CLASS-form 'in' RAISES (gated __iter__); restriction-by-table bypasses; join with undeclared operand RAISES (the one gated read path, previously untested); Aggregation __len__ bypasses while its fetch RAISES; delete_quick ungated (documented); INSERT...SELECT skips per-row key consistency (documented exception); self.upstream[SelfPart] raises while direct self.PartName read is allowed; context cleared after raising make(); nested push/pop token restore. test_cascade_integrity.py: enforce post-check raises 'before its master' AND rolls back; empty-materialization sentinel previews master at 0 and deletes cleanly; U3 upward arm with SECONDARY -> master FK restricts only the correct master (bare proj() would restrict both). Also corrects the provenance.py module docstring (audit item B6): context is pushed/popped inside _populate_one in whichever process/thread runs make() — the old across-threads/fork-inheritance rationale was inaccurate. Behavior unchanged. New files only — no conflicts with the open #1480/#1484 branches.
1 parent b50840c commit 2b148a4

3 files changed

Lines changed: 529 additions & 4 deletions

File tree

src/datajoint/provenance.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@
1414
``datajoint-docs/src/reference/specs/provenance.md`` §3.
1515
1616
Implementation note: the active-make context is stored in a
17-
``contextvars.ContextVar`` so it propagates correctly across threads
18-
that share the parent's context (e.g. the populate-in-subprocess path
19-
which uses ``multiprocessing`` workers, each of which inherits its
20-
parent's contextvar binding at fork time).
17+
``contextvars.ContextVar``. No cross-process or cross-thread propagation is
18+
required: ``_populate_one`` pushes the context immediately before invoking
19+
``make()`` and pops it in a ``finally``, all within whichever process or
20+
thread executes that ``make()`` call — so distributed populate workers each
21+
establish their own context locally. The ContextVar provides per-task
22+
isolation and token-based nesting (a nested make restores the outer context
23+
exactly on pop).
2124
"""
2225

2326
from __future__ import annotations
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""
2+
Integration tests for cascade part-integrity paths flagged as untested by the
3+
post-2.3 audit: the Table.delete() enforce post-check (data-driven, with
4+
rollback), the empty-materialization sentinel, and the U3 upward-rule arm
5+
(non-primary master FK).
6+
"""
7+
8+
import pytest
9+
10+
import datajoint as dj
11+
from datajoint.errors import DataJointError
12+
13+
14+
@pytest.fixture(scope="function")
15+
def schema_by_backend(connection_by_backend, db_creds_by_backend, request):
16+
"""Create a fresh schema per test (mirrors test_cascade_delete.py)."""
17+
backend = db_creds_by_backend["backend"]
18+
import time
19+
20+
test_id = str(int(time.time() * 1000))[-8:]
21+
schema_name = f"djtest_cintegrity_{backend}_{test_id}"[:64]
22+
23+
if connection_by_backend.is_connected:
24+
try:
25+
connection_by_backend.query(
26+
f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}"
27+
)
28+
except Exception:
29+
pass
30+
31+
schema = dj.Schema(schema_name, connection=connection_by_backend)
32+
yield schema
33+
34+
if connection_by_backend.is_connected:
35+
try:
36+
connection_by_backend.query(
37+
f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}"
38+
)
39+
except Exception:
40+
pass
41+
42+
43+
def test_enforce_postcheck_raises_and_rolls_back(schema_by_backend):
44+
"""The data-driven post-check in Table.delete (part rows deleted without
45+
their master reaching the cascade) must raise AND roll the transaction
46+
back — previously only the direct Part.delete pre-guard had coverage."""
47+
48+
@schema_by_backend
49+
class Ext(dj.Manual):
50+
definition = """
51+
ext_id : int32
52+
"""
53+
54+
@schema_by_backend
55+
class Master(dj.Manual):
56+
definition = """
57+
master_id : int32
58+
"""
59+
60+
class P(dj.Part):
61+
definition = """
62+
-> master
63+
-> Ext
64+
"""
65+
66+
Ext.insert([(1,)])
67+
Master.insert([(1,)])
68+
Master.P.insert([(1, 1)])
69+
70+
# Cascade from Ext reaches the Part but not the Master -> enforce post-check.
71+
with pytest.raises(DataJointError, match="before its master"):
72+
(Ext & {"ext_id": 1}).delete(prompt=False) # part_integrity="enforce" default
73+
74+
# Rollback: nothing was deleted.
75+
assert len(Ext()) == 1, "rollback must restore the seed row"
76+
assert len(Master.P()) == 1, "rollback must restore the part row"
77+
assert len(Master()) == 1
78+
79+
80+
def test_cascade_empty_materialization_sentinel(schema_by_backend):
81+
"""part_integrity='cascade' with a seed matching ZERO part rows: the
82+
upward walk materializes an empty master set (always-false sentinel);
83+
preview and delete complete without error and no master row is touched."""
84+
85+
@schema_by_backend
86+
class Ext(dj.Manual):
87+
definition = """
88+
ext_id : int32
89+
"""
90+
91+
@schema_by_backend
92+
class Master(dj.Manual):
93+
definition = """
94+
master_id : int32
95+
"""
96+
97+
class P(dj.Part):
98+
definition = """
99+
-> master
100+
-> Ext
101+
"""
102+
103+
Ext.insert([(1,), (2,)])
104+
Master.insert([(1,)])
105+
Master.P.insert([(1, 1)]) # only ext 1 is referenced; ext 2 matches nothing
106+
107+
counts = dj.Diagram.cascade(Ext & {"ext_id": 2}, part_integrity="cascade").counts()
108+
assert (
109+
counts.get(Master.full_table_name, 1) == 0
110+
), f"master must appear with zero matches (empty-materialization sentinel); got {counts}"
111+
112+
(Ext & {"ext_id": 2}).delete(prompt=False, part_integrity="cascade")
113+
assert len(Ext & {"ext_id": 2}) == 0
114+
assert len(Master()) == 1, "master row must be untouched"
115+
assert len(Master.P()) == 1, "part row must be untouched"
116+
117+
118+
def test_upward_u3_nonprimary_master_fk(schema_by_backend):
119+
"""U3 arm of the upward walk: a Part whose `-> master` FK is SECONDARY
120+
(below the ---). The part's restriction attrs are not a subset of the
121+
master's PK and the edge is non-aliased, so U3 must project the part onto
122+
its FK columns (`proj(*attr_map.keys())`) to carry `master_id` upward.
123+
A bare `proj()` (the pre-#1468 form) would project to the part's PK
124+
(ext_id, part_id), share no attributes with Master, and restrict BOTH
125+
masters instead of the correct one — the assertion below distinguishes."""
126+
127+
@schema_by_backend
128+
class Ext(dj.Manual):
129+
definition = """
130+
ext_id : int32
131+
"""
132+
133+
@schema_by_backend
134+
class Master(dj.Manual):
135+
definition = """
136+
master_id : int32
137+
"""
138+
139+
class P(dj.Part):
140+
definition = """
141+
-> Ext
142+
part_id : int32
143+
---
144+
-> master
145+
"""
146+
147+
Ext.insert([(1,), (2,)])
148+
Master.insert([(1,), (2,)])
149+
# part under master 1 references ext 1; part under master 2 references ext 2
150+
Master.P.insert([(1, 10, 1), (2, 20, 2)])
151+
152+
counts = dj.Diagram.cascade(Ext & {"ext_id": 1}, part_integrity="cascade").counts()
153+
154+
assert counts.get(Master.full_table_name, 0) == 1, (
155+
f"only master 1 (via the secondary-FK part row) must be restricted; got {counts} — "
156+
"a bare proj() on the U3 arm would have restricted both masters."
157+
)

0 commit comments

Comments
 (0)