diff --git a/DuckDB.NET.Benchmarks/PreparedCommandBenchmark.cs b/DuckDB.NET.Benchmarks/PreparedCommandBenchmark.cs index 6f35a47..4472181 100644 --- a/DuckDB.NET.Benchmarks/PreparedCommandBenchmark.cs +++ b/DuckDB.NET.Benchmarks/PreparedCommandBenchmark.cs @@ -46,6 +46,15 @@ public int ExecutePrepared() return (int)preparedCommand.ExecuteScalar()!; } + // Preserves the former ExecuteScalar path as a direct same-process comparison. + [Benchmark] + public int ExecutePreparedViaReader() + { + preparedParameter.Value = nextValue++; + using var reader = preparedCommand.ExecuteReader(); + return reader.Read() ? (int)reader.GetValue(0) : default; + } + private DuckDBCommand CreateCommand(out DuckDBParameter changingParameter) { var command = connection.CreateCommand(); diff --git a/DuckDB.NET.Data/DuckDBCommand.cs b/DuckDB.NET.Data/DuckDBCommand.cs index 98e6be2..f21bdaa 100644 --- a/DuckDB.NET.Data/DuckDBCommand.cs +++ b/DuckDB.NET.Data/DuckDBCommand.cs @@ -5,6 +5,7 @@ using Apache.Arrow; using Apache.Arrow.Ipc; using DuckDB.NET.Data.Arrow; +using DuckDB.NET.Data.DataChunk.Reader; using PreparedStatementBase = DuckDB.NET.Data.PreparedStatement.PreparedStatement; using ReusablePreparedStatement = DuckDB.NET.Data.PreparedStatement.ReusablePreparedStatement; @@ -121,6 +122,11 @@ public override int ExecuteNonQuery() { EnsureConnectionOpen(); + if (preparedStatement is { } reusableStatement) + { + return ExecutePreparedScalar(reusableStatement, connection!.NativeConnection); + } + using var reader = ExecuteReader(); return reader.Read() ? reader.GetValue(0) : null; } @@ -292,6 +298,66 @@ private int ExecutePreparedNonQuery( } } + private object? ExecutePreparedScalar( + ReusablePreparedStatement reusableStatement, + DuckDBNativeConnection nativeConnection) + { + BeginPreparedExecution(); + + try + { + var result = reusableStatement.Execute(parameters, UseStreamingMode, nativeConnection); + + try + { + if (NativeMethods.Query.DuckDBResultReturnType(result) != DuckDBResultType.QueryResult || + NativeMethods.Query.DuckDBColumnCount(ref result) == 0) + { + return null; + } + + return ReadFirstValue(ref result); + } + finally + { + result.Close(); + } + } + finally + { + CompletePreparedExecution(); + } + } + + private static object? ReadFirstValue(ref DuckDBResult result) + { + var streamingResult = NativeMethods.Types.DuckDBResultIsStreaming(result) > 0; + long chunkIndex = 0; + + while (true) + { + using var chunk = streamingResult + ? NativeMethods.StreamingResult.DuckDBStreamFetchChunk(result) + : NativeMethods.Types.DuckDBResultGetChunk(result, chunkIndex++); + + if (chunk is null || chunk.IsInvalid) + { + return null; + } + + if (NativeMethods.DataChunks.DuckDBDataChunkGetSize(chunk) == 0) + { + continue; + } + + var vector = NativeMethods.DataChunks.DuckDBDataChunkGetVector(chunk, 0); + using var logicalType = NativeMethods.Query.DuckDBColumnLogicalType(ref result, 0); + using var reader = VectorDataReaderFactory.CreateReader(vector, logicalType); + + return reader.IsValid(0) ? reader.GetValue(0) : DBNull.Value; + } + } + private void BeginPreparedExecution() { activeExecutions++; diff --git a/DuckDB.NET.Test/DuckDBCommandTests.cs b/DuckDB.NET.Test/DuckDBCommandTests.cs index 298cff5..75aa356 100644 --- a/DuckDB.NET.Test/DuckDBCommandTests.cs +++ b/DuckDB.NET.Test/DuckDBCommandTests.cs @@ -35,6 +35,81 @@ public void PreparedCommandCanBeExecutedRepeatedlyWithNewParameterValues() command.ExecuteScalar().Should().Be(22); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PreparedExecuteScalarHandlesValuesNullsAndEmptyResults(bool useStreamingMode) + { + using var command = Connection.CreateCommand(); + command.CommandText = "SELECT $value::INTEGER"; + command.Parameters.Add(new DuckDBParameter("value", 42)); + command.UseStreamingMode = useStreamingMode; + command.Prepare(); + + command.ExecuteScalar().Should().Be(42); + + command.Parameters["value"].Value = DBNull.Value; + command.ExecuteScalar().Should().Be(DBNull.Value); + + command.CommandText = "SELECT 42 WHERE FALSE"; + command.Parameters.Clear(); + command.Prepare(); + command.ExecuteScalar().Should().BeNull(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PreparedExecuteScalarHandlesRepresentativeReaderTypes(bool useStreamingMode) + { + using var command = Connection.CreateCommand(); + command.UseStreamingMode = useStreamingMode; + + command.CommandText = "SELECT 'duckdb'::VARCHAR"; + command.Prepare(); + command.ExecuteScalar().Should().Be("duckdb"); + + command.CommandText = "SELECT DATE '2026-07-21'"; + command.Prepare(); + command.ExecuteScalar().Should().Be(new DateOnly(2026, 7, 21)); + + command.CommandText = "SELECT [1, 2, 3]::INTEGER[]"; + command.Prepare(); + command.ExecuteScalar().Should().BeEquivalentTo(new[] { 1, 2, 3 }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PreparedExecuteScalarCanBeReusedAfterMaterializationFailure(bool useStreamingMode) + { + using var command = Connection.CreateCommand(); + command.CommandText = "SELECT CASE WHEN $infinite THEN DATE 'infinity' ELSE DATE '2026-07-21' END"; + command.Parameters.Add(new DuckDBParameter("infinite", true)); + command.UseStreamingMode = useStreamingMode; + command.Prepare(); + + command.Invoking(value => value.ExecuteScalar()) + .Should().Throw() + .WithMessage("Cannot convert infinite date value*"); + + command.Parameters["infinite"].Value = false; + command.ExecuteScalar().Should().Be(new DateOnly(2026, 7, 21)); + } + + [Fact] + public void PreparedExecuteScalarReturnsNullForNonQueryStatement() + { + using var connection = new DuckDBConnection("DataSource=:memory:"); + connection.Open(); + + using var command = connection.CreateCommand(); + command.CommandText = "CREATE TABLE prepared_scalar(value INTEGER)"; + command.Prepare(); + + command.ExecuteScalar().Should().BeNull(); + } + [Fact] public void PreparedCommandClearsBindingsBeforeReuse() {