diff --git a/DuckDB.NET.Benchmarks/Benchmarks.csproj b/DuckDB.NET.Benchmarks/Benchmarks.csproj
new file mode 100644
index 00000000..f09daf7e
--- /dev/null
+++ b/DuckDB.NET.Benchmarks/Benchmarks.csproj
@@ -0,0 +1,31 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+ Full
+ true
+ ..\keyPair.snk
+
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ PreserveNewest
+ runtimes\%(RecursiveDir)\%(FileName)%(Extension)
+
+
+
+
diff --git a/DuckDB.NET.Benchmarks/NativeLibraryLoader.cs b/DuckDB.NET.Benchmarks/NativeLibraryLoader.cs
new file mode 100644
index 00000000..f9af56f3
--- /dev/null
+++ b/DuckDB.NET.Benchmarks/NativeLibraryLoader.cs
@@ -0,0 +1,53 @@
+using System.Reflection;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+
+namespace DuckDB.NET.Benchmarks;
+
+///
+/// Loads the platform-native DuckDB library for the benchmark process.
+///
+internal static class NativeLibraryLoader
+{
+ [ModuleInitializer]
+ public static void Init()
+ {
+ if (GetRid() is not { } rid)
+ {
+ return;
+ }
+
+ _ = NativeLibrary.TryLoad(Path.Join("runtimes", rid, "native", "duckdb"), Assembly.GetExecutingAssembly(), DllImportSearchPath.AssemblyDirectory, out _) ||
+ NativeLibrary.TryLoad(Path.Join("runtimes", rid, "native", "libduckdb"), Assembly.GetExecutingAssembly(), DllImportSearchPath.AssemblyDirectory, out _);
+ }
+
+ private static string? GetRid()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return RuntimeInformation.ProcessArchitecture switch
+ {
+ Architecture.X64 => "win-x64",
+ Architecture.Arm64 => "win-arm64",
+ _ => null,
+ };
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ return RuntimeInformation.ProcessArchitecture switch
+ {
+ Architecture.X64 => "linux-x64",
+ Architecture.Arm64 => "linux-arm64",
+ _ => null,
+ };
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ return "osx";
+ }
+
+ return null;
+ }
+}
diff --git a/DuckDB.NET.Benchmarks/PreparedCommandBenchmark.cs b/DuckDB.NET.Benchmarks/PreparedCommandBenchmark.cs
new file mode 100644
index 00000000..6f35a47a
--- /dev/null
+++ b/DuckDB.NET.Benchmarks/PreparedCommandBenchmark.cs
@@ -0,0 +1,101 @@
+using BenchmarkDotNet.Attributes;
+using DuckDB.NET.Data;
+
+namespace DuckDB.NET.Benchmarks;
+
+[MemoryDiagnoser]
+public class PreparedCommandBenchmark
+{
+ private DuckDBConnection connection = null!;
+ private DuckDBCommand unpreparedCommand = null!;
+ private DuckDBCommand preparedCommand = null!;
+ private DuckDBParameter unpreparedParameter = null!;
+ private DuckDBParameter preparedParameter = null!;
+ private int nextValue;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ connection = new DuckDBConnection("DataSource=:memory:");
+ connection.Open();
+
+ unpreparedCommand = CreateCommand(out unpreparedParameter);
+ preparedCommand = CreateCommand(out preparedParameter);
+ preparedCommand.Prepare();
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ preparedCommand.Dispose();
+ unpreparedCommand.Dispose();
+ connection.Dispose();
+ }
+
+ [Benchmark(Baseline = true)]
+ public int ExecuteUnprepared()
+ {
+ unpreparedParameter.Value = nextValue++;
+ return (int)unpreparedCommand.ExecuteScalar()!;
+ }
+
+ [Benchmark]
+ public int ExecutePrepared()
+ {
+ preparedParameter.Value = nextValue++;
+ return (int)preparedCommand.ExecuteScalar()!;
+ }
+
+ private DuckDBCommand CreateCommand(out DuckDBParameter changingParameter)
+ {
+ var command = connection.CreateCommand();
+ command.CommandText = "SELECT $first::INTEGER + $second::INTEGER + $third::INTEGER";
+ changingParameter = new DuckDBParameter("first", 1);
+ command.Parameters.Add(changingParameter);
+ command.Parameters.Add(new DuckDBParameter("second", 2));
+ command.Parameters.Add(new DuckDBParameter("third", 3));
+ return command;
+ }
+}
+
+[MemoryDiagnoser]
+public class PreparedCommandSetupBenchmark
+{
+ private DuckDBConnection connection = null!;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ connection = new DuckDBConnection("DataSource=:memory:");
+ connection.Open();
+ }
+
+ [GlobalCleanup]
+ public void Cleanup()
+ {
+ connection.Dispose();
+ }
+
+ [Benchmark(Baseline = true)]
+ public void CreateUnpreparedCommand()
+ {
+ using var command = CreateCommand();
+ }
+
+ [Benchmark]
+ public void CreateAndPrepareCommand()
+ {
+ using var command = CreateCommand();
+ command.Prepare();
+ }
+
+ private DuckDBCommand CreateCommand()
+ {
+ var command = connection.CreateCommand();
+ command.CommandText = "SELECT $first::INTEGER + $second::INTEGER + $third::INTEGER";
+ command.Parameters.Add(new DuckDBParameter("first", 1));
+ command.Parameters.Add(new DuckDBParameter("second", 2));
+ command.Parameters.Add(new DuckDBParameter("third", 3));
+ return command;
+ }
+}
diff --git a/DuckDB.NET.Benchmarks/Program.cs b/DuckDB.NET.Benchmarks/Program.cs
new file mode 100644
index 00000000..edb9949c
--- /dev/null
+++ b/DuckDB.NET.Benchmarks/Program.cs
@@ -0,0 +1,15 @@
+using BenchmarkDotNet.Configs;
+using BenchmarkDotNet.Jobs;
+using BenchmarkDotNet.Running;
+using BenchmarkDotNet.Toolchains.InProcess.Emit;
+using DuckDB.NET.Benchmarks;
+
+// The repo's Directory.Build.props renames the output assembly to DuckDB.NET.Benchmarks
+// while the project file stays Benchmarks.csproj, so BenchmarkDotNet's default toolchain
+// can't locate the csproj. Run in-process to avoid the separate build/spawn step.
+var config = DefaultConfig.Instance
+ .AddJob(Job.ShortRun.WithToolchain(InProcessEmitToolchain.Instance));
+
+BenchmarkSwitcher
+ .FromTypes([typeof(PreparedCommandBenchmark), typeof(PreparedCommandSetupBenchmark)])
+ .Run(args, config);
diff --git a/DuckDB.NET.Bindings/NativeMethods/NativeMethods.PreparedStatements.cs b/DuckDB.NET.Bindings/NativeMethods/NativeMethods.PreparedStatements.cs
index 68f20cf3..4ab9a151 100644
--- a/DuckDB.NET.Bindings/NativeMethods/NativeMethods.PreparedStatements.cs
+++ b/DuckDB.NET.Bindings/NativeMethods/NativeMethods.PreparedStatements.cs
@@ -110,6 +110,12 @@ public static partial class PreparedStatements
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
public static partial DuckDBState DuckDBBindNull(DuckDBPreparedStatement preparedStatement, long index);
+ // Clears values from a reusable prepared statement before rebinding it. This prevents a
+ // parameter omitted by a later named collection from retaining its previous value.
+ [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_clear_bindings")]
+ [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
+ public static partial DuckDBState DuckDBClearBindings(DuckDBPreparedStatement preparedStatement);
+
[LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_execute_prepared")]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]
public static partial DuckDBState DuckDBExecutePrepared(DuckDBPreparedStatement preparedStatement, out DuckDBResult result);
diff --git a/DuckDB.NET.Data/DuckDBCommand.cs b/DuckDB.NET.Data/DuckDBCommand.cs
index c1e1b7d0..5689b4c8 100644
--- a/DuckDB.NET.Data/DuckDBCommand.cs
+++ b/DuckDB.NET.Data/DuckDBCommand.cs
@@ -5,6 +5,8 @@
using Apache.Arrow;
using Apache.Arrow.Ipc;
using DuckDB.NET.Data.Arrow;
+using PreparedStatementBase = DuckDB.NET.Data.PreparedStatement.PreparedStatement;
+using ReusablePreparedStatement = DuckDB.NET.Data.PreparedStatement.ReusablePreparedStatement;
namespace DuckDB.NET.Data;
@@ -12,6 +14,12 @@ public class DuckDBCommand : DbCommand
{
private DuckDBConnection? connection;
private readonly DuckDBParameterCollection parameters = new();
+ private ReusablePreparedStatement? preparedStatement;
+ private DuckDBConnection? preparedConnection;
+ private List<(DuckDBConnection Connection, ReusablePreparedStatement Statement)>? deferredPreparedStatements;
+ private HashSet? registeredConnections;
+ private int activeExecutions;
+ private bool disposed;
protected override DbTransaction? DbTransaction { get; set; }
protected override DbParameterCollection DbParameterCollection => parameters;
@@ -39,15 +47,35 @@ public override string CommandText
get;
set
{
- // TODO: We shouldn't be able to change the CommandText when the command is in execution (requires CommandState implementation)
- field = value ?? string.Empty;
+ EnsureNotDisposed();
+
+ var newValue = value ?? string.Empty;
+ if (string.Equals(field, newValue, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ InvalidatePreparedStatements();
+ field = newValue;
}
} = string.Empty;
protected override DbConnection? DbConnection
{
get => connection;
- set => connection = (DuckDBConnection?)value;
+ set
+ {
+ EnsureNotDisposed();
+
+ var newConnection = (DuckDBConnection?)value;
+ if (ReferenceEquals(connection, newConnection))
+ {
+ return;
+ }
+
+ InvalidatePreparedStatements();
+ connection = newConnection;
+ }
}
public DuckDBCommand()
@@ -70,7 +98,7 @@ public override int ExecuteNonQuery()
{
EnsureConnectionOpen();
- var results = PreparedStatement.PreparedStatement.PrepareMultiple(connection!.NativeConnection, CommandText, parameters, UseStreamingMode);
+ var results = ExecuteStatements();
var count = 0;
@@ -106,7 +134,7 @@ protected override DbDataReader ExecuteDbDataReader(CommandBehavior behavior)
{
EnsureConnectionOpen();
- var results = PreparedStatement.PreparedStatement.PrepareMultiple(connection!.NativeConnection, CommandText, parameters, UseStreamingMode);
+ var results = ExecuteStatements();
var reader = new DuckDBDataReader(this, results, behavior);
@@ -125,7 +153,7 @@ public IArrowArrayStream ExecuteArrowStream()
{
EnsureConnectionOpen();
- var results = PreparedStatement.PreparedStatement.PrepareMultiple(connection!.NativeConnection, CommandText, parameters, UseStreamingMode);
+ var results = ExecuteStatements();
foreach (var result in results)
{
@@ -164,17 +192,260 @@ public async IAsyncEnumerable ExecuteArrowBatchesAsync([EnumeratorC
}
}
- public override void Prepare() { }
+ public override void Prepare()
+ {
+ EnsureNotDisposed();
+ EnsureConnectionOpen();
+
+ if (preparedStatement is not null)
+ {
+ return;
+ }
+
+ var statement = PreparedStatementBase.TryPrepareReusable(connection!.NativeConnection, CommandText);
+ if (statement is null)
+ {
+ return;
+ }
+
+ preparedStatement = statement;
+ preparedConnection = connection;
+ RefreshPreparedCommandRegistrations();
+ }
protected override DbParameter CreateDbParameter() => new DuckDBParameter();
internal void CloseConnection() => Connection!.Close();
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && !disposed)
+ {
+ disposed = true;
+ InvalidatePreparedStatements();
+
+ if (activeExecutions == 0)
+ {
+ UnregisterFromConnections();
+ }
+ }
+
+ base.Dispose(disposing);
+ }
+
+ private IEnumerable ExecuteStatements()
+ {
+ EnsureNotDisposed();
+
+ var nativeConnection = connection!.NativeConnection;
+ var reusableStatement = preparedStatement;
+
+ return reusableStatement is null
+ ? PreparedStatementBase.PrepareMultiple(nativeConnection, CommandText, parameters, UseStreamingMode)
+ : ExecutePreparedStatement(reusableStatement, nativeConnection);
+ }
+
+ private IEnumerable ExecutePreparedStatement(
+ ReusablePreparedStatement reusableStatement,
+ DuckDBNativeConnection nativeConnection)
+ {
+ activeExecutions++;
+
+ try
+ {
+ yield return reusableStatement.Execute(parameters, UseStreamingMode, nativeConnection);
+ }
+ finally
+ {
+ activeExecutions--;
+
+ if (activeExecutions == 0)
+ {
+ DisposeDeferredPreparedStatements();
+ }
+
+ if (disposed && activeExecutions == 0)
+ {
+ UnregisterFromConnections();
+ }
+ }
+ }
+
+ private void InvalidatePreparedStatements()
+ {
+ var statement = preparedStatement;
+ var statementConnection = preparedConnection;
+ preparedStatement = null;
+ preparedConnection = null;
+
+ if (statement is null)
+ {
+ return;
+ }
+
+ if (activeExecutions > 0)
+ {
+ deferredPreparedStatements ??= [];
+ deferredPreparedStatements.Add((statementConnection!, statement));
+ RefreshPreparedCommandRegistrations();
+ return;
+ }
+
+ statement.Dispose();
+ RefreshPreparedCommandRegistrations();
+ }
+
+ private void DisposeDeferredPreparedStatements()
+ {
+ var deferredStatements = deferredPreparedStatements;
+ if (deferredStatements is null)
+ {
+ return;
+ }
+
+ foreach (var deferredStatement in deferredStatements)
+ {
+ deferredStatement.Statement.Dispose();
+ }
+
+ deferredPreparedStatements = null;
+ RefreshPreparedCommandRegistrations();
+ }
+
+ internal void OnConnectionClosing(DuckDBConnection closingConnection)
+ {
+ if (ReferenceEquals(preparedConnection, closingConnection))
+ {
+ preparedStatement?.Dispose();
+ preparedStatement = null;
+ preparedConnection = null;
+ }
+
+ for (var index = deferredPreparedStatements?.Count - 1 ?? -1; index >= 0; index--)
+ {
+ var deferredStatement = deferredPreparedStatements![index];
+ if (!ReferenceEquals(deferredStatement.Connection, closingConnection))
+ {
+ continue;
+ }
+
+ deferredStatement.Statement.Dispose();
+ deferredPreparedStatements.RemoveAt(index);
+ }
+
+ if (deferredPreparedStatements?.Count == 0)
+ {
+ deferredPreparedStatements = null;
+ }
+
+ RefreshPreparedCommandRegistrations();
+ }
+
+ private void RefreshPreparedCommandRegistrations()
+ {
+ List? connectionsToRemove = null;
+
+ if (registeredConnections is not null)
+ {
+ foreach (var registeredConnection in registeredConnections)
+ {
+ if (IsConnectionRequired(registeredConnection))
+ {
+ continue;
+ }
+
+ connectionsToRemove ??= [];
+ connectionsToRemove.Add(registeredConnection);
+ }
+ }
+
+ if (connectionsToRemove is not null)
+ {
+ foreach (var registeredConnection in connectionsToRemove)
+ {
+ registeredConnection.UnregisterPreparedCommand(this);
+ registeredConnections!.Remove(registeredConnection);
+ }
+ }
+
+ if (registeredConnections?.Count == 0)
+ {
+ registeredConnections = null;
+ }
+
+ if (preparedConnection is not null)
+ {
+ RegisterWithConnection(preparedConnection);
+ }
+
+ if (deferredPreparedStatements is not null)
+ {
+ foreach (var deferredStatement in deferredPreparedStatements)
+ {
+ RegisterWithConnection(deferredStatement.Connection);
+ }
+ }
+ }
+
+ private bool IsConnectionRequired(DuckDBConnection candidate)
+ {
+ if (ReferenceEquals(preparedConnection, candidate))
+ {
+ return true;
+ }
+
+ if (deferredPreparedStatements is null)
+ {
+ return false;
+ }
+
+ foreach (var deferredStatement in deferredPreparedStatements)
+ {
+ if (ReferenceEquals(deferredStatement.Connection, candidate))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private void RegisterWithConnection(DuckDBConnection requiredConnection)
+ {
+ registeredConnections ??= [];
+ if (registeredConnections.Add(requiredConnection))
+ {
+ requiredConnection.RegisterPreparedCommand(this);
+ }
+ }
+
+ private void UnregisterFromConnections()
+ {
+ if (registeredConnections is null)
+ {
+ return;
+ }
+
+ foreach (var registeredConnection in registeredConnections)
+ {
+ registeredConnection.UnregisterPreparedCommand(this);
+ }
+
+ registeredConnections = null;
+ }
+
+ private void EnsureNotDisposed()
+ {
+ ObjectDisposedException.ThrowIf(disposed, this);
+ }
+
private void EnsureConnectionOpen([CallerMemberName] string operation = "")
{
+ EnsureNotDisposed();
+
if (Connection is null || Connection.State != ConnectionState.Open)
{
throw new InvalidOperationException($"{operation} requires an open connection");
}
}
-}
\ No newline at end of file
+}
diff --git a/DuckDB.NET.Data/DuckDBConnection.cs b/DuckDB.NET.Data/DuckDBConnection.cs
index 9f20e8ad..2dd35758 100644
--- a/DuckDB.NET.Data/DuckDBConnection.cs
+++ b/DuckDB.NET.Data/DuckDBConnection.cs
@@ -12,7 +12,8 @@ public partial class DuckDBConnection : DbConnection
private DuckDBConnectionString? parsedConnection;
private ConnectionReference? connectionReference;
private bool inMemoryDuplication = false;
-
+ private List>? preparedCommands;
+
private static readonly StateChangeEventArgs FromClosedToOpenEventArgs = new(ConnectionState.Closed, ConnectionState.Open);
private static readonly StateChangeEventArgs FromOpenToClosedEventArgs = new(ConnectionState.Open, ConnectionState.Closed);
@@ -88,6 +89,8 @@ public override void Close()
throw new InvalidOperationException("Connection is already closed.");
}
+ InvalidatePreparedCommands();
+
if (connectionReference is not null) //Should always be the case
{
connectionManager.ReturnConnectionReference(connectionReference);
@@ -97,6 +100,68 @@ public override void Close()
OnStateChange(FromOpenToClosedEventArgs);
}
+ internal void RegisterPreparedCommand(DuckDBCommand command)
+ {
+ preparedCommands ??= [];
+
+ for (var index = preparedCommands.Count - 1; index >= 0; index--)
+ {
+ if (!preparedCommands[index].TryGetTarget(out var preparedCommand))
+ {
+ preparedCommands.RemoveAt(index);
+ continue;
+ }
+
+ if (ReferenceEquals(preparedCommand, command))
+ {
+ return;
+ }
+ }
+
+ preparedCommands.Add(new WeakReference(command));
+ }
+
+ internal void UnregisterPreparedCommand(DuckDBCommand command)
+ {
+ if (preparedCommands is null)
+ {
+ return;
+ }
+
+ for (var index = preparedCommands.Count - 1; index >= 0; index--)
+ {
+ if (!preparedCommands[index].TryGetTarget(out var preparedCommand) || ReferenceEquals(preparedCommand, command))
+ {
+ preparedCommands.RemoveAt(index);
+ }
+ }
+
+ if (preparedCommands.Count == 0)
+ {
+ preparedCommands = null;
+ }
+ }
+
+ private void InvalidatePreparedCommands()
+ {
+ if (preparedCommands is not { Count: > 0 })
+ {
+ return;
+ }
+
+ var commands = new WeakReference[preparedCommands.Count];
+ preparedCommands.CopyTo(commands);
+ preparedCommands = null;
+
+ foreach (var commandReference in commands)
+ {
+ if (commandReference.TryGetTarget(out var command))
+ {
+ command.OnConnectionClosing(this);
+ }
+ }
+ }
+
public override void Open()
{
if (connectionState == ConnectionState.Open)
diff --git a/DuckDB.NET.Data/PreparedStatement/PreparedStatement.cs b/DuckDB.NET.Data/PreparedStatement/PreparedStatement.cs
index 4f7d559d..95aa1918 100644
--- a/DuckDB.NET.Data/PreparedStatement/PreparedStatement.cs
+++ b/DuckDB.NET.Data/PreparedStatement/PreparedStatement.cs
@@ -2,15 +2,19 @@
namespace DuckDB.NET.Data.PreparedStatement;
-internal sealed class PreparedStatement : IDisposable
+internal class PreparedStatement : IDisposable
{
- private readonly DuckDBPreparedStatement statement;
+ protected readonly DuckDBPreparedStatement Statement;
- private PreparedStatement(DuckDBPreparedStatement statement)
+ internal PreparedStatement(DuckDBPreparedStatement statement)
{
- this.statement = statement;
+ Statement = statement;
}
+ protected virtual bool RequiresClearBindings => false;
+
+ protected virtual long ParameterCount => NativeMethods.PreparedStatements.DuckDBParams(Statement);
+
public static IEnumerable PrepareMultiple(DuckDBNativeConnection connection, string query, DuckDBParameterCollection parameters, bool useStreamingMode)
{
var statementCount = NativeMethods.ExtractStatements.DuckDBExtractStatements(connection, query, out var extractedStatements);
@@ -35,6 +39,7 @@ public static IEnumerable PrepareMultiple(DuckDBNativeConnection c
else
{
var errorMessage = NativeMethods.PreparedStatements.DuckDBPrepareError(statement);
+ statement.Dispose();
if (string.IsNullOrEmpty(errorMessage))
{
@@ -47,13 +52,61 @@ public static IEnumerable PrepareMultiple(DuckDBNativeConnection c
}
}
- private DuckDBResult Execute(DuckDBParameterCollection parameterCollection, bool useStreamingMode, DuckDBNativeConnection connection)
+ public static ReusablePreparedStatement? TryPrepareReusable(DuckDBNativeConnection connection, string query)
+ {
+ var statementCount = NativeMethods.ExtractStatements.DuckDBExtractStatements(connection, query, out var extractedStatements);
+
+ using (extractedStatements)
+ {
+ if (statementCount <= 0)
+ {
+ var error = NativeMethods.ExtractStatements.DuckDBExtractStatementsError(extractedStatements);
+ throw new DuckDBException(error);
+ }
+
+ // DuckDB can expand one logical command into dependent statements. Dynamic PIVOT,
+ // IMPORT and some PRAGMAs require earlier statements to execute before later ones can
+ // be prepared. Keep those commands on the existing per-execution path.
+ if (statementCount != 1)
+ {
+ return null;
+ }
+
+ var status = NativeMethods.ExtractStatements.DuckDBPrepareExtractedStatement(connection, extractedStatements, 0, out var statement);
+ if (status.IsSuccess())
+ {
+ return new ReusablePreparedStatement(statement);
+ }
+
+ var errorMessage = NativeMethods.PreparedStatements.DuckDBPrepareError(statement);
+ statement.Dispose();
+
+ if (string.IsNullOrEmpty(errorMessage))
+ {
+ errorMessage = "DuckDBQuery failed";
+ }
+
+ throw new DuckDBException(errorMessage, UdfExceptionStore.Retrieve(connection));
+ }
+ }
+
+ internal DuckDBResult Execute(DuckDBParameterCollection parameterCollection, bool useStreamingMode, DuckDBNativeConnection connection)
{
- BindParameters(statement, parameterCollection);
+ if (RequiresClearBindings)
+ {
+ var clearState = NativeMethods.PreparedStatements.DuckDBClearBindings(Statement);
+ if (!clearState.IsSuccess())
+ {
+ var errorMessage = NativeMethods.PreparedStatements.DuckDBPrepareError(Statement);
+ throw new InvalidOperationException($"Unable to clear prepared statement bindings: {errorMessage}");
+ }
+ }
+
+ BindParameters(parameterCollection);
var status = useStreamingMode
- ? NativeMethods.PreparedStatements.DuckDBExecutePreparedStreaming(statement, out var queryResult)
- : NativeMethods.PreparedStatements.DuckDBExecutePrepared(statement, out queryResult);
+ ? NativeMethods.PreparedStatements.DuckDBExecutePreparedStreaming(Statement, out var queryResult)
+ : NativeMethods.PreparedStatements.DuckDBExecutePrepared(Statement, out queryResult);
if (!status.IsSuccess())
{
@@ -80,9 +133,9 @@ private DuckDBResult Execute(DuckDBParameterCollection parameterCollection, bool
return queryResult;
}
- private static void BindParameters(DuckDBPreparedStatement preparedStatement, DuckDBParameterCollection parameterCollection)
+ private void BindParameters(DuckDBParameterCollection parameterCollection)
{
- var expectedParameters = NativeMethods.PreparedStatements.DuckDBParams(preparedStatement);
+ var expectedParameters = ParameterCount;
if (parameterCollection.Count < expectedParameters)
{
throw new InvalidOperationException($"Invalid number of parameters. Expected {expectedParameters}, got {parameterCollection.Count}");
@@ -108,10 +161,9 @@ private static void BindParameters(DuckDBPreparedStatement preparedStatement, Du
for (var i = 0; i < count; i++)
{
var param = parameterCollection[i];
- var state = NativeMethods.PreparedStatements.DuckDBBindParameterIndex(preparedStatement, out var index, param.ParameterName);
- if (state.IsSuccess())
+ if (TryGetParameterIndex(param.ParameterName, out var index))
{
- BindParameter(preparedStatement, index, param);
+ BindParameter(index, param);
}
}
}
@@ -120,29 +172,107 @@ private static void BindParameters(DuckDBPreparedStatement preparedStatement, Du
for (var i = 0; i < expectedParameters; ++i)
{
var param = parameterCollection[i];
- BindParameter(preparedStatement, i + 1, param);
+ BindParameter(i + 1, param);
}
}
}
- private static void BindParameter(DuckDBPreparedStatement preparedStatement, long index, DuckDBParameter parameter)
+ protected virtual bool TryGetParameterIndex(string parameterName, out long index)
+ {
+ if (string.IsNullOrEmpty(parameterName))
+ {
+ index = 0;
+ return false;
+ }
+
+ var state = NativeMethods.PreparedStatements.DuckDBBindParameterIndex(Statement, out var nativeIndex, parameterName);
+ index = state.IsSuccess() ? nativeIndex : 0;
+
+ return index > 0;
+ }
+
+ protected virtual void BindParameter(long index, DuckDBParameter parameter)
+ {
+ using var parameterLogicalType = NativeMethods.PreparedStatements.DuckDBParamLogicalType(Statement, index);
+ BindParameter(index, parameter, parameterLogicalType);
+ }
+
+ protected void BindParameter(long index, DuckDBParameter parameter, DuckDBLogicalType parameterLogicalType)
{
- using var parameterLogicalType = NativeMethods.PreparedStatements.DuckDBParamLogicalType(preparedStatement, index);
var duckDBType = NativeMethods.LogicalType.DuckDBGetTypeId(parameterLogicalType);
using var duckDBValue = parameter.Value.ToDuckDBValue(parameterLogicalType, duckDBType, parameter.DbType);
- var result = NativeMethods.PreparedStatements.DuckDBBindValue(preparedStatement, index, duckDBValue);
+ var result = NativeMethods.PreparedStatements.DuckDBBindValue(Statement, index, duckDBValue);
if (!result.IsSuccess())
{
- var errorMessage = NativeMethods.PreparedStatements.DuckDBPrepareError(preparedStatement);
+ var errorMessage = NativeMethods.PreparedStatements.DuckDBPrepareError(Statement);
throw new InvalidOperationException($"Unable to bind parameter {index}: {errorMessage}");
}
}
- public void Dispose()
+ public virtual void Dispose()
+ {
+ Statement.Dispose();
+ }
+}
+
+internal sealed class ReusablePreparedStatement : PreparedStatement
+{
+ private readonly long cachedParameterCount;
+ private readonly DuckDBLogicalType[] cachedParameterTypes;
+ private readonly Dictionary cachedParameterIndices = new(StringComparer.Ordinal);
+
+ public ReusablePreparedStatement(DuckDBPreparedStatement statement)
+ : base(statement)
+ {
+ cachedParameterCount = NativeMethods.PreparedStatements.DuckDBParams(statement);
+ cachedParameterTypes = new DuckDBLogicalType[cachedParameterCount];
+
+ try
+ {
+ for (var index = 0; index < cachedParameterTypes.Length; index++)
+ {
+ cachedParameterTypes[index] = NativeMethods.PreparedStatements.DuckDBParamLogicalType(statement, index + 1);
+ }
+ }
+ catch
+ {
+ Dispose();
+ throw;
+ }
+ }
+
+ protected override bool RequiresClearBindings => true;
+
+ protected override long ParameterCount => cachedParameterCount;
+
+ protected override bool TryGetParameterIndex(string parameterName, out long index)
+ {
+ if (cachedParameterIndices.TryGetValue(parameterName, out index))
+ {
+ return index > 0;
+ }
+
+ var found = base.TryGetParameterIndex(parameterName, out index);
+ cachedParameterIndices.Add(parameterName, index);
+
+ return found;
+ }
+
+ protected override void BindParameter(long index, DuckDBParameter parameter)
{
- statement.Dispose();
+ BindParameter(index, parameter, cachedParameterTypes[index - 1]);
+ }
+
+ public override void Dispose()
+ {
+ foreach (var parameterType in cachedParameterTypes)
+ {
+ parameterType?.Dispose();
+ }
+
+ base.Dispose();
}
-}
\ No newline at end of file
+}
diff --git a/DuckDB.NET.Test/DuckDBCommandTests.cs b/DuckDB.NET.Test/DuckDBCommandTests.cs
index b807f2b6..8bfd4d8b 100644
--- a/DuckDB.NET.Test/DuckDBCommandTests.cs
+++ b/DuckDB.NET.Test/DuckDBCommandTests.cs
@@ -17,4 +17,222 @@ public void SetCommandTextAndConnection()
cmd.CommandText.Should().Be("Select 1");
cmd.Connection.Should().Be(Connection);
}
-}
\ No newline at end of file
+
+ [Fact]
+ public void PreparedCommandCanBeExecutedRepeatedlyWithNewParameterValues()
+ {
+ using var command = Connection.CreateCommand();
+ command.CommandText = "SELECT $left::INTEGER + $right::INTEGER";
+ command.Parameters.Add(new DuckDBParameter("left", 10));
+ command.Parameters.Add(new DuckDBParameter("right", 1));
+
+ command.Prepare();
+
+ command.ExecuteScalar().Should().Be(11);
+
+ command.Parameters["left"].Value = 20;
+ command.Parameters["right"].Value = 2;
+ command.ExecuteScalar().Should().Be(22);
+ }
+
+ [Fact]
+ public void PreparedCommandClearsBindingsBeforeReuse()
+ {
+ using var command = Connection.CreateCommand();
+ command.CommandText = "SELECT $used::INTEGER";
+ command.Parameters.Add(new DuckDBParameter("used", 10));
+ command.Prepare();
+
+ command.ExecuteScalar().Should().Be(10);
+
+ command.Parameters.Clear();
+ command.Parameters.Add(new DuckDBParameter("unused", 20));
+ command.Invoking(value => value.ExecuteScalar()).Should().Throw();
+
+ command.Parameters.Clear();
+ command.Parameters.Add(new DuckDBParameter("used", 30));
+ command.ExecuteScalar().Should().Be(30);
+ }
+
+ [Fact]
+ public void PreparePreservesMultipleResultSetsAcrossExecutions()
+ {
+ using var command = Connection.CreateCommand();
+ command.CommandText = "SELECT $value::INTEGER; SELECT $value::INTEGER + 1";
+ command.Parameters.Add(new DuckDBParameter("value", 10));
+ command.Prepare();
+
+ AssertResults(command, 10, 11);
+
+ command.Parameters["value"].Value = 20;
+ AssertResults(command, 20, 21);
+
+ static void AssertResults(DuckDBCommand preparedCommand, int first, int second)
+ {
+ using var reader = preparedCommand.ExecuteReader();
+
+ reader.Read().Should().BeTrue();
+ reader.GetInt32(0).Should().Be(first);
+ reader.NextResult().Should().BeTrue();
+ reader.Read().Should().BeTrue();
+ reader.GetInt32(0).Should().Be(second);
+ reader.NextResult().Should().BeFalse();
+ }
+ }
+
+ [Fact]
+ public void PreparePreservesDependentMultipleStatements()
+ {
+ using var connection = new DuckDBConnection("DataSource=:memory:");
+ connection.Open();
+
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ CREATE TABLE IF NOT EXISTS prepared_dependency(value INTEGER);
+ DELETE FROM prepared_dependency;
+ INSERT INTO prepared_dependency VALUES ($value);
+ SELECT value FROM prepared_dependency;
+ """;
+ command.Parameters.Add(new DuckDBParameter("value", 42));
+ command.Prepare();
+
+ command.ExecuteScalar().Should().Be(42);
+
+ command.Parameters["value"].Value = 84;
+ command.ExecuteScalar().Should().Be(84);
+ }
+
+ [Fact]
+ public void StaticPivotCanBePreparedAndReused()
+ {
+ using var connection = CreatePivotConnection();
+
+ using var command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT *
+ FROM Cities
+ PIVOT (
+ SUM(Population)
+ FOR Year IN (2022, 2023)
+ GROUP BY Country, Name
+ );
+ """;
+ command.Prepare();
+
+ AssertPivotValue(command, "2022", 3_688_647L);
+
+ ExecuteNonQuery(connection, "INSERT INTO Cities VALUES ('Georgia', 'Tbilisi', 2022, 100)");
+
+ AssertPivotValue(command, "2022", 3_688_747L);
+ }
+
+ [Fact]
+ public void DynamicPivotIsPreparedPerExecutionSoNewColumnsAreVisible()
+ {
+ using var connection = CreatePivotConnection();
+
+ using var command = connection.CreateCommand();
+ command.CommandText = "PIVOT Cities ON Year USING SUM(Population);";
+ command.Prepare();
+
+ GetColumnNames(command).Should().Equal("Country", "Name", "2022", "2023");
+
+ ExecuteNonQuery(connection, "INSERT INTO Cities VALUES ('Georgia', 'Tbilisi', 2024, 3800000)");
+
+ GetColumnNames(command).Should().Equal("Country", "Name", "2022", "2023", "2024");
+ }
+
+ [Fact]
+ public void ChangingCommandTextInvalidatesPreparedStatements()
+ {
+ using var command = Connection.CreateCommand();
+ command.CommandText = "SELECT $value::INTEGER";
+ command.Parameters.Add(new DuckDBParameter("value", 10));
+ command.Prepare();
+
+ command.ExecuteScalar().Should().Be(10);
+
+ command.CommandText = "SELECT $value::INTEGER * 2";
+ command.ExecuteScalar().Should().Be(20);
+
+ command.Prepare();
+ command.Parameters["value"].Value = 15;
+ command.ExecuteScalar().Should().Be(30);
+ }
+
+ [Fact]
+ public void InvalidatedPreparedStatementsRemainAliveForAnActiveReader()
+ {
+ using var command = Connection.CreateCommand();
+ command.CommandText = "SELECT value FROM range(1, 4) AS values(value)";
+ command.Prepare();
+
+ using var reader = command.ExecuteReader();
+ reader.Read().Should().BeTrue();
+ reader.GetInt64(0).Should().Be(1);
+
+ command.CommandText = "SELECT 4";
+ command.ExecuteScalar().Should().Be(4);
+
+ reader.Read().Should().BeTrue();
+ reader.GetInt64(0).Should().Be(2);
+ reader.Read().Should().BeTrue();
+ reader.GetInt64(0).Should().Be(3);
+ reader.Read().Should().BeFalse();
+ }
+
+ [Fact]
+ public void ClosingConnectionInvalidatesPreparedStatementsBeforeDisconnect()
+ {
+ using var connection = new DuckDBConnection("DataSource=:memory:");
+ connection.Open();
+
+ using var command = connection.CreateCommand();
+ command.CommandText = "SELECT $value::INTEGER";
+ command.Parameters.Add(new DuckDBParameter("value", 10));
+ command.Prepare();
+ command.ExecuteScalar().Should().Be(10);
+
+ connection.Close();
+ connection.Open();
+
+ command.Parameters["value"].Value = 20;
+ command.ExecuteScalar().Should().Be(20);
+ }
+
+ private static DuckDBConnection CreatePivotConnection()
+ {
+ var connection = new DuckDBConnection("DataSource=:memory:");
+ connection.Open();
+
+ ExecuteNonQuery(connection, """
+ CREATE TABLE Cities(Country VARCHAR, Name VARCHAR, Year INT, Population INT);
+ INSERT INTO Cities VALUES
+ ('Georgia', 'Tbilisi', 2022, 3688647),
+ ('Georgia', 'Tbilisi', 2023, 3736400);
+ """);
+
+ return connection;
+ }
+
+ private static void ExecuteNonQuery(DuckDBConnection connection, string commandText)
+ {
+ using var command = connection.CreateCommand();
+ command.CommandText = commandText;
+ command.ExecuteNonQuery();
+ }
+
+ private static void AssertPivotValue(DuckDBCommand command, string columnName, long expected)
+ {
+ using var reader = command.ExecuteReader();
+ reader.Read().Should().BeTrue();
+ reader.GetInt64(reader.GetOrdinal(columnName)).Should().Be(expected);
+ reader.NextResult().Should().BeFalse();
+ }
+
+ private static string[] GetColumnNames(DuckDBCommand command)
+ {
+ using var reader = command.ExecuteReader();
+ return Enumerable.Range(0, reader.FieldCount).Select(reader.GetName).ToArray();
+ }
+}
diff --git a/DuckDB.NET.Test/Parameters/ParameterCollectionTests.cs b/DuckDB.NET.Test/Parameters/ParameterCollectionTests.cs
index c4c2f896..245bcdfa 100644
--- a/DuckDB.NET.Test/Parameters/ParameterCollectionTests.cs
+++ b/DuckDB.NET.Test/Parameters/ParameterCollectionTests.cs
@@ -46,10 +46,10 @@ public void ParameterCountMismatchTest()
}
[Fact]
- public void PrepareCommandNoOperationTest()
+ public void PrepareInvalidCommandThrows()
{
Command.CommandText = "SELECT ? FROM nowhere";
- Command.Invoking(dbCommand => dbCommand.Prepare()).Should().NotThrow();
+ Command.Invoking(dbCommand => dbCommand.Prepare()).Should().Throw();
}
[Fact]
diff --git a/DuckDB.NET.slnx b/DuckDB.NET.slnx
index 715e640c..7f57624a 100644
--- a/DuckDB.NET.slnx
+++ b/DuckDB.NET.slnx
@@ -1,5 +1,6 @@
+