Skip to content

fix(Driver): classify exceptions by SQLSTATE before the message - #270

Merged
roxblnfk merged 6 commits into
2.xfrom
fix/268-map-exception-sqlstate
Sep 18, 2026
Merged

roxblnfk merged 6 commits into
2.xfrom
fix/268-map-exception-sqlstate

Conversation

@roxblnfk

@roxblnfk roxblnfk commented Sep 17, 2026

Copy link
Copy Markdown
Member

🔍 What was changed

The SQLSTATE now decides which StatementException subclass a driver raises, in all four drivers. The message needles remain only where the client library itself reports a lost socket under HY000: mysqlnd and libpq. The ODBC driver beneath pdo_sqlsrv files every transport failure under class 08, so SQL Server has no message fallback at all. On MySQL the fallback is additionally gated on the absence of a server error number, because mysql files plenty of its own errors under HY000 and their text carries table and constraint names.

  • A constraint violation is no longer reported as a ConnectionException because of a value the server echoed back. A uuid containing 0800 or a table named connections was enough, on Postgres and on SQL Server.
  • Postgres 23P01 (exclusion violation) reaches ConstrainException. The state was compared numerically, and (int) '23P01' is 23, below the >= 23000 test.
  • MySQL raises ConstrainException for the two integrity violations it files under HY000 rather than class 23: a CHECK the row failed (3819) and a required column left out of the statement (1364). Postgres reports the same two as 23514 and 23502, SQL Server as 23000.
  • SQLServerDriver::create() no longer reaches the server. The version check moved to createPDO(), so a failed connection surfaces on the first statement and is classified by mapException() like on every other driver.
  • SQL Server HY000 "Connection is busy with results for another command" is a plain StatementException. It is client misuse with MARS off; the reconnect dropped the open result set and let the retry succeed, hiding the mistake.

Behaviour that changes beyond the reported bug: an error the server did classify is no longer matched against the needles, so a failure like 42P01 relation "no_such_connections_table" does not exist is now a plain StatementException. It used to be a ConnectionException, which made Driver::statement() disconnect and re-run the statement.

Why?

A misclassified constraint violation is not just a wrong class name. Outside a transaction Driver::statement() disconnects and retries on a ConnectionException, so a data error dropped the connection — losing temp tables, advisory locks and session settings — and application code catching the narrower ConstrainException never saw it. See #268 for how intermittent that looks in practice.

The MySQL and SQL Server parts are the same defect found while verifying the first: #268 reports Postgres, but SQL Server had the identical message-before-code ordering, and MySQL leaves two violations outside class 23 entirely.

Checklist

Review notes

PDO reports the SQLSTATE in three places that do not agree. At connect time getCode() holds the driver's own number — libpq's 7, mysql's 2002 — and the SQLSTATE appears only in errorInfo and the SQLSTATE[...] message prefix; once a statement has been sent, all three agree. Driver::getSqlState() reads them in that order for this reason.

The ODBC driver translates its own strings, so on a localized install the English needles matched nothing at all and a dropped SQL Server link was never recognised as one. That path is now covered by SQLSTATE class 08, and a localized 08S01 is in the test data.

The ConnectionExceptionTest data sets that were removed (new \Exception('0800'), new \Exception('080P'), and new \Exception('Bad connection') for SQL Server) encoded the heuristic being fixed. They are replaced by exceptions carrying real states — 08006, 08P01, 57P01, 53300, 08001, 08S01 — which is what the needles were reaching for by matching the message prefix.

Driver::connect() still exposes the raw PDOException, and a query wraps whatever failed inside Driver::statement() into a StatementException with the cause in getPrevious(). That is the existing contract: formatDatetime() already throws a DriverException inside the same try, and it reaches the caller wrapped. The SQL Server version check now follows the same rule rather than getting its own exit.

fix(MySQL): report the integrity violations mysql files under HY000
fix(SQLServer): classify a failed connection in create()
test(Driver): cover exception mapping across all four drivers

Every driver read its needle list over the message before looking at the error code, so any value the server echoes back could decide the class: a uuid containing `0800` or a table named `connections` turned a constraint violation into a ConnectionException, which made `Driver::statement()` drop the connection and re-run the statement while application code catching ConstrainException saw nothing. SQLSTATE now decides, and the needles are consulted only for the states PDO invents itself (`HY`, `IM`) — a dropped socket arrives as HY000 with the reason only in the text. This also repairs reconnect on a localized ODBC install, where the English needles matched nothing at all.

Comparing the state as a string rather than casting it to an int fixes Postgres `23P01`: `(int) '23P01'` is 23, which fell below the `>= 23000` test, so exclusion violations arrived as the base StatementException.

Mysql leaves two integrity violations outside class 23 — a CHECK the row failed and a required column left out of the statement — which Postgres reports as 23514 and 23502 and SQL Server as 23000. They are now matched by error number, listed one by one because their HY000 neighbours are DDL errors and coercion failures.

`SQLServerDriver::create()` reaches the server to reject a version it cannot compile for, which makes it the one place a connection failure happens outside `Driver::statement()`; it let the raw PDOException escape instead of classifying it.

Closes #268

Assisted-By: Claude Opus 5
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.77419% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.66%. Comparing base (ef2144e) to head (01de44a).

Files with missing lines Patch % Lines
src/Driver/MySQL/MySQLDriver.php 95.65% 1 Missing ⚠️
src/Driver/SQLServer/SQLServerDriver.php 90.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##                2.x     #270   +/-   ##
=========================================
  Coverage     95.65%   95.66%           
- Complexity     2207     2230   +23     
=========================================
  Files           142      142           
  Lines          6307     6341   +34     
=========================================
+ Hits           6033     6066   +33     
- Misses          274      275    +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

MySQL still permits HY000 message false positives, PostgreSQL omits 57P04, and SQLSTATE parsing introduces an undeclared extension dependency.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Classifies database exceptions by SQLSTATE before message heuristics, preventing incorrect reconnects.

Changes:

  • Adds shared SQLSTATE extraction and classification.
  • Handles driver-specific constraint and connection errors.
  • Adds unit and functional regression coverage.
File summaries
File Description
src/Driver/Driver.php Adds SQLSTATE helpers.
src/Driver/MySQL/MySQLDriver.php Revises MySQL exception mapping.
src/Driver/Postgres/PostgresDriver.php Revises PostgreSQL exception mapping.
src/Driver/SQLite/SQLiteDriver.php Uses SQLSTATE class 23.
src/Driver/SQLServer/SQLServerDriver.php Revises mapping and wraps connection failures.
tests/Database/Unit/Driver/MapExceptionTest.php Adds cross-driver mapping tests.
tests/Database/Functional/Driver/Common/Query/ExceptionsTest.php Tests false-positive prevention.
tests/Database/Functional/Driver/MySQL/Query/ExceptionsTest.php Tests MySQL-specific constraints.
tests/Database/Functional/Driver/Postgres/Connection/ConnectionExceptionTest.php Updates PostgreSQL connection cases.
tests/Database/Functional/Driver/Postgres/Query/ExceptionsTest.php Tests exclusion constraints.
tests/Database/Functional/Driver/SQLServer/Connection/ConnectionExceptionTest.php Updates SQL Server connection cases.
tests/Database/Functional/Driver/SQLServer/Driver/DriverTest.php Tests connection failure wrapping.
Review details
  • Files reviewed: 12/12 changed files
  • Comments generated: 4
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Driver/Driver.php Outdated
Comment thread src/Driver/MySQL/MySQLDriver.php Outdated
Comment thread src/Driver/Postgres/PostgresDriver.php Outdated
Comment thread src/Driver/SQLServer/SQLServerDriver.php Outdated
fix(Driver): match SQLSTATE with PCRE instead of ext-ctype
fix(Postgres): treat 57P04 and 57P05 as connection failures
docs(SQLServer): declare the StatementException create() can now throw

HY000 is a sound gate for the other drivers, whose servers always classify what they reject, but mysql files plenty of its own errors under it and their text carries table and constraint names. A DDL failure on a constraint named `connections` therefore still reached the needles and came back as a ConnectionException, disconnecting and retrying over a statement the server had already refused — the original defect, surviving inside its own fix. The server numbers its errors from 1000 upwards and leaves 2000-2999 to the client library, which is what lets the two be told apart; 4031 is listed explicitly because the server raises it to close an idle connection, and gating on the number alone would otherwise stop recognising it.

`ctype_alnum()` made ext-ctype a hard requirement of every exception mapping, while composer.json asks only for ext-pdo. PCRE is already a dependency of the same method.

Assisted-By: Claude Opus 5
Comments that restated the branch they sit above, or retold what the mapping used to do, are gone; what stays is what the code cannot carry — the int cast that truncates `23P01`, the reason the 57 states are listed instead of taken as a class, the three places PDO keeps a SQLSTATE, and the localized ODBC strings no needle can match.

Assisted-By: Claude Opus 5

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

MySQL can still retry server-classified messages and non-connection client errors as connection failures.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/Driver/MySQL/MySQLDriver.php:109

  • The entire 2001–2099 client-error range is not limited to lost sockets. For example, errno 2014 is CR_COMMANDS_OUT_OF_SYNC and 2031 is CR_PARAMS_NOT_BOUND; now that getErrno() reads errorInfo[1], statement-side occurrences enter this branch, are retried, and disconnect the session even though reconnecting cannot fix the request. Restrict this condition to client errnos that actually indicate a broken connection.
  • Files reviewed: 12/12 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread src/Driver/MySQL/MySQLDriver.php Outdated
The CR_* range was widened from getCode() to errorInfo, which let it match numbers a statement raises rather than a connect attempt: CR_COMMANDS_OUT_OF_SYNC and CR_PARAMS_NOT_BOUND became ConnectionException, so a driver holding an unbuffered result disconnected and reran the statement over a mistake the reconnect could only repeat. The range is back on getCode(), where a CR_* number appears only before any statement exists, and the losses that happen under an open session are named individually.

The message fallback also has to see the SQLSTATE again: an exception carrying no errorInfo yields errno 0, and without the state from the message prefix a server-classified failure would reach the needles the same way it does in the other drivers.

Assisted-By: Claude Opus 5
… connect

docs(tests): correct the comments on PDO's code property and on SQLite echoing values

Every other driver's create() is a pure factory, and a failed connection reaches the caller through Driver::statement(), where mapException() classifies it. The sqlserver driver connected inside create() to read the server version and so had to classify that one failure by hand, under a made-up 'CONNECT' query. Moving the version check into createPDO() puts the connection back on the shared path and removes the special case.

Assisted-By: Claude Fable 5.1
…age fallback

test(Driver): cover "Connection is busy" as client misuse on SQL Server

The library reaches SQL Server only through pdo_sqlsrv, and the ODBC layer beneath it files every transport failure under class 08 on its own: a dropped link is 08S01, a refused connect is 08001, and even EPIPE arrives as 08S01. The needles therefore matched nothing genuine, and the bare `connection` needle turned "Connection is busy with results for another command" into a ConnectionException. That is client misuse with MARS off, and the reconnect dropped the open result set so the retry succeeded, hiding the mistake. MySQL and Postgres keep their fallback because mysqlnd and libpq report a lost socket under HY000; the ODBC driver does not.

Assisted-By: Claude Fable 5.1

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Lazy SQL Server version validation currently exposes inconsistent exception types, and the PR description contradicts the implemented factory behavior.

Get a fresh assessment by requesting another Copilot review.

Review details
  • Files reviewed: 12/12 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread src/Driver/SQLServer/SQLServerDriver.php
Comment thread src/Driver/SQLServer/SQLServerDriver.php
@roxblnfk
roxblnfk merged commit 8a83940 into 2.x Sep 18, 2026
37 of 42 checks passed
@roxblnfk
roxblnfk deleted the fix/268-map-exception-sqlstate branch September 18, 2026 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants