From 9d72e6feba566a18c7c13a3f835fc8bf5d6dde1d Mon Sep 17 00:00:00 2001 From: "Dailey, Alaric" Date: Tue, 20 Oct 2026 06:36:53 -0500 Subject: [PATCH 1/2] Report -1 for statements that are not INSERT, UPDATE or DELETE DbCommand.ExecuteNonQuery: "For UPDATE, INSERT, and DELETE statements, the return value is the number of rows affected by the command. For all other types of statements, the return value is -1." DbDataReader.RecordsAffected: "-1 for SELECT statements; 0 if no rows were affected". The reader returned -1 even after a DELETE. Co-Authored-By: Claude Opus 5.5 --- DuckDB.NET.Data/DuckDBCommand.cs | 14 +++++- DuckDB.NET.Data/DuckDBDataReader.cs | 12 ++++- DuckDB.NET.Test/ExecuteNonQueryTests.cs | 63 ++++++++++++++++++++++++- 3 files changed, 85 insertions(+), 4 deletions(-) diff --git a/DuckDB.NET.Data/DuckDBCommand.cs b/DuckDB.NET.Data/DuckDBCommand.cs index 48fa3720..d1916d66 100644 --- a/DuckDB.NET.Data/DuckDBCommand.cs +++ b/DuckDB.NET.Data/DuckDBCommand.cs @@ -72,18 +72,28 @@ public override int ExecuteNonQuery() var results = PreparedStatement.PreparedStatement.PrepareMultiple(connection!.NativeConnection, CommandText, parameters, UseStreamingMode); - var count = 0; + // DbCommand.ExecuteNonQuery: "For UPDATE, INSERT, and DELETE statements, the return value is the number of + // rows affected by the command. For all other types of statements, the return value is -1." + var count = -1; foreach (var result in results) { var current = result; - count += (int)NativeMethods.Query.DuckDBRowsChanged(ref current); + if (CountsRows(NativeMethods.Query.DuckDBResultStatementType(current))) + { + count = Math.Max(count, 0) + (int)NativeMethods.Query.DuckDBRowsChanged(ref current); + } + result.Close(); } return count; } + /// The statements whose affected-row count ADO.NET reports; every other statement reports -1. + internal static bool CountsRows(DuckDBStatementType statementType) => + statementType is DuckDBStatementType.Insert or DuckDBStatementType.Update or DuckDBStatementType.Delete; + public override object? ExecuteScalar() { EnsureConnectionOpen(); diff --git a/DuckDB.NET.Data/DuckDBDataReader.cs b/DuckDB.NET.Data/DuckDBDataReader.cs index 2e750c89..dc367ea6 100644 --- a/DuckDB.NET.Data/DuckDBDataReader.cs +++ b/DuckDB.NET.Data/DuckDBDataReader.cs @@ -21,6 +21,7 @@ public sealed class DuckDBDataReader : DbDataReader private ulong rowsReadFromCurrentChunk; private bool closed; + private int recordsAffected = -1; private bool hasRows; private bool streamingResult; private bool streamEnded; @@ -54,6 +55,11 @@ private bool InitNextReader() while (resultEnumerator.MoveNext()) { var result = resultEnumerator.Current; + if (DuckDBCommand.CountsRows(NativeMethods.Query.DuckDBResultStatementType(result))) + { + recordsAffected = Math.Max(recordsAffected, 0) + (int)NativeMethods.Query.DuckDBRowsChanged(ref result); + } + if (NativeMethods.Query.DuckDBResultReturnType(result) == DuckDBResultType.QueryResult) { // Release the previous result set before replacing it: nothing else holds it, and a materialized @@ -341,7 +347,11 @@ public override bool IsDBNull(int ordinal) public override object this[string name] => GetValue(GetOrdinal(name)); - public override int RecordsAffected => -1; + /// + /// The rows inserted, updated or deleted by the statements read so far; -1 when none of them was an INSERT, UPDATE + /// or DELETE. DbDataReader.RecordsAffected: "-1 for SELECT statements; 0 if no rows were affected". + /// + public override int RecordsAffected => recordsAffected; public override bool HasRows => hasRows; diff --git a/DuckDB.NET.Test/ExecuteNonQueryTests.cs b/DuckDB.NET.Test/ExecuteNonQueryTests.cs index 8ac48ede..e55b6231 100644 --- a/DuckDB.NET.Test/ExecuteNonQueryTests.cs +++ b/DuckDB.NET.Test/ExecuteNonQueryTests.cs @@ -41,4 +41,65 @@ public void ExecuteQueryWithUnicodeText() results.Should().BeEquivalentTo(words); } } -} \ No newline at end of file + + // https://learn.microsoft.com/dotnet/api/system.data.common.dbcommand.executenonquery: + // "For UPDATE, INSERT, and DELETE statements, the return value is the number of rows affected by the command. + // For all other types of statements, the return value is -1." + [Theory] + [InlineData("SELECT 42")] + [InlineData("CREATE TABLE minus_one (id INTEGER)")] + [InlineData("SET threads = 1")] + public async Task NonDmlStatementsReturnMinusOne(string sql) + { + Command.CommandText = sql; + Command.ExecuteNonQuery().Should().Be(-1); + + Command.CommandText = sql.Replace("minus_one", "minus_one_async"); + (await Command.ExecuteNonQueryAsync()).Should().Be(-1); + } + + [Fact] + public void DmlStatementsReturnTheirRowCountIncludingZero() + { + Command.CommandText = "CREATE TABLE counted (id INTEGER); INSERT INTO counted VALUES (1), (2), (3);"; + Command.ExecuteNonQuery().Should().Be(3, "only the INSERT is counted"); + + Command.CommandText = "UPDATE counted SET id = id + 10 WHERE id > 100"; + Command.ExecuteNonQuery().Should().Be(0); + + Command.CommandText = "DELETE FROM counted WHERE id = 2"; + Command.ExecuteNonQuery().Should().Be(1); + } + + // https://learn.microsoft.com/dotnet/api/system.data.common.dbdatareader.recordsaffected: + // "The number of rows changed, inserted, or deleted. -1 for SELECT statements; 0 if no rows were affected" + [Fact] + public void RecordsAffectedCountsDmlRunThroughExecuteReader() + { + Command.CommandText = "CREATE TABLE through_reader (id INTEGER); INSERT INTO through_reader VALUES (1), (2);"; + Command.ExecuteNonQuery(); + + Command.CommandText = "DELETE FROM through_reader WHERE id = 2"; + using (var reader = Command.ExecuteReader()) + { + reader.Close(); + reader.RecordsAffected.Should().Be(1); + } + + Command.CommandText = "DELETE FROM through_reader WHERE id = 99"; + using (var reader = Command.ExecuteReader()) + { + reader.Close(); + reader.RecordsAffected.Should().Be(0); + } + + Command.CommandText = "SELECT id FROM through_reader"; + using (var reader = Command.ExecuteReader()) + { + while (reader.Read()) { } + reader.Close(); + reader.RecordsAffected.Should().Be(-1); + } + } +} + From 700ea93ed708bd77ad343675c9bf29bbf48f2925 Mon Sep 17 00:00:00 2001 From: "Dailey, Alaric" Date: Tue, 20 Oct 2026 08:48:04 -0500 Subject: [PATCH 2/2] Count MERGE COPY and RETURNING rows --- DuckDB.NET.Bindings/DuckDBStatementType.cs | 6 ++- DuckDB.NET.Data/DuckDBCommand.cs | 11 +++-- DuckDB.NET.Data/DuckDBDataReader.cs | 8 ++-- DuckDB.NET.Test/ExecuteNonQueryTests.cs | 50 +++++++++++++++++++++- 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/DuckDB.NET.Bindings/DuckDBStatementType.cs b/DuckDB.NET.Bindings/DuckDBStatementType.cs index 3169983c..47881b85 100644 --- a/DuckDB.NET.Bindings/DuckDBStatementType.cs +++ b/DuckDB.NET.Bindings/DuckDBStatementType.cs @@ -20,7 +20,6 @@ public enum DuckDBStatementType Drop, Export, Pragma, - Show, Vacuum, Call, Set, @@ -31,4 +30,7 @@ public enum DuckDBStatementType Attach, Detach, Multi, -} \ No newline at end of file + CopyDatabase = 28, + UpdateExtensions = 29, + MergeInto = 30, +} diff --git a/DuckDB.NET.Data/DuckDBCommand.cs b/DuckDB.NET.Data/DuckDBCommand.cs index d1916d66..63d9ddb5 100644 --- a/DuckDB.NET.Data/DuckDBCommand.cs +++ b/DuckDB.NET.Data/DuckDBCommand.cs @@ -81,7 +81,7 @@ public override int ExecuteNonQuery() var current = result; if (CountsRows(NativeMethods.Query.DuckDBResultStatementType(current))) { - count = Math.Max(count, 0) + (int)NativeMethods.Query.DuckDBRowsChanged(ref current); + count = Math.Max(count, 0) + (int)GetRowsAffected(ref current); } result.Close(); @@ -92,7 +92,12 @@ public override int ExecuteNonQuery() /// The statements whose affected-row count ADO.NET reports; every other statement reports -1. internal static bool CountsRows(DuckDBStatementType statementType) => - statementType is DuckDBStatementType.Insert or DuckDBStatementType.Update or DuckDBStatementType.Delete; + statementType is DuckDBStatementType.Insert or DuckDBStatementType.Update or DuckDBStatementType.Delete or DuckDBStatementType.Copy or DuckDBStatementType.MergeInto; + + internal static long GetRowsAffected(ref DuckDBResult result) => + NativeMethods.Query.DuckDBResultReturnType(result) == DuckDBResultType.QueryResult + ? NativeMethods.Query.DuckDBRowCount(ref result) + : NativeMethods.Query.DuckDBRowsChanged(ref result); public override object? ExecuteScalar() { @@ -191,4 +196,4 @@ private void EnsureConnectionOpen([CallerMemberName] string operation = "") throw new InvalidOperationException($"{operation} requires an open connection"); } } -} \ No newline at end of file +} diff --git a/DuckDB.NET.Data/DuckDBDataReader.cs b/DuckDB.NET.Data/DuckDBDataReader.cs index dc367ea6..6eaa927e 100644 --- a/DuckDB.NET.Data/DuckDBDataReader.cs +++ b/DuckDB.NET.Data/DuckDBDataReader.cs @@ -57,7 +57,7 @@ private bool InitNextReader() var result = resultEnumerator.Current; if (DuckDBCommand.CountsRows(NativeMethods.Query.DuckDBResultStatementType(result))) { - recordsAffected = Math.Max(recordsAffected, 0) + (int)NativeMethods.Query.DuckDBRowsChanged(ref result); + recordsAffected = Math.Max(recordsAffected, 0) + (int)DuckDBCommand.GetRowsAffected(ref result); } if (NativeMethods.Query.DuckDBResultReturnType(result) == DuckDBResultType.QueryResult) @@ -348,8 +348,8 @@ public override bool IsDBNull(int ordinal) public override object this[string name] => GetValue(GetOrdinal(name)); /// - /// The rows inserted, updated or deleted by the statements read so far; -1 when none of them was an INSERT, UPDATE - /// or DELETE. DbDataReader.RecordsAffected: "-1 for SELECT statements; 0 if no rows were affected". + /// The rows inserted, updated, deleted, or copied by the statements read so far; -1 when none of them reports an + /// affected-row count. DbDataReader.RecordsAffected: "-1 for SELECT statements; 0 if no rows were affected". /// public override int RecordsAffected => recordsAffected; @@ -470,4 +470,4 @@ private static void ThrowNoRowRead() { throw new InvalidOperationException("No row has been read"); } -} \ No newline at end of file +} diff --git a/DuckDB.NET.Test/ExecuteNonQueryTests.cs b/DuckDB.NET.Test/ExecuteNonQueryTests.cs index e55b6231..97fe7027 100644 --- a/DuckDB.NET.Test/ExecuteNonQueryTests.cs +++ b/DuckDB.NET.Test/ExecuteNonQueryTests.cs @@ -1,4 +1,6 @@ -namespace DuckDB.NET.Test; +using DuckDB.NET.Test.Helpers; + +namespace DuckDB.NET.Test; public class ExecuteNonQueryTests(DuckDBDatabaseFixture db) : DuckDBTestBase(db) { @@ -71,6 +73,51 @@ public void DmlStatementsReturnTheirRowCountIncludingZero() Command.ExecuteNonQuery().Should().Be(1); } + [Fact] + public void MergeReportsItsRowCount() + { + Command.CommandText = "CREATE TABLE merge_counted (id INTEGER, name VARCHAR); INSERT INTO merge_counted VALUES (1, 'old');"; + Command.ExecuteNonQuery(); + + Command.CommandText = "MERGE INTO merge_counted AS target USING (VALUES (1, 'updated'), (2, 'inserted')) AS source(id, name) ON target.id = source.id WHEN MATCHED THEN UPDATE SET name = source.name WHEN NOT MATCHED THEN INSERT (id, name) VALUES (source.id, source.name);"; + Command.ExecuteNonQuery().Should().Be(2); + } + + [Fact] + public void CopyReportsItsRowCount() + { + using var file = DisposableFile.GenerateInTemp("csv"); + Command.CommandText = "CREATE TABLE copy_counted (id INTEGER); INSERT INTO copy_counted VALUES (1), (2), (3);"; + Command.ExecuteNonQuery(); + + Command.CommandText = $"COPY copy_counted TO '{file.FileName}' (FORMAT CSV);"; + Command.ExecuteNonQuery().Should().Be(3); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReturningReportsItsRowCount(bool useStreamingMode) + { + var tableName = useStreamingMode ? "returning_counted_streaming" : "returning_counted_materialized"; + using var command = new DuckDBCommand + { + Connection = Connection, + UseStreamingMode = useStreamingMode, + CommandText = $"CREATE TABLE {tableName} (id INTEGER); INSERT INTO {tableName} VALUES (1), (2);" + }; + command.ExecuteNonQuery(); + + command.CommandText = $"INSERT INTO {tableName} VALUES (3), (4) RETURNING id;"; + command.ExecuteNonQuery().Should().Be(2); + + command.CommandText = $"UPDATE {tableName} SET id = id + 10 WHERE id <= 2 RETURNING id;"; + using var reader = command.ExecuteReader(); + while (reader.Read()) { } + reader.Close(); + reader.RecordsAffected.Should().Be(2); + } + // https://learn.microsoft.com/dotnet/api/system.data.common.dbdatareader.recordsaffected: // "The number of rows changed, inserted, or deleted. -1 for SELECT statements; 0 if no rows were affected" [Fact] @@ -102,4 +149,3 @@ public void RecordsAffectedCountsDmlRunThroughExecuteReader() } } } -