Skip to content

Commit eda8d97

Browse files
committed
Preserve attribute type metadata across Table.alter
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.
1 parent b3877dd commit eda8d97

4 files changed

Lines changed: 69 additions & 19 deletions

File tree

src/datajoint/adapters/base.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,6 +617,26 @@ def supports_inline_indexes(self) -> bool:
617617
"""
618618
return True # Default for MySQL, override in PostgreSQL
619619

620+
def get_pending_enum_ddl(self, schema_name: str) -> list[str]:
621+
"""
622+
DDL for backend types that must exist before the columns using them,
623+
clearing the pending list as it reads.
624+
625+
Backends that spell column types inline (MySQL) have none. PostgreSQL
626+
overrides this to emit CREATE TYPE for enums registered while parsing.
627+
628+
Parameters
629+
----------
630+
schema_name : str
631+
Schema used to qualify the type names.
632+
633+
Returns
634+
-------
635+
list[str]
636+
Empty for MySQL; CREATE TYPE statements for PostgreSQL.
637+
"""
638+
return []
639+
620640
@property
621641
def supports_column_position(self) -> bool:
622642
"""

src/datajoint/declare.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -678,7 +678,7 @@ def alter(
678678
adapter,
679679
*,
680680
schema_name: str | None = None,
681-
) -> tuple[list[str], list[str], list[str]]:
681+
) -> tuple[list[str], list[str], list[str], dict]:
682682
"""
683683
Generate SQL ALTER commands for table definition changes.
684684
@@ -700,11 +700,15 @@ def alter(
700700
Returns
701701
-------
702702
tuple
703-
Three-element tuple:
703+
Four-element tuple:
704704
705705
- sql : list[str] - SQL ALTER commands
706706
- new_stores : list[str] - New external stores used
707707
- pre_ddl : list[str] - DDL to run before the ALTER (e.g. CREATE TYPE)
708+
- column_comments : dict - Comments to reapply after the ALTER. On
709+
backends that store them out of line these carry the ``:type:``
710+
prefix that ``heading`` reads back as ``original_type``, so skipping
711+
them silently loses the declared type of an added attribute.
708712
709713
Raises
710714
------
@@ -719,16 +723,13 @@ def alter(
719723
index_sql,
720724
external_stores,
721725
_fk_attribute_map,
722-
_column_comments,
726+
column_comments,
723727
) = prepare_declare(definition, context, adapter)
724728

725-
# prepare_declare registers backend types (PostgreSQL enums) on the adapter as
726-
# a side effect. Drain them here, between the two parses: the old definition's
727-
# types already exist in the database, so draining after both would emit
728-
# CREATE TYPE for those as well.
729-
pre_ddl = []
730-
if schema_name and hasattr(adapter, "get_pending_enum_ddl"):
731-
pre_ddl.extend(adapter.get_pending_enum_ddl(schema_name))
729+
# prepare_declare registers backend types (PostgreSQL enums) on the adapter
730+
# as a side effect, so each parse must be drained separately to tell the two
731+
# apart. Type names are content hashes, making the statements comparable.
732+
new_type_ddl = adapter.get_pending_enum_ddl(schema_name) if schema_name else []
732733

733734
(
734735
table_comment_,
@@ -741,10 +742,12 @@ def alter(
741742
_column_comments_,
742743
) = prepare_declare(old_definition, context, adapter)
743744

744-
# Discard what the old definition registered, so it cannot leak into the next
745-
# declare() on this adapter (get_pending_enum_ddl clears as it reads).
746-
if schema_name and hasattr(adapter, "get_pending_enum_ddl"):
747-
adapter.get_pending_enum_ddl(schema_name)
745+
# Whatever the old definition registered already exists in the database, so
746+
# only the difference needs creating. Draining both also leaves nothing
747+
# behind to leak into the next declare() on this adapter, including on the
748+
# NotImplementedError paths below.
749+
old_type_ddl = set(adapter.get_pending_enum_ddl(schema_name)) if schema_name else set()
750+
pre_ddl = [ddl for ddl in new_type_ddl if ddl not in old_type_ddl]
748751

749752
# analyze differences between declarations
750753
sql = list()
@@ -761,7 +764,7 @@ def alter(
761764
# For PostgreSQL: would need COMMENT ON TABLE, but that's not an ALTER TABLE clause
762765
# Keep MySQL syntax for now (ALTER TABLE ... COMMENT="...")
763766
sql.append(f'COMMENT="{table_comment}"')
764-
return sql, [e for e in external_stores if e not in external_stores_], pre_ddl
767+
return sql, [e for e in external_stores if e not in external_stores_], pre_ddl, column_comments
765768

766769

767770
def _parse_index_args(args: str) -> list[str]:

src/datajoint/table.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ def alter(self, prompt=True, context=None):
322322
context = dict(frame.f_globals, **frame.f_locals)
323323
del frame
324324
old_definition = self.describe(context=context)
325-
sql, _external_stores, pre_ddl = alter(
325+
sql, _external_stores, pre_ddl, column_comments = alter(
326326
self.definition,
327327
old_definition,
328328
context,
@@ -346,10 +346,21 @@ def alter(self, prompt=True, context=None):
346346
for ddl in pre_ddl:
347347
try:
348348
self.connection.query(_substitute_database(ddl, self.database))
349-
except Exception:
350-
# Ignore errors (type may already exist)
351-
pass
349+
except Exception as error:
350+
# Enum type names are content hashes shared by every
351+
# table in the schema using the same value set, so the
352+
# type may already exist. Logged rather than dropped:
353+
# a genuine failure surfaces on the ALTER below.
354+
logger.debug("pre-DDL skipped (%s): %s", error, ddl)
352355
self.connection.query(sql)
356+
# Reapply comments. Where they are stored out of line they
357+
# carry the `:type:` prefix heading reads back as
358+
# original_type, without which describe() loses an added
359+
# attribute's declared type and cannot re-parse the table.
360+
for col_name, comment in column_comments.items():
361+
comment_ddl = self.connection.adapter.column_comment_ddl(self.full_table_name, col_name, comment)
362+
if comment_ddl:
363+
self.connection.query(_substitute_database(comment_ddl, self.database))
353364
except AccessError:
354365
# skip if no create privilege
355366
pass

tests/integration/test_multi_backend.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,25 @@ class Subject(dj.Manual):
204204

205205
heading = Subject().heading
206206
assert "status" in heading.names
207+
# `type` is the generated type name on PostgreSQL but the full spelling on
208+
# MySQL; `original_type` is the definition's own text on both.
209+
assert heading["status"].original_type == "enum('active', 'retired', 'transferred')"
207210

208211
# The added column round-trips a value from its own domain.
209212
Subject.insert1({"subject_id": 1, "species": "mouse", "status": "active"})
210213
assert (Subject & {"subject_id": 1}).fetch1("status") == "active"
211214

215+
# Altering again proves the first alter left the type recoverable: describe()
216+
# feeds the next alter, so a column whose declared type was not recorded
217+
# makes the table permanently un-alterable.
218+
Subject.definition = """
219+
subject_id : int32
220+
---
221+
species : enum('mouse', 'rat')
222+
status : enum('active', 'retired', 'transferred')
223+
note = null : varchar(32)
224+
"""
225+
Subject.alter(prompt=False)
226+
assert "note" in Subject().heading.names
227+
212228
schema.drop()

0 commit comments

Comments
 (0)