Skip to content

Commit 53306a4

Browse files
authored
feat: enhance database error handling with granular exceptions (#93)
Introduce a comprehensive exception hierarchy for database errors, enabling more precise error handling across multiple database adapters. Implement integration tests to validate the correct mapping of specific database errors to the new exception types. Address known issues in existing tests and ensure compatibility with all database adapters. Resolves #90 (Enhancement: Make exceptions better)
1 parent eaa258d commit 53306a4

File tree

24 files changed

+2641
-397
lines changed

24 files changed

+2641
-397
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ version = "{current_version}"
229229
"""
230230

231231
[tool.codespell]
232-
ignore-words-list = "te,ECT"
232+
ignore-words-list = "te,ECT,SELCT"
233233
skip = 'uv.lock'
234234

235235
[tool.coverage.run]

sqlspec/adapters/adbc/driver.py

Lines changed: 127 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import decimal
1010
from typing import TYPE_CHECKING, Any, Optional, cast
1111

12-
from adbc_driver_manager.dbapi import DatabaseError, IntegrityError, OperationalError, ProgrammingError
1312
from sqlglot import exp
1413

1514
from sqlspec.adapters.adbc.data_dictionary import AdbcDataDictionary
@@ -18,7 +17,19 @@
1817
from sqlspec.core.parameters import ParameterStyle, ParameterStyleConfig
1918
from sqlspec.core.statement import SQL, StatementConfig
2019
from sqlspec.driver import SyncDriverAdapterBase
21-
from sqlspec.exceptions import MissingDependencyError, SQLParsingError, SQLSpecError
20+
from sqlspec.exceptions import (
21+
CheckViolationError,
22+
DatabaseConnectionError,
23+
DataError,
24+
ForeignKeyViolationError,
25+
IntegrityError,
26+
MissingDependencyError,
27+
NotNullViolationError,
28+
SQLParsingError,
29+
SQLSpecError,
30+
TransactionError,
31+
UniqueViolationError,
32+
)
2233
from sqlspec.typing import Empty
2334
from sqlspec.utils.logging import get_logger
2435

@@ -342,48 +353,130 @@ def __exit__(self, *_: Any) -> None:
342353

343354

344355
class AdbcExceptionHandler:
345-
"""Context manager for handling database exceptions."""
356+
"""Context manager for handling ADBC database exceptions.
357+
358+
ADBC propagates underlying database errors. Exception mapping
359+
depends on the specific ADBC driver being used.
360+
"""
346361

347362
__slots__ = ()
348363

349364
def __enter__(self) -> None:
350365
return None
351366

352367
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
368+
_ = exc_tb
353369
if exc_type is None:
354370
return
371+
self._map_adbc_exception(exc_val)
355372

356-
try:
357-
if issubclass(exc_type, IntegrityError):
358-
e = exc_val
359-
msg = f"Integrity constraint violation: {e}"
360-
raise SQLSpecError(msg) from e
361-
if issubclass(exc_type, ProgrammingError):
362-
e = exc_val
363-
error_msg = str(e).lower()
364-
if "syntax" in error_msg or "parse" in error_msg:
365-
msg = f"SQL syntax error: {e}"
366-
raise SQLParsingError(msg) from e
367-
msg = f"Programming error: {e}"
368-
raise SQLSpecError(msg) from e
369-
if issubclass(exc_type, OperationalError):
370-
e = exc_val
371-
msg = f"Operational error: {e}"
372-
raise SQLSpecError(msg) from e
373-
if issubclass(exc_type, DatabaseError):
374-
e = exc_val
375-
msg = f"Database error: {e}"
376-
raise SQLSpecError(msg) from e
377-
except ImportError:
378-
pass
379-
if issubclass(exc_type, Exception):
380-
e = exc_val
381-
error_msg = str(e).lower()
382-
if "parse" in error_msg or "syntax" in error_msg:
383-
msg = f"SQL parsing failed: {e}"
384-
raise SQLParsingError(msg) from e
385-
msg = f"Unexpected database operation error: {e}"
386-
raise SQLSpecError(msg) from e
373+
def _map_adbc_exception(self, e: Any) -> None:
374+
"""Map ADBC exception to SQLSpec exception.
375+
376+
ADBC drivers may expose SQLSTATE codes or driver-specific codes.
377+
378+
Args:
379+
e: ADBC exception instance
380+
"""
381+
sqlstate = getattr(e, "sqlstate", None)
382+
383+
if sqlstate:
384+
self._map_sqlstate_exception(e, sqlstate)
385+
else:
386+
self._map_message_based_exception(e)
387+
388+
def _map_sqlstate_exception(self, e: Any, sqlstate: str) -> None:
389+
"""Map SQLSTATE code to exception.
390+
391+
Args:
392+
e: Exception instance
393+
sqlstate: SQLSTATE error code
394+
"""
395+
if sqlstate == "23505":
396+
self._raise_unique_violation(e)
397+
elif sqlstate == "23503":
398+
self._raise_foreign_key_violation(e)
399+
elif sqlstate == "23502":
400+
self._raise_not_null_violation(e)
401+
elif sqlstate == "23514":
402+
self._raise_check_violation(e)
403+
elif sqlstate.startswith("23"):
404+
self._raise_integrity_error(e)
405+
elif sqlstate.startswith("42"):
406+
self._raise_parsing_error(e)
407+
elif sqlstate.startswith("08"):
408+
self._raise_connection_error(e)
409+
elif sqlstate.startswith("40"):
410+
self._raise_transaction_error(e)
411+
elif sqlstate.startswith("22"):
412+
self._raise_data_error(e)
413+
else:
414+
self._raise_generic_error(e)
415+
416+
def _map_message_based_exception(self, e: Any) -> None:
417+
"""Map exception using message-based detection.
418+
419+
Args:
420+
e: Exception instance
421+
"""
422+
error_msg = str(e).lower()
423+
424+
if "unique" in error_msg or "duplicate" in error_msg:
425+
self._raise_unique_violation(e)
426+
elif "foreign key" in error_msg:
427+
self._raise_foreign_key_violation(e)
428+
elif "not null" in error_msg or "null value" in error_msg:
429+
self._raise_not_null_violation(e)
430+
elif "check constraint" in error_msg:
431+
self._raise_check_violation(e)
432+
elif "constraint" in error_msg:
433+
self._raise_integrity_error(e)
434+
elif "syntax" in error_msg:
435+
self._raise_parsing_error(e)
436+
elif "connection" in error_msg or "connect" in error_msg:
437+
self._raise_connection_error(e)
438+
else:
439+
self._raise_generic_error(e)
440+
441+
def _raise_unique_violation(self, e: Any) -> None:
442+
msg = f"ADBC unique constraint violation: {e}"
443+
raise UniqueViolationError(msg) from e
444+
445+
def _raise_foreign_key_violation(self, e: Any) -> None:
446+
msg = f"ADBC foreign key constraint violation: {e}"
447+
raise ForeignKeyViolationError(msg) from e
448+
449+
def _raise_not_null_violation(self, e: Any) -> None:
450+
msg = f"ADBC not-null constraint violation: {e}"
451+
raise NotNullViolationError(msg) from e
452+
453+
def _raise_check_violation(self, e: Any) -> None:
454+
msg = f"ADBC check constraint violation: {e}"
455+
raise CheckViolationError(msg) from e
456+
457+
def _raise_integrity_error(self, e: Any) -> None:
458+
msg = f"ADBC integrity constraint violation: {e}"
459+
raise IntegrityError(msg) from e
460+
461+
def _raise_parsing_error(self, e: Any) -> None:
462+
msg = f"ADBC SQL parsing error: {e}"
463+
raise SQLParsingError(msg) from e
464+
465+
def _raise_connection_error(self, e: Any) -> None:
466+
msg = f"ADBC connection error: {e}"
467+
raise DatabaseConnectionError(msg) from e
468+
469+
def _raise_transaction_error(self, e: Any) -> None:
470+
msg = f"ADBC transaction error: {e}"
471+
raise TransactionError(msg) from e
472+
473+
def _raise_data_error(self, e: Any) -> None:
474+
msg = f"ADBC data error: {e}"
475+
raise DataError(msg) from e
476+
477+
def _raise_generic_error(self, e: Any) -> None:
478+
msg = f"ADBC database error: {e}"
479+
raise SQLSpecError(msg) from e
387480

388481

389482
class AdbcDriver(SyncDriverAdapterBase):

sqlspec/adapters/aiosqlite/driver.py

Lines changed: 120 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,18 @@
1212
from sqlspec.core.parameters import ParameterStyle, ParameterStyleConfig
1313
from sqlspec.core.statement import StatementConfig
1414
from sqlspec.driver import AsyncDriverAdapterBase
15-
from sqlspec.exceptions import SQLParsingError, SQLSpecError
15+
from sqlspec.exceptions import (
16+
CheckViolationError,
17+
DatabaseConnectionError,
18+
DataError,
19+
ForeignKeyViolationError,
20+
IntegrityError,
21+
NotNullViolationError,
22+
OperationalError,
23+
SQLParsingError,
24+
SQLSpecError,
25+
UniqueViolationError,
26+
)
1627
from sqlspec.utils.serializers import to_json
1728

1829
if TYPE_CHECKING:
@@ -26,6 +37,15 @@
2637

2738
__all__ = ("AiosqliteCursor", "AiosqliteDriver", "AiosqliteExceptionHandler", "aiosqlite_statement_config")
2839

40+
SQLITE_CONSTRAINT_UNIQUE_CODE = 2067
41+
SQLITE_CONSTRAINT_FOREIGNKEY_CODE = 787
42+
SQLITE_CONSTRAINT_NOTNULL_CODE = 1811
43+
SQLITE_CONSTRAINT_CHECK_CODE = 531
44+
SQLITE_CONSTRAINT_CODE = 19
45+
SQLITE_CANTOPEN_CODE = 14
46+
SQLITE_IOERR_CODE = 10
47+
SQLITE_MISMATCH_CODE = 20
48+
2949

3050
aiosqlite_statement_config = StatementConfig(
3151
dialect="sqlite",
@@ -74,7 +94,11 @@ async def __aexit__(self, *_: Any) -> None:
7494

7595

7696
class AiosqliteExceptionHandler:
77-
"""Async context manager for AIOSQLite database exceptions."""
97+
"""Async context manager for handling aiosqlite database exceptions.
98+
99+
Maps SQLite extended result codes to specific SQLSpec exceptions
100+
for better error handling in application code.
101+
"""
78102

79103
__slots__ = ()
80104

@@ -84,38 +108,103 @@ async def __aenter__(self) -> None:
84108
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
85109
if exc_type is None:
86110
return
87-
if issubclass(exc_type, aiosqlite.IntegrityError):
88-
e = exc_val
89-
msg = f"AIOSQLite integrity constraint violation: {e}"
90-
raise SQLSpecError(msg) from e
91-
if issubclass(exc_type, aiosqlite.OperationalError):
92-
e = exc_val
93-
error_msg = str(e).lower()
94-
if "locked" in error_msg:
95-
msg = f"AIOSQLite database locked: {e}. Consider enabling WAL mode or reducing concurrency."
96-
raise SQLSpecError(msg) from e
97-
if "syntax" in error_msg or "malformed" in error_msg:
98-
msg = f"AIOSQLite SQL syntax error: {e}"
99-
raise SQLParsingError(msg) from e
100-
msg = f"AIOSQLite operational error: {e}"
101-
raise SQLSpecError(msg) from e
102-
if issubclass(exc_type, aiosqlite.DatabaseError):
103-
e = exc_val
104-
msg = f"AIOSQLite database error: {e}"
105-
raise SQLSpecError(msg) from e
106111
if issubclass(exc_type, aiosqlite.Error):
107-
e = exc_val
108-
msg = f"AIOSQLite error: {e}"
109-
raise SQLSpecError(msg) from e
110-
if issubclass(exc_type, Exception):
111-
e = exc_val
112-
error_msg = str(e).lower()
113-
if "parse" in error_msg or "syntax" in error_msg:
114-
msg = f"SQL parsing failed: {e}"
115-
raise SQLParsingError(msg) from e
116-
msg = f"Unexpected async database operation error: {e}"
112+
self._map_sqlite_exception(exc_val)
113+
114+
def _map_sqlite_exception(self, e: Any) -> None:
115+
"""Map SQLite exception to SQLSpec exception.
116+
117+
Args:
118+
e: aiosqlite.Error instance
119+
120+
Raises:
121+
Specific SQLSpec exception based on error code
122+
"""
123+
error_code = getattr(e, "sqlite_errorcode", None)
124+
error_name = getattr(e, "sqlite_errorname", None)
125+
error_msg = str(e).lower()
126+
127+
if "locked" in error_msg:
128+
msg = f"AIOSQLite database locked: {e}. Consider enabling WAL mode or reducing concurrency."
117129
raise SQLSpecError(msg) from e
118130

131+
if not error_code:
132+
if "unique constraint" in error_msg:
133+
self._raise_unique_violation(e, 0)
134+
elif "foreign key constraint" in error_msg:
135+
self._raise_foreign_key_violation(e, 0)
136+
elif "not null constraint" in error_msg:
137+
self._raise_not_null_violation(e, 0)
138+
elif "check constraint" in error_msg:
139+
self._raise_check_violation(e, 0)
140+
elif "syntax" in error_msg:
141+
self._raise_parsing_error(e, None)
142+
else:
143+
self._raise_generic_error(e)
144+
return
145+
146+
if error_code == SQLITE_CONSTRAINT_UNIQUE_CODE or error_name == "SQLITE_CONSTRAINT_UNIQUE":
147+
self._raise_unique_violation(e, error_code)
148+
elif error_code == SQLITE_CONSTRAINT_FOREIGNKEY_CODE or error_name == "SQLITE_CONSTRAINT_FOREIGNKEY":
149+
self._raise_foreign_key_violation(e, error_code)
150+
elif error_code == SQLITE_CONSTRAINT_NOTNULL_CODE or error_name == "SQLITE_CONSTRAINT_NOTNULL":
151+
self._raise_not_null_violation(e, error_code)
152+
elif error_code == SQLITE_CONSTRAINT_CHECK_CODE or error_name == "SQLITE_CONSTRAINT_CHECK":
153+
self._raise_check_violation(e, error_code)
154+
elif error_code == SQLITE_CONSTRAINT_CODE or error_name == "SQLITE_CONSTRAINT":
155+
self._raise_integrity_error(e, error_code)
156+
elif error_code == SQLITE_CANTOPEN_CODE or error_name == "SQLITE_CANTOPEN":
157+
self._raise_connection_error(e, error_code)
158+
elif error_code == SQLITE_IOERR_CODE or error_name == "SQLITE_IOERR":
159+
self._raise_operational_error(e, error_code)
160+
elif error_code == SQLITE_MISMATCH_CODE or error_name == "SQLITE_MISMATCH":
161+
self._raise_data_error(e, error_code)
162+
elif error_code == 1 or "syntax" in error_msg:
163+
self._raise_parsing_error(e, error_code)
164+
else:
165+
self._raise_generic_error(e)
166+
167+
def _raise_unique_violation(self, e: Any, code: int) -> None:
168+
msg = f"SQLite unique constraint violation [code {code}]: {e}"
169+
raise UniqueViolationError(msg) from e
170+
171+
def _raise_foreign_key_violation(self, e: Any, code: int) -> None:
172+
msg = f"SQLite foreign key constraint violation [code {code}]: {e}"
173+
raise ForeignKeyViolationError(msg) from e
174+
175+
def _raise_not_null_violation(self, e: Any, code: int) -> None:
176+
msg = f"SQLite not-null constraint violation [code {code}]: {e}"
177+
raise NotNullViolationError(msg) from e
178+
179+
def _raise_check_violation(self, e: Any, code: int) -> None:
180+
msg = f"SQLite check constraint violation [code {code}]: {e}"
181+
raise CheckViolationError(msg) from e
182+
183+
def _raise_integrity_error(self, e: Any, code: int) -> None:
184+
msg = f"SQLite integrity constraint violation [code {code}]: {e}"
185+
raise IntegrityError(msg) from e
186+
187+
def _raise_parsing_error(self, e: Any, code: "Optional[int]") -> None:
188+
code_str = f"[code {code}]" if code else ""
189+
msg = f"SQLite SQL syntax error {code_str}: {e}"
190+
raise SQLParsingError(msg) from e
191+
192+
def _raise_connection_error(self, e: Any, code: int) -> None:
193+
msg = f"SQLite connection error [code {code}]: {e}"
194+
raise DatabaseConnectionError(msg) from e
195+
196+
def _raise_operational_error(self, e: Any, code: int) -> None:
197+
msg = f"SQLite operational error [code {code}]: {e}"
198+
raise OperationalError(msg) from e
199+
200+
def _raise_data_error(self, e: Any, code: int) -> None:
201+
msg = f"SQLite data error [code {code}]: {e}"
202+
raise DataError(msg) from e
203+
204+
def _raise_generic_error(self, e: Any) -> None:
205+
msg = f"SQLite database error: {e}"
206+
raise SQLSpecError(msg) from e
207+
119208

120209
class AiosqliteDriver(AsyncDriverAdapterBase):
121210
"""AIOSQLite driver for async SQLite database operations."""

0 commit comments

Comments
 (0)