From ad32f3824c4d4cbfd8927aa09306570777c5bb24 Mon Sep 17 00:00:00 2001 From: "Dailey, Alaric" Date: Tue, 20 Oct 2026 08:39:44 -0500 Subject: [PATCH 1/2] Make HasRows report whether rows exist --- .../FbDataReaderTests.cs | 33 +++++++++++++++ .../FirebirdClient/FbDataReader.cs | 40 +++++++++++++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDataReaderTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDataReaderTests.cs index c87c804bc..5828dffac 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDataReaderTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDataReaderTests.cs @@ -54,6 +54,39 @@ public async Task ReadTest() } } + [Test] + public async Task HasRowsIsFalseForAnEmptyResultTest() + { + // DbDataReader.HasRows: "true if the DbDataReader contains one or more rows; otherwise false." + await using (var command = new FbCommand("select INT_FIELD from TEST where 1 = 0", Connection)) + { + await using (var reader = await command.ExecuteReaderAsync()) + { + Assert.IsFalse(reader.HasRows); + Assert.IsFalse(await reader.ReadAsync()); + } + } + } + + [Test] + public async Task HasRowsDoesNotSkipTheFirstRowTest() + { + await using (var command = new FbCommand("select INT_FIELD from TEST where INT_FIELD in (1, 2) order by INT_FIELD", Connection)) + { + await using (var reader = await command.ExecuteReaderAsync()) + { + Assert.IsTrue(reader.HasRows); + Assert.IsTrue(reader.HasRows); + Assert.IsTrue(await reader.ReadAsync()); + Assert.AreEqual(1, reader.GetInt32(0)); + Assert.IsTrue(reader.Read()); + Assert.AreEqual(2, reader.GetInt32(0)); + Assert.IsFalse(reader.Read()); + Assert.IsTrue(reader.HasRows, "HasRows describes the result, not the current position"); + } + } + } + [Test] public async Task ReadClobTest() { diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs index 0cf1d40c4..903fc03c8 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs @@ -51,6 +51,9 @@ public sealed class FbDataReader : DbDataReader private bool _eof; private bool _isClosed; private int _position; + private DbValue[] _peekedRow; + private bool _hasPeekedRow; + private bool _hasRows; private int _recordsAffected; private Dictionary _columnsIndexesOrdinal; private Dictionary _columnsIndexesOrdinalCI; @@ -102,9 +105,25 @@ public override int Depth } } + /// + /// DbDataReader.HasRows: "true if the DbDataReader contains one or more rows; otherwise false." Before the first + /// Read this fetches one row ahead; Read then returns that row. + /// public override bool HasRows { - get { return _command.HasFields; } + get + { + if (!_hasRows && !_isClosed && !_hasPeekedRow && _position == StartPosition && _command != null && _command.HasFields && !IsCommandBehavior(CommandBehavior.SchemaOnly)) + { + using (var explicitCancellation = ExplicitCancellation.Enter(CancellationToken.None, _command.Cancel)) + { + _peekedRow = _command.Fetch(); + } + _hasPeekedRow = true; + _hasRows = _peekedRow != null; + } + return _hasRows; + } } public override bool IsClosed @@ -166,6 +185,8 @@ public override void Close() _command = null; _connection = null; _row = null; + _peekedRow = null; + _hasPeekedRow = false; _schemaTable = null; _fields = null; } @@ -195,6 +216,8 @@ public override async Task CloseAsync() _command = null; _connection = null; _row = null; + _peekedRow = null; + _hasPeekedRow = false; _schemaTable = null; _fields = null; } @@ -229,10 +252,11 @@ public override bool Read() { using (var explicitCancellation = ExplicitCancellation.Enter(CancellationToken.None, _command.Cancel)) { - _row = _command.Fetch(); + _row = TakePeekedRow(out var peeked) ? peeked : _command.Fetch(); if (_row != null) { _position++; + _hasRows = true; return true; } else @@ -259,10 +283,11 @@ public override async Task ReadAsync(CancellationToken cancellationToken) { using (var explicitCancellation = ExplicitCancellation.Enter(cancellationToken, _command.Cancel)) { - _row = await _command.FetchAsync(explicitCancellation.CancellationToken).ConfigureAwait(false); + _row = TakePeekedRow(out var peeked) ? peeked : await _command.FetchAsync(explicitCancellation.CancellationToken).ConfigureAwait(false); if (_row != null) { _position++; + _hasRows = true; return true; } else @@ -1012,6 +1037,15 @@ private FbDbType GetProviderType(int i) return (FbDbType)_fields[i].DbDataType; } + private bool TakePeekedRow(out DbValue[] row) + { + row = _peekedRow; + var hadPeekedRow = _hasPeekedRow; + _peekedRow = null; + _hasPeekedRow = false; + return hadPeekedRow; + } + private void UpdateRecordsAffected() { if (_command != null && !_command.IsDisposed) From a31867d8b189f60a2daece932f1aeb65260c3673 Mon Sep 17 00:00:00 2001 From: "Dailey, Alaric" Date: Wed, 4 Nov 2026 08:49:17 -0600 Subject: [PATCH 2/2] Keep stored procedure output when HasRows peeks HasRows fetches one row ahead. For a stored procedure that row is the output-parameter row, so closing the reader without calling Read left SetOutputParameters with an empty queue and the output parameters unset. Close and CloseAsync now pass the peeked row to SetOutputParameters. Also drop a duplicated HasRows assertion and document why _hasPeekedRow is kept. Co-Authored-By: Claude Haiku 5.5 --- .../FbDataReaderTests.cs | 53 ++++++++++++++++++- .../FirebirdClient/FbDataReader.cs | 9 +++- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDataReaderTests.cs b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDataReaderTests.cs index 5828dffac..05b7773a5 100644 --- a/src/FirebirdSql.Data.FirebirdClient.Tests/FbDataReaderTests.cs +++ b/src/FirebirdSql.Data.FirebirdClient.Tests/FbDataReaderTests.cs @@ -75,7 +75,6 @@ public async Task HasRowsDoesNotSkipTheFirstRowTest() { await using (var reader = await command.ExecuteReaderAsync()) { - Assert.IsTrue(reader.HasRows); Assert.IsTrue(reader.HasRows); Assert.IsTrue(await reader.ReadAsync()); Assert.AreEqual(1, reader.GetInt32(0)); @@ -87,6 +86,58 @@ public async Task HasRowsDoesNotSkipTheFirstRowTest() } } + [Test] + public async Task HasRowsThenCloseKeepsStoredProcedureOutputParametersTest() + { + await using (var create = Connection.CreateCommand()) + { + create.CommandText = "RECREATE PROCEDURE HASROWS_OUTPUT (P1 INTEGER) RETURNS (R1 INTEGER) AS BEGIN R1 = P1 * 2; END"; + await create.ExecuteNonQueryAsync(); + } + + await using (var command = new FbCommand("EXECUTE PROCEDURE HASROWS_OUTPUT(?)", Connection)) + { + command.CommandType = CommandType.StoredProcedure; + command.Parameters.Add("P1", FbDbType.Integer).Value = 21; + var output = command.Parameters.Add("R1", FbDbType.Integer); + output.Direction = ParameterDirection.Output; + + await using (var reader = await command.ExecuteReaderAsync()) + { + Assert.IsTrue(reader.HasRows); + await reader.CloseAsync(); + } + + Assert.AreEqual(42, output.Value, "HasRows must not consume the stored procedure output row"); + } + } + + [Test] + public void HasRowsThenCloseKeepsStoredProcedureOutputParametersSyncTest() + { + using (var create = Connection.CreateCommand()) + { + create.CommandText = "RECREATE PROCEDURE HASROWS_OUTPUT (P1 INTEGER) RETURNS (R1 INTEGER) AS BEGIN R1 = P1 * 2; END"; + create.ExecuteNonQuery(); + } + + using (var command = new FbCommand("EXECUTE PROCEDURE HASROWS_OUTPUT(?)", Connection)) + { + command.CommandType = CommandType.StoredProcedure; + command.Parameters.Add("P1", FbDbType.Integer).Value = 21; + var output = command.Parameters.Add("R1", FbDbType.Integer); + output.Direction = ParameterDirection.Output; + + using (var reader = command.ExecuteReader()) + { + Assert.IsTrue(reader.HasRows); + reader.Close(); + } + + Assert.AreEqual(42, output.Value, "HasRows must not consume the stored procedure output row"); + } + } + [Test] public async Task ReadClobTest() { diff --git a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs index 903fc03c8..c9e53707a 100644 --- a/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs +++ b/src/FirebirdSql.Data.FirebirdClient/FirebirdClient/FbDataReader.cs @@ -51,6 +51,9 @@ public sealed class FbDataReader : DbDataReader private bool _eof; private bool _isClosed; private int _position; + // Row fetched ahead by HasRows. _hasPeekedRow is needed because a peek that found nothing + // leaves _peekedRow null, and that must still count as "already fetched" so the statement is not + // fetched again. _peekedRow is only non-null while _hasPeekedRow is true. private DbValue[] _peekedRow; private bool _hasPeekedRow; private bool _hasRows; @@ -169,7 +172,8 @@ public override void Close() { if (_command.CommandType == CommandType.StoredProcedure) { - _command.SetOutputParameters(); + // HasRows may already have fetched the output row; hand it over rather than losing it. + _command.SetOutputParameters(_peekedRow); } if (_command.HasImplicitTransaction) { @@ -200,7 +204,8 @@ public override async Task CloseAsync() { if (_command.CommandType == CommandType.StoredProcedure) { - await _command.SetOutputParametersAsync(CancellationToken.None).ConfigureAwait(false); + // HasRows may already have fetched the output row; hand it over rather than losing it. + await _command.SetOutputParametersAsync(_peekedRow, CancellationToken.None).ConfigureAwait(false); } if (_command.HasImplicitTransaction) {