Skip to content

Commit 3ada53e

Browse files
committed
fix: NOT IN NULL semantics, lock decode safety, tighten Any types
- NOT IN (val, NULL) now returns UNKNOWN (FALSE in WHERE) per SQL spec - Lock file reader handles corrupted/non-ASCII content gracefully - reflection.py: connection params typed as ExcelConnection (TYPE_CHECKING) - connection.py: credential param uses Credential type alias - Add 4 tests for NOT IN/IN NULL semantics - Add .hypothesis and :memory: to .gitignore
1 parent 27a3fc8 commit 3ada53e

6 files changed

Lines changed: 112 additions & 14 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,7 @@ site/
3939

4040
# OS
4141
.DS_Store
42+
43+
# Test artifacts
44+
.hypothesis/
45+
:memory:

src/excel_dbapi/connection.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
from .engines.result import ExecutionResult
1414
from .exceptions import InterfaceError, NotSupportedError, OperationalError
1515

16+
#: Credential accepted by cloud backends. Concrete forms:
17+
#: ``str`` (static token), ``TokenProvider`` protocol,
18+
#: azure-identity credential (``get_token(scope)``), or zero-arg callable.
19+
Credential = str | Callable[[], str] | object | None
20+
1621
_MUTATING_ACTIONS = frozenset({"INSERT", "CREATE", "DROP", "UPDATE", "DELETE", "ALTER"})
1722

1823
P = ParamSpec("P")
@@ -59,7 +64,7 @@ def __init__(
5964
create: bool = False,
6065
data_only: bool = True,
6166
sanitize_formulas: bool = True,
62-
credential: Any = None,
67+
credential: Credential = None,
6368
**backend_options: Any,
6469
):
6570
"""

src/excel_dbapi/engines/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ def _acquire_lock(self) -> None:
8282

8383
def _clear_stale_lock(self) -> bool:
8484
try:
85-
with open(self._lock_path, "r", encoding="ascii") as handle:
85+
with open(self._lock_path, "r", encoding="ascii", errors="replace") as handle:
8686
raw_pid = handle.read().strip()
87-
except OSError:
87+
except (OSError, UnicodeDecodeError):
8888
return False
8989

9090
try:

src/excel_dbapi/executor.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2955,12 +2955,17 @@ def _resolve_operand(operand: Any, *, as_column: bool) -> Any:
29552955
else:
29562956
candidates = (resolved_candidates,)
29572957

2958+
has_null = False
29582959
for candidate in candidates:
29592960
candidate_value = _resolve_operand(candidate, as_column=False)
2961+
if candidate_value is None:
2962+
has_null = True
2963+
continue
29602964
left, right = self._coerce_for_compare(row_value, candidate_value)
29612965
if left == right:
29622966
return False
2963-
return True
2967+
# SQL: x NOT IN (..., NULL, ...) is UNKNOWN when no match → FALSE in WHERE
2968+
return not has_null
29642969
if operator == "BETWEEN":
29652970
if row_value is None:
29662971
return False

src/excel_dbapi/reflection.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,38 @@
44

55
import datetime
66
from collections import Counter
7-
from typing import Any, cast
7+
from typing import TYPE_CHECKING, Any
88

99
from excel_dbapi.engines.base import TableData
1010

11+
if TYPE_CHECKING:
12+
from excel_dbapi.connection import ExcelConnection
13+
1114
METADATA_SHEET = "__excel_meta__"
1215

1316

14-
def list_tables(connection: Any, include_meta: bool = False) -> list[str]:
17+
def list_tables(connection: ExcelConnection, include_meta: bool = False) -> list[str]:
1518
"""Return worksheet names, excluding metadata sheet by default."""
16-
sheets = cast(list[str], connection.engine.list_sheets())
19+
sheets: list[str] = connection.engine.list_sheets()
1720
if not include_meta:
1821
sheets = [sheet for sheet in sheets if sheet != METADATA_SHEET]
1922
return sheets
2023

2124

22-
def has_table(connection: Any, table_name: str) -> bool:
25+
def has_table(connection: ExcelConnection, table_name: str) -> bool:
2326
"""Check if a worksheet exists (case-insensitive)."""
2427
return _resolve_sheet_name(connection, table_name) is not None
2528

2629

27-
def _resolve_sheet_name(connection: Any, table_name: str) -> str | None:
28-
for sheet_name in cast(list[str], connection.engine.list_sheets()):
30+
def _resolve_sheet_name(connection: ExcelConnection, table_name: str) -> str | None:
31+
for sheet_name in connection.engine.list_sheets():
2932
if sheet_name.lower() == table_name.lower():
3033
return sheet_name
3134
return None
3235

3336

3437
def get_columns(
35-
connection: Any, table_name: str, sample_size: int | None = 100
38+
connection: ExcelConnection, table_name: str, sample_size: int | None = 100
3639
) -> list[dict[str, Any]]:
3740
"""Return column metadata by sampling data rows."""
3841
resolved_table_name = _resolve_sheet_name(connection, table_name)
@@ -98,7 +101,7 @@ def _classify_value_type(value: Any) -> str:
98101

99102

100103
def write_table_metadata(
101-
connection: Any, table_name: str, columns: list[dict[str, Any]]
104+
connection: ExcelConnection, table_name: str, columns: list[dict[str, Any]]
102105
) -> None:
103106
"""Write column metadata to the hidden metadata sheet."""
104107
engine = connection.engine
@@ -150,7 +153,7 @@ def write_table_metadata(
150153

151154

152155
def read_table_metadata(
153-
connection: Any, table_name: str
156+
connection: ExcelConnection, table_name: str
154157
) -> list[dict[str, Any]] | None:
155158
"""Read column metadata from the metadata sheet."""
156159
sheets = connection.engine.list_sheets()
@@ -175,7 +178,7 @@ def read_table_metadata(
175178
]
176179

177180

178-
def remove_table_metadata(connection: Any, table_name: str) -> None:
181+
def remove_table_metadata(connection: ExcelConnection, table_name: str) -> None:
179182
"""Remove metadata for a table from the metadata sheet."""
180183
sheets = connection.engine.list_sheets()
181184
if METADATA_SHEET not in sheets:

tests/test_not_and_parens.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,3 +913,84 @@ def test_join_rejects_subquery_in_precedence_group(self, tmp_path: Path) -> None
913913
)
914914
rows = cur.fetchall()
915915
assert rows == [("Alice",)]
916+
917+
918+
class TestNotInNullSemantics:
919+
"""SQL-standard NULL handling for IN/NOT IN operators.
920+
921+
Per SQL spec: ``x NOT IN (1, NULL)`` yields UNKNOWN when x != 1,
922+
which is treated as FALSE in WHERE clauses.
923+
"""
924+
925+
def test_not_in_with_null_candidate(self, tmp_path: Path) -> None:
926+
"""NOT IN with NULL candidate should return empty when no match."""
927+
f = tmp_path / "test.xlsx"
928+
wb = Workbook()
929+
ws = wb.active
930+
assert ws is not None
931+
ws.title = "data"
932+
ws.append(["id", "val"])
933+
ws.append([1, 10])
934+
ws.append([2, 20])
935+
ws.append([3, 30])
936+
wb.save(f)
937+
938+
with ExcelConnection(str(f), engine="openpyxl") as conn:
939+
cur = conn.cursor()
940+
# val NOT IN (10, NULL): 10 matches → excluded; 20, 30 → UNKNOWN → excluded
941+
cur.execute("SELECT id FROM data WHERE val NOT IN (10, NULL)")
942+
assert cur.fetchall() == []
943+
944+
def test_not_in_without_null(self, tmp_path: Path) -> None:
945+
"""NOT IN without NULL works normally."""
946+
f = tmp_path / "test.xlsx"
947+
wb = Workbook()
948+
ws = wb.active
949+
assert ws is not None
950+
ws.title = "data"
951+
ws.append(["id", "val"])
952+
ws.append([1, 10])
953+
ws.append([2, 20])
954+
ws.append([3, 30])
955+
wb.save(f)
956+
957+
with ExcelConnection(str(f), engine="openpyxl") as conn:
958+
cur = conn.cursor()
959+
cur.execute("SELECT id FROM data WHERE val NOT IN (10, 20)")
960+
assert cur.fetchall() == [(3,)]
961+
962+
def test_not_in_only_null(self, tmp_path: Path) -> None:
963+
"""NOT IN (NULL) → UNKNOWN for all rows → no rows returned."""
964+
f = tmp_path / "test.xlsx"
965+
wb = Workbook()
966+
ws = wb.active
967+
assert ws is not None
968+
ws.title = "data"
969+
ws.append(["id", "val"])
970+
ws.append([1, 10])
971+
ws.append([2, 20])
972+
wb.save(f)
973+
974+
with ExcelConnection(str(f), engine="openpyxl") as conn:
975+
cur = conn.cursor()
976+
cur.execute("SELECT id FROM data WHERE val NOT IN (NULL)")
977+
assert cur.fetchall() == []
978+
979+
def test_in_with_null_candidate(self, tmp_path: Path) -> None:
980+
"""IN with NULL candidate should find matches, skip NULLs."""
981+
f = tmp_path / "test.xlsx"
982+
wb = Workbook()
983+
ws = wb.active
984+
assert ws is not None
985+
ws.title = "data"
986+
ws.append(["id", "val"])
987+
ws.append([1, 10])
988+
ws.append([2, 20])
989+
ws.append([3, 30])
990+
wb.save(f)
991+
992+
with ExcelConnection(str(f), engine="openpyxl") as conn:
993+
cur = conn.cursor()
994+
# val IN (10, NULL): 10 matches → included; 20, 30 don't match → excluded
995+
cur.execute("SELECT id FROM data WHERE val IN (10, NULL)")
996+
assert cur.fetchall() == [(1,)]

0 commit comments

Comments
 (0)