Skip to content

Commit a1c5cef

Browse files
feat: Add DDL generation adapter methods (Phase 7 Part 1)
Add 6 new abstract methods to DatabaseAdapter for backend-agnostic DDL: Abstract methods (base.py): - format_column_definition(): Format column SQL with proper quoting and COMMENT - table_options_clause(): Generate ENGINE clause (MySQL) or empty (PostgreSQL) - table_comment_ddl(): Generate COMMENT ON TABLE for PostgreSQL (None for MySQL) - column_comment_ddl(): Generate COMMENT ON COLUMN for PostgreSQL (None for MySQL) - enum_type_ddl(): Generate CREATE TYPE for PostgreSQL enums (None for MySQL) - job_metadata_columns(): Return backend-specific job metadata columns MySQL implementation (mysql.py): - format_column_definition(): Backtick quoting with inline COMMENT - table_options_clause(): Returns "ENGINE=InnoDB, COMMENT ..." - table/column_comment_ddl(): Return None (inline comments) - enum_type_ddl(): Return None (inline enum) - job_metadata_columns(): datetime(3), float types PostgreSQL implementation (postgres.py): - format_column_definition(): Double-quote quoting, no inline comment - table_options_clause(): Returns empty string - table_comment_ddl(): COMMENT ON TABLE statement - column_comment_ddl(): COMMENT ON COLUMN statement - enum_type_ddl(): CREATE TYPE ... AS ENUM statement - job_metadata_columns(): timestamp, real types Unit tests added: - TestDDLMethods: 6 tests for MySQL DDL methods - TestPostgreSQLDDLMethods: 6 tests for PostgreSQL DDL methods - Updated TestAdapterInterface to check for new methods All tests pass. Pre-commit hooks pass. Part of Phase 7: Multi-backend DDL support. Related: #1338 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 5ddd3b7 commit a1c5cef

File tree

4 files changed

+472
-0
lines changed

4 files changed

+472
-0
lines changed

src/datajoint/adapters/base.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,166 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None)
709709
"""
710710
...
711711

712+
# =========================================================================
713+
# DDL Generation
714+
# =========================================================================
715+
716+
@abstractmethod
717+
def format_column_definition(
718+
self,
719+
name: str,
720+
sql_type: str,
721+
nullable: bool = False,
722+
default: str | None = None,
723+
comment: str | None = None,
724+
) -> str:
725+
"""
726+
Format a column definition for DDL.
727+
728+
Parameters
729+
----------
730+
name : str
731+
Column name.
732+
sql_type : str
733+
SQL type (already backend-specific, e.g., 'bigint', 'varchar(255)').
734+
nullable : bool, optional
735+
Whether column is nullable. Default False.
736+
default : str | None, optional
737+
Default value expression (e.g., 'NULL', '"value"', 'CURRENT_TIMESTAMP').
738+
comment : str | None, optional
739+
Column comment.
740+
741+
Returns
742+
-------
743+
str
744+
Formatted column definition (without trailing comma).
745+
746+
Examples
747+
--------
748+
MySQL: `name` bigint NOT NULL COMMENT "user ID"
749+
PostgreSQL: "name" bigint NOT NULL
750+
"""
751+
...
752+
753+
@abstractmethod
754+
def table_options_clause(self, comment: str | None = None) -> str:
755+
"""
756+
Generate table options clause (ENGINE, etc.) for CREATE TABLE.
757+
758+
Parameters
759+
----------
760+
comment : str | None, optional
761+
Table-level comment.
762+
763+
Returns
764+
-------
765+
str
766+
Table options clause (e.g., 'ENGINE=InnoDB, COMMENT "..."' for MySQL).
767+
768+
Examples
769+
--------
770+
MySQL: ENGINE=InnoDB, COMMENT "experiment sessions"
771+
PostgreSQL: (empty string, comments handled separately)
772+
"""
773+
...
774+
775+
@abstractmethod
776+
def table_comment_ddl(self, full_table_name: str, comment: str) -> str | None:
777+
"""
778+
Generate DDL for table-level comment (if separate from CREATE TABLE).
779+
780+
Parameters
781+
----------
782+
full_table_name : str
783+
Fully qualified table name (quoted).
784+
comment : str
785+
Table comment.
786+
787+
Returns
788+
-------
789+
str or None
790+
DDL statement for table comment, or None if handled inline.
791+
792+
Examples
793+
--------
794+
MySQL: None (inline)
795+
PostgreSQL: COMMENT ON TABLE "schema"."table" IS 'comment text'
796+
"""
797+
...
798+
799+
@abstractmethod
800+
def column_comment_ddl(self, full_table_name: str, column_name: str, comment: str) -> str | None:
801+
"""
802+
Generate DDL for column-level comment (if separate from CREATE TABLE).
803+
804+
Parameters
805+
----------
806+
full_table_name : str
807+
Fully qualified table name (quoted).
808+
column_name : str
809+
Column name (unquoted).
810+
comment : str
811+
Column comment.
812+
813+
Returns
814+
-------
815+
str or None
816+
DDL statement for column comment, or None if handled inline.
817+
818+
Examples
819+
--------
820+
MySQL: None (inline)
821+
PostgreSQL: COMMENT ON COLUMN "schema"."table"."column" IS 'comment text'
822+
"""
823+
...
824+
825+
@abstractmethod
826+
def enum_type_ddl(self, type_name: str, values: list[str]) -> str | None:
827+
"""
828+
Generate DDL for enum type definition (if needed before CREATE TABLE).
829+
830+
Parameters
831+
----------
832+
type_name : str
833+
Enum type name.
834+
values : list[str]
835+
Enum values.
836+
837+
Returns
838+
-------
839+
str or None
840+
DDL statement for enum type, or None if handled inline.
841+
842+
Examples
843+
--------
844+
MySQL: None (inline enum('val1', 'val2'))
845+
PostgreSQL: CREATE TYPE "type_name" AS ENUM ('val1', 'val2')
846+
"""
847+
...
848+
849+
@abstractmethod
850+
def job_metadata_columns(self) -> list[str]:
851+
"""
852+
Return job metadata column definitions for Computed/Imported tables.
853+
854+
Returns
855+
-------
856+
list[str]
857+
List of column definition strings (fully formatted with quotes).
858+
859+
Examples
860+
--------
861+
MySQL:
862+
["`_job_start_time` datetime(3) DEFAULT NULL",
863+
"`_job_duration` float DEFAULT NULL",
864+
"`_job_version` varchar(64) DEFAULT ''"]
865+
PostgreSQL:
866+
['"_job_start_time" timestamp DEFAULT NULL',
867+
'"_job_duration" real DEFAULT NULL',
868+
'"_job_version" varchar(64) DEFAULT \'\'']
869+
"""
870+
...
871+
712872
# =========================================================================
713873
# Error Translation
714874
# =========================================================================

src/datajoint/adapters/mysql.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,101 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None)
695695
return_clause = f" returning {return_type}" if return_type else ""
696696
return f"json_value({quoted_col}, _utf8mb4'$.{path}'{return_clause})"
697697

698+
# =========================================================================
699+
# DDL Generation
700+
# =========================================================================
701+
702+
def format_column_definition(
703+
self,
704+
name: str,
705+
sql_type: str,
706+
nullable: bool = False,
707+
default: str | None = None,
708+
comment: str | None = None,
709+
) -> str:
710+
"""
711+
Format a column definition for MySQL DDL.
712+
713+
Examples
714+
--------
715+
>>> adapter.format_column_definition('user_id', 'bigint', nullable=False, comment='user ID')
716+
"`user_id` bigint NOT NULL COMMENT \\"user ID\\""
717+
"""
718+
parts = [self.quote_identifier(name), sql_type]
719+
if default:
720+
parts.append(default) # e.g., "DEFAULT NULL" or "NOT NULL DEFAULT 5"
721+
elif not nullable:
722+
parts.append("NOT NULL")
723+
if comment:
724+
parts.append(f'COMMENT "{comment}"')
725+
return " ".join(parts)
726+
727+
def table_options_clause(self, comment: str | None = None) -> str:
728+
"""
729+
Generate MySQL table options clause.
730+
731+
Examples
732+
--------
733+
>>> adapter.table_options_clause('test table')
734+
'ENGINE=InnoDB, COMMENT "test table"'
735+
>>> adapter.table_options_clause()
736+
'ENGINE=InnoDB'
737+
"""
738+
clause = "ENGINE=InnoDB"
739+
if comment:
740+
clause += f', COMMENT "{comment}"'
741+
return clause
742+
743+
def table_comment_ddl(self, full_table_name: str, comment: str) -> str | None:
744+
"""
745+
MySQL uses inline COMMENT in CREATE TABLE, so no separate DDL needed.
746+
747+
Examples
748+
--------
749+
>>> adapter.table_comment_ddl('`schema`.`table`', 'test comment')
750+
None
751+
"""
752+
return None # MySQL uses inline COMMENT
753+
754+
def column_comment_ddl(self, full_table_name: str, column_name: str, comment: str) -> str | None:
755+
"""
756+
MySQL uses inline COMMENT in column definitions, so no separate DDL needed.
757+
758+
Examples
759+
--------
760+
>>> adapter.column_comment_ddl('`schema`.`table`', 'column', 'test comment')
761+
None
762+
"""
763+
return None # MySQL uses inline COMMENT
764+
765+
def enum_type_ddl(self, type_name: str, values: list[str]) -> str | None:
766+
"""
767+
MySQL uses inline enum type in column definition, so no separate DDL needed.
768+
769+
Examples
770+
--------
771+
>>> adapter.enum_type_ddl('status_type', ['active', 'inactive'])
772+
None
773+
"""
774+
return None # MySQL uses inline enum
775+
776+
def job_metadata_columns(self) -> list[str]:
777+
"""
778+
Return MySQL-specific job metadata column definitions.
779+
780+
Examples
781+
--------
782+
>>> adapter.job_metadata_columns()
783+
["`_job_start_time` datetime(3) DEFAULT NULL",
784+
"`_job_duration` float DEFAULT NULL",
785+
"`_job_version` varchar(64) DEFAULT ''"]
786+
"""
787+
return [
788+
"`_job_start_time` datetime(3) DEFAULT NULL",
789+
"`_job_duration` float DEFAULT NULL",
790+
"`_job_version` varchar(64) DEFAULT ''",
791+
]
792+
698793
# =========================================================================
699794
# Error Translation
700795
# =========================================================================

src/datajoint/adapters/postgres.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,6 +759,99 @@ def json_path_expr(self, column: str, path: str, return_type: str | None = None)
759759
# Note: PostgreSQL jsonb_extract_path_text doesn't use return type parameter
760760
return f"jsonb_extract_path_text({quoted_col}, {path_args})"
761761

762+
# =========================================================================
763+
# DDL Generation
764+
# =========================================================================
765+
766+
def format_column_definition(
767+
self,
768+
name: str,
769+
sql_type: str,
770+
nullable: bool = False,
771+
default: str | None = None,
772+
comment: str | None = None,
773+
) -> str:
774+
"""
775+
Format a column definition for PostgreSQL DDL.
776+
777+
Examples
778+
--------
779+
>>> adapter.format_column_definition('user_id', 'bigint', nullable=False, comment='user ID')
780+
'"user_id" bigint NOT NULL'
781+
"""
782+
parts = [self.quote_identifier(name), sql_type]
783+
if default:
784+
parts.append(default)
785+
elif not nullable:
786+
parts.append("NOT NULL")
787+
# Note: PostgreSQL comments handled separately via COMMENT ON
788+
return " ".join(parts)
789+
790+
def table_options_clause(self, comment: str | None = None) -> str:
791+
"""
792+
Generate PostgreSQL table options clause (empty - no ENGINE in PostgreSQL).
793+
794+
Examples
795+
--------
796+
>>> adapter.table_options_clause('test table')
797+
''
798+
>>> adapter.table_options_clause()
799+
''
800+
"""
801+
return "" # PostgreSQL uses COMMENT ON TABLE separately
802+
803+
def table_comment_ddl(self, full_table_name: str, comment: str) -> str | None:
804+
"""
805+
Generate COMMENT ON TABLE statement for PostgreSQL.
806+
807+
Examples
808+
--------
809+
>>> adapter.table_comment_ddl('"schema"."table"', 'test comment')
810+
'COMMENT ON TABLE "schema"."table" IS \\'test comment\\''
811+
"""
812+
return f"COMMENT ON TABLE {full_table_name} IS '{comment}'"
813+
814+
def column_comment_ddl(self, full_table_name: str, column_name: str, comment: str) -> str | None:
815+
"""
816+
Generate COMMENT ON COLUMN statement for PostgreSQL.
817+
818+
Examples
819+
--------
820+
>>> adapter.column_comment_ddl('"schema"."table"', 'column', 'test comment')
821+
'COMMENT ON COLUMN "schema"."table"."column" IS \\'test comment\\''
822+
"""
823+
quoted_col = self.quote_identifier(column_name)
824+
return f"COMMENT ON COLUMN {full_table_name}.{quoted_col} IS '{comment}'"
825+
826+
def enum_type_ddl(self, type_name: str, values: list[str]) -> str | None:
827+
"""
828+
Generate CREATE TYPE statement for PostgreSQL enum.
829+
830+
Examples
831+
--------
832+
>>> adapter.enum_type_ddl('status_type', ['active', 'inactive'])
833+
'CREATE TYPE "status_type" AS ENUM (\\'active\\', \\'inactive\\')'
834+
"""
835+
quoted_values = ", ".join(f"'{v}'" for v in values)
836+
return f"CREATE TYPE {self.quote_identifier(type_name)} AS ENUM ({quoted_values})"
837+
838+
def job_metadata_columns(self) -> list[str]:
839+
"""
840+
Return PostgreSQL-specific job metadata column definitions.
841+
842+
Examples
843+
--------
844+
>>> adapter.job_metadata_columns()
845+
['"_job_start_time" timestamp DEFAULT NULL',
846+
'"_job_duration" real DEFAULT NULL',
847+
'"_job_version" varchar(64) DEFAULT \\'\\'']
848+
"""
849+
return [
850+
'"_job_start_time" timestamp DEFAULT NULL',
851+
'"_job_duration" real DEFAULT NULL',
852+
"\"_job_version\" varchar(64) DEFAULT ''",
853+
]
854+
762855
# =========================================================================
763856
# Error Translation
764857
# =========================================================================

0 commit comments

Comments
 (0)