diff --git a/src/datajoint/declare.py b/src/datajoint/declare.py index 1370628bc..dfd4c85df 100644 --- a/src/datajoint/declare.py +++ b/src/datajoint/declare.py @@ -855,6 +855,14 @@ def compile_attribute( DataJointError If syntax is invalid, primary key is nullable, or blob has invalid default. """ + if line.lstrip().startswith("_"): + raise DataJointError( + f'Attribute name in line "{line}" starts with an underscore. ' + "Names with leading underscore are reserved for platform-managed " + "columns (e.g. _job_start_time, _singleton). Use a regular " + "attribute name; if you need to control visibility at the call " + "site, use proj()." + ) try: match = attribute_parser.parse_string(line + "#", parse_all=True) except pp.ParseException as err: diff --git a/tests/unit/test_declare_hidden_attribute.py b/tests/unit/test_declare_hidden_attribute.py new file mode 100644 index 000000000..0a1db6555 --- /dev/null +++ b/tests/unit/test_declare_hidden_attribute.py @@ -0,0 +1,51 @@ +"""Unit tests for the leading-underscore guard in attribute declarations. + +Regression coverage for issue #1433: declarations like ``_hidden: bool`` +previously failed with a cryptic ``pyparsing.ParseException``. The framework +intentionally does not support user-defined hidden attributes — those names +are reserved for platform-managed columns (e.g. ``_job_start_time``, +``_singleton``) which DataJoint injects programmatically after parsing. + +This test ensures the user gets a clear ``DataJointError`` pointing to the +right alternative, not a parser-internals error. +""" + +import pytest + +from datajoint.declare import attribute_parser, compile_attribute +from datajoint.errors import DataJointError + + +@pytest.mark.parametrize( + "line", + [ + "_hidden: bool", + "_params_hash: varchar(32)", + " _leading_whitespace: int32", + ], +) +def test_compile_attribute_rejects_leading_underscore(line): + """The leading-underscore guard fires before the parser, so adapter is unused.""" + with pytest.raises(DataJointError, match="reserved for platform-managed"): + compile_attribute(line, in_key=False, foreign_key_sql=[], context={}, adapter=None) + + +def test_parser_still_rejects_leading_underscore(): + """Parser regex itself remains strict; the helpful error fires before the parser.""" + import pyparsing as pp + + with pytest.raises(pp.ParseException): + attribute_parser.parse_string("_hidden: bool#", parse_all=True) + + +def test_parser_still_accepts_plain_names(): + match = attribute_parser.parse_string("name: varchar(40)#", parse_all=True) + assert match["name"] == "name" + + +def test_parser_rejects_digit_start(): + """Numeric leading char remains invalid (preserved behavior).""" + import pyparsing as pp + + with pytest.raises(pp.ParseException): + attribute_parser.parse_string("1bad: int32#", parse_all=True)