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:
Ryan Malloy 2026-05-04 11:59:03 -06:00
parent 2bacbc4e53
commit d04000dfc3
6 changed files with 373 additions and 12 deletions

View 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

View 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

View File

@ -301,16 +301,27 @@ class Connection:
) )
def _raise_sq_err(self) -> None: 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``: Wire format (per IfxSqli.receiveError, line 2717):
``[short sqlcode][short isamcode][int statementOffset][...]`` [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] sqlcode = struct.unpack("!h", self._sock.read_exact(2))[0]
isamcode = 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 offset = struct.unpack("!i", self._sock.read_exact(4))[0]
# Drain any remaining error payload (varies by sqlcode) until SQ_EOT. near_token = ""
# Best-effort: read shorts and discard until we hit 0x000c. 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: try:
while True: while True:
next_tag = struct.unpack("!h", self._sock.read_exact(2))[0] next_tag = struct.unpack("!h", self._sock.read_exact(2))[0]
@ -318,7 +329,20 @@ class Connection:
break break
except OperationalError: except OperationalError:
pass 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 ------------------------------------------------ # -- login PDU assembly ------------------------------------------------

View File

@ -24,6 +24,7 @@ import struct
from collections.abc import Iterator from collections.abc import Iterator
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from . import _errcodes
from ._messages import MessageType from ._messages import MessageType
from ._protocol import IfxStreamReader, make_pdu_writer from ._protocol import IfxStreamReader, make_pdu_writer
from ._resultset import ColumnInfo, parse_describe, parse_tuple_payload from ._resultset import ColumnInfo, parse_describe, parse_tuple_payload
@ -568,11 +569,30 @@ class Cursor:
self._rowcount = rows self._rowcount = rows
def _raise_sq_err(self, reader: IfxStreamReader) -> None: 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() sqlcode = reader.read_short()
isamcode = reader.read_short() isamcode = reader.read_short()
reader.read_int() # offset into statement offset = reader.read_int()
# Drain remaining error bytes until SQ_EOT. 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: try:
while True: while True:
t = reader.read_short() t = reader.read_short()
@ -580,7 +600,21 @@ class Cursor:
break break
except Exception: except Exception:
pass 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): class _SocketReader(IfxStreamReader):

View File

@ -98,4 +98,4 @@ def test_decimal_param_binding_raises_for_now(conn_params: ConnParams) -> None:
cur = conn.cursor() cur = conn.cursor()
cur.execute("CREATE TEMP TABLE td2 (n DECIMAL(10,2))") cur.execute("CREATE TEMP TABLE td2 (n DECIMAL(10,2))")
with pytest.raises(NotImplementedError, match="Decimal"): 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
View 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"