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); + } + } +}