fix: braces in comments crash declaration, and alter() drops type metadata - #1548
fix: braces in comments crash declaration, and alter() drops type metadata#1548MilagrosMarin wants to merge 3 commits into
Conversation
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.
dimitri-yatsenko
left a comment
There was a problem hiding this comment.
Approving. I verified the two claims the fix rests on rather than reading the reasoning, and both hold.
The fragment really is the only one. '"{database}".' at adapters/postgres.py:368 is the sole producer of that placeholder anywhere in src/ for DDL purposes — every other {database} hit is either an already-interpolated f-string or a schemas.py logger message. So matching the full quote-wrapped, dot-suffixed fragment is not merely narrower than the bare token, it is exact.
The three sites are all of them. table.py:161, :166, :173 were the only format(database= consumers in the package, and all three are converted. Nothing else in the declare path needed touching.
The alter-path reasoning checks out too, for a reason worth stating because it is not obvious from the diff: alter() does "ALTER TABLE {tab}".format(tab=...) on the literal prefix and then concatenates the joined user SQL after the format call, so user content never passes through str.format. Your rejected alternative — escaping at comment-emission time — would indeed have corrupted the rename convention, since alter routes COMMENT "{ name }" through the same compile_attribute.
Good instinct pinning the mechanism in a unit test rather than only the symptom. And the PostgreSQL detail in the description is the useful part of this report: an exception swallowed by except Exception: pass in the pre-DDL loop, surfacing much later as "type does not exist", is a failure mode nobody would have found from the KeyError alone.
One latent bug this uncovers — follow-up, not a change request
alter() never substitutes the placeholder at all, and it can emit it. declare.alter() builds its attribute SQL through _make_attribute_alter → compile_attribute, which for a PostgreSQL enum returns '"{database}".' + quote_identifier(type_name). That string is joined into the ALTER TABLE statement after the only .format() call, so it reaches the server as the literal text "{database}".enumtype.
Pre-existing, out of scope here, and your _substitute_database helper is exactly what it needs — one call at table.py:317, plus a test altering a table to add an enum attribute on Postgres. Worth its own issue so it does not get lost; happy to file it if you would rather keep moving.
Follow-up:
|
dimitri-yatsenko
left a comment
There was a problem hiding this comment.
Switching to request-changes. To be clear about what this is and is not: the code in this diff is correct — I verified the fragment is the only producer, the three sites are all the consumers, and the alternatives were rightly rejected. The request is about scope, not correctness.
1. Apply the fix to alter() in this PR
Detailed in the comment above. The short version: alter() assembles DDL from the same prepare_declare() and never substitutes the placeholder, so a PostgreSQL enum attribute reaches the server as the literal "{database}"."enum_a1b2c3d4". And because the pending-enum drain lives in declare() rather than prepare_declare(), no CREATE TYPE is issued either — so substitution alone converts one bad error into another.
The reason to do it here rather than in a follow-up: _substitute_database is the fix, and it exists as of this diff. Merging declare-only leaves the identical defect live on the sibling path, with the helper sitting right next to it unused. The next person to hit it will find a function named for exactly their problem and wonder why it was not applied — which is worse than the bug.
Concretely:
declare.alter()returnspre_ddlas a third element, drainingget_pending_enum_ddl(schema_name)asdeclare()does atdeclare.py:534.Table.alter()runs those statements before theALTER TABLE, and passes the statement through_substitute_database.- One test: declare a table with an
enumattribute on PostgreSQL,alter()it to add a secondenumwith different values, assert the column exists with the right type.
If that turns out to be more than a small change — the NotImplementedError guards in alter() suggest that path has sharp edges I have not explored — say so and I will approve the declare-only fix with a linked issue instead. I would rather be told the scope is wrong than have it forced.
2. Correct one claim in the description
At default log level the error is swallowed by the logging excepthook ("Uncaught exception", no traceback)
Not on master. logging.py:58 states that DataJoint intentionally installs no process-wide sys.excepthook, removed in #1516 specifically so uncaught exceptions get Python's full traceback. That was true before #1516 and is not now.
This matters only because the PR description is the decision record. As written, a future reader debugging a similar swallowed error will go looking for a mechanism that no longer exists. The diagnosis difficulty on the PostgreSQL path is real and well told — it is the except Exception: pass in the pre-DDL loop, not an excepthook.
Still worth saying
The bug itself is a good find, and the report is unusually thorough — the enum-values-in-pre_ddl path degrading into "type does not exist" much later is the kind of detail that only comes from actually chasing it. Pinning the substitution mechanism in a unit test rather than only the symptom is the right instinct.
Table.alter skipped both steps declare performs on adapter output: it
never substituted the PostgreSQL schema placeholder, and never issued
the adapter's pending CREATE TYPE. Adding an enum attribute therefore
emitted a literal "{database}"."enum_<hash>" against a type that was
never created.
declare.alter now accepts an optional schema_name and returns the
pre-DDL alongside the ALTER clauses. The pending types are drained
between the two prepare_declare calls, since both register types as a
side effect and the old definition's types already exist; what the old
parse registers is discarded so it cannot leak into the next declare
on the same adapter.
Adding a non-primary attribute also emitted MySQL's AFTER positioning
clause, which PostgreSQL has no equivalent for. Gate it behind a new
supports_column_position adapter property, dropping the position
before it can force a statement so a reorder-only change stays a
no-op rather than becoming a positionless MODIFY.
|
Thanks @dimitri-yatsenko — both addressed. ✅ ✅ Test. Writing the test surfaced a second blocker, which is why this branch grew a bit: with the placeholder and pre-DDL fixed, the PostgreSQL ALTER then failed on That covers ✅ PR description. Removed the excepthook claim — it was wrong. DataJoint deliberately installs no process-wide Full |
An attribute added by alter() never had its declared type recorded on backends that store column comments out of line. The comment carries the `:type:` prefix that heading reads back as original_type, so on PostgreSQL the column came back as its generated type name: describe() no longer round-tripped, and since describe() feeds the next alter(), the table became permanently un-alterable. declare.alter() now returns the new definition's column comments and Table.alter reapplies them after the ALTER, mirroring what declare() already does with its post-DDL. Emit CREATE TYPE only for types the new definition adds, by subtracting what the old definition registered rather than discarding it. Previously every alter of a table with any pre-existing enum issued a statement certain to fail, which the surrounding guard then swallowed, leaving no way to tell an expected collision from a real error. Enum type names are content hashes shared across a schema, so a collision remains possible and the guard stays -- but it now logs instead of discarding. Give the base adapter a get_pending_enum_ddl returning nothing, so the call site can drop its hasattr check and match the capability properties alongside it. Assert the added column's original_type and alter a second time, which is what fails when the type is not recoverable.
MilagrosMarin
left a comment
There was a problem hiding this comment.
Thanks @dimitri-yatsenko — both addressed. Taking your scope question seriously, though: it grew, and I want to be straight about how much.
✅ Description. Excepthook claim removed. You're right — logging.py says DataJoint deliberately installs none, per #1516. Reworded to attribute the diagnosis difficulty to the swallowed CREATE TYPE, which is what it actually was.
✅ alter(). Done, but it needed four fixes rather than the two we scoped, each only visible after the previous one landed:
- Placeholder substitution — as you specified.
pre_ddlreturned and issued — as you specified, with one wrinkle. Draining the new definition's types also picks up types the old definition already created, so every alter of a table with a pre-existing enum emitted aCREATE TYPEcertain to fail. I subtract the old parse's drain instead of discarding it; names are content hashes so the statements compare directly. The guard stays — a hash is shared by every table in the schema using the same value set — but it logs now rather than discarding, since adding a freshexcept Exception: passin the fix for one seemed like poor form._make_attribute_alteremitted MySQL'sAFTERclause unconditionally; PostgreSQL failed withsyntax error at or near "AFTER". Gated behind asupports_column_positionproperty next tosupports_inline_indexes/auto_indexes_foreign_keys, dropping the position before it can force a statement so a reorder-only change stays a no-op.- The one worth flagging.
original_typetravels in the column comment as:type:comment, which PostgreSQL stores viaCOMMENT ON COLUMN— post-DDL thatdeclare()emits andalter()discarded. So an added attribute came back as its generated type name,describe()stopped round-tripping, and sincedescribe()feeds the nextalter(), the table became permanently un-alterable (Unsupported attribute type enum_<hash>).alter()now returns the column comments and reapplies them.
Your test spec is what found (4). My first version asserted the column existed and a value round-tripped — both pass against a column whose declared type has been lost. Writing "with the right type" as you actually worded it failed on assert None == "enum('active', 'retired', 'transferred')". The test now also alters a second time, which is the behavioral form of the same invariant.
On the exit you offered. This is past "a small change" — alter() on PostgreSQL was substantially unimplemented rather than slightly broken. I kept it here because the four are interdependent and splitting means re-deriving the chain, but if you'd rather see the declare-only fix merged and this as a separate PR, say so and I'll split it — the offer was fair and I don't want to have forced the scope by attrition.
Still MySQL-only, out of scope, happy to file as one issue: MODIFY and CHANGE (PostgreSQL needs ALTER COLUMN … TYPE and RENAME COLUMN), and the table-comment clause at declare.py:761 that the code already flags.
Full integration + unit suites green on both backends.
Summary
A
{...}sequence in any table or attribute comment crashed table declaration with an opaqueKeyError— e.g.:The assembled DDL — including verbatim user comments — was passed through
str.format(), which interprets any brace pair as a template field. On the PostgreSQL path, brace-containing enum values additionally made the pre-DDLCREATE TYPEraise inside theexcept Exception: passguard, so the statement was silently never sent andCREATE TABLElater failed with a baffling "type does not exist" — that swallowed pre-DDL error, not theKeyErroritself, is what made this hard to diagnose.Fix
The only intended placeholder in the DDL is inserted by the PostgreSQL adapter for enum type qualification, always as the exact fragment
'"{database}".'(adapters/postgres.py). The threeformat(database=...)sites inTable.declarenowstr.replacethat exact fragment instead, so braces in user comments and enum values — including a bare literal{database}— pass through verbatim.The same defect was live on
Table.alter, which turned out to be missing rather more than the substitution. Four things were wrong, each visible only after fixing the one before it:No placeholder substitution. Adding an
enumattribute on PostgreSQL emitted a literal"{database}"."enum_<hash>".declare.alter()now takes an optionalschema_name;Table.alterruns its output through the same_substitute_databasehelper asdeclare.No
CREATE TYPE. The pending-type drain lived indeclare(), soalter()issued none and substitution alone would have traded one error for another.declare.alter()now returns the pre-DDL. It drains between the twoprepare_declarecalls and subtracts what the old definition registered rather than discarding it — both parses register types as a side effect, so the new definition's drain otherwise includes types that already exist, making every alter of a table with any pre-existing enum issue a statement certain to fail. Type names are content hashes, so the statements compare directly. The guard around the loop stays (a hash is shared by every table in the schema using the same value set, so a genuine collision is possible) but now logs instead of discarding.MySQL-only positioning. Adding a non-primary attribute emitted
AFTER "col", which PostgreSQL has no equivalent for. Asupports_column_positionadapter property gates it, followingsupports_inline_indexes/auto_indexes_foreign_keys. The position is dropped before it can force a statement, so a reorder-only change stays a no-op rather than becoming a positionlessMODIFY.Type metadata silently lost.
original_typetravels in the column comment as:type:comment; PostgreSQL stores that withCOMMENT ON COLUMN, whichdeclare()emits as post-DDL andalter()discarded. An added attribute therefore came back as its generated type name,describe()stopped round-tripping, and — sincedescribe()is the input to the nextalter()— the table became permanently un-alterable, failing withUnsupported attribute type enum_<hash>.declare.alter()now returns the column comments andTable.alterreapplies them after the ALTER.This makes
ADDandDROPwork on PostgreSQL (COLUMNis optional there for both). Still MySQL-only and out of scope:MODIFYandCHANGE(PostgreSQL needsALTER COLUMN … TYPEandRENAME COLUMN), and the table-comment clause atdeclare.py:761, which the surrounding code already flags.Two alternatives were considered and rejected:
declare.py: the alter path parsesCOMMENT "{ name }"as the attribute-rename convention through the samecompile_attribute, and alter's DDL never goes through theformatcall — escaping there would corrupt renames.{database}token: a user comment legitimately containing that literal text would be silently rewritten (the oldformathad the same defect); matching the full quote-wrapped, dot-suffixed fragment shrinks the collision surface to a string that cannot appear in a sane comment.Tests
tests/integration/test_declare.py::test_braces_in_comments— braces in table and attribute comments (including a bare literal{database}) round-trip through the heading verbatim. Fails on master with theKeyError.tests/integration/test_multi_backend.py::test_braces_in_comments_by_backend— same invariant on both backends, exercising the PostgreSQLCOMMENT ONpath the MySQL-only test cannot reach.tests/integration/test_multi_backend.py::test_alter_adds_enum_attribute— declares a table with anenum, alters it to add a secondenumwith a different value set, then asserts the column exists, thatoriginal_typeis the declared spelling, that a value round-trips, and that the table can be altered again. The last two are what catch (4): a presence-only assertion passes against a column whose declared type was lost.tests/unit/test_ddl_substitution.py— pins the mechanism: the adapter fragment is replaced, user brace text is untouched.Full
test_declare.py+test_alter.py+test_multi_backend.pypass, and the full integration + unit suites are green.