Skip to content

Commit b37dd2b

Browse files
refactor(provenance): inline the tier test, carry _prov through INSERT ... SELECT
Three follow-ups from review of #1555. The Entry-table test is now inlined at its two call sites rather than wrapped in `provenance.is_entry_table`. The import stays deferred inside the function: user_tables imports table, which imports declare, so a module-scope import is a cycle -- which is what the wrapper had been hiding. `insert(QueryExpression)` builds INSERT ... SELECT and returned before the provenance was attached, so copied rows landed with NULL. It now carries the source's `_prov` across. A copied row did not originate in the destination, so the source's record is the true one; re-stamping it here would claim an origin that is not where the data came from. `heading.as_sql` resolves an explicitly named hidden attribute against the full attribute set. Default field lists are built from `attributes` and still never contain hidden names, so nothing else changes. 681 passed, 14 skipped.
1 parent 7cf941a commit b37dd2b

7 files changed

Lines changed: 61 additions & 29 deletions

File tree

‎src/datajoint/declare.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212

1313
import pyparsing as pp
1414

15-
from . import provenance
1615
from .codecs import lookup_codec
1716
from .condition import translate_attribute
1817
from .errors import DataJointError
@@ -526,7 +525,14 @@ def declare(
526525
# from outside the pipeline. Computed and Imported tables have no use for
527526
# it -- their provenance is entailed by the foreign-key graph -- and a part
528527
# inherits its master's.
529-
if config.provenance.capture and provenance.is_entry_table(table_name):
528+
# Matched against the Manual tier itself, not by excluding the other tiers'
529+
# prefixes: enumerating exclusions makes every tier added later an Entry
530+
# table by default, which is how job tables (`~`) first acquired the slot.
531+
# Imported here rather than at module scope: user_tables imports table,
532+
# which imports this module.
533+
from .user_tables import Manual
534+
535+
if config.provenance.capture and re.fullmatch(Manual.tier_regexp, table_name):
530536
attribute_sql.extend(adapter.provenance_columns())
531537

532538
if not primary_key:

‎src/datajoint/deploy.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,12 @@ def add_prov_column(target: "TargetType", dry_run: bool = True) -> dict:
227227
- Rows already present keep ``NULL``. Provenance is recorded at insert and
228228
is never reconstructed after the fact.
229229
"""
230+
import re
231+
230232
from . import provenance
231233
from .schemas import _Schema
232234
from .table import Table
235+
from .user_tables import Manual
233236

234237
if isinstance(target, _Schema):
235238
connection = target.connection
@@ -266,7 +269,7 @@ def add_prov_column(target: "TargetType", dry_run: bool = True) -> dict:
266269
}
267270

268271
for table_name in table_names:
269-
if not provenance.is_entry_table(table_name):
272+
if not re.fullmatch(Manual.tier_regexp, table_name):
270273
continue
271274
result["tables_analyzed"] += 1
272275

‎src/datajoint/heading.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,11 @@ def quote(name):
397397
return adapter.quote_identifier(name) if adapter else f'"{name}"'
398398

399399
def render_field(name):
400-
attr = self.attributes[name]
400+
# `attributes` hides underscore-prefixed names, so a caller that asks
401+
# for one by name -- copying `_prov` through an INSERT ... SELECT --
402+
# falls back to the full set. Default field lists are unaffected:
403+
# they are built from `attributes` and never contain hidden names.
404+
attr = self.attributes.get(name) or self._attributes[name]
401405
if attr.attribute_expression is None:
402406
return quote(name)
403407
else:

‎src/datajoint/provenance.py‎

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
import contextvars
3030
import datetime
3131
import json
32-
import re
3332
from typing import Any
3433

3534
#: Name of the hidden attribute. Hidden attributes are excluded from
@@ -90,23 +89,6 @@ def _jsonable(value):
9089
return str(value)
9190

9291

93-
def is_entry_table(table_name):
94-
"""Whether a stripped table name denotes an Entry (``dj.Manual``) table.
95-
96-
Matched against ``Manual.tier_regexp``, the definition the rest of the
97-
library uses, rather than by excluding the prefixes of the other tiers.
98-
Enumerating prefixes means every tier added later is an Entry table until
99-
someone remembers this function -- which is how job tables (``~``) first
100-
acquired the slot.
101-
102-
A part table carries its master's name and ``__`` before its own, so it
103-
fails the match and inherits its master's provenance, which is what we want.
104-
"""
105-
from .user_tables import Manual
106-
107-
return re.fullmatch(Manual.tier_regexp, table_name) is not None
108-
109-
11092
def build_payload(connection, config=None):
11193
"""Assemble the provenance record for rows inserted on this connection.
11294

‎src/datajoint/table.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,12 @@ def insert(
878878
except StopIteration:
879879
pass
880880
fields = list(name for name in rows.heading if name in self.heading)
881+
# Carry provenance across rather than leaving the copies NULL. A row
882+
# copied from another table did not originate here, so the source's
883+
# record is the true one; re-stamping it with this moment would claim
884+
# an origin that is not where the data came from.
885+
if self._has_prov_attribute() and provenance.PROV_ATTRIBUTE in (rows.heading._attributes or {}):
886+
fields.append(provenance.PROV_ATTRIBUTE)
881887
quoted_fields = ",".join(self.adapter.quote_identifier(f) for f in fields)
882888

883889
# Duplicate handling (backend-agnostic)

‎tests/integration/test_entry_provenance.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,26 @@ def test_job_tables_do_not_get_prov(schema_prov):
246246
).fetchall()
247247
}
248248
assert provenance.PROV_ATTRIBUTE not in columns, f"{name} carries {provenance.PROV_ATTRIBUTE}"
249+
250+
251+
def test_insert_from_query_carries_provenance_across(schema_prov):
252+
"""`insert(QueryExpression)` builds INSERT ... SELECT and used to leave NULL.
253+
254+
A copied row did not originate in the destination, so the source's record is
255+
the true one; re-stamping it here would claim an origin that is not where
256+
the data came from.
257+
"""
258+
schema, t = schema_prov
259+
t["Subject"].insert1({"subject_id": 40, "species": "mouse"})
260+
(original,) = _raw_prov(t["Subject"]() & "subject_id = 40")
261+
assert original is not None
262+
263+
class SubjectCopy(dj.Manual):
264+
definition = t["Subject"].definition
265+
266+
schema(SubjectCopy)
267+
SubjectCopy.insert(t["Subject"]() & "subject_id = 40")
268+
269+
(copied,) = _raw_prov(SubjectCopy())
270+
assert copied is not None, "copied row lost its provenance"
271+
assert copied == original, "copied row was re-stamped instead of carrying its origin"

‎tests/unit/test_provenance.py‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import json
8+
import re
89

910
import pytest
1011

@@ -46,8 +47,14 @@ def config():
4647
],
4748
)
4849
def test_is_entry_table(table_name, expected):
49-
"""Only Entry tables get the slot; parts inherit their master's."""
50-
assert provenance.is_entry_table(table_name) is expected
50+
"""Only Entry tables get the slot; parts inherit their master's.
51+
52+
Exercises the predicate `declare` and `deploy` use: a match against the
53+
Manual tier itself, rather than a list of prefixes to exclude.
54+
"""
55+
from datajoint.user_tables import Manual
56+
57+
assert bool(re.fullmatch(Manual.tier_regexp, table_name)) is expected
5158

5259

5360
def test_payload_is_none_without_anything_to_say(config):
@@ -138,18 +145,19 @@ def test_entry_test_follows_the_tier_definition_not_a_prefix_list():
138145
`Manual.tier_regexp` inverts that: a name is an Entry table only if the
139146
library says it is.
140147
"""
141-
import re
142-
143148
from datajoint.user_tables import Computed, Imported, Lookup, Manual, Part
144149

145-
assert provenance.is_entry_table("subject")
150+
def grants_prov(name):
151+
return re.fullmatch(Manual.tier_regexp, name) is not None
152+
153+
assert grants_prov("subject")
146154
for tier in (Lookup, Imported, Computed, Part):
147155
sample = {Lookup: "#param", Imported: "_ingest", Computed: "__analysis", Part: "subject__detail"}[tier]
148156
assert re.fullmatch(tier.tier_regexp, sample), f"{sample} is not a {tier.__name__}"
149-
assert not provenance.is_entry_table(sample)
157+
assert not grants_prov(sample)
150158
# The job prefix belongs to no user tier at all, which is how it slipped through.
151159
assert not any(re.fullmatch(t.tier_regexp, "~~analysis") for t in (Manual, Lookup, Imported, Computed, Part))
152-
assert not provenance.is_entry_table("~~analysis")
160+
assert not grants_prov("~~analysis")
153161

154162

155163
def test_serialize_survives_a_deployment_supplied_source(config):

0 commit comments

Comments
 (0)