FIx: Add opt-in batch error continuation for sqlsrv and PDO next-result APIs#1600
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## dev #1600 +/- ##
==========================================
+ Coverage 85.77% 85.80% +0.02%
==========================================
Files 23 23
Lines 7213 7247 +34
==========================================
+ Hits 6187 6218 +31
- Misses 1026 1029 +3
🚀 New features to boost your workflow:
|
e7c13ef to
0b73262
Compare
When SQLMoreResults returns SQL_ERROR for a mid-batch statement failure
(e.g. divide-by-zero with XACT_ABORT OFF), the statement handle remains
valid. The old code called SQLCancel() in the catch block, which aborted
the entire remaining batch.
This change:
1. Removes SQLCancel from the catch block so the handle stays alive.
2. Adds a throw_on_errors parameter (default true) to
core_sqlsrv_next_result to scope the non-throwing behaviour:
- throw_on_errors=true (flush loops, closeCursor, param binding):
uses the throwing core::SQLMoreResults wrapper — SQL_ERROR exits
the loop via exception. Same behaviour as before.
- throw_on_errors=false (sqlsrv_next_result / PDO::nextRowset):
calls SQLMoreResults directly, reports the error through the
normal error handler, clears any pending PDO exception, and
falls through to new_result_set(). The batch remains navigable.
3. Tests both extensions (ERRMODE_WARNING and ERRMODE_EXCEPTION for PDO)
and verifies re-execute after a batch error (flush loop).
Fixes #1599
0b73262 to
a2d3fcc
Compare
…ing APIs When SQLMoreResults returns SQL_ERROR for a mid-batch statement failure (e.g. divide-by-zero with XACT_ABORT OFF), the ODBC spec says it should return SQL_SUCCESS_WITH_INFO when the batch is not aborted and the failed statement is not the last one. The Microsoft ODBC driver incorrectly returns SQL_ERROR, but the statement handle remains valid. The old code called SQLCancel() in the catch block, which aborted the entire remaining batch, making subsequent result sets unreachable. This change adds a throw_on_errors parameter (default true) to core_sqlsrv_next_result: - throw_on_errors=true (default): used by internal flush loops (closeCursor, re-execute, param binding). Uses the throwing core::SQLMoreResults wrapper and calls SQLCancel in the catch block. Same behavior as before. - throw_on_errors=false: used only by sqlsrv_next_result() and PDO::nextRowset(). Calls SQLMoreResults directly, reports the error through the normal error handler, clears any pending PDO exception, and falls through to new_result_set(). The batch remains navigable. SQLCancel is NOT called in the catch block for this path. Tests cover both extensions (ERRMODE_WARNING and ERRMODE_EXCEPTION for PDO), re-execute after a batch error (flush loop), and error visibility via sqlsrv_errors() / errorInfo(). Fixes #1599
647cc77 to
6f911c2
Compare
Impact Analysis of the current changesThere are 6 call sites for
Compatibility & Behavioral Concerns1. Breaking change:
|
| Before | After | |
|---|---|---|
| Return value on mid-batch error | false |
true |
Error available via sqlsrv_errors() |
Yes | Yes |
| Can reach subsequent result sets | No | Yes |
Applications that check sqlsrv_next_result() === false to detect batch errors will silently miss the error. This is a user-visible behavior change with no opt-out mechanism.
2. Breaking change: PDO::nextRowset() exception behavior (HIGH)
-
ERRMODE_EXCEPTION: Previously threwPDOException. Now the PR explicitly callszend_clear_exception()to suppress it and returns success. Existingcatch (PDOException $e)blocks aroundnextRowset()will stop firing. Error info is still available viaerrorInfo(), but code structured around exception-based error handling will silently skip errors. -
ERRMODE_WARNING/ERRMODE_SILENT: Previously returned an error indicator; now returns success. Same concern as the SQLSRV driver.
3. SQLCancel removed from shared catch block — affects all callers (MEDIUM-HIGH)
The catch block is shared between throw_on_errors=true (internal flush loops, closeCursor, param binding) and throw_on_errors=false (user-facing). Removing SQLCancel from the catch block affects the internal paths too:
- When
throw_on_errors=true, acore::SQLMoreResults()SQL_ERROR still throwsCoreExceptioninto this catch block. - Previously,
SQLCancelensured the ODBC handle was cleaned up. Now the exception propagates without canceling the statement. - For error scenarios unrelated to mid-batch failures (network errors, connection drops, OOM), not calling
SQLCancelcould leave the ODBC handle in an indeterminate state.
Recommendation: The SQLCancel removal should be conditional — only skip it when throw_on_errors=false. Something like:
catch( core::CoreException& e ) {
if( throw_on_errors ) {
SQLCancel( stmt->handle() );
}
throw e;
}4. zend_clear_exception() is overly broad (MEDIUM)
zend_clear_exception() clears any pending Zend exception, not just the one raised by the ODBC error handler. If another exception was already pending (e.g., from user code or a prior operation), this would silently consume it. A more targeted approach would check whether the pending exception matches the expected type before clearing.
5. new_result_set() called after SQL_ERROR (LOW-MEDIUM)
When SQLMoreResults returns SQL_ERROR, the code falls through to stmt->new_result_set(), which resets internal metadata and may call SQLNumResultCols/SQLRowCount. Whether the ODBC handle is in a valid state for these calls after SQL_ERROR depends on the driver implementation.
6. No opt-out to retain old behavior (HIGH)
Applications that relied on the batch-aborting behavior as a safety mechanism — stopping processing after any statement error — have no way to restore the old behavior. A connection or statement attribute to control this would provide a safer migration path.
Summary of Recommendations
- Make
SQLCancelremoval conditional onthrow_on_errors— the internal flush/closeCursor paths should still callSQLCancelon error to properly clean up the ODBC handle. - Narrow
zend_clear_exception()— check the pending exception type or only clear if it was raised by this specific error handler invocation. - Document the behavior change in release notes — callers checking
sqlsrv_next_result() === falseor catchingPDOExceptionfromnextRowset()need to update. - Add a connection attribute (e.g.,
SQLSRV_ATTR_BATCH_ERROR_CONTINUE) to let users opt in to new behavior, preserving backward compatibility.
…vi/batch-error-sqlcancel
Add optional batch error continuation controls for both sqlsrv and PDO next-result APIs, preserving legacy fail-on-error behavior by default while allowing users to opt-in to continue-after-error semantics. Changes: - Add batch_error_continue boolean flag to shared connection struct - Initialize flag to false to preserve backward-compatible behavior - Expose BatchErrorContinue option for sqlsrv_connect() - Expose SQLSRV_ATTR_BATCH_ERROR_CONTINUE attribute for PDO - Gate both sqlsrv_next_result() and PDOStatement::nextRowset() on flag - Updated tests to validate both default and opt-in behaviors Design: - Default (batch_error_continue=false): sqlsrv_next_result/nextRowset return false on mid-batch SQL errors, matching pre-fix semantics - Opt-in (batch_error_continue=true): continue past mid-batch errors while reporting them via sqlsrv_errors()/errorInfo() Impact: - Existing applications unaffected; no behavior changes without opt-in - Users must explicitly set the flag to enable new behavior - Full backward compatibility maintained
…rosoft/msphpsql into jahnvi/batch-error-sqlcancel
David-Engel
left a comment
There was a problem hiding this comment.
Thanks for adding this — the opt-in design is sound, the default is preserved, and the happy path is well tested. I verified a few things while reviewing: pdo_sqlsrv_dbh : public sqlsrv_conn so the PDO attribute set on driver_dbh correctly reaches stmt->conn->batch_error_continue; source/sqlsrv/shared and source/pdo_sqlsrv/shared are symlinks to source/shared so editing the shared file is correct; and the reordered SS_CONN_OPTIONS enum is only matched by value in the option table (not used as an array index), so reordering is safe. A few items below.
Blocking
1. The shared else branch overloads throw_on_errors=false and silently changes the default re-execute path.
core_sqlsrv_next_result(..., throw_on_errors=false) previously meant "drain silently" and was used only by the sqlsrv re-execute flush loop in source/sqlsrv/stmt.cpp (~L297):
while( stmt->past_next_result_end == false ) {
core_sqlsrv_next_result( stmt, false, false ); // silent flush
}This PR repurposes that same else branch to call call_error_handler(stmt, SQLSRV_ERROR_ODBC, 0/1) and zend_clear_exception(). Because the flush loop always passes false regardless of the opt-in flag, a user who has not opted in but re-executes a prepared statement whose prior batch had a mid-batch error/warning will now get those errors/warnings pushed into sqlsrv_errors() where they were previously swallowed. That contradicts the stated contract ("legacy error handling remains the default") and is untested — Test 3 only re-executes on the opt-in connection.
Two distinct semantics are being conflated:
- flush loop: don't throw, don't report (silent drain)
- opt-in user: don't throw, do report, continue
Suggest introducing a separate parameter (e.g. bool report_errors / bool continue_on_error) distinct from throw_on_errors, so the flush loop keeps draining silently and only the user-facing opt-in path (sqlsrv_next_result / pdo_sqlsrv_stmt_next_rowset) reports.
Suggestions
2. SQL_ERROR is treated uniformly as "continuable." In the new branch in source/shared/core_stmt.cpp, any SQL_ERROR from SQLMoreResults is reported, the exception cleared, and execution falls through to new_result_set(). That's valid for a recoverable mid-batch error (divide-by-zero), but a genuinely fatal SQL_ERROR (e.g. a communication link failure) would also be reported as success (nextRowset() returns true) and the code would keep trying to advance. Given it's opt-in this is an acceptable trade-off, but please add a code comment and a CHANGELOG/docs note that true can be returned even for an unrecoverable error.
3. Environment-dependent test assertions. Whether divide-by-zero surfaces as SQL_ERROR vs SQL_SUCCESS_WITH_INFO depends on session settings (ANSI_WARNINGS, XACT_ABORT) and the MSODBC driver version. The tests pin SQLSTATE 22012 and exact bool(true)/bool(false) navigation outcomes — this is the most likely source of cross-version flakiness. Please confirm the tests pass against the oldest supported MSODBC 18 and across SQL Server versions in CI.
4. zend_clear_exception() is unconditional. It clears whatever zend exception is pending, not specifically the one this handler set. In practice the only pending exception here is the PDO ERRMODE_EXCEPTION one, but a narrower approach (clear only when in PDO exception mode / only the just-set exception) would be safer against masking an unrelated pending exception.
Nits
5. Attribute is connection-level only. SQLSRV_ATTR_BATCH_ERROR_CONTINUE is read from stmt->conn at nextRowset time and only handled in pdo_sqlsrv_dbh_set_attr/get_attr, so $stmt->setAttribute(...) won't take effect. Fine if intended — worth documenting that it must be set on the connection (or in the PDO constructor driver options).
6. Docs. Only CHANGELOG.md is updated. The new option/attribute should also be documented in the reference docs and cross-referenced from issue #1599.
- Split non-throwing next-result behavior into explicit modes (silent internal drain vs opt-in user-facing diagnostics) - Preserve default re-execute flush semantics (no surfaced diagnostics) - Gate sqlsrv_next_result/PDO nextRowset reporting strictly on opt-in - Guard zend_clear_exception behind pending-exception check - Reduce test flakiness by asserting diagnostic presence instead of fixed SQLSTATE - Add coverage for default silent re-execute and PDO constructor opt-in path - Clarify connection-scope behavior in code and changelog (issue #1599)
…rosoft/msphpsql into jahnvi/batch-error-sqlcancel
SELECT 1/0 with ANSI_WARNINGS ON (the SQL Server default) produces
SQL_SUCCESS_WITH_INFO (a NULL result set + warning), not SQL_ERROR.
This caused the tests to fail across all CI environments because
nextRowset() returned true (valid result set) instead of the expected
false (error).
RAISERROR('...', 16, 1) always produces SQL_ERROR from SQLMoreResults
regardless of ANSI_WARNINGS or ARITHABORT settings, making it a
reliable cross-environment trigger for the mid-batch error path that
this feature is designed to handle.
…enabled The PDO execute and param_hook flush loops used throw_on_errors=true (the default), which means a mid-batch SQL_ERROR from a prior partial navigation would throw, call SQLCancel, and fail the re-execute. The sqlsrv driver already handled this correctly with silent drain (throw_on_errors=false). Apply the same conditional logic to PDO: when batch_error_continue is enabled, flush silently; otherwise preserve the existing throwing behavior. Also adds PDO re-execute tests (Tests 6 and 7) to validate that re-executing after partial batch navigation works correctly for both opt-in and default connections.
…uation After SQLMoreResults returns SQL_ERROR for a non-result-producing statement (e.g. RAISERROR), there is no active ODBC cursor. The PDO nextRowset code unconditionally called SQLNumResultCols/SQLRowCount after core_sqlsrv_next_result, which failed with 'Invalid cursor state' (SQLSTATE 24000). Fix: 1. In core_sqlsrv_next_result: when report_errors=true and r==SQL_ERROR, clean up the previous result set and return early WITHOUT creating a new result set object. The batch remains navigable. 2. In pdo_sqlsrv_stmt_next_rowset: check current_results != NULL before calling SQLNumResultCols/SQLRowCount. On error markers (NULL results), set column_count=0 and row_count=0. 3. In test: suppress ERRMODE_WARNING output with @ operator so expected output stays deterministic across environments.
David-Engel
left a comment
There was a problem hiding this comment.
Thanks for the rework — this addresses all of my earlier feedback well.
Resolved
- Overloaded
throw_on_errors(blocking): The new dedicatedreport_errorsparameter (defaultfalse) cleanly separates the silent internal drain from the user-facing opt-in continuation. Call sites are consistent, and the default sqlsrv re-execute flush loop stays silent. The new sqlsrv Test 3 assertingErrors after re-execute: NULLon a non-opt-in connection directly locks in the no-regression behavior. 👍 - Uniform
SQL_ERRORhandling: Now clearly documented in code comments and the CHANGELOG as an opt-in trade-off where next-result may return success while reporting diagnostics. - Environment-dependent tests: Switching to
RAISERROR(...,16,1)andError captured: yeschecks (instead of divide-by-zero / exact SQLSTATE) makes these much more robust across driver/server versions. zend_clear_exception(): Now guarded withif( EG(exception) != NULL ).- Connection-scope attribute: Documented in CHANGELOG + comments, and Test 5 verifies constructor opt-in.
I also checked the new SQL_ERROR/report_errors cleanup path: the manual current_results teardown mirrors the existing pattern in ~sqlsrv_stmt() / new_result_set(), there's no double-free (current_results is nulled and clean_up_results_metadata() only clears the metadata vector), and the PDO nextRowset current_results != NULL guard correctly skips SQLNumResultCols/SQLRowCount on the error marker.
Remaining nits (non-blocking)
- In the error path,
stmt->column_count = 0; stmt->row_count = 0;are immediately overwritten byclean_up_results_metadata()(which sets them toACTIVE_NUM_COLS_INVALID/ACTIVE_NUM_ROWS_INVALID). They're dead assignments — drop them or rely on the helper. - The ~12-line
current_resultsteardown block now appears in three places (destructor,new_result_set, error path). Consider extracting a smallfree_current_results()helper for maintainability. - Minor awareness note (pre-existing, not introduced here): sqlsrv re-execute always silent-drains, while PDO re-execute throws when not opted in. Just flagging the asymmetry.
Nothing blocking from my side — looks good to merge once the nits are optionally cleaned up and CI confirms the new tests pass across the supported driver/server matrix.
…ad assignments 1. Extract the ~12-line current_results teardown block into a free_current_results() helper method on sqlsrv_stmt. Used in the destructor, new_result_set(), and the SQL_ERROR opt-in path. 2. Remove dead stmt->column_count = 0 / stmt->row_count = 0 assignments that were immediately overwritten by clean_up_results_metadata() (which sets them to ACTIVE_NUM_COLS_INVALID / ACTIVE_NUM_ROWS_INVALID).
Github issue #1599
This pull request introduces an opt-in feature to allow batch error continuation for the
sqlsrvand PDO drivers' next-result APIs, addressing scenarios where multi-statement batches may encounter mid-batch errors (such as a divide-by-zero). By default, legacy error handling is preserved, but users can now explicitly enable continuation past errors, allowing access to subsequent result sets. The implementation includes new driver attributes and connection options, updates to error handling logic, and comprehensive functional tests.Batch Error Continuation Feature:
SQLSRV_ATTR_BATCH_ERROR_CONTINUE/BatchErrorContinue) to opt in to batch error continuation for bothsqlsrvand PDO drivers. This allows users to continue processing result sets after a mid-batch error, instead of aborting as in legacy behavior.SQLMoreResultsare reported but do not abort result set navigation, and exceptions are cleared to allow continued execution.sqlsrv_next_resultand PDOnextRowsetimplementations to respect the new batch error continuation setting.Documentation and Changelog:
CHANGELOG.mdto document the addition of batch error continuation controls and clarify that legacy error handling remains the default.Testing:
sqlsrvand PDO drivers to verify default and opt-in behaviors, error reporting, and correct navigation of result sets after batch errors.