Skip to content

Commit 2352df3

Browse files
committed
fix: close oracle round 16 gaps
- Wrap raw non-DB-API exceptions (TypeError, ImportError) during backend construction as OperationalError - Translate Graph token-provider failures at execution time to InterfaceError - Replace .lower() with .casefold() for Unicode-safe identifier comparison in executor, parser, and base engine - Reset lastrowid to None at start of execute() to prevent stale values
1 parent fafae84 commit 2352df3

7 files changed

Lines changed: 105 additions & 15 deletions

File tree

src/excel_dbapi/connection.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from .engines.result import ExecutionResult
2424
from .exceptions import (
2525
DatabaseError,
26+
Error,
2627
InterfaceError,
2728
NotSupportedError,
2829
OperationalError,
@@ -173,8 +174,9 @@ def __init__(
173174
and isinstance(exc, InvalidFileException)
174175
):
175176
raise OperationalError(str(exc)) from exc
176-
# Re-raise other unexpected exceptions
177-
raise
177+
if isinstance(exc, Error):
178+
raise
179+
raise OperationalError(str(exc)) from exc
178180

179181
# Guard: non-transactional backends reject autocommit=False
180182
if not autocommit and not getattr(self.engine, "supports_transactions", True):

src/excel_dbapi/cursor.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def execute(self, query: str, params: Sequence[Any] | None = None) -> "ExcelCurs
7373
self.description = None
7474
self.rowcount = -1
7575
self._has_result_set = False
76+
self.lastrowid = None
7677
try:
7778
result: ExecutionResult = self.connection.execute(query, params)
7879
except ValueError as exc:

src/excel_dbapi/engines/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ def _normalize_headers(raw: list[Any]) -> list[str]:
261261
seen: set[str] = set()
262262
lower_map: dict[str, str] = {}
263263
for h in headers:
264-
key = h.lower()
264+
key = h.casefold()
265265
if key in seen:
266266
raise DataError(
267267
f"Duplicate header: {h!r} (conflicts with {lower_map[key]!r})"

src/excel_dbapi/engines/graph/client.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import httpx
99

10-
from ...exceptions import InterfaceError, OperationalError
10+
from ...exceptions import Error, InterfaceError, OperationalError
1111
from .auth import TokenProvider
1212

1313
_BASE_URL = "https://graph.microsoft.com/v1.0"
@@ -92,8 +92,17 @@ def close(self) -> None:
9292
# -- internals -----------------------------------------------------------
9393

9494
def _build_headers(self) -> dict[str, str]:
95+
try:
96+
token = self._token_provider.get_token()
97+
except Exception as exc:
98+
if isinstance(exc, Error):
99+
raise
100+
raise InterfaceError(
101+
f"Failed to acquire authentication token: {exc}"
102+
) from exc
103+
95104
headers: dict[str, str] = {
96-
"Authorization": f"Bearer {self._token_provider.get_token()}",
105+
"Authorization": f"Bearer {token}",
97106
"Content-Type": "application/json",
98107
}
99108
if self._session_id is not None:

src/excel_dbapi/executor.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -825,7 +825,7 @@ def _resolve_upsert_column(column_name: str) -> Any:
825825
columns = parsed["columns"]
826826
seen: set[str] = set()
827827
for col in columns:
828-
lower = col.lower()
828+
lower = col.casefold()
829829
if lower in seen:
830830
raise ValueError(f"Duplicate column name '{col}' in CREATE TABLE")
831831
seen.add(lower)
@@ -888,8 +888,8 @@ def _resolve_upsert_column(column_name: str) -> Any:
888888

889889
if operation == "ADD_COLUMN":
890890
col = parsed["column"]
891-
if col in data.headers or col.lower() in {
892-
h.lower() for h in data.headers
891+
if col in data.headers or col.casefold() in {
892+
h.casefold() for h in data.headers
893893
}:
894894
raise ValueError(f"Column '{col}' already exists in '{table}'")
895895
data.headers.append(col)
@@ -3733,13 +3733,13 @@ def _to_number(self, value: Any) -> float | None:
37333733

37343734
def _resolve_sheet_name(self, requested_name: str) -> str | None:
37353735
sheets = self.backend.list_sheets()
3736-
lowered = {name.lower(): name for name in sheets}
3737-
return lowered.get(requested_name.lower())
3736+
lowered = {name.casefold(): name for name in sheets}
3737+
return lowered.get(requested_name.casefold())
37383738

37393739
def _resolve_cte_name(self, requested_name: str) -> str | None:
3740-
lowered = requested_name.lower()
3740+
lowered = requested_name.casefold()
37413741
for name in self._cte_tables:
3742-
if name.lower() == lowered:
3742+
if name.casefold() == lowered:
37433743
return name
37443744
return None
37453745

src/excel_dbapi/parser.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5059,7 +5059,7 @@ def _parse_compound(
50595059

50605060

50615061
def _query_references_name(query: Dict[str, Any], name: str) -> bool:
5062-
lowered = name.lower()
5062+
lowered = name.casefold()
50635063
action = str(query.get("action", "")).upper()
50645064
if action == "COMPOUND":
50655065
for branch in query.get("queries", []):
@@ -5071,7 +5071,7 @@ def _query_references_name(query: Dict[str, Any], name: str) -> bool:
50715071
return False
50725072

50735073
for source in _query_source_references(query):
5074-
if source.lower() == lowered:
5074+
if source.casefold() == lowered:
50755075
return True
50765076
return False
50775077

@@ -5100,7 +5100,7 @@ def _parse_with_query(
51005100
cte_name = tokens[index]
51015101
if not re.fullmatch(_IDENTIFIER_PATTERN, cte_name):
51025102
raise ValueError(f"Invalid CTE name: {cte_name}")
5103-
lowered_name = cte_name.lower()
5103+
lowered_name = cte_name.casefold()
51045104
if lowered_name in cte_names:
51055105
raise ValueError(f"Duplicate CTE name: {cte_name}")
51065106
cte_names.add(lowered_name)

tests/test_oracle_round16_fixes.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from pathlib import Path
2+
from typing import Any, cast
3+
4+
import httpx
5+
import pytest
6+
from openpyxl import Workbook
7+
8+
from excel_dbapi import connect
9+
from excel_dbapi.connection import ExcelConnection
10+
from excel_dbapi.exceptions import Error, OperationalError, ProgrammingError
11+
12+
13+
def _create_workbook(
14+
path: Path, sheet: str, headers: list[object], rows: list[list[object]]
15+
) -> None:
16+
workbook = Workbook()
17+
worksheet = workbook.active
18+
assert worksheet is not None
19+
worksheet.title = sheet
20+
worksheet.append(headers)
21+
for row in rows:
22+
worksheet.append(row)
23+
workbook.save(path)
24+
workbook.close()
25+
26+
27+
def test_graph_invalid_credential_is_wrapped_as_operational_error() -> None:
28+
with pytest.raises(OperationalError, match="Cannot normalise"):
29+
connect(
30+
"msgraph://drives/d/items/i",
31+
engine="graph",
32+
credential=cast(Any, 42),
33+
)
34+
35+
36+
def test_graph_token_provider_failure_is_translated_during_execute() -> None:
37+
class ExplodingTokenProvider:
38+
def get_token(self, *args: Any) -> Any:
39+
del args
40+
raise RuntimeError("token boom")
41+
42+
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={}))
43+
with ExcelConnection(
44+
"msgraph://drives/drv-test/items/itm-test",
45+
engine="graph",
46+
credential=ExplodingTokenProvider(),
47+
transport=transport,
48+
) as conn:
49+
cursor = conn.cursor()
50+
with pytest.raises(
51+
Error, match="Failed to acquire authentication token: token boom"
52+
):
53+
cursor.execute("SELECT * FROM Employees")
54+
55+
56+
def test_executor_resolves_unicode_sheet_names_with_casefold(tmp_path: Path) -> None:
57+
file_path = tmp_path / "unicode-sheet-casefold.xlsx"
58+
_create_workbook(file_path, "Straße", ["id"], [[1]])
59+
60+
with ExcelConnection(str(file_path), engine="openpyxl") as conn:
61+
cursor = conn.cursor()
62+
cursor.execute("SELECT * FROM STRASSE")
63+
assert cursor.fetchall() == [(1,)]
64+
65+
66+
def test_lastrowid_is_cleared_after_failed_execute(tmp_path: Path) -> None:
67+
file_path = tmp_path / "lastrowid-reset.xlsx"
68+
_create_workbook(file_path, "Sheet1", ["id", "name"], [[1, "Alice"]])
69+
70+
with ExcelConnection(str(file_path), engine="openpyxl") as conn:
71+
cursor = conn.cursor()
72+
cursor.execute("INSERT INTO Sheet1 VALUES (2, 'Bob')")
73+
assert cursor.lastrowid is not None
74+
75+
with pytest.raises(ProgrammingError):
76+
cursor.execute("SELECT * FROM MissingSheet")
77+
78+
assert cursor.lastrowid is None

0 commit comments

Comments
 (0)