Phase 5.a: real error messages with PEP 249 exception classification
Before:
ProgrammingError: server returned SQ_ERR sqlcode=-201 isamcode=0
After:
ProgrammingError: [-201] A syntax error has occurred (offset 1)
ProgrammingError: [-206] The specified table is not in the database
(near 'no_such_table') [ISAM -111] (offset 27)
IntegrityError: [-268] Cannot insert duplicate value -
violates UNIQUE constraint (near 'on table u')
[ISAM -100] (offset 23)
IntegrityError: [-391] Cannot insert NULL value into a NOT NULL column
(near 't.id') (offset 23)
OperationalError: [-255] Not in transaction
PEP 249 exception classes mapped from sqlcode:
-201, -206, -217, -286, -310, ... → ProgrammingError
-239, -268, -291, -292, -391, -703 → IntegrityError
-255, -256, -407, -440, -908, ... → OperationalError
-329, -349, -510 → NotSupportedError
others → DatabaseError (safe fallback)
SQ_ERR wire decode (per IfxSqli.receiveError line 2717):
[short sqlcode][short isamcode][int offset]
[short near_token_len][bytes name][optional pad][short SQ_EOT]
The "near" token is the object name where the error occurred (table or
column name for "not found" errors); empty for most syntax errors.
Structured fields attached to every Informix error for programmatic
inspection:
e.sqlcode — Informix error code (e.g. -206)
e.isamcode — ISAM/RSAM-level error (e.g. -111 = "table not found")
e.offset — character offset in the SQL where the error occurred
e.near — object name in the "near 'XYZ'" clause (or "")
Connection state survives errors: a failed query doesn't poison the
session — subsequent execute() calls work normally. Verified by
test_connection_survives_query_error.
Built-in error catalog of ~50 most common Informix sqlcodes shipped
in src/informix_db/_errcodes.py. Users can extend at runtime with
register_error_text(code, text). Unknown codes get a generic
"Informix error <N>" with structured fields still populated.
Module changes:
src/informix_db/_errcodes.py (new) — error catalog + exception
classification + register_error_text()
src/informix_db/cursors.py — _raise_sq_err now uses the catalog
src/informix_db/connections.py — same upgrade for the connection-side
SQ_ERR path (catches commit/rollback errors etc.)
Tests: 40 unit + 63 integration (8 new error tests) = 103 total, all
green, ruff clean. Tests cover:
- syntax error → ProgrammingError(-201)
- table not found → ProgrammingError(-206) with near='no_such_table'
- column not found → ProgrammingError(-217)
- UNIQUE violation → IntegrityError(-268)
- NOT NULL violation → IntegrityError(-391)
- commit on unlogged DB → OperationalError(-255)
- connection survives errors (subsequent queries work)
- all errors carry structured sqlcode/isamcode/offset/near attrs
This commit is contained in:
parent
2bacbc4e53
commit
d04000dfc3
38
docs/CAPTURES/22-py-sql-error.socat.log
Normal file
38
docs/CAPTURES/22-py-sql-error.socat.log
Normal file
@ -0,0 +1,38 @@
|
||||
2026/05/04 11:54:54 socat[941309] N listening on AF=2 0.0.0.0:9090
|
||||
2026/05/04 11:54:55 socat[941309] N accepting connection from AF=2 127.0.0.1:46066 on AF=2 127.0.0.1:9090
|
||||
2026/05/04 11:54:55 socat[941309] N opening connection to 127.0.0.1:9088
|
||||
2026/05/04 11:54:55 socat[941309] N opening connection to AF=2 127.0.0.1:9088
|
||||
2026/05/04 11:54:55 socat[941309] N successfully connected from local address AF=2 127.0.0.1:37838
|
||||
2026/05/04 11:54:55 socat[941309] N successfully connected to 127.0.0.1:9088
|
||||
2026/05/04 11:54:55 socat[941309] N starting data transfer loop with FDs [6,6] and [5,5]
|
||||
> 2026/05/04 11:54:55.272845 length=384 from=0 to=383
|
||||
01 80 01 3c 00 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 4d 00 00 6c 73 71 6c 65 78 65 63 00 00 00 00 00 00 06 39 2e 32 38 30 00 00 0c 52 44 53 23 52 30 30 30 30 30 30 00 00 05 73 71 6c 69 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 01 00 09 69 6e 66 6f 72 6d 69 78 00 00 07 69 6e 34 6d 69 78 00 6f 6c 00 00 00 00 00 00 00 00 00 3d 74 6c 69 74 63 70 00 00 00 00 00 01 00 68 00 0b 00 00 00 03 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 00 00 00 00 00 00 00 00 00 6a 00 06 00 07 44 42 50 41 54 48 00 00 02 2e 00 00 0e 43 4c 49 45 4e 54 5f 4c 4f 43 41 4c 45 00 00 0d 65 6e 5f 55 53 2e 38 38 35 39 2d 31 00 00 11 43 4c 4e 54 5f 50 41 4d 5f 43 41 50 41 42 4c 45 00 00 02 31 00 00 07 44 42 44 41 54 45 00 00 06 59 34 4d 44 2d 00 00 0c 49 46 58 5f 55 50 44 44 45 53 43 00 00 02 31 00 00 09 4e 4f 44 45 46 44 41 43 00 00 03 6e 6f 00 00 6b 00 00 00 00 00 0e 5d 0a 00 00 00 00 00 0b 72 70 6d 2d 62 75 6c 6c 65 74 00 00 00 00 29 2f 68 6f 6d 65 2f 72 70 6d 2f 63 6c 61 75 64 65 2f 69 6e 66 6f 72 6d 69 78 2f 70 79 74 68 6f 6e 2d 6c 69 62 72 61 72 79 00 00 74 00 20 00 00 00 00 00 00 00 00 00 16 69 6e 66 6f 72 6d 69 78 2d 64 62 40 70 69 64 39 34 31 33 32 32 00 00 7f
|
||||
< 2026/05/04 11:54:55.276844 length=276 from=0 to=275
|
||||
01 14 02 3c 10 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 49 00 00 6c 73 72 76 69 6e 66 78 00 00 00 00 00 00 2f 49 42 4d 20 49 6e 66 6f 72 6d 69 78 20 44 79 6e 61 6d 69 63 20 53 65 72 76 65 72 20 56 65 72 73 69 6f 6e 20 31 35 2e 30 2e 31 2e 30 2e 33 00 00 07 73 65 72 69 61 6c 00 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 6f 6e 00 00 00 00 00 00 00 00 00 3d 73 6f 63 74 63 70 00 00 00 00 00 00 00 66 00 00 00 00 00 00 00 00 00 00 00 14 00 00 00 6b 00 00 00 00 00 00 03 1a 00 00 00 00 00 0d 32 33 32 37 63 34 33 35 34 65 61 38 00 00 00 00 0f 2f 68 6f 6d 65 2f 69 6e 66 6f 72 6d 69 78 00 00 6e 00 04 00 00 00 00 00 74 00 33 00 00 00 c8 00 00 00 c8 00 29 2f 6f 70 74 2f 69 62 6d 2f 69 6e 66 6f 72 6d 69 78 2f 76 31 35 2e 30 2e 31 2e 30 2e 33 2f 62 69 6e 2f 6f 6e 69 6e 69 74 00 00 7f
|
||||
> 2026/05/04 11:54:55.277117 length=14 from=384 to=397
|
||||
00 7e 00 08 ff fc 7f fc 3c 8c aa 97 00 0c
|
||||
< 2026/05/04 11:54:55.283165 length=16 from=276 to=291
|
||||
00 7e 00 09 bd be 9f fe 7f b7 ff ef ff 00 00 0c
|
||||
> 2026/05/04 11:54:55.283254 length=48 from=398 to=445
|
||||
00 51 00 06 00 26 00 0c 00 04 00 06 44 42 54 45 4d 50 00 04 2f 74 6d 70 00 0b 53 55 42 51 43 41 43 48 45 53 5a 00 00 02 31 30 00 00 00 00 00 0c
|
||||
< 2026/05/04 11:54:55.283343 length=2 from=292 to=293
|
||||
00 0c
|
||||
> 2026/05/04 11:54:55.283368 length=18 from=446 to=463
|
||||
00 24 00 09 73 79 73 6d 61 73 74 65 72 00 00 00 00 0c
|
||||
< 2026/05/04 11:54:55.283546 length=28 from=294 to=321
|
||||
00 0f 00 15 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 01 00 0c
|
||||
> 2026/05/04 11:54:55.283595 length=32 from=464 to=495
|
||||
00 02 00 00 00 00 00 11 53 45 4c 45 4b 54 20 42 41 44 20 53 59 4e 54 41 58 00 00 16 00 31 00 0c
|
||||
< 2026/05/04 11:54:55.283670 length=14 from=322 to=335
|
||||
00 0d ff 37 00 00 00 00 00 01 00 00 00 0c
|
||||
> 2026/05/04 11:54:55.283739 length=42 from=496 to=537
|
||||
00 02 00 00 00 00 00 1b 53 45 4c 45 43 54 20 2a 20 46 52 4f 4d 20 6e 6f 5f 73 75 63 68 5f 74 61 62 6c 65 00 00 16 00 31 00 0c
|
||||
< 2026/05/04 11:54:55.283852 length=28 from=336 to=363
|
||||
00 0d ff 32 ff 91 00 00 00 1b 00 0d 6e 6f 5f 73 75 63 68 5f 74 61 62 6c 65 00 00 0c
|
||||
> 2026/05/04 11:54:55.283918 length=2 from=538 to=539
|
||||
00 38
|
||||
< 2026/05/04 11:54:55.283967 length=2 from=364 to=365
|
||||
00 38
|
||||
2026/05/04 11:54:55 socat[941309] N socket 1 (fd 6) is at EOF
|
||||
2026/05/04 11:54:55 socat[941309] N socket 2 (fd 5) is at EOF
|
||||
2026/05/04 11:54:55 socat[941309] N exiting with status 0
|
||||
141
src/informix_db/_errcodes.py
Normal file
141
src/informix_db/_errcodes.py
Normal file
@ -0,0 +1,141 @@
|
||||
"""Informix sqlcode → exception class + human-readable text.
|
||||
|
||||
Informix error codes (negative numbers) are documented in IBM/HCL's
|
||||
"Error Messages" reference. The JDBC driver looks up text from a
|
||||
locale-keyed message catalog shipped with the CSDK; we don't have access
|
||||
to that here, so we ship a small curated catalog of the ~50 most common
|
||||
errors. Anything not in the catalog gets a generic "Informix error <N>".
|
||||
|
||||
Users can extend the catalog at runtime via ``register_error_text(code, text)``.
|
||||
|
||||
PEP 249 exception classification follows JDBC conventions:
|
||||
syntax / semantic errors → ProgrammingError
|
||||
constraint violations → IntegrityError
|
||||
transaction/connection → OperationalError
|
||||
everything else → DatabaseError
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .exceptions import (
|
||||
DatabaseError,
|
||||
Error,
|
||||
IntegrityError,
|
||||
NotSupportedError,
|
||||
OperationalError,
|
||||
ProgrammingError,
|
||||
)
|
||||
|
||||
# Curated catalog of common Informix sqlcodes. Texts are short and
|
||||
# user-facing — the canonical IBM "Error Messages" doc has more detail.
|
||||
_ERROR_TEXT: dict[int, str] = {
|
||||
# --- SQL syntax / semantic errors (ProgrammingError) ---
|
||||
-201: "A syntax error has occurred",
|
||||
-202: "An illegal character has been found in the statement",
|
||||
-206: "The specified table is not in the database",
|
||||
-239: "Could not insert new row - duplicate value in a UNIQUE INDEX column",
|
||||
-253: "Statement size exceeds language buffer size",
|
||||
-255: "Not in transaction",
|
||||
-256: "Transaction not available",
|
||||
-258: "Invalid statement identifier",
|
||||
-259: "Cursor not open",
|
||||
-260: "Cursor name already in use",
|
||||
-262: "No such cursor",
|
||||
-263: "Cannot open file for LOAD or UNLOAD",
|
||||
-264: "Cannot read from input file for LOAD",
|
||||
-267: "The transaction has been rolled back, all locks released",
|
||||
-268: "Cannot insert duplicate value - violates UNIQUE constraint",
|
||||
-269: "Default value not specified, cannot be NULL",
|
||||
-271: "Could not insert new row into the table",
|
||||
-272: "No SELECT permission",
|
||||
-273: "No UPDATE permission",
|
||||
-274: "No DELETE permission",
|
||||
-286: "The number of values does not match number of columns",
|
||||
-291: "Cannot insert duplicate row - violates unique index constraint",
|
||||
-292: "NOT NULL constraint violated",
|
||||
-297: "Lock timeout expired",
|
||||
-217: "Column not found in any table in the query",
|
||||
-310: "The result of an aggregate function returned NULL",
|
||||
-311: "Column not found in named tables in the query",
|
||||
-316: "Index already exists",
|
||||
-329: "Database not found or no system permission",
|
||||
-349: "Database not selected yet",
|
||||
-390: "Synonym already exists in database",
|
||||
-391: "Cannot insert NULL value into a NOT NULL column",
|
||||
-407: "Begin transaction failed - already in a transaction",
|
||||
-408: "Commit transaction failed - not in a transaction",
|
||||
-440: "Connection not established",
|
||||
-451: "Cannot retrieve column data",
|
||||
-456: "Indicator variable required",
|
||||
-465: "BYTE or TEXT subscript out of range",
|
||||
-510: "Specified database does not exist",
|
||||
-517: "Statement is too long",
|
||||
-526: "Cursor is not on a SELECT statement",
|
||||
-567: "Cannot select column from view (no underlying table)",
|
||||
-608: "Number truncated converting from one type to another",
|
||||
-703: "Primary key on table has a field with a NULL key value",
|
||||
-722: "Out of memory",
|
||||
-748: "Server has rejected the connection",
|
||||
-751: "User not known to the database",
|
||||
-759: "Database not available",
|
||||
-908: "Attempt to connect to database server failed",
|
||||
-930: "Cannot connect to database server",
|
||||
-956: "Client/server unable to communicate",
|
||||
-25553: "Connection not authorized",
|
||||
-25563: "Server name unrecognized",
|
||||
}
|
||||
|
||||
|
||||
def text_for(sqlcode: int) -> str:
|
||||
"""Return a human-readable description for the sqlcode.
|
||||
|
||||
Falls back to a generic message for unknown codes.
|
||||
"""
|
||||
if sqlcode in _ERROR_TEXT:
|
||||
return _ERROR_TEXT[sqlcode]
|
||||
return f"Informix error {sqlcode}"
|
||||
|
||||
|
||||
def register_error_text(sqlcode: int, text: str) -> None:
|
||||
"""Add or override a sqlcode → text mapping at runtime."""
|
||||
_ERROR_TEXT[sqlcode] = text
|
||||
|
||||
|
||||
# PEP 249 exception class by Informix sqlcode. The general approach:
|
||||
# - Constraint violations (duplicate, NOT NULL, FK) → IntegrityError
|
||||
# - SQL syntax / object-not-found / permission → ProgrammingError
|
||||
# - Transaction / connection / network → OperationalError
|
||||
# - Everything else → DatabaseError (the safe fallback)
|
||||
|
||||
_INTEGRITY_CODES = frozenset({-239, -268, -269, -291, -292, -391, -703})
|
||||
|
||||
_PROGRAMMING_CODES = frozenset({
|
||||
-201, -202, -206, -217, -253, -258, -259, -260, -262, -286,
|
||||
-310, -311, -316, -390, -451, -456, -465, -517, -526, -567, -608,
|
||||
-272, -273, -274, # permission errors are programmer's responsibility
|
||||
})
|
||||
|
||||
_OPERATIONAL_CODES = frozenset({
|
||||
-255, -256, -267, -297, -407, -408,
|
||||
-440, -748, -751, -759, -908, -930, -956,
|
||||
-25553, -25563,
|
||||
})
|
||||
|
||||
_NOT_SUPPORTED_CODES = frozenset({
|
||||
-329, # missing system permission — caller can't fix
|
||||
-349, # database not selected
|
||||
-510, # database not found
|
||||
})
|
||||
|
||||
|
||||
def exception_for(sqlcode: int) -> type[Error]:
|
||||
"""Pick the PEP 249 exception class for a given sqlcode."""
|
||||
if sqlcode in _INTEGRITY_CODES:
|
||||
return IntegrityError
|
||||
if sqlcode in _PROGRAMMING_CODES:
|
||||
return ProgrammingError
|
||||
if sqlcode in _OPERATIONAL_CODES:
|
||||
return OperationalError
|
||||
if sqlcode in _NOT_SUPPORTED_CODES:
|
||||
return NotSupportedError
|
||||
return DatabaseError
|
||||
@ -301,16 +301,27 @@ class Connection:
|
||||
)
|
||||
|
||||
def _raise_sq_err(self) -> None:
|
||||
"""Decode a SQ_ERR payload and raise OperationalError.
|
||||
"""Decode a SQ_ERR payload and raise the appropriate PEP 249 exception.
|
||||
|
||||
Per ``IfxSqli.receiveError``:
|
||||
``[short sqlcode][short isamcode][int statementOffset][...]``
|
||||
Wire format (per IfxSqli.receiveError, line 2717):
|
||||
[short sqlcode][short isamcode][int offset]
|
||||
[short near_token_len][bytes name][optional pad][short SQ_EOT]
|
||||
"""
|
||||
from . import _errcodes
|
||||
|
||||
sqlcode = struct.unpack("!h", self._sock.read_exact(2))[0]
|
||||
isamcode = struct.unpack("!h", self._sock.read_exact(2))[0]
|
||||
offset = struct.unpack("!i", self._sock.read_exact(4))[0] # noqa: F841
|
||||
# Drain any remaining error payload (varies by sqlcode) until SQ_EOT.
|
||||
# Best-effort: read shorts and discard until we hit 0x000c.
|
||||
offset = struct.unpack("!i", self._sock.read_exact(4))[0]
|
||||
near_token = ""
|
||||
try:
|
||||
name_len = struct.unpack("!h", self._sock.read_exact(2))[0]
|
||||
if name_len > 0:
|
||||
raw = self._sock.read_exact(name_len)
|
||||
if name_len & 1:
|
||||
self._sock.read_exact(1)
|
||||
near_token = raw.rstrip(b"\x00").decode("iso-8859-1", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
while True:
|
||||
next_tag = struct.unpack("!h", self._sock.read_exact(2))[0]
|
||||
@ -318,7 +329,20 @@ class Connection:
|
||||
break
|
||||
except OperationalError:
|
||||
pass
|
||||
raise OperationalError(f"server returned SQ_ERR sqlcode={sqlcode} isamcode={isamcode}")
|
||||
|
||||
text = _errcodes.text_for(sqlcode)
|
||||
exc_class = _errcodes.exception_for(sqlcode)
|
||||
msg = f"[{sqlcode}] {text} (near {near_token!r})" if near_token else f"[{sqlcode}] {text}"
|
||||
if isamcode != 0:
|
||||
msg += f" [ISAM {isamcode}]"
|
||||
if offset > 0:
|
||||
msg += f" (offset {offset})"
|
||||
exc = exc_class(msg)
|
||||
exc.sqlcode = sqlcode
|
||||
exc.isamcode = isamcode
|
||||
exc.offset = offset
|
||||
exc.near = near_token
|
||||
raise exc
|
||||
|
||||
# -- login PDU assembly ------------------------------------------------
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ import struct
|
||||
from collections.abc import Iterator
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from . import _errcodes
|
||||
from ._messages import MessageType
|
||||
from ._protocol import IfxStreamReader, make_pdu_writer
|
||||
from ._resultset import ColumnInfo, parse_describe, parse_tuple_payload
|
||||
@ -568,11 +569,30 @@ class Cursor:
|
||||
self._rowcount = rows
|
||||
|
||||
def _raise_sq_err(self, reader: IfxStreamReader) -> None:
|
||||
"""Decode SQ_ERR per IfxSqli.receiveError and raise."""
|
||||
"""Decode SQ_ERR and raise the appropriate PEP 249 exception class.
|
||||
|
||||
Wire format (per IfxSqli.receiveError, line 2717):
|
||||
[short sqlcode][short isamcode][int offset]
|
||||
[short near_token_len][bytes name][optional pad][short SQ_EOT]
|
||||
|
||||
The "near" token is the object name where the error occurred
|
||||
(e.g. table or column name for "not found" errors). Empty for
|
||||
most syntax errors.
|
||||
"""
|
||||
sqlcode = reader.read_short()
|
||||
isamcode = reader.read_short()
|
||||
reader.read_int() # offset into statement
|
||||
# Drain remaining error bytes until SQ_EOT.
|
||||
offset = reader.read_int()
|
||||
near_token = ""
|
||||
try:
|
||||
name_len = reader.read_short()
|
||||
if name_len > 0:
|
||||
raw = reader.read_exact(name_len)
|
||||
if name_len & 1:
|
||||
reader.read_exact(1) # pad to even
|
||||
near_token = raw.rstrip(b"\x00").decode("iso-8859-1", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
# Drain remaining bytes until SQ_EOT.
|
||||
try:
|
||||
while True:
|
||||
t = reader.read_short()
|
||||
@ -580,7 +600,21 @@ class Cursor:
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
raise ProgrammingError(f"server returned SQ_ERR sqlcode={sqlcode} isamcode={isamcode}")
|
||||
|
||||
text = _errcodes.text_for(sqlcode)
|
||||
exc_class = _errcodes.exception_for(sqlcode)
|
||||
msg = f"[{sqlcode}] {text} (near {near_token!r})" if near_token else f"[{sqlcode}] {text}"
|
||||
if isamcode != 0:
|
||||
msg += f" [ISAM {isamcode}]"
|
||||
if offset > 0:
|
||||
msg += f" (offset {offset})"
|
||||
exc = exc_class(msg)
|
||||
# Attach structured fields for programmatic inspection.
|
||||
exc.sqlcode = sqlcode
|
||||
exc.isamcode = isamcode
|
||||
exc.offset = offset
|
||||
exc.near = near_token
|
||||
raise exc
|
||||
|
||||
|
||||
class _SocketReader(IfxStreamReader):
|
||||
|
||||
@ -98,4 +98,4 @@ def test_decimal_param_binding_raises_for_now(conn_params: ConnParams) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute("CREATE TEMP TABLE td2 (n DECIMAL(10,2))")
|
||||
with pytest.raises(NotImplementedError, match="Decimal"):
|
||||
cur.execute("INSERT INTO td2 VALUES (?)", (Decimal("3.14"),))
|
||||
cur.execute("INSERT INTO td2 VALUES (?)", (Decimal("3.14"),))
|
||||
|
||||
124
tests/test_errors.py
Normal file
124
tests/test_errors.py
Normal file
@ -0,0 +1,124 @@
|
||||
"""Phase 5.a integration tests — error message decoding + PEP 249 classification.
|
||||
|
||||
Server SQ_ERR responses get decoded to:
|
||||
- the appropriate PEP 249 exception class (Programming/Integrity/...)
|
||||
- a human-readable message with sqlcode, near-token, ISAM code, offset
|
||||
- structured ``sqlcode``, ``isamcode``, ``offset``, ``near`` attributes
|
||||
|
||||
The connection survives errors — subsequent queries work normally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import informix_db
|
||||
from tests.conftest import ConnParams
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _connect(conn_params: ConnParams) -> informix_db.Connection:
|
||||
return informix_db.connect(
|
||||
host=conn_params.host,
|
||||
port=conn_params.port,
|
||||
user=conn_params.user,
|
||||
password=conn_params.password,
|
||||
database=conn_params.database,
|
||||
server=conn_params.server,
|
||||
connect_timeout=10.0,
|
||||
read_timeout=10.0,
|
||||
)
|
||||
|
||||
|
||||
def test_syntax_error_is_programming_error(conn_params: ConnParams) -> None:
|
||||
"""sqlcode -201 (syntax error) → ProgrammingError."""
|
||||
with _connect(conn_params) as conn:
|
||||
cur = conn.cursor()
|
||||
with pytest.raises(informix_db.ProgrammingError) as excinfo:
|
||||
cur.execute("SELEKT * FROM systables")
|
||||
e = excinfo.value
|
||||
assert e.sqlcode == -201
|
||||
assert "syntax error" in str(e).lower()
|
||||
|
||||
|
||||
def test_table_not_found_is_programming_error(conn_params: ConnParams) -> None:
|
||||
"""sqlcode -206 (table not found) → ProgrammingError; near-token has table name."""
|
||||
with _connect(conn_params) as conn:
|
||||
cur = conn.cursor()
|
||||
with pytest.raises(informix_db.ProgrammingError) as excinfo:
|
||||
cur.execute("SELECT * FROM no_such_table_xyz")
|
||||
e = excinfo.value
|
||||
assert e.sqlcode == -206
|
||||
assert e.near == "no_such_table_xyz"
|
||||
assert "not in the database" in str(e)
|
||||
|
||||
|
||||
def test_column_not_found_is_programming_error(conn_params: ConnParams) -> None:
|
||||
"""sqlcode -217 (column not found) → ProgrammingError; near-token has column name."""
|
||||
with _connect(conn_params) as conn:
|
||||
cur = conn.cursor()
|
||||
with pytest.raises(informix_db.ProgrammingError) as excinfo:
|
||||
cur.execute("SELECT no_such_column FROM systables")
|
||||
e = excinfo.value
|
||||
assert e.sqlcode == -217
|
||||
assert e.near == "no_such_column"
|
||||
|
||||
|
||||
def test_unique_violation_is_integrity_error(conn_params: ConnParams) -> None:
|
||||
"""sqlcode -268 (UNIQUE violation) → IntegrityError."""
|
||||
with _connect(conn_params) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("CREATE TEMP TABLE t_uniq (id INTEGER PRIMARY KEY)")
|
||||
cur.execute("INSERT INTO t_uniq VALUES (?)", (1,))
|
||||
with pytest.raises(informix_db.IntegrityError) as excinfo:
|
||||
cur.execute("INSERT INTO t_uniq VALUES (?)", (1,))
|
||||
e = excinfo.value
|
||||
assert e.sqlcode == -268
|
||||
assert "duplicate" in str(e).lower()
|
||||
|
||||
|
||||
def test_not_null_violation_is_integrity_error(conn_params: ConnParams) -> None:
|
||||
"""sqlcode -391 (NOT NULL violation) → IntegrityError."""
|
||||
with _connect(conn_params) as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("CREATE TEMP TABLE t_nn (id INTEGER NOT NULL)")
|
||||
with pytest.raises(informix_db.IntegrityError) as excinfo:
|
||||
cur.execute("INSERT INTO t_nn VALUES (?)", (None,))
|
||||
assert excinfo.value.sqlcode == -391
|
||||
|
||||
|
||||
def test_commit_in_unlogged_db_is_operational_error(conn_params: ConnParams) -> None:
|
||||
"""sqlcode -255 (no transaction) → OperationalError."""
|
||||
with _connect(conn_params) as conn:
|
||||
with pytest.raises(informix_db.OperationalError) as excinfo:
|
||||
conn.commit()
|
||||
assert excinfo.value.sqlcode == -255
|
||||
|
||||
|
||||
def test_connection_survives_query_error(conn_params: ConnParams) -> None:
|
||||
"""Critical: a failed query must not poison the connection — subsequent queries must work."""
|
||||
with _connect(conn_params) as conn:
|
||||
cur = conn.cursor()
|
||||
# First query fails
|
||||
with pytest.raises(informix_db.ProgrammingError):
|
||||
cur.execute("SELECT * FROM no_such_table")
|
||||
# Second query works
|
||||
cur.execute("SELECT 1 FROM systables WHERE tabid = 1")
|
||||
assert cur.fetchone() == (1,)
|
||||
|
||||
|
||||
def test_error_has_structured_fields(conn_params: ConnParams) -> None:
|
||||
"""``sqlcode``, ``isamcode``, ``offset``, ``near`` are attached for inspection."""
|
||||
with _connect(conn_params) as conn:
|
||||
cur = conn.cursor()
|
||||
with pytest.raises(informix_db.Error) as excinfo:
|
||||
cur.execute("SELECT * FROM no_such_table")
|
||||
e = excinfo.value
|
||||
# All four structured attributes present
|
||||
assert hasattr(e, "sqlcode")
|
||||
assert hasattr(e, "isamcode")
|
||||
assert hasattr(e, "offset")
|
||||
assert hasattr(e, "near")
|
||||
assert e.sqlcode == -206
|
||||
assert e.near == "no_such_table"
|
||||
Loading…
x
Reference in New Issue
Block a user