Skip to content

Commit ee22cc0

Browse files
committed
fix(declare): substitute the DDL schema placeholder without str.format
A brace sequence in a table or attribute comment (e.g. '# {data, config} payload') crashed declaration with an opaque KeyError at sql.format(database=...), which interpreted user comment text as template fields. Replace the whole-DDL str.format with a plain str.replace of the exact adapter-inserted fragment '"{database}".' (PostgreSQL enum type qualification — its only producer), so braces in user comments and enum values — including a bare literal {database} — pass through verbatim.
1 parent 8e6ef31 commit ee22cc0

4 files changed

Lines changed: 75 additions & 3 deletions

File tree

src/datajoint/table.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@
3333
# Legacy regexp and query kept for reference but no longer used
3434

3535

36+
def _substitute_database(ddl: str, database: str) -> str:
37+
"""Replace the adapter-inserted schema placeholder in DDL.
38+
39+
Matches the exact quoted fragment produced by the PostgreSQL adapter for
40+
enum type qualification (``'"{database}".'`` — see adapters/postgres.py)
41+
rather than the bare token, and uses ``str.replace`` rather than
42+
``str.format``, so braces in user-supplied comments and enum values —
43+
including a literal ``{database}`` — pass through verbatim.
44+
"""
45+
return ddl.replace('"{database}".', f'"{database}".')
46+
47+
3648
@dataclass
3749
class ValidationResult:
3850
"""
@@ -158,19 +170,19 @@ def declare(self, context=None):
158170
# Call declaration hook for validation (subclasses like AutoPopulate can override)
159171
self._declare_check(primary_key, fk_attribute_map)
160172

161-
sql = sql.format(database=self.database)
173+
sql = _substitute_database(sql, self.database)
162174
try:
163175
# Execute pre-DDL statements (e.g., CREATE TYPE for PostgreSQL enums)
164176
for ddl in pre_ddl:
165177
try:
166-
self.connection.query(ddl.format(database=self.database))
178+
self.connection.query(_substitute_database(ddl, self.database))
167179
except Exception:
168180
# Ignore errors (type may already exist)
169181
pass
170182
self.connection.query(sql)
171183
# Execute post-DDL statements (e.g., COMMENT ON for PostgreSQL)
172184
for ddl in post_ddl:
173-
self.connection.query(ddl.format(database=self.database))
185+
self.connection.query(_substitute_database(ddl, self.database))
174186
except AccessError:
175187
# Only suppress if table already exists (idempotent declaration)
176188
# Otherwise raise - user needs to know about permission issues

tests/integration/test_declare.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,24 @@ class Part(dj.Part):
236236
]
237237

238238

239+
def test_braces_in_comments(schema_any):
240+
"""Braces in table and attribute comments are literal text, not
241+
str.format template fields."""
242+
243+
class BraceComment(dj.Manual):
244+
definition = """
245+
# payload spec: {data, config}
246+
brace_id : int
247+
---
248+
payload = null : varchar(32) # {data, config} payload
249+
note = null : varchar(64) # mentions {database} literally
250+
"""
251+
252+
schema_any(BraceComment, context=dict(BraceComment=BraceComment))
253+
assert BraceComment.heading["payload"].comment == "{data, config} payload"
254+
assert BraceComment.heading["note"].comment == "mentions {database} literally"
255+
256+
239257
def test_bad_attribute_name(schema_any):
240258
class BadName(dj.Manual):
241259
definition = """

tests/integration/test_multi_backend.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,32 @@ class TypeTest(dj.Manual):
119119
schema.drop()
120120

121121

122+
@pytest.mark.backend_agnostic
123+
def test_braces_in_comments_by_backend(connection_by_backend, backend, prefix):
124+
"""Braces in table and attribute comments are literal text on both
125+
backends — the MySQL path carries them inline in CREATE TABLE, the
126+
PostgreSQL path in post-DDL COMMENT ON statements."""
127+
schema = dj.Schema(
128+
f"{prefix}_multi_backend_{backend}_braces",
129+
connection=connection_by_backend,
130+
)
131+
132+
@schema
133+
class BraceCommented(dj.Manual):
134+
definition = """
135+
# payload spec: {data, config}
136+
id : int
137+
---
138+
payload = null : varchar(32) # {data, config} payload
139+
"""
140+
141+
assert BraceCommented.is_declared
142+
assert BraceCommented.heading["payload"].comment == "{data, config} payload"
143+
144+
# Cleanup
145+
schema.drop()
146+
147+
122148
@pytest.mark.backend_agnostic
123149
def test_table_comments(connection_by_backend, backend, prefix):
124150
"""Test that table comments are preserved on both backends."""
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Unit tests for the DDL schema-placeholder substitution in Table.declare."""
2+
3+
from datajoint.table import _substitute_database
4+
5+
6+
def test_placeholder_fragment_substituted():
7+
"""The exact adapter-inserted fragment (see adapters/postgres.py enum
8+
qualification) is replaced with the quoted schema name."""
9+
assert _substitute_database('"{database}".enum_abc NOT NULL', "myschema") == '"myschema".enum_abc NOT NULL'
10+
11+
12+
def test_user_braces_pass_through():
13+
"""Brace text outside the adapter fragment — including a bare literal
14+
{database} in a comment — is never touched."""
15+
ddl = '`payload` varchar(32) COMMENT "{data, config} payload for {database}"'
16+
assert _substitute_database(ddl, "myschema") == ddl

0 commit comments

Comments
 (0)