Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions DuckDB.NET.Bindings/DuckDBStatementType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ public enum DuckDBStatementType
Drop,
Export,
Pragma,
Show,
Vacuum,
Call,
Set,
Expand All @@ -31,4 +30,7 @@ public enum DuckDBStatementType
Attach,
Detach,
Multi,
}
CopyDatabase = 28,
UpdateExtensions = 29,
MergeInto = 30,
}
21 changes: 18 additions & 3 deletions DuckDB.NET.Data/DuckDBCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>The statements whose affected-row count ADO.NET reports; every other statement reports -1.</summary>
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();
Expand Down Expand Up @@ -181,4 +196,4 @@ private void EnsureConnectionOpen([CallerMemberName] string operation = "")
throw new InvalidOperationException($"{operation} requires an open connection");
}
}
}
}
14 changes: 12 additions & 2 deletions DuckDB.NET.Data/DuckDBDataReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -341,7 +347,11 @@ public override bool IsDBNull(int ordinal)

public override object this[string name] => GetValue(GetOrdinal(name));

public override int RecordsAffected => -1;
/// <summary>
/// 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".
/// </summary>
public override int RecordsAffected => recordsAffected;

public override bool HasRows => hasRows;

Expand Down Expand Up @@ -460,4 +470,4 @@ private static void ThrowNoRowRead()
{
throw new InvalidOperationException("No row has been read");
}
}
}
111 changes: 109 additions & 2 deletions DuckDB.NET.Test/ExecuteNonQueryTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace DuckDB.NET.Test;
using DuckDB.NET.Test.Helpers;

namespace DuckDB.NET.Test;

public class ExecuteNonQueryTests(DuckDBDatabaseFixture db) : DuckDBTestBase(db)
{
Expand Down Expand Up @@ -41,4 +43,109 @@ public void ExecuteQueryWithUnicodeText()
results.Should().BeEquivalentTo(words);
}
}
}

// 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);
}
}
}
Loading