Skip to content

Commit 6ba8a67

Browse files
committed
feat: implement ALTER TABLE (ADD/DROP/RENAME COLUMN) in SQLAlchemy dialect
1 parent fe03658 commit 6ba8a67

4 files changed

Lines changed: 222 additions & 4 deletions

File tree

src/sqlalchemy_excel/ddl.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
CREATE TABLE → creates a worksheet and writes column metadata.
44
DROP TABLE → deletes the worksheet and removes metadata.
5-
ALTER TABLE → rejected (not supported).
5+
ALTER TABLE → supports ADD/DROP/RENAME COLUMN.
66
"""
77

88
from __future__ import annotations
@@ -16,6 +16,26 @@
1616
class ExcelDDLCompiler(compiler.DDLCompiler):
1717
"""Compiles DDL statements for excel-dbapi."""
1818

19+
def _format_alter_table_name(self, operation: Any) -> str:
20+
schema = getattr(operation, "schema", None)
21+
if schema is not None:
22+
raise exc.CompileError("Excel dialect does not support schemas")
23+
24+
table_name = getattr(operation, "table_name", None)
25+
if table_name is None:
26+
table = getattr(operation, "table", None)
27+
table_name = getattr(table, "name", None)
28+
29+
if not isinstance(table_name, str) or not table_name:
30+
raise exc.CompileError("ALTER TABLE operation requires a table name")
31+
32+
return self.preparer.quote(table_name)
33+
34+
def _format_alter_column_name(self, column_name: Any) -> str:
35+
if not isinstance(column_name, str) or not column_name:
36+
raise exc.CompileError("ALTER TABLE operation requires a column name")
37+
return self.preparer.quote(column_name)
38+
1939
def visit_create_table(self, create: Any, **kw: Any) -> str:
2040
"""Compile CREATE TABLE into SQL that excel-dbapi's parser accepts.
2141
@@ -38,6 +58,46 @@ def visit_drop_table(self, drop: Any, **kw: Any) -> str:
3858
table_name = self.preparer.format_table(table)
3959
return f"DROP TABLE {table_name}"
4060

61+
def visit_add_column(self, create: Any, **kw: Any) -> str:
62+
table_name = self._format_alter_table_name(create)
63+
column = getattr(create, "column", None)
64+
if column is None:
65+
raise exc.CompileError("ALTER TABLE ADD COLUMN requires a column")
66+
67+
col_name = self.preparer.format_column(column)
68+
col_type = self.dialect.type_compiler.process(column.type)
69+
return f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type}"
70+
71+
def visit_drop_column(self, drop: Any, **kw: Any) -> str:
72+
table_name = self._format_alter_table_name(drop)
73+
74+
column_name = getattr(drop, "column_name", None)
75+
if column_name is None:
76+
column = getattr(drop, "column", None)
77+
column_name = getattr(column, "name", column)
78+
79+
column_name = self._format_alter_column_name(column_name)
80+
return f"ALTER TABLE {table_name} DROP COLUMN {column_name}"
81+
82+
def visit_rename_column(self, rename: Any, **kw: Any) -> str:
83+
table_name = self._format_alter_table_name(rename)
84+
85+
old_column_name = getattr(rename, "column_name", None)
86+
if old_column_name is None:
87+
old_column_name = getattr(rename, "old_column_name", None)
88+
if old_column_name is None:
89+
old_column_name = getattr(rename, "old_name", None)
90+
91+
new_column_name = getattr(rename, "new_column_name", None)
92+
if new_column_name is None:
93+
new_column_name = getattr(rename, "name", None)
94+
if new_column_name is None:
95+
new_column_name = getattr(rename, "new_name", None)
96+
97+
old_name = self._format_alter_column_name(old_column_name)
98+
new_name = self._format_alter_column_name(new_column_name)
99+
return f"ALTER TABLE {table_name} RENAME COLUMN {old_name} TO {new_name}"
100+
41101
def visit_create_index(
42102
self,
43103
create: Any,

src/sqlalchemy_excel/dialect.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ class ExcelDialect( # type: ignore[misc] # pyright: ignore[reportIncompatibleM
9191
default_paramstyle: str = "qmark"
9292

9393
# ── Feature flags ──────────────────────────────────────
94-
supports_alter: bool = False
94+
supports_alter: bool = True
9595
supports_sequences: bool = False
9696
supports_schemas: bool = False
9797
supports_views: bool = False
@@ -173,6 +173,7 @@ def do_execute(
173173
"""Execute a statement, normalizing whitespace for excel-dbapi."""
174174
normalized = re.sub(r"\s+", " ", statement).strip()
175175
cursor.execute(normalized, parameters)
176+
self._sync_alter_table_metadata(cursor, normalized)
176177

177178
def do_execute_no_params(
178179
self,
@@ -183,6 +184,66 @@ def do_execute_no_params(
183184
"""Execute a statement with no parameters."""
184185
normalized = re.sub(r"\s+", " ", statement).strip()
185186
cursor.execute(normalized, None)
187+
self._sync_alter_table_metadata(cursor, normalized)
188+
189+
def _sync_alter_table_metadata(self, cursor: Any, statement: str) -> None:
190+
if not statement.upper().startswith("ALTER TABLE "):
191+
return
192+
193+
import excel_dbapi
194+
195+
tokens = statement.split()
196+
if len(tokens) < 6:
197+
return
198+
199+
table_name = tokens[2].strip('"')
200+
operation = tokens[3].upper()
201+
202+
raw_conn = cursor.connection
203+
current_meta = excel_dbapi.read_table_metadata(raw_conn, table_name) or []
204+
205+
type_map = {col["name"]: col["type_name"] for col in current_meta}
206+
nullable_map = {col["name"]: col.get("nullable", True) for col in current_meta}
207+
pk_map = {col["name"]: col.get("primary_key", False) for col in current_meta}
208+
209+
if operation == "ADD" and len(tokens) == 7 and tokens[4].upper() == "COLUMN":
210+
added_type = tokens[6].upper()
211+
if added_type == "FLOAT":
212+
added_type = "REAL"
213+
type_map[tokens[5].strip('"')] = added_type
214+
215+
if operation == "DROP" and len(tokens) == 6 and tokens[4].upper() == "COLUMN":
216+
removed = tokens[5].strip('"')
217+
type_map.pop(removed, None)
218+
nullable_map.pop(removed, None)
219+
pk_map.pop(removed, None)
220+
221+
if (
222+
operation == "RENAME"
223+
and len(tokens) == 8
224+
and tokens[4].upper() == "COLUMN"
225+
and tokens[6].upper() == "TO"
226+
):
227+
old_name = tokens[5].strip('"')
228+
new_name = tokens[7].strip('"')
229+
if old_name in type_map:
230+
type_map[new_name] = type_map.pop(old_name)
231+
if old_name in nullable_map:
232+
nullable_map[new_name] = nullable_map.pop(old_name)
233+
if old_name in pk_map:
234+
pk_map[new_name] = pk_map.pop(old_name)
235+
236+
live_columns = excel_dbapi.get_columns(raw_conn, table_name)
237+
columns = [
238+
{
239+
"name": col["name"],
240+
"type_name": type_map.get(col["name"], col.get("type", "TEXT")),
241+
"nullable": nullable_map.get(col["name"], True),
242+
"primary_key": pk_map.get(col["name"], False),
243+
}
244+
for col in live_columns
245+
]
246+
excel_dbapi.write_table_metadata(raw_conn, table_name, columns)
186247

187248
def do_ping(self, dbapi_connection: Any) -> bool:
188249
"""Ping the connection by verifying it's not closed."""

tests/test_alter_table.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from sqlalchemy import Column, Integer, MetaData, String, Table, inspect
6+
from sqlalchemy.sql.ddl import ExecutableDDLElement
7+
8+
9+
class AddColumn(ExecutableDDLElement):
10+
__visit_name__ = "add_column"
11+
12+
def __init__(self, table_name: str, column: Column[Any]) -> None:
13+
self.table_name = table_name
14+
self.column = column
15+
self.schema = None
16+
17+
18+
class DropColumn(ExecutableDDLElement):
19+
__visit_name__ = "drop_column"
20+
21+
def __init__(self, table_name: str, column_name: str) -> None:
22+
self.table_name = table_name
23+
self.column_name = column_name
24+
self.schema = None
25+
26+
27+
class RenameColumn(ExecutableDDLElement):
28+
__visit_name__ = "rename_column"
29+
30+
def __init__(
31+
self,
32+
table_name: str,
33+
old_column_name: str,
34+
new_column_name: str,
35+
) -> None:
36+
self.table_name = table_name
37+
self.column_name = old_column_name
38+
self.new_column_name = new_column_name
39+
self.schema = None
40+
41+
42+
def _create_users_table(metadata: MetaData) -> Table:
43+
return Table(
44+
"users",
45+
metadata,
46+
Column("id", Integer, primary_key=True),
47+
Column("name", String),
48+
Column("age", Integer),
49+
)
50+
51+
52+
def test_alter_table_add_column(engine) -> None:
53+
metadata = MetaData()
54+
_create_users_table(metadata)
55+
metadata.create_all(engine)
56+
57+
ddl = AddColumn("users", Column("email", String))
58+
compiled = str(ddl.compile(dialect=engine.dialect)).strip()
59+
assert compiled == "ALTER TABLE users ADD COLUMN email TEXT"
60+
61+
with engine.begin() as conn:
62+
conn.execute(ddl)
63+
64+
columns = [col["name"] for col in inspect(engine).get_columns("users")]
65+
assert columns == ["id", "name", "age", "email"]
66+
67+
68+
def test_alter_table_drop_column(engine) -> None:
69+
metadata = MetaData()
70+
_create_users_table(metadata)
71+
metadata.create_all(engine)
72+
73+
ddl = DropColumn("users", "age")
74+
compiled = str(ddl.compile(dialect=engine.dialect)).strip()
75+
assert compiled == "ALTER TABLE users DROP COLUMN age"
76+
77+
with engine.begin() as conn:
78+
conn.execute(ddl)
79+
80+
columns = [col["name"] for col in inspect(engine).get_columns("users")]
81+
assert columns == ["id", "name"]
82+
83+
84+
def test_alter_table_rename_column(engine) -> None:
85+
metadata = MetaData()
86+
_create_users_table(metadata)
87+
metadata.create_all(engine)
88+
89+
ddl = RenameColumn("users", "name", "full_name")
90+
compiled = str(ddl.compile(dialect=engine.dialect)).strip()
91+
assert compiled == "ALTER TABLE users RENAME COLUMN name TO full_name"
92+
93+
with engine.begin() as conn:
94+
conn.execute(ddl)
95+
96+
columns = [col["name"] for col in inspect(engine).get_columns("users")]
97+
assert columns == ["id", "full_name", "age"]

tests/test_dialect.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ def test_driver(self, engine):
3939
def test_paramstyle(self, engine):
4040
assert engine.dialect.default_paramstyle == "qmark"
4141

42-
def test_no_alter(self, engine):
43-
assert engine.dialect.supports_alter is False
42+
def test_supports_alter(self, engine):
43+
assert engine.dialect.supports_alter is True
4444

4545
def test_no_sequences(self, engine):
4646
assert engine.dialect.supports_sequences is False

0 commit comments

Comments
 (0)