diff --git a/docs/CAPTURES/22-py-sql-error.socat.log b/docs/CAPTURES/22-py-sql-error.socat.log new file mode 100644 index 0000000..5fcdce4 --- /dev/null +++ b/docs/CAPTURES/22-py-sql-error.socat.log @@ -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 diff --git a/src/informix_db/_errcodes.py b/src/informix_db/_errcodes.py new file mode 100644 index 0000000..74b7054 --- /dev/null +++ b/src/informix_db/_errcodes.py @@ -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 ". + +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 diff --git a/src/informix_db/connections.py b/src/informix_db/connections.py index d16702f..bbf6ec6 100644 --- a/src/informix_db/connections.py +++ b/src/informix_db/connections.py @@ -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 ------------------------------------------------ diff --git a/src/informix_db/cursors.py b/src/informix_db/cursors.py index 3df68e5..a5ec7ee 100644 --- a/src/informix_db/cursors.py +++ b/src/informix_db/cursors.py @@ -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): diff --git a/tests/test_decimal.py b/tests/test_decimal.py index 80dfb1e..ac6c43c 100644 --- a/tests/test_decimal.py +++ b/tests/test_decimal.py @@ -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"),)) \ No newline at end of file + cur.execute("INSERT INTO td2 VALUES (?)", (Decimal("3.14"),)) diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..d92c7c2 --- /dev/null +++ b/tests/test_errors.py @@ -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"