Skip to content

Commit 3c514aa

Browse files
authored
FIX: Prepared statement silently fails on insert - SET NOCOUNT ON fixes it (#1590)
* FIX: Prepared statement silently fails on insert - SET NOCOUNT ON fixes it * Increasing code coverage report * Fuxung failing test * Removing catch block * Address review: consolidate drain tests, use GetTempTableName, move cleanup outside try - Merged pdo_stmt_dtor_drain_{results,multi_resultset,statistics}.phpt into pdo_insert_with_extra_result_sets.phpt (now has 6 test cases) - Merged sqlsrv_stmt_dtor_drain_{results,multi_resultset,statistics}.phpt into sqlsrv_insert_with_extra_result_sets.phpt (now has 5 test cases) - Use GetTempTableName/getTempTableName for unique table names - Move cleanup outside try block with IF EXISTS guards - Delete 6 redundant test files
1 parent 6e6c3d6 commit 3c514aa

4 files changed

Lines changed: 229 additions & 0 deletions

File tree

source/pdo_sqlsrv/pdo_stmt.cpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,21 @@ int pdo_sqlsrv_stmt_dtor( _Inout_ pdo_stmt_t *stmt )
502502
driver_stmt->placeholders = NULL;
503503
}
504504

505+
// Consume all pending result sets before freeing the statement handle.
506+
// When MARS is enabled, freeing a statement with unconsumed results causes
507+
// the ODBC driver to cancel the batch execution, which can roll back
508+
// uncommitted implicit transactions, leading to silent data loss.
509+
// This can occur when triggers, SET STATISTICS, or SET NOCOUNT OFF
510+
// generate additional result sets beyond the primary result.
511+
if (driver_stmt->executed && !driver_stmt->past_next_result_end) {
512+
close_active_stream(driver_stmt);
513+
SQLRETURN r = SQL_SUCCESS;
514+
while (r == SQL_SUCCESS || r == SQL_SUCCESS_WITH_INFO) {
515+
r = SQLMoreResults(driver_stmt->handle());
516+
}
517+
driver_stmt->past_next_result_end = true;
518+
}
519+
505520
(( sqlsrv_stmt* )driver_stmt )->~sqlsrv_stmt();
506521

507522
sqlsrv_free( driver_stmt );

source/sqlsrv/stmt.cpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1344,6 +1344,19 @@ void __cdecl sqlsrv_stmt_dtor( _Inout_ zend_resource *rsrc )
13441344
}
13451345
}
13461346

1347+
// Consume all pending result sets before freeing the statement handle.
1348+
// When MARS is enabled, freeing a statement with unconsumed results causes
1349+
// the ODBC driver to cancel the batch execution, which can roll back
1350+
// uncommitted implicit transactions, leading to silent data loss.
1351+
if (stmt->executed && !stmt->past_next_result_end) {
1352+
close_active_stream(stmt);
1353+
SQLRETURN r = SQL_SUCCESS;
1354+
while (r == SQL_SUCCESS || r == SQL_SUCCESS_WITH_INFO) {
1355+
r = SQLMoreResults(stmt->handle());
1356+
}
1357+
stmt->past_next_result_end = true;
1358+
}
1359+
13471360
stmt->~ss_sqlsrv_stmt();
13481361
sqlsrv_free( stmt );
13491362
rsrc->ptr = NULL;
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
--TEST--
2+
Verify stmt dtor drains unconsumed result sets to prevent silent data loss
3+
--DESCRIPTION--
4+
When triggers, SET STATISTICS PROFILE ON, or multi-result batches produce extra
5+
result sets, the statement destructor must drain them before freeing the ODBC
6+
handle. Otherwise, with MARS enabled, freeing the handle cancels the batch and
7+
silently rolls back uncommitted implicit transactions.
8+
--SKIPIF--
9+
<?php require('skipif.inc'); ?>
10+
--FILE--
11+
<?php
12+
require_once('MsCommon.inc');
13+
14+
$tableName = GetTempTableName('pdo_drain_results', false);
15+
$triggerName = 'trg_' . $tableName;
16+
17+
try {
18+
$conn = connect();
19+
20+
// Create test table
21+
$conn->exec("IF OBJECT_ID('$tableName', 'U') IS NOT NULL DROP TABLE $tableName");
22+
$conn->exec("CREATE TABLE $tableName (id INT IDENTITY(1,1) PRIMARY KEY, val VARCHAR(50))");
23+
24+
// Test 1: Prepared statement with SET STATISTICS PROFILE ON
25+
$stmt = $conn->prepare("SET STATISTICS PROFILE ON; INSERT INTO $tableName (val) VALUES ('row1'), ('row2'), ('row3')");
26+
$stmt->execute();
27+
$stmt = null;
28+
29+
$count = $conn->query("SELECT COUNT(*) FROM $tableName")->fetchColumn();
30+
echo "Test 1 (STATISTICS PROFILE): " . ($count == 3 ? "PASS" : "FAIL (got $count)") . "\n";
31+
32+
// Test 2: Prepared statement with STATISTICS PROFILE + NOCOUNT OFF
33+
$conn->exec("DELETE FROM $tableName");
34+
$stmt = $conn->prepare("SET STATISTICS PROFILE ON; SET NOCOUNT OFF; INSERT INTO $tableName (val) VALUES ('a'), ('b'), ('c'), ('d'), ('e')");
35+
$stmt->execute();
36+
$stmt = null;
37+
38+
$count = $conn->query("SELECT COUNT(*) FROM $tableName")->fetchColumn();
39+
echo "Test 2 (STATISTICS + NOCOUNT): " . ($count == 5 ? "PASS" : "FAIL (got $count)") . "\n";
40+
41+
// Test 3: PDO::exec baseline (already worked before the fix)
42+
$conn->exec("DELETE FROM $tableName");
43+
$conn->exec("SET STATISTICS PROFILE ON; SET NOCOUNT OFF; INSERT INTO $tableName (val) VALUES ('x'), ('y')");
44+
$conn2 = connect();
45+
$count = $conn2->query("SELECT COUNT(*) FROM $tableName")->fetchColumn();
46+
$conn2 = null;
47+
echo "Test 3 (exec baseline): " . ($count == 2 ? "PASS" : "FAIL (got $count)") . "\n";
48+
49+
// Test 4: Trigger producing extra result sets
50+
$conn->exec("SET STATISTICS PROFILE OFF");
51+
$conn->exec("DELETE FROM $tableName");
52+
$conn->exec("IF OBJECT_ID('$triggerName', 'TR') IS NOT NULL DROP TRIGGER $triggerName");
53+
$conn->exec("
54+
CREATE TRIGGER $triggerName ON $tableName
55+
AFTER INSERT AS
56+
BEGIN
57+
SELECT COUNT(*) AS trigger_count FROM $tableName
58+
END
59+
");
60+
61+
$stmt = $conn->prepare("INSERT INTO $tableName (val) VALUES ('t1'), ('t2'), ('t3')");
62+
$stmt->execute();
63+
unset($stmt);
64+
65+
$count = $conn->query("SELECT COUNT(*) FROM $tableName")->fetchColumn();
66+
echo "Test 4 (trigger): " . ($count == 3 ? "PASS" : "FAIL (got $count)") . "\n";
67+
68+
// Test 5: Multi-result SELECT batch with partial consumption
69+
$stmt = $conn->prepare("SELECT 1 AS a; SELECT 2 AS b; SELECT 3 AS c");
70+
$stmt->execute();
71+
$row = $stmt->fetch(PDO::FETCH_ASSOC);
72+
echo "Test 5 (multi-result first): " . ($row['a'] == 1 ? "PASS" : "FAIL") . "\n";
73+
unset($stmt);
74+
75+
$row = $conn->query("SELECT 42 AS answer")->fetch(PDO::FETCH_ASSOC);
76+
echo "Test 5 (after drain): " . ($row['answer'] == 42 ? "PASS" : "FAIL") . "\n";
77+
78+
} catch (PDOException $e) {
79+
echo "Error: " . $e->getMessage() . "\n";
80+
}
81+
82+
// Cleanup outside try block — runs even on failure
83+
$conn = connect();
84+
$conn->exec("IF OBJECT_ID('$triggerName', 'TR') IS NOT NULL DROP TRIGGER $triggerName");
85+
$conn->exec("IF OBJECT_ID('$tableName', 'U') IS NOT NULL DROP TABLE $tableName");
86+
$conn = null;
87+
88+
echo "Done.\n";
89+
?>
90+
--EXPECT--
91+
Test 1 (STATISTICS PROFILE): PASS
92+
Test 2 (STATISTICS + NOCOUNT): PASS
93+
Test 3 (exec baseline): PASS
94+
Test 4 (trigger): PASS
95+
Test 5 (multi-result first): PASS
96+
Test 5 (after drain): PASS
97+
Done.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
--TEST--
2+
Verify stmt dtor drains unconsumed result sets to prevent silent data loss (sqlsrv)
3+
--DESCRIPTION--
4+
When triggers, SET STATISTICS PROFILE ON, or multi-result batches produce extra
5+
result sets, the statement destructor must drain them before freeing the ODBC
6+
handle. Otherwise, with MARS enabled, freeing the handle cancels the batch and
7+
silently rolls back uncommitted implicit transactions.
8+
--SKIPIF--
9+
<?php require('skipif.inc'); ?>
10+
--FILE--
11+
<?php
12+
require_once('MsCommon.inc');
13+
14+
$tableName = getTempTableName('sqlsrv_drain_results', false);
15+
$triggerName = 'trg_' . $tableName;
16+
17+
$conn = connect();
18+
if ($conn === false) {
19+
die(print_r(sqlsrv_errors(), true));
20+
}
21+
22+
// Create test table
23+
dropTable($conn, $tableName);
24+
$stmt = sqlsrv_query($conn, "CREATE TABLE $tableName (id INT IDENTITY(1,1) PRIMARY KEY, val VARCHAR(50))");
25+
if ($stmt === false) die(print_r(sqlsrv_errors(), true));
26+
sqlsrv_free_stmt($stmt);
27+
28+
// Test 1: sqlsrv_prepare + execute with STATISTICS PROFILE
29+
$stmt = sqlsrv_prepare($conn, "SET STATISTICS PROFILE ON; INSERT INTO $tableName (val) VALUES ('row1'), ('row2'), ('row3')");
30+
if ($stmt === false) die(print_r(sqlsrv_errors(), true));
31+
if (!sqlsrv_execute($stmt)) die(print_r(sqlsrv_errors(), true));
32+
sqlsrv_free_stmt($stmt);
33+
34+
$stmt = sqlsrv_query($conn, "SELECT COUNT(*) AS cnt FROM $tableName");
35+
$row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC);
36+
echo "Test 1 (STATISTICS PROFILE): " . ($row['cnt'] == 3 ? "PASS" : "FAIL (got {$row['cnt']})") . "\n";
37+
sqlsrv_free_stmt($stmt);
38+
39+
// Test 2: sqlsrv_query with STATISTICS PROFILE + NOCOUNT OFF
40+
$stmt = sqlsrv_query($conn, "DELETE FROM $tableName");
41+
sqlsrv_free_stmt($stmt);
42+
43+
$stmt = sqlsrv_query($conn, "SET STATISTICS PROFILE ON; SET NOCOUNT OFF; INSERT INTO $tableName (val) VALUES ('a'), ('b'), ('c'), ('d'), ('e')");
44+
if ($stmt === false) die(print_r(sqlsrv_errors(), true));
45+
sqlsrv_free_stmt($stmt);
46+
47+
$stmt = sqlsrv_query($conn, "SELECT COUNT(*) AS cnt FROM $tableName");
48+
$row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC);
49+
echo "Test 2 (STATISTICS + NOCOUNT): " . ($row['cnt'] == 5 ? "PASS" : "FAIL (got {$row['cnt']})") . "\n";
50+
sqlsrv_free_stmt($stmt);
51+
52+
// Test 3: Trigger producing extra result sets
53+
$stmt = sqlsrv_query($conn, "SET STATISTICS PROFILE OFF");
54+
sqlsrv_free_stmt($stmt);
55+
$stmt = sqlsrv_query($conn, "DELETE FROM $tableName");
56+
sqlsrv_free_stmt($stmt);
57+
58+
sqlsrv_query($conn, "IF OBJECT_ID('$triggerName', 'TR') IS NOT NULL DROP TRIGGER $triggerName");
59+
$stmt = sqlsrv_query($conn, "
60+
CREATE TRIGGER $triggerName ON $tableName
61+
AFTER INSERT AS
62+
BEGIN
63+
SELECT COUNT(*) AS trigger_count FROM $tableName
64+
END
65+
");
66+
if ($stmt === false) die(print_r(sqlsrv_errors(), true));
67+
sqlsrv_free_stmt($stmt);
68+
69+
$stmt = sqlsrv_prepare($conn, "INSERT INTO $tableName (val) VALUES ('t1'), ('t2'), ('t3')");
70+
if (!sqlsrv_execute($stmt)) die(print_r(sqlsrv_errors(), true));
71+
sqlsrv_free_stmt($stmt);
72+
73+
$stmt = sqlsrv_query($conn, "SELECT COUNT(*) AS cnt FROM $tableName");
74+
$row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC);
75+
echo "Test 3 (trigger): " . ($row['cnt'] == 3 ? "PASS" : "FAIL (got {$row['cnt']})") . "\n";
76+
sqlsrv_free_stmt($stmt);
77+
78+
// Test 4: Multi-result SELECT batch with partial consumption
79+
$stmt = sqlsrv_query($conn, "SELECT 1 AS a; SELECT 2 AS b; SELECT 3 AS c");
80+
sqlsrv_fetch($stmt);
81+
$first = sqlsrv_get_field($stmt, 0);
82+
echo "Test 4 (multi-result first): " . ($first == 1 ? "PASS" : "FAIL") . "\n";
83+
sqlsrv_free_stmt($stmt);
84+
85+
$stmt = sqlsrv_query($conn, "SELECT 42 AS answer");
86+
sqlsrv_fetch($stmt);
87+
$answer = sqlsrv_get_field($stmt, 0);
88+
echo "Test 4 (after drain): " . ($answer == 42 ? "PASS" : "FAIL") . "\n";
89+
sqlsrv_free_stmt($stmt);
90+
91+
// Cleanup — runs regardless of test outcome
92+
sqlsrv_query($conn, "IF OBJECT_ID('$triggerName', 'TR') IS NOT NULL DROP TRIGGER $triggerName");
93+
dropTable($conn, $tableName);
94+
sqlsrv_close($conn);
95+
96+
echo "Done.\n";
97+
?>
98+
--EXPECT--
99+
Test 1 (STATISTICS PROFILE): PASS
100+
Test 2 (STATISTICS + NOCOUNT): PASS
101+
Test 3 (trigger): PASS
102+
Test 4 (multi-result first): PASS
103+
Test 4 (after drain): PASS
104+
Done.

0 commit comments

Comments
 (0)