Skip to content

Commit 8a16bb4

Browse files
committed
fix: close oracle round 13 gaps
1 parent 49e356c commit 8a16bb4

7 files changed

Lines changed: 189 additions & 6 deletions

File tree

docs/DEVELOPMENT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ sqlalchemy-excel/
120120
│ ├── types.py # ExcelTypeCompiler (type mappings)
121121
│ ├── reflection.py # ExcelInspectionMixin (schema inspection)
122122
│ └── py.typed # PEP 561 marker file
123-
├── tests/ # 18 test modules (400 passed, 2 xfailed)
123+
├── tests/ # 18 test modules (402 passed, 2 xfailed)
124124
│ ├── conftest.py # Shared fixtures
125125
│ ├── test_alter_table.py
126126
│ ├── test_dialect.py

src/sqlalchemy_excel/compiler.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,30 @@ def _on_conflict_target(self, clause: Any, **kw: Any) -> str:
979979

980980
return target_text
981981

982+
@staticmethod
983+
def _rewrite_upsert_same_table_column(value: Any, insert_statement: Any) -> Any:
984+
if not isinstance(value, elements.ColumnElement):
985+
return value
986+
987+
value_table = getattr(value, "table", None)
988+
insert_table = getattr(insert_statement, "table", None)
989+
if (
990+
value_table is None
991+
or insert_table is None
992+
or value_table is not insert_table
993+
):
994+
return value
995+
996+
excluded_columns = getattr(insert_statement, "excluded", None)
997+
value_key = getattr(value, "key", None)
998+
if excluded_columns is None or value_key is None:
999+
return value
1000+
1001+
if value_key in excluded_columns:
1002+
return excluded_columns[value_key]
1003+
1004+
return value
1005+
9821006
def visit_on_conflict_do_nothing(self, on_conflict: Any, **kw: Any) -> str:
9831007
target_text = self._on_conflict_target(on_conflict, **kw)
9841008

@@ -1010,6 +1034,8 @@ def visit_on_conflict_do_update(self, on_conflict: Any, **kw: Any) -> str:
10101034
else:
10111035
continue
10121036

1037+
value = self._rewrite_upsert_same_table_column(value, insert_statement)
1038+
10131039
if coercions._is_literal(value):
10141040
value = elements.BindParameter(None, value, type_=c.type)
10151041

src/sqlalchemy_excel/dialect.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -709,6 +709,9 @@ def has_table(
709709
"""Check if a worksheet (table) exists."""
710710
import excel_dbapi
711711

712+
if schema is not None:
713+
return False
714+
712715
raw_conn = connection.connection.dbapi_connection
713716
return cast("bool", excel_dbapi.has_table(raw_conn, table_name))
714717

src/sqlalchemy_excel/reflection.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from __future__ import annotations
88

9+
import logging
10+
from contextlib import suppress
911
from typing import Any, cast
1012

1113
from sqlalchemy import types as sa_types
@@ -28,6 +30,8 @@
2830
"TIMESTAMP": sa_types.DateTime,
2931
}
3032

33+
_LOG = logging.getLogger(__name__)
34+
3135

3236
def _sa_type_from_name(type_name: str) -> sa_types.TypeEngine[Any]:
3337
"""Convert an excel-dbapi type name to a SQLAlchemy type instance."""
@@ -51,6 +55,9 @@ def get_table_names(
5155
"""Return all worksheet names, excluding the metadata sheet."""
5256
import excel_dbapi
5357

58+
if schema is not None:
59+
return []
60+
5461
raw_conn = connection.connection.dbapi_connection
5562
return cast("list[str]", excel_dbapi.list_tables(raw_conn, include_meta=False))
5663

@@ -77,6 +84,9 @@ def get_columns(
7784
"""
7885
import excel_dbapi
7986

87+
if schema is not None:
88+
raise NoSuchTableError(table_name)
89+
8090
raw_conn = self._raw_connection(connection)
8191
table_exists = cast("bool", excel_dbapi.has_table(raw_conn, table_name))
8292
if not table_exists:
@@ -123,6 +133,9 @@ def get_pk_constraint(
123133
"""Return primary key constraint info from the metadata sheet."""
124134
import excel_dbapi
125135

136+
if schema is not None:
137+
raise NoSuchTableError(table_name)
138+
126139
raw_conn = self._raw_connection(connection)
127140
table_exists = cast("bool", excel_dbapi.has_table(raw_conn, table_name))
128141
if not table_exists:
@@ -147,6 +160,8 @@ def get_foreign_keys(
147160
**kw: Any,
148161
) -> list[dict[str, Any]]:
149162
"""Excel does not support foreign keys."""
163+
if schema is not None:
164+
raise NoSuchTableError(table_name)
150165
self._assert_table_exists(connection, table_name)
151166
return []
152167

@@ -158,6 +173,8 @@ def get_indexes(
158173
**kw: Any,
159174
) -> list[dict[str, Any]]:
160175
"""Excel does not support indexes."""
176+
if schema is not None:
177+
raise NoSuchTableError(table_name)
161178
self._assert_table_exists(connection, table_name)
162179
return []
163180

@@ -169,6 +186,8 @@ def get_unique_constraints(
169186
**kw: Any,
170187
) -> list[dict[str, Any]]:
171188
"""Excel does not support unique constraints."""
189+
if schema is not None:
190+
raise NoSuchTableError(table_name)
172191
self._assert_table_exists(connection, table_name)
173192
return []
174193

@@ -180,6 +199,8 @@ def get_check_constraints(
180199
**kw: Any,
181200
) -> list[dict[str, Any]]:
182201
"""Excel does not support check constraints."""
202+
if schema is not None:
203+
raise NoSuchTableError(table_name)
183204
self._assert_table_exists(connection, table_name)
184205
return []
185206

@@ -191,6 +212,8 @@ def get_table_comment(
191212
**kw: Any,
192213
) -> dict[str, Any]:
193214
"""Excel does not support table comments."""
215+
if schema is not None:
216+
raise NoSuchTableError(table_name)
194217
self._assert_table_exists(connection, table_name)
195218
return {"text": None}
196219

@@ -208,9 +231,33 @@ def _metadata_header_matches(
208231
header_names: list[str] | None,
209232
) -> bool:
210233
if header_names is None:
211-
return True
234+
return False
212235
return [col["name"] for col in meta] == header_names
213236

237+
@staticmethod
238+
def _cursor_header_names(raw_conn: Any, table_name: str) -> list[str] | None:
239+
try:
240+
cursor = raw_conn.cursor()
241+
except Exception:
242+
return None
243+
244+
try:
245+
cursor.execute(f"SELECT * FROM {table_name} LIMIT 0")
246+
description = getattr(cursor, "description", None)
247+
if not description:
248+
return None
249+
header_names = [
250+
str(column[0])
251+
for column in description
252+
if column is not None and column[0] is not None
253+
]
254+
return header_names or None
255+
except Exception:
256+
return None
257+
finally:
258+
with suppress(Exception):
259+
cursor.close()
260+
214261
def _validated_metadata(
215262
self,
216263
raw_conn: Any,
@@ -223,6 +270,15 @@ def _validated_metadata(
223270
if meta is None:
224271
return None
225272

273+
if header_names is None:
274+
header_names = self._cursor_header_names(raw_conn, table_name)
275+
if header_names is None:
276+
_LOG.warning(
277+
"Could not validate metadata headers for table '%s'; using stored metadata",
278+
table_name,
279+
)
280+
return meta
281+
226282
if not self._metadata_header_matches(meta, header_names):
227283
excel_dbapi.remove_table_metadata(raw_conn, table_name)
228284
return None

tests/test_reflection.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ def test_metadata_sheet_excluded(self, populated_engine):
5858
tables = insp.get_table_names()
5959
assert "__excel_meta__" not in tables
6060

61+
def test_nonexistent_schema_returns_no_tables(self, populated_engine):
62+
insp = inspect(populated_engine)
63+
assert insp.get_table_names(schema="nonexistent") == []
64+
6165

6266
class TestHasTable:
6367
"""Test has_table."""
@@ -70,6 +74,10 @@ def test_nonexistent_table(self, populated_engine):
7074
insp = inspect(populated_engine)
7175
assert not insp.has_table("nonexistent")
7276

77+
def test_schema_argument_returns_false(self, populated_engine):
78+
insp = inspect(populated_engine)
79+
assert not insp.has_table("users", schema="nonexistent")
80+
7381

7482
class TestGetColumns:
7583
"""Test get_columns — from metadata sheet."""

tests/test_reflection_full.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,60 @@ def test_reflection_rebuilds_columns_when_metadata_headers_are_stale(tmp_path) -
107107
engine.dispose()
108108

109109

110+
def test_reflection_validates_metadata_with_cursor_when_headers_unavailable(
111+
tmp_path, monkeypatch: pytest.MonkeyPatch
112+
) -> None:
113+
engine = _engine_for(tmp_path)
114+
115+
with engine.begin() as conn:
116+
conn.execute(text("CREATE TABLE users (id INTEGER, name TEXT)"))
117+
118+
with engine.connect() as conn:
119+
import excel_dbapi
120+
121+
raw_conn = conn.connection.dbapi_connection
122+
excel_dbapi.write_table_metadata(
123+
raw_conn,
124+
"users",
125+
[
126+
{
127+
"name": "old_id",
128+
"type_name": "INTEGER",
129+
"nullable": True,
130+
"primary_key": False,
131+
},
132+
{
133+
"name": "old_name",
134+
"type_name": "TEXT",
135+
"nullable": True,
136+
"primary_key": False,
137+
},
138+
],
139+
)
140+
141+
from sqlalchemy_excel.reflection import ExcelInspectionMixin
142+
143+
monkeypatch.setattr(
144+
ExcelInspectionMixin,
145+
"_worksheet_header_names",
146+
staticmethod(lambda _raw_conn, _table_name: None),
147+
)
148+
149+
inspector = inspect(engine)
150+
columns = inspector.get_columns("users")
151+
assert [column["name"] for column in columns] == ["id", "name"]
152+
153+
with engine.connect() as conn:
154+
import excel_dbapi
155+
156+
raw_conn = conn.connection.dbapi_connection
157+
metadata = excel_dbapi.read_table_metadata(raw_conn, "users")
158+
assert metadata is not None
159+
assert [column["name"] for column in metadata] == ["id", "name"]
160+
161+
engine.dispose()
162+
163+
110164
def test_table_scoped_reflection_methods_raise_for_missing_table(tmp_path) -> None:
111165
engine = _engine_for(tmp_path)
112166
inspector = inspect(engine)
@@ -123,3 +177,20 @@ def test_table_scoped_reflection_methods_raise_for_missing_table(tmp_path) -> No
123177
inspector.get_table_comment("missing")
124178

125179
engine.dispose()
180+
181+
182+
def test_table_scoped_reflection_methods_raise_for_non_default_schema(tmp_path) -> None:
183+
engine = _engine_for(tmp_path)
184+
185+
with engine.begin() as conn:
186+
conn.execute(text("CREATE TABLE users (id INTEGER, name TEXT)"))
187+
188+
inspector = inspect(engine)
189+
with pytest.raises(exc.NoSuchTableError):
190+
inspector.get_columns("users", schema="nonexistent")
191+
with pytest.raises(exc.NoSuchTableError):
192+
inspector.get_pk_constraint("users", schema="nonexistent")
193+
with pytest.raises(exc.NoSuchTableError):
194+
inspector.get_foreign_keys("users", schema="nonexistent")
195+
196+
engine.dispose()

tests/test_upsert.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -160,15 +160,34 @@ def test_expression_set_value(self, populated_engine, items_table):
160160
assert rows == [(1, "Alice", 31, "A")]
161161

162162
def test_set_with_table_columns(self, populated_engine, items_table):
163-
upsert_stmt = insert(items_table).on_conflict_do_update(
164-
index_elements=["id"],
165-
set_=items_table.c,
163+
with populated_engine.begin() as conn:
164+
conn.execute(
165+
insert(items_table).values(id=1, name="Alice", age=30, code="A")
166+
)
167+
168+
upsert_stmt = (
169+
insert(items_table)
170+
.values(id=1, name="Renamed", age=41, code="B")
171+
.on_conflict_do_update(
172+
index_elements=["id"],
173+
set_=items_table.c,
174+
)
166175
)
167176
compiled = " ".join(
168177
str(upsert_stmt.compile(dialect=populated_engine.dialect)).split()
169178
)
170179
assert "items." not in compiled
171-
assert " SET id = id, name = name, age = age, code = code" in compiled
180+
assert (
181+
" SET id = excluded.id, name = excluded.name, age = excluded.age,"
182+
" code = excluded.code"
183+
) in compiled
184+
185+
with populated_engine.begin() as conn:
186+
conn.execute(upsert_stmt)
187+
188+
with populated_engine.connect() as conn:
189+
rows = conn.execute(select(items_table).order_by(items_table.c.id)).all()
190+
assert rows == [(1, "Renamed", 41, "B")]
172191

173192
def test_multi_column_conflict_target(
174193
self, populated_engine, composite_items_table

0 commit comments

Comments
 (0)