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 48fa3720..63d9ddb5 100644
--- a/DuckDB.NET.Data/DuckDBCommand.cs
+++ b/DuckDB.NET.Data/DuckDBCommand.cs
@@ -72,18 +72,33 @@ 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)GetRowsAffected(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 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()
{
EnsureConnectionOpen();
@@ -181,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 2e750c89..6eaa927e 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)DuckDBCommand.GetRowsAffected(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, 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;
public override bool HasRows => hasRows;
@@ -460,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 8ac48ede..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)
{
@@ -41,4 +43,109 @@ 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);
+ }
+
+ [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]
+ 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);
+ }
+ }
+}