From be9cf1669098bc04bf5edab6d616df38d0ad0aa8 Mon Sep 17 00:00:00 2001 From: Skuirrels Date: Wed, 15 Jul 2026 08:47:35 +0100 Subject: [PATCH] Reuse prepared statements in DuckDBCommand --- DuckDB.NET.Benchmarks/Benchmarks.csproj | 31 ++ DuckDB.NET.Benchmarks/NativeLibraryLoader.cs | 53 ++++ .../PreparedCommandBenchmark.cs | 101 ++++++ DuckDB.NET.Benchmarks/Program.cs | 15 + .../NativeMethods.PreparedStatements.cs | 6 + DuckDB.NET.Data/DuckDBCommand.cs | 287 +++++++++++++++++- DuckDB.NET.Data/DuckDBConnection.cs | 67 +++- .../PreparedStatement/PreparedStatement.cs | 172 +++++++++-- DuckDB.NET.Test/DuckDBCommandTests.cs | 220 +++++++++++++- .../Parameters/ParameterCollectionTests.cs | 4 +- DuckDB.NET.slnx | 1 + 11 files changed, 924 insertions(+), 33 deletions(-) create mode 100644 DuckDB.NET.Benchmarks/Benchmarks.csproj create mode 100644 DuckDB.NET.Benchmarks/NativeLibraryLoader.cs create mode 100644 DuckDB.NET.Benchmarks/PreparedCommandBenchmark.cs create mode 100644 DuckDB.NET.Benchmarks/Program.cs 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 @@ +