Skip to content

Commit acaa1de

Browse files
committed
fix: preserve ALTER metadata flags and normalize raw ALTER type aliases
1 parent 050c93e commit acaa1de

5 files changed

Lines changed: 232 additions & 34 deletions

File tree

docs/USAGE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ engine = create_engine("excel:///data.xlsx", connect_args={"engine": "openpyxl"}
3030

3131
**Important**: Absolute paths require **four slashes** total (`excel:////absolute/path.xlsx`).
3232

33+
> **Source checkout note**: In development/source mode (without an installed entry point), import `sqlalchemy_excel` before `create_engine(...)` so SQLAlchemy registers `excel://` and `excel+graph://` dialects.
34+
3335
## Basic ORM Usage
3436

3537
### Define Models

src/sqlalchemy_excel/dialect.py

Lines changed: 117 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,39 @@ def _driver_type_from_declared(type_expr: str) -> str:
177177
return "TEXT"
178178

179179

180+
def _parse_alter_add_column(statement: str) -> tuple[str, str, str] | None:
181+
match = re.match(
182+
r'^ALTER TABLE\s+("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s+ADD\s+COLUMN\s+("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s+(.+?)\s*;?$',
183+
statement,
184+
re.IGNORECASE,
185+
)
186+
if match is None:
187+
return None
188+
return match.group(1), match.group(2), match.group(3)
189+
190+
191+
def _parse_alter_drop_column(statement: str) -> tuple[str, str] | None:
192+
match = re.match(
193+
r'^ALTER TABLE\s+("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s+DROP\s+COLUMN\s+("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s*;?$',
194+
statement,
195+
re.IGNORECASE,
196+
)
197+
if match is None:
198+
return None
199+
return match.group(1), match.group(2)
200+
201+
202+
def _parse_alter_rename_column(statement: str) -> tuple[str, str, str] | None:
203+
match = re.match(
204+
r'^ALTER TABLE\s+("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s+RENAME\s+COLUMN\s+("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s+TO\s+("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s*;?$',
205+
statement,
206+
re.IGNORECASE,
207+
)
208+
if match is None:
209+
return None
210+
return match.group(1), match.group(2), match.group(3)
211+
212+
180213
def _statement_for_driver_execution(statement: str) -> str:
181214
create_match = re.match(
182215
r'^CREATE TABLE\s+("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s*\((.*)\)\s*;?$',
@@ -202,15 +235,11 @@ def _statement_for_driver_execution(statement: str) -> str:
202235
if simplified:
203236
return f"CREATE TABLE {table_name} ({', '.join(simplified)})"
204237

205-
if not statement.upper().startswith("ALTER TABLE "):
206-
return statement
207-
208-
tokens = statement.split()
209-
if len(tokens) < 7:
210-
return statement
211-
212-
if tokens[3].upper() == "ADD" and tokens[4].upper() == "COLUMN":
213-
return " ".join(tokens[:7])
238+
add_match = _parse_alter_add_column(statement)
239+
if add_match is not None:
240+
table_name, column_name, remainder = add_match
241+
normalized_type = _driver_type_from_declared(remainder)
242+
return f"ALTER TABLE {table_name} ADD COLUMN {column_name} {normalized_type}"
214243

215244
return statement
216245

@@ -326,10 +355,11 @@ def do_execute(
326355
) -> None:
327356
"""Execute a statement, normalizing whitespace for excel-dbapi."""
328357
normalized = _normalize_statement_whitespace_quote_aware(statement)
358+
pre_alter_meta = self._read_pre_alter_metadata(cursor, normalized)
329359
cursor.execute(_statement_for_driver_execution(normalized), parameters)
330360
self._sync_create_table_metadata(cursor, normalized)
331361
self._sync_drop_table_metadata(cursor, normalized)
332-
self._sync_alter_table_metadata(cursor, normalized)
362+
self._sync_alter_table_metadata(cursor, normalized, pre_alter_meta)
333363

334364
def do_execute_no_params(
335365
self,
@@ -339,10 +369,36 @@ def do_execute_no_params(
339369
) -> None:
340370
"""Execute a statement with no parameters."""
341371
normalized = _normalize_statement_whitespace_quote_aware(statement)
372+
pre_alter_meta = self._read_pre_alter_metadata(cursor, normalized)
342373
cursor.execute(_statement_for_driver_execution(normalized), None)
343374
self._sync_create_table_metadata(cursor, normalized)
344375
self._sync_drop_table_metadata(cursor, normalized)
345-
self._sync_alter_table_metadata(cursor, normalized)
376+
self._sync_alter_table_metadata(cursor, normalized, pre_alter_meta)
377+
378+
def _read_pre_alter_metadata(
379+
self, cursor: Any, statement: str
380+
) -> list[dict[str, Any]] | None:
381+
if (
382+
_parse_alter_add_column(statement) is None
383+
and _parse_alter_drop_column(statement) is None
384+
and _parse_alter_rename_column(statement) is None
385+
):
386+
return None
387+
388+
table_match = re.match(
389+
r'^ALTER TABLE\s+("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\b',
390+
statement,
391+
re.IGNORECASE,
392+
)
393+
if table_match is None:
394+
return None
395+
396+
import excel_dbapi
397+
398+
table_name = self._unquote_identifier(table_match.group(1))
399+
raw_conn = cursor.connection
400+
current = excel_dbapi.read_table_metadata(raw_conn, table_name) or []
401+
return [dict(column) for column in current]
346402

347403
@staticmethod
348404
def _unquote_identifier(identifier: str) -> str:
@@ -404,9 +460,24 @@ def _sync_create_table_metadata(self, cursor: Any, statement: str) -> None:
404460
columns: list[dict[str, Any]] = []
405461
table_pk_columns: set[str] = set()
406462
for part in self._split_sql_list(column_block):
407-
upper_part = part.upper()
463+
stripped = part.strip()
464+
upper_part = stripped.upper()
465+
466+
if upper_part.startswith("CONSTRAINT ") and " PRIMARY KEY" in upper_part:
467+
pk_match = re.search(
468+
r"PRIMARY KEY\s*\((.+)\)\s*$",
469+
stripped,
470+
re.IGNORECASE,
471+
)
472+
if pk_match is not None:
473+
for name in self._split_sql_list(pk_match.group(1)):
474+
table_pk_columns.add(self._unquote_identifier(name.strip()))
475+
continue
476+
408477
if upper_part.startswith("PRIMARY KEY"):
409-
pk_match = re.match(r"^PRIMARY KEY\s*\((.+)\)\s*$", part, re.IGNORECASE)
478+
pk_match = re.match(
479+
r"^PRIMARY KEY\s*\((.+)\)\s*$", stripped, re.IGNORECASE
480+
)
410481
if pk_match is not None:
411482
for name in self._split_sql_list(pk_match.group(1)):
412483
table_pk_columns.add(self._unquote_identifier(name.strip()))
@@ -423,7 +494,7 @@ def _sync_create_table_metadata(self, cursor: Any, statement: str) -> None:
423494

424495
col_match = re.match(
425496
r'^("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s+(.+)$',
426-
part,
497+
stripped,
427498
)
428499
if col_match is None:
429500
continue
@@ -474,29 +545,45 @@ def _sync_drop_table_metadata(self, cursor: Any, statement: str) -> None:
474545
raw_conn = cursor.connection
475546
excel_dbapi.remove_table_metadata(raw_conn, table_name)
476547

477-
def _sync_alter_table_metadata(self, cursor: Any, statement: str) -> None:
548+
def _sync_alter_table_metadata(
549+
self,
550+
cursor: Any,
551+
statement: str,
552+
pre_alter_meta: list[dict[str, Any]] | None = None,
553+
) -> None:
478554
if not statement.upper().startswith("ALTER TABLE "):
479555
return
480556

557+
add_match = _parse_alter_add_column(statement)
558+
drop_match = _parse_alter_drop_column(statement)
559+
rename_match = _parse_alter_rename_column(statement)
560+
if add_match is None and drop_match is None and rename_match is None:
561+
return
562+
481563
import excel_dbapi
482564

483-
tokens = statement.split()
484-
if len(tokens) < 6:
565+
table_match = re.match(
566+
r'^ALTER TABLE\s+("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\b',
567+
statement,
568+
re.IGNORECASE,
569+
)
570+
if table_match is None:
485571
return
486572

487-
table_name = tokens[2].strip('"')
488-
operation = tokens[3].upper()
573+
table_name = self._unquote_identifier(table_match.group(1))
489574

490575
raw_conn = cursor.connection
491-
current_meta = excel_dbapi.read_table_metadata(raw_conn, table_name) or []
576+
current_meta = pre_alter_meta
577+
if current_meta is None:
578+
current_meta = excel_dbapi.read_table_metadata(raw_conn, table_name) or []
492579

493580
type_map = {col["name"]: col["type_name"] for col in current_meta}
494581
nullable_map = {col["name"]: col.get("nullable", True) for col in current_meta}
495582
pk_map = {col["name"]: col.get("primary_key", False) for col in current_meta}
496583

497-
if operation == "ADD" and len(tokens) >= 7 and tokens[4].upper() == "COLUMN":
498-
col_name = tokens[5].strip('"')
499-
remainder = " ".join(tokens[6:])
584+
if add_match is not None:
585+
_table_name, raw_col_name, remainder = add_match
586+
col_name = self._unquote_identifier(raw_col_name)
500587
extracted = self._extract_declared_type_name(remainder)
501588
added_type = self._normalize_metadata_type_name(extracted or "TEXT")
502589
type_map[col_name] = added_type
@@ -507,20 +594,17 @@ def _sync_alter_table_metadata(self, cursor: Any, statement: str) -> None:
507594
nullable_map[col_name] = "NOT NULL" not in tail and not is_pk
508595
pk_map[col_name] = is_pk
509596

510-
if operation == "DROP" and len(tokens) == 6 and tokens[4].upper() == "COLUMN":
511-
removed = tokens[5].strip('"')
597+
if drop_match is not None:
598+
_table_name, raw_removed = drop_match
599+
removed = self._unquote_identifier(raw_removed)
512600
type_map.pop(removed, None)
513601
nullable_map.pop(removed, None)
514602
pk_map.pop(removed, None)
515603

516-
if (
517-
operation == "RENAME"
518-
and len(tokens) == 8
519-
and tokens[4].upper() == "COLUMN"
520-
and tokens[6].upper() == "TO"
521-
):
522-
old_name = tokens[5].strip('"')
523-
new_name = tokens[7].strip('"')
604+
if rename_match is not None:
605+
_table_name, raw_old_name, raw_new_name = rename_match
606+
old_name = self._unquote_identifier(raw_old_name)
607+
new_name = self._unquote_identifier(raw_new_name)
524608
if old_name in type_map:
525609
type_map[new_name] = type_map.pop(old_name)
526610
if old_name in nullable_map:

tests/test_alter_table.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,40 @@ def test_alter_table_rename_column(engine) -> None:
127127

128128
columns = [col["name"] for col in inspect(engine).get_columns("users")]
129129
assert columns == ["id", "full_name", "age"]
130+
131+
132+
def test_alter_table_preserves_existing_pk_and_nullability(engine) -> None:
133+
metadata = MetaData()
134+
Table(
135+
"users",
136+
metadata,
137+
Column("id", Integer, primary_key=True),
138+
Column("age", Integer, nullable=False),
139+
Column("name", String),
140+
)
141+
metadata.create_all(engine)
142+
143+
with engine.begin() as conn:
144+
conn.execute(AddColumn("users", Column("email", String)))
145+
146+
inspector = inspect(engine)
147+
columns_after_add = {col["name"]: col for col in inspector.get_columns("users")}
148+
assert columns_after_add["age"]["nullable"] is False
149+
assert inspector.get_pk_constraint("users")["constrained_columns"] == ["id"]
150+
151+
with engine.begin() as conn:
152+
conn.execute(RenameColumn("users", "age", "years"))
153+
154+
inspector = inspect(engine)
155+
columns_after_rename = {col["name"]: col for col in inspector.get_columns("users")}
156+
assert columns_after_rename["years"]["nullable"] is False
157+
assert inspector.get_pk_constraint("users")["constrained_columns"] == ["id"]
158+
159+
with engine.begin() as conn:
160+
conn.execute(DropColumn("users", "name"))
161+
162+
inspector = inspect(engine)
163+
final_columns = {col["name"]: col for col in inspector.get_columns("users")}
164+
assert "name" not in final_columns
165+
assert final_columns["years"]["nullable"] is False
166+
assert inspector.get_pk_constraint("users")["constrained_columns"] == ["id"]

tests/test_e2e.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3465,6 +3465,23 @@ def test_e2e_raw_create_table_reflects_table_level_composite_primary_key(
34653465
engine.dispose()
34663466

34673467

3468+
def test_e2e_raw_create_table_reflects_named_composite_primary_key(tmp_path) -> None:
3469+
engine = _engine_for(tmp_path)
3470+
3471+
with engine.begin() as conn:
3472+
conn.exec_driver_sql(
3473+
"CREATE TABLE memberships (a INTEGER, b INTEGER, CONSTRAINT pk_memberships PRIMARY KEY (a, b))"
3474+
)
3475+
3476+
inspector = inspect(engine)
3477+
assert inspector.get_pk_constraint("memberships")["constrained_columns"] == [
3478+
"a",
3479+
"b",
3480+
]
3481+
3482+
engine.dispose()
3483+
3484+
34683485
def test_e2e_raw_create_table_numeric_aliases_reflect_as_float(tmp_path) -> None:
34693486
engine = _engine_for(tmp_path)
34703487

@@ -3530,6 +3547,64 @@ def test_e2e_alter_table_add_float_column_reflects_as_float(tmp_path) -> None:
35303547
engine.dispose()
35313548

35323549

3550+
def test_e2e_raw_alter_add_numeric_aliases_reflect_as_float(tmp_path) -> None:
3551+
engine = _engine_for(tmp_path)
3552+
3553+
with engine.begin() as conn:
3554+
conn.exec_driver_sql("CREATE TABLE metrics (id INTEGER PRIMARY KEY)")
3555+
conn.exec_driver_sql("ALTER TABLE metrics ADD COLUMN x DECIMAL")
3556+
conn.exec_driver_sql("ALTER TABLE metrics ADD COLUMN y NUMERIC")
3557+
conn.exec_driver_sql("ALTER TABLE metrics ADD COLUMN z DOUBLE")
3558+
conn.exec_driver_sql("ALTER TABLE metrics ADD COLUMN w DOUBLE PRECISION")
3559+
3560+
inspector = inspect(engine)
3561+
columns = {
3562+
column["name"]: column["type"] for column in inspector.get_columns("metrics")
3563+
}
3564+
assert isinstance(columns["x"], sa.Float)
3565+
assert isinstance(columns["y"], sa.Float)
3566+
assert isinstance(columns["z"], sa.Float)
3567+
assert isinstance(columns["w"], sa.Float)
3568+
3569+
engine.dispose()
3570+
3571+
3572+
def test_e2e_raw_alter_preserves_existing_pk_and_nullability(tmp_path) -> None:
3573+
engine = _engine_for(tmp_path)
3574+
3575+
with engine.begin() as conn:
3576+
conn.exec_driver_sql(
3577+
"CREATE TABLE users (id INTEGER PRIMARY KEY, age INTEGER NOT NULL, name TEXT)"
3578+
)
3579+
3580+
with engine.begin() as conn:
3581+
conn.exec_driver_sql("ALTER TABLE users ADD COLUMN email TEXT")
3582+
3583+
inspector = inspect(engine)
3584+
columns_after_add = {col["name"]: col for col in inspector.get_columns("users")}
3585+
assert columns_after_add["age"]["nullable"] is False
3586+
assert inspector.get_pk_constraint("users")["constrained_columns"] == ["id"]
3587+
3588+
with engine.begin() as conn:
3589+
conn.exec_driver_sql("ALTER TABLE users RENAME COLUMN age TO years")
3590+
3591+
inspector = inspect(engine)
3592+
columns_after_rename = {col["name"]: col for col in inspector.get_columns("users")}
3593+
assert columns_after_rename["years"]["nullable"] is False
3594+
assert inspector.get_pk_constraint("users")["constrained_columns"] == ["id"]
3595+
3596+
with engine.begin() as conn:
3597+
conn.exec_driver_sql("ALTER TABLE users DROP COLUMN name")
3598+
3599+
inspector = inspect(engine)
3600+
final_columns = {col["name"]: col for col in inspector.get_columns("users")}
3601+
assert "name" not in final_columns
3602+
assert final_columns["years"]["nullable"] is False
3603+
assert inspector.get_pk_constraint("users")["constrained_columns"] == ["id"]
3604+
3605+
engine.dispose()
3606+
3607+
35333608
def test_e2e_alter_table_drop_column(tmp_path) -> None:
35343609
engine = _engine_for(tmp_path)
35353610
metadata = MetaData()

tests/test_graph_dialect.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from sqlalchemy.dialects import registry
1010
from sqlalchemy.engine import make_url
1111

12-
import sqlalchemy_excel # noqa: F401
12+
import sqlalchemy_excel # noqa: F401 # Needed for dialect registration side effect in source checkout tests.
1313

1414
if TYPE_CHECKING:
1515
import httpx as httpx_types

0 commit comments

Comments
 (0)