From 549d4737ea93e8367355f8b7a7915841621eeb0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Silveira?= Date: Mon, 31 Aug 2026 11:12:38 +0200 Subject: [PATCH 1/5] Download the native libraries during restore and stage them outside obj A clean clone needed two builds. The first downloaded the libraries and copied none of them; a second build then picked them up. Three separate defects produced that. The download guard was the existence of obj/runtimes//native. It recorded no version, so raising the DuckDB release left every machine that had built once on the old engine, and a checkout targeting v1.5.5 kept running v1.2.0 and failed 127 tests with EntryPointNotFoundException. MakeDir also ran before DownloadFile, so a download that failed left an empty directory that satisfied the guard and every later build skipped in silence. The libraries were staged under obj, which the SDK lists in DefaultItemExcludes. Every item glob over them matched nothing and reported nothing, so the files reached no output directory and a cold dotnet pack shipped DuckDB.NET.Bindings.Full with no native library in it. The download now runs during restore, which is the only phase that precedes evaluation, where item globs expand. The hook is a NuGet target rather than the public Restore target, because Restore only runs on the project the command names and a solution build would skip every project inside it. It carries no compatibility guarantee, so if a later SDK renames it the symptom is a missing library on a clean clone. Staging moves to artifacts/natives///native. That is outside obj, so globs see it, and the version in the path makes the directory the cache key: a bump lands in a fresh folder and cannot inherit the previous engine. Incrementality follows the SDK cache file convention, where a value rather than a source file drives the decision, as CreateGeneratedAssemblyInfoInputsCacheFile does in Microsoft.NET.GenerateAssemblyInfo.targets. The download cache file is written last, so a failure leaves it older than the inputs and the next build retries. The fetch also completes before the installed payload is cleared, so a download that fails over a dead network leaves the working engine in place. Bindings declares the libraries as Content in the project body. Content flows across a ProjectReference, so the test, benchmark and sample projects each get the engine from the reference they already had and their own globs are gone. Copying is unconditional because every project that runs needs the engine beside its assembly; only packing stays gated on BuildType. DuckDbVersion moves to Directory.Build.props and drives the artifact URL, the staging path and both packages' release notes, which each carried the version as hand typed prose. The release and nightly download targets differed in one step and are now one target. DuckDbArtifactRoot still wins when set, which the nightly CI job does through the environment. --- Directory.Build.props | 7 +- DuckDB.NET.Benchmarks/Benchmarks.csproj | 10 -- DuckDB.NET.Bindings/Bindings.csproj | 38 ++++-- .../DownloadNativeLibs.targets | 114 +++++++++++------- DuckDB.NET.Data/Data.csproj | 2 +- DuckDB.NET.Test/Test.csproj | 12 -- 6 files changed, 107 insertions(+), 76 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index d00d035c..a6a75b3a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -39,7 +39,12 @@ - ManagedOnly + ManagedOnly + 1.5.5 + + $(MSBuildThisFileDirectory)artifacts\natives\$(DuckDbVersion)\ This package does not include a copy of the native DuckDB library. diff --git a/DuckDB.NET.Benchmarks/Benchmarks.csproj b/DuckDB.NET.Benchmarks/Benchmarks.csproj index 2de0564f..8ced6dcd 100644 --- a/DuckDB.NET.Benchmarks/Benchmarks.csproj +++ b/DuckDB.NET.Benchmarks/Benchmarks.csproj @@ -20,14 +20,4 @@ - - - - false - PreserveNewest - runtimes\%(RecursiveDir)\%(FileName)%(Extension) - - - diff --git a/DuckDB.NET.Bindings/Bindings.csproj b/DuckDB.NET.Bindings/Bindings.csproj index 3ba02773..d99b9617 100644 --- a/DuckDB.NET.Bindings/Bindings.csproj +++ b/DuckDB.NET.Bindings/Bindings.csproj @@ -3,7 +3,7 @@ DuckDB Bindings for C#. -- Updated to DuckDB v1.5.5 +- Updated to DuckDB v$(DuckDbVersion) New features: - Added bindings for the Arrow C Data Interface (duckdb_to_arrow_schema, duckdb_data_chunk_to_arrow, duckdb_result_get_arrow_options) @@ -11,7 +11,7 @@ New features: DuckDB.NET.Native win-x64;win-arm64;linux-x64;linux-arm64;osx - https://github.com/duckdb/duckdb/releases/download/v1.5.5 + https://github.com/duckdb/duckdb/releases/download/v$(DuckDbVersion) True ..\keyPair.snk true @@ -23,7 +23,16 @@ New features: - + + + + <_PackDuckDbNatives Condition="'$(BuildType)' == 'Full' ">true + <_PackDuckDbNatives Condition="'$(_PackDuckDbNatives)' == ''">false + + + <_NativeLib Include="win-x64" LibUrl="$(DuckDbArtifactRoot)/libduckdb-windows-amd64.zip" /> <_NativeLib Include="win-arm64" LibUrl="$(DuckDbArtifactRoot)/libduckdb-windows-arm64.zip" /> @@ -36,20 +45,29 @@ New features: <_NativeLib Include="linux-x64" LibUrl="$(DuckDbArtifactRoot)/duckdb-binaries-linux-amd64.zip" InnerZipName="libduckdb-linux-amd64.zip" Condition="$([MSBuild]::IsOSPlatform('Linux'))" /> <_NativeLib Include="osx" LibUrl="$(DuckDbArtifactRoot)/duckdb-binaries-osx.zip" InnerZipName="libduckdb-osx-universal.zip" Condition="$([MSBuild]::IsOSPlatform('OSX'))" /> - + - + - - + + + false PreserveNewest - true + $(_PackDuckDbNatives) \runtimes - runtimes\%(RecursiveDir)\%(FileName)%(Extension) - + runtimes\%(RecursiveDir)%(FileName)%(Extension) + + + + diff --git a/DuckDB.NET.Bindings/DownloadNativeLibs.targets b/DuckDB.NET.Bindings/DownloadNativeLibs.targets index 0a73e20c..0c89ee23 100644 --- a/DuckDB.NET.Bindings/DownloadNativeLibs.targets +++ b/DuckDB.NET.Bindings/DownloadNativeLibs.targets @@ -1,47 +1,77 @@ - - - - - - $(MSBuildProjectDirectory)\obj\runtimes\$(Rid)\native.zip - - - - - - + + + + $(DuckDbNativeRoot)$(Rid) + $(RidDir)\native + $(RidDir)\native.zip + $(RidDir)\temp + $(RidDir)\duckdb.inputs.cache + $(RidDir)\duckdb.download.cache + + + + + + + + + + + + - - - - $(MSBuildProjectDirectory)\obj\runtimes\$(Rid)\native.zip - $(MSBuildProjectDirectory)\obj\runtimes\$(Rid)\temp - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DuckDB.NET.Data/Data.csproj b/DuckDB.NET.Data/Data.csproj index 15b11e3c..5096979a 100644 --- a/DuckDB.NET.Data/Data.csproj +++ b/DuckDB.NET.Data/Data.csproj @@ -3,7 +3,7 @@ DuckDB ADO.NET Provider for C#. -- Updated to DuckDB v1.5.5 +- Updated to DuckDB v$(DuckDbVersion) New features: - Apache Arrow result streaming via DuckDBCommand.ExecuteArrowStream and ExecuteArrowBatchesAsync (#26) diff --git a/DuckDB.NET.Test/Test.csproj b/DuckDB.NET.Test/Test.csproj index 37ced068..9eb1d118 100644 --- a/DuckDB.NET.Test/Test.csproj +++ b/DuckDB.NET.Test/Test.csproj @@ -26,16 +26,4 @@ - - - - - - false - PreserveNewest - \runtimes - runtimes\%(RecursiveDir)\%(FileName)%(Extension) - - - From 355483b23a01b30f013679a7bd72faf853a20ce0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Silveira?= Date: Mon, 31 Aug 2026 11:12:38 +0200 Subject: [PATCH 2/5] Resolve the native library from the assembly that declares the imports A NuGet package writes a runtimeTargets entry into deps.json and the host then probes runtimes/{rid}/native on its own. A project reference produces no such entry, so every project in this repository loaded the library itself: the test project through a module initializer, the benchmarks through a second copy of the same code, and the samples by compiling the test project's file through a Compile Include that reached across projects. DuckDBNativeLibrary registers a DllImportResolver for the Bindings assembly, which is where DuckDbLibrary and its imports live. One copy of the runtime identifier logic now serves every consumer, in this repository and downstream. The resolver returns zero for a name it does not own and for a file it cannot find, so default probing still applies and a library already present on the system still loads. It passes a relative path with DllImportSearchPath.AssemblyDirectory, which keeps it working under single file publish where Assembly.Location is empty, and the four argument overload applies the platform naming convention so one name matches libduckdb.dylib, libduckdb.so and duckdb.dll. --- DuckDB.NET.Benchmarks/NativeLibraryLoader.cs | 48 ------------ DuckDB.NET.Bindings/DuckDBNativeLibrary.cs | 74 +++++++++++++++++++ .../NativeMethods/NativeMethods.All.cs | 2 +- DuckDB.NET.Samples/Program.cs | 7 -- DuckDB.NET.Samples/Samples.csproj | 4 - .../Helpers/NativeLibraryHelper.cs | 45 ----------- DuckDB.NET.Test/ModuleInit.cs | 2 - 7 files changed, 75 insertions(+), 107 deletions(-) delete mode 100644 DuckDB.NET.Benchmarks/NativeLibraryLoader.cs create mode 100644 DuckDB.NET.Bindings/DuckDBNativeLibrary.cs delete mode 100644 DuckDB.NET.Test/Helpers/NativeLibraryHelper.cs diff --git a/DuckDB.NET.Benchmarks/NativeLibraryLoader.cs b/DuckDB.NET.Benchmarks/NativeLibraryLoader.cs deleted file mode 100644 index 429de245..00000000 --- a/DuckDB.NET.Benchmarks/NativeLibraryLoader.cs +++ /dev/null @@ -1,48 +0,0 @@ -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 Environment.Is64BitProcess ? "win-x64" : "win-x86"; - } - - 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.Bindings/DuckDBNativeLibrary.cs b/DuckDB.NET.Bindings/DuckDBNativeLibrary.cs new file mode 100644 index 00000000..9fa0a3a9 --- /dev/null +++ b/DuckDB.NET.Bindings/DuckDBNativeLibrary.cs @@ -0,0 +1,74 @@ +using System.IO; +using System.Reflection; + +namespace DuckDB.NET.Native; + +/// +/// Resolves the native DuckDB library from the runtimes folder beside this assembly. +/// +/// +/// A NuGet package writes a runtimeTargets entry into deps.json and the host probes +/// runtimes/{rid}/native on its own. A project reference produces no such entry, so without this +/// resolver every project that consumes DuckDB.NET by reference has to load the library itself. +/// +internal static class DuckDBNativeLibrary +{ + // CA2255 warns against ModuleInitializer in a library. Registering a DllImport resolver is the + // case the rule exempts: the resolver has to be in place before the first P/Invoke, and those + // live on nested types whose static constructors the outer type cannot hook. +#pragma warning disable CA2255 + [ModuleInitializer] +#pragma warning restore CA2255 + internal static void Register() => + NativeLibrary.SetDllImportResolver(typeof(DuckDBNativeLibrary).Assembly, Resolve); + + private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (libraryName != NativeMethods.DuckDbLibrary || GetRuntimeIdentifier() is not { } rid) + { + return IntPtr.Zero; + } + + // The four-argument overload applies the platform naming convention, so "duckdb" matches + // libduckdb.dylib, libduckdb.so and duckdb.dll. The two-argument one needs an exact path. + // Passing a relative path keeps this working under single-file publish, where + // Assembly.Location is empty. + foreach (var candidate in (string[])["duckdb", "libduckdb"]) + { + var path = Path.Combine("runtimes", rid, "native", candidate); + + if (NativeLibrary.TryLoad(path, assembly, DllImportSearchPath.AssemblyDirectory, out var handle)) + { + return handle; + } + } + + // Zero hands the name back to the default probing, so a library already on the system or + // resolved from a NuGet package still loads. + return IntPtr.Zero; + } + + /// + /// These identifiers name the folders the build stages, not the full RID graph. DuckDB ships one + /// universal macOS binary, so that folder is "osx" rather than osx-x64 and osx-arm64. + /// + private static string? GetRuntimeIdentifier() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return Environment.Is64BitProcess ? "win-x64" : "win-x86"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "linux-x64", + Architecture.Arm64 => "linux-arm64", + _ => null, + }; + } + + return RuntimeInformation.IsOSPlatform(OSPlatform.OSX) ? "osx" : null; + } +} diff --git a/DuckDB.NET.Bindings/NativeMethods/NativeMethods.All.cs b/DuckDB.NET.Bindings/NativeMethods/NativeMethods.All.cs index 51183c92..aa935918 100644 --- a/DuckDB.NET.Bindings/NativeMethods/NativeMethods.All.cs +++ b/DuckDB.NET.Bindings/NativeMethods/NativeMethods.All.cs @@ -2,7 +2,7 @@ public static partial class NativeMethods { - private const string DuckDbLibrary = "duckdb"; + internal const string DuckDbLibrary = "duckdb"; //Grouped according to https://duckdb.org/docs/archive/0.8.1/api/c/api } \ No newline at end of file diff --git a/DuckDB.NET.Samples/Program.cs b/DuckDB.NET.Samples/Program.cs index f575c49c..0d1f6297 100644 --- a/DuckDB.NET.Samples/Program.cs +++ b/DuckDB.NET.Samples/Program.cs @@ -1,7 +1,6 @@ using Dapper; using DuckDB.NET.Data; using DuckDB.NET.Native; -using DuckDB.NET.Test.Helpers; using System; using System.Data.Common; using System.Diagnostics; @@ -15,12 +14,6 @@ class Program { static void Main(string[] args) { - if (!NativeLibraryHelper.TryLoad()) - { - Console.Error.WriteLine("native assembly not found"); - return; - } - DapperSample(); AdoNetSamples(); diff --git a/DuckDB.NET.Samples/Samples.csproj b/DuckDB.NET.Samples/Samples.csproj index c22e1f3e..ad364db8 100644 --- a/DuckDB.NET.Samples/Samples.csproj +++ b/DuckDB.NET.Samples/Samples.csproj @@ -9,10 +9,6 @@ Full - - - - diff --git a/DuckDB.NET.Test/Helpers/NativeLibraryHelper.cs b/DuckDB.NET.Test/Helpers/NativeLibraryHelper.cs deleted file mode 100644 index 80cb8411..00000000 --- a/DuckDB.NET.Test/Helpers/NativeLibraryHelper.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.IO; -using System.Reflection; -using System.Runtime.InteropServices; - -namespace DuckDB.NET.Test.Helpers; - -static class NativeLibraryHelper -{ - public static bool TryLoad() - { - if (GetRid() is not { } rid) - { - return false; - } - - 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 Environment.Is64BitProcess ? "win-x64" : "win-x86"; - } - - 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; - } -} \ No newline at end of file diff --git a/DuckDB.NET.Test/ModuleInit.cs b/DuckDB.NET.Test/ModuleInit.cs index a9b77c3e..230f8901 100644 --- a/DuckDB.NET.Test/ModuleInit.cs +++ b/DuckDB.NET.Test/ModuleInit.cs @@ -1,5 +1,4 @@ using System.Runtime.CompilerServices; -using DuckDB.NET.Test.Helpers; #nullable enable namespace DuckDB.NET.Test; @@ -8,7 +7,6 @@ public static class ModuleInit [ModuleInitializer] public static void Init() { - NativeLibraryHelper.TryLoad(); AssertionOptions.AssertEquivalencyUsing(options => options.Using(new DateTimeOffsetTimeComparer())); } From 75dedb3d8ecf463319bf01af012e25f49826cef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Silveira?= Date: Mon, 31 Aug 2026 11:54:09 +0200 Subject: [PATCH 3/5] Document how to change the DuckDB version Raising the version used to touch three files and nothing checked that they agreed. Commit fd45dbb updated the artifact URL and left both packages' release notes on the previous version. It is one property now, so say where it is. --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index ea5f8b02..181d1832 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,18 @@ using var duckDBConnection = new DuckDBConnection("DataSource=md:{your_database} If you want to build DuckDB extensions with C#, see [Giorgi/DuckDB.ExtensionKit](https://github.com/Giorgi/DuckDB.ExtensionKit). +## Updating the DuckDB version + +Set `DuckDbVersion` in `Directory.Build.props`: + +```xml +1.5.5 +``` + +That property drives the download URL, the staging path, the download cache, and the release notes in both packages. Build once and the new engine downloads. + +Each version is staged in its own folder under `artifacts/natives/`, so a previous version stays on disk and switching back downloads nothing. To try a version without editing the file, pass `-p:DuckDbVersion=1.4.1` to `dotnet build`. + ## Known Issues When debugging your project that uses DuckDB.NET library, you may get the following error: **System.AccessViolationException: Attempted to read or write protected memory. This is often an indication that other memory is corrupt**. The error happens due to debugger interaction with the native memory. For a workaround check out [Debugger Options mess up debugging session during Marshalling From 152704294f36a45d23410b9b73e5ff4781e9b7fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Silveira?= Date: Sun, 30 Aug 2026 19:24:14 +0200 Subject: [PATCH 4/5] Bind named parameters by the name the statement declares The named branch of BindParameters looped over the parameter collection and asked duckdb_bind_parameter_index for each ParameterName. A name that the statement did not declare returned a failure that the loop discarded, so the value bound nothing and the caller got no report. Closes #203. A ParameterName can now carry the SQL prefix that other ADO.NET providers accept: an entry named "$PARM1" binds a statement that declares $PARM1. Only "$" is stripped, because a quoted parameter name can start with "@" and stripping that character would let an entry named "@foo" answer for a declared "foo". The loop now runs over the statement. It reads each declared name with the new duckdb_parameter_name binding, finds the value in the collection, and throws InvalidOperationException that names the parameter when no entry supplies it. This changes behavior. A declared parameter with no supplied value now throws before execution, where it previously bound nothing and let the engine report a later error that named nothing. Code that relied on the old behavior was already producing a failed query. IndexOf(string) gains the same tolerance, so a caller who adds "$id" can retrieve it by "id". --- .../ParameterBindingBenchmark.cs | 86 ++++++ DuckDB.NET.Benchmarks/Program.cs | 2 +- .../NativeMethods.PreparedStatements.cs | 5 + DuckDB.NET.Data/DuckDBParameterCollection.cs | 13 +- .../PreparedStatement/PreparedStatement.cs | 45 ++- .../Parameters/NamedParameterTests.cs | 282 ++++++++++++++++++ .../Parameters/ParameterCollectionTests.cs | 3 +- README.md | 6 + 8 files changed, 434 insertions(+), 8 deletions(-) create mode 100644 DuckDB.NET.Benchmarks/ParameterBindingBenchmark.cs create mode 100644 DuckDB.NET.Test/Parameters/NamedParameterTests.cs diff --git a/DuckDB.NET.Benchmarks/ParameterBindingBenchmark.cs b/DuckDB.NET.Benchmarks/ParameterBindingBenchmark.cs new file mode 100644 index 00000000..93207671 --- /dev/null +++ b/DuckDB.NET.Benchmarks/ParameterBindingBenchmark.cs @@ -0,0 +1,86 @@ +using System.Text; +using BenchmarkDotNet.Attributes; +using DuckDB.NET.Data; + +namespace DuckDB.NET.Benchmarks; + +[MemoryDiagnoser] +public class ParameterBindingBenchmark +{ + private DuckDBConnection connection = null!; + private DuckDBCommand exact = null!; + private DuckDBCommand prefixed = null!; + private DuckDBCommand positional = null!; + + [Params(1, 8, 32)] + public int ParameterCount { get; set; } + + [GlobalSetup] + public void Setup() + { + connection = new DuckDBConnection("DataSource=:memory:"); + connection.Open(); + + exact = BuildNamed(""); + prefixed = BuildNamed("$"); + positional = BuildPositional(); + } + + [GlobalCleanup] + public void Cleanup() + { + exact.Dispose(); + prefixed.Dispose(); + positional.Dispose(); + connection.Dispose(); + } + + [Benchmark(Baseline = true)] + public object? NamedExact() => exact.ExecuteScalar(); + + [Benchmark] + public object? NamedPrefixed() => prefixed.ExecuteScalar(); + + [Benchmark] + public object? Positional() => positional.ExecuteScalar(); + + private DuckDBCommand BuildNamed(string parameterNamePrefix) + { + var command = connection.CreateCommand(); + var sql = new StringBuilder("SELECT "); + + for (var i = 0; i < ParameterCount; i++) + { + if (i > 0) + { + sql.Append(" + "); + } + + sql.Append("$p").Append(i).Append("::INT"); + command.Parameters.Add(new DuckDBParameter($"{parameterNamePrefix}p{i}", i)); + } + + command.CommandText = sql.ToString(); + return command; + } + + private DuckDBCommand BuildPositional() + { + var command = connection.CreateCommand(); + var sql = new StringBuilder("SELECT "); + + for (var i = 0; i < ParameterCount; i++) + { + if (i > 0) + { + sql.Append(" + "); + } + + sql.Append("?::INT"); + command.Parameters.Add(new DuckDBParameter(i)); + } + + command.CommandText = sql.ToString(); + return command; + } +} diff --git a/DuckDB.NET.Benchmarks/Program.cs b/DuckDB.NET.Benchmarks/Program.cs index 00251844..be8c822d 100644 --- a/DuckDB.NET.Benchmarks/Program.cs +++ b/DuckDB.NET.Benchmarks/Program.cs @@ -11,5 +11,5 @@ .AddJob(Job.Default.WithToolchain(InProcessEmitToolchain.Instance)); BenchmarkSwitcher - .FromTypes([typeof(AppenderBenchmark), typeof(MappedAppenderBenchmark)]) + .FromTypes([typeof(AppenderBenchmark), typeof(MappedAppenderBenchmark), typeof(ParameterBindingBenchmark)]) .Run(args, config); diff --git a/DuckDB.NET.Bindings/NativeMethods/NativeMethods.PreparedStatements.cs b/DuckDB.NET.Bindings/NativeMethods/NativeMethods.PreparedStatements.cs index 68f20cf3..c2c768cf 100644 --- a/DuckDB.NET.Bindings/NativeMethods/NativeMethods.PreparedStatements.cs +++ b/DuckDB.NET.Bindings/NativeMethods/NativeMethods.PreparedStatements.cs @@ -26,6 +26,11 @@ public static partial class PreparedStatements [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] public static partial long DuckDBParams(DuckDBPreparedStatement preparedStatement); + [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_parameter_name")] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + [return: MarshalUsing(typeof(DuckDBCallerOwnedStringMarshaller))] + public static partial string DuckDBParameterName(DuckDBPreparedStatement preparedStatement, long index); + // Maybe [SuppressGCTransition]: map insertion with small node allocation [LibraryImport(DuckDbLibrary, EntryPoint = "duckdb_bind_value")] [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] diff --git a/DuckDB.NET.Data/DuckDBParameterCollection.cs b/DuckDB.NET.Data/DuckDBParameterCollection.cs index 86204812..feaa22ff 100644 --- a/DuckDB.NET.Data/DuckDBParameterCollection.cs +++ b/DuckDB.NET.Data/DuckDBParameterCollection.cs @@ -70,7 +70,18 @@ protected override void SetParameter(string parameterName, DbParameter value) } public override int IndexOf(string parameterName) - => parameters.FindIndex(p => p.ParameterName.Equals(parameterName, StringComparison.Ordinal)); + { + var exact = parameters.FindIndex(p => string.Equals(p.ParameterName, parameterName, StringComparison.Ordinal)); + + return exact >= 0 + ? exact + : parameters.FindIndex(p => string.Equals(StripParameterPrefix(p.ParameterName), parameterName, StringComparison.Ordinal)); + } + + internal static string StripParameterPrefix(string? name) + => name is { Length: > 1 } && name[0] is '$' + ? name.Substring(1) + : name ?? string.Empty; public override bool Contains(string value) => IndexOf(value) != -1; diff --git a/DuckDB.NET.Data/PreparedStatement/PreparedStatement.cs b/DuckDB.NET.Data/PreparedStatement/PreparedStatement.cs index 4f7d559d..c882c07b 100644 --- a/DuckDB.NET.Data/PreparedStatement/PreparedStatement.cs +++ b/DuckDB.NET.Data/PreparedStatement/PreparedStatement.cs @@ -105,14 +105,19 @@ private static void BindParameters(DuckDBPreparedStatement preparedStatement, Du if (hasNamedParameters) { - for (var i = 0; i < count; i++) + // A named statement declares its parameters densely at indices 1..duckdb_nparams and + // every declared name is non-empty, so a miss is a missing value, never a gap. + for (long index = 1; index <= expectedParameters; index++) { - var param = parameterCollection[i]; - var state = NativeMethods.PreparedStatements.DuckDBBindParameterIndex(preparedStatement, out var index, param.ParameterName); - if (state.IsSuccess()) + var name = NativeMethods.PreparedStatements.DuckDBParameterName(preparedStatement, index); + + var match = IndexOfParameter(parameterCollection, name); + if (match < 0) { - BindParameter(preparedStatement, index, param); + throw new InvalidOperationException($"No value supplied for parameter '{name}'."); } + + BindParameter(preparedStatement, index, parameterCollection[match]); } } else @@ -125,6 +130,36 @@ private static void BindParameters(DuckDBPreparedStatement preparedStatement, Du } } + private static int IndexOfParameter(DuckDBParameterCollection parameters, string name) + { + // A quoted parameter name may itself start with '$', so the exact pass must cover the + // whole collection before any prefix is stripped. + for (var i = 0; i < parameters.Count; i++) + { + if (string.Equals(parameters[i].ParameterName, name, StringComparison.Ordinal)) + { + return i; + } + } + + for (var i = 0; i < parameters.Count; i++) + { + if (MatchesWithoutPrefix(parameters[i].ParameterName, name)) + { + return i; + } + } + + return -1; + } + + // '$' is the only prefix DuckDB can produce. '@' parses as the unary absolute-value operator, + // so no declared name can ever come from it: SELECT @foo binds abs(foo), a column reference. + private static bool MatchesWithoutPrefix(string? parameterName, string name) + => parameterName is { Length: > 1 } + && parameterName[0] is '$' + && parameterName.AsSpan(1).SequenceEqual(name); + private static void BindParameter(DuckDBPreparedStatement preparedStatement, long index, DuckDBParameter parameter) { using var parameterLogicalType = NativeMethods.PreparedStatements.DuckDBParamLogicalType(preparedStatement, index); diff --git a/DuckDB.NET.Test/Parameters/NamedParameterTests.cs b/DuckDB.NET.Test/Parameters/NamedParameterTests.cs new file mode 100644 index 00000000..dfa56e16 --- /dev/null +++ b/DuckDB.NET.Test/Parameters/NamedParameterTests.cs @@ -0,0 +1,282 @@ +using DuckDB.NET.Test.Helpers; + +namespace DuckDB.NET.Test.Parameters; + +public class NamedParameterTests(DuckDBDatabaseFixture db) : DuckDBTestBase(db) +{ + [Fact] // EC1 — https://github.com/Giorgi/DuckDB.NET/issues/203 + public void BindsFromDollarPrefixedParameterName() + { + Command.CommandText = "SELECT $PARM1::INT"; + Command.Parameters.Add(new DuckDBParameter("$PARM1", 42)); + + Command.ExecuteScalar().Should().Be(42); + } + + // EC2 — '$' is the only marker DuckDB has, so it is the only prefix stripped. '@' is the one + // people actually type out of SQL Server habit; the rest guard against generalising the strip + // to any leading punctuation. + [Theory] + [InlineData("@name")] + [InlineData("?name")] + [InlineData(":name")] + public void ThrowsForAnyParameterNamePrefixOtherThanDollar(string parameterName) + { + Command.CommandText = "SELECT $name::INT"; + Command.Parameters.Add(new DuckDBParameter(parameterName, 42)); + + Command.Invoking(command => command.ExecuteScalar()) + .Should().Throw() + .WithMessage("*name*"); + } + + [Fact] // EC3 + public void BindsFromUnprefixedParameterName() + { + Command.CommandText = "SELECT $name::INT"; + Command.Parameters.Add(new DuckDBParameter("name", 42)); + + Command.ExecuteScalar().Should().Be(42); + } + + [Fact] // EC4 + public void PrefersExactMatchWhenBothPrefixedAndUnprefixedEntriesExist() + { + Command.CommandText = "SELECT $id::INT"; + Command.Parameters.Add(new DuckDBParameter("$id", 24)); + Command.Parameters.Add(new DuckDBParameter("id", 42)); + + Command.ExecuteScalar().Should().Be(42); + } + + [Fact] // EC5 — regression against collapsing the two matching passes into one + public void BindsQuotedNameThatItselfStartsWithDollar() + { + Command.CommandText = """SELECT $"$foo"::INT"""; + Command.Parameters.Add(new DuckDBParameter("$foo", 42)); + + Command.ExecuteScalar().Should().Be(42); + } + + // A quoted marker can declare a name that starts with '@', which is why '@' must not be a + // stripped prefix: stripping it would let an entry named "@foo" answer for a declared "foo". + [Fact] + public void BindsQuotedNameThatItselfStartsWithAt() + { + Command.CommandText = """SELECT $"@foo"::INT"""; + Command.Parameters.Add(new DuckDBParameter("@foo", 42)); + + Command.ExecuteScalar().Should().Be(42); + } + + [Fact] // EC6 + public void BindsQuotedNameConsistingOfDigits() + { + Command.CommandText = """SELECT $"1"::INT"""; + Command.Parameters.Add(new DuckDBParameter("1", 42)); + + Command.ExecuteScalar().Should().Be(42); + } + + [Fact] // EC7 + public void BindsPositionalStatementWhenEveryEntryCarriesAnOrdinalName() + { + Command.CommandText = "SELECT ?::INT - ?::INT"; + Command.Parameters.Add(new DuckDBParameter("1", 42)); + Command.Parameters.Add(new DuckDBParameter("2", 24)); + + Command.ExecuteScalar().Should().Be(18); + } + + [Fact] // EC8 + public void BindsNamesDifferingOnlyInCaseAsOneParameter() + { + Command.CommandText = "SELECT $Id::INT + $id::INT"; + Command.Parameters.Add(new DuckDBParameter("Id", 21)); + + Command.ExecuteScalar().Should().Be(42); + } + + [Fact] // EC9 + public void BindsRepeatedNameIntoEveryPosition() + { + Command.CommandText = "SELECT $name::INT + $name::INT"; + Command.Parameters.Add(new DuckDBParameter("name", 21)); + + Command.ExecuteScalar().Should().Be(42); + } + + [Fact] // EC10 + public void BindsEachStatementOfAMultiStatementCommandFromOneCollection() + { + using var defer = new Defer(() => Connection.Execute("DROP TABLE NamedParameterMultiStatement;")); + + Connection.Execute("CREATE TABLE NamedParameterMultiStatement (Value INTEGER);"); + + Command.CommandText = """ + INSERT INTO NamedParameterMultiStatement VALUES ($first::INT); + INSERT INTO NamedParameterMultiStatement VALUES ($second::INT); + """; + Command.Parameters.Add(new DuckDBParameter("first", 42)); + Command.Parameters.Add(new DuckDBParameter("second", 24)); + Command.ExecuteNonQuery(); + + Connection.Query("SELECT Value FROM NamedParameterMultiStatement ORDER BY Value;") + .Should().Equal(24, 42); + } + + [Fact] // EC11 — the defect being fixed: this used to bind nothing and report nothing + public void ThrowsWhenNoEntryMatchesADeclaredName() + { + Command.CommandText = "SELECT $name::INT"; + Command.Parameters.Add(new DuckDBParameter("other", 42)); + + Command.Invoking(command => command.ExecuteScalar()) + .Should().Throw() + .WithMessage("*name*"); + } + + [Fact] // EC12 + public void ThrowsWhenTheOnlyCandidateDiffersInCase() + { + Command.CommandText = "SELECT $name::INT"; + Command.Parameters.Add(new DuckDBParameter("Name", 42)); + + Command.Invoking(command => command.ExecuteScalar()) + .Should().Throw() + .WithMessage("*name*"); + } + + [Fact] // EC13 + public void ThrowsCountGuardWhenFewerEntriesThanDeclaredParameters() + { + Command.CommandText = "SELECT $first::INT + $second::INT"; + Command.Parameters.Add(new DuckDBParameter("first", 42)); + + Command.Invoking(command => command.ExecuteScalar()) + .Should().Throw() + .WithMessage("Invalid number of parameters. Expected 2, got 1"); + } + + [Fact] // EC14 + public void IgnoresAnEntryMatchingNothingDeclared() + { + Command.CommandText = "SELECT $used::INT"; + Command.Parameters.Add(new DuckDBParameter("unused", 24)); + Command.Parameters.Add(new DuckDBParameter("used", 42)); + + Command.ExecuteScalar().Should().Be(42); + } + + [Theory] // EC15 + [InlineData(null)] + [InlineData("")] + public void IgnoresAnEntryWithNoParameterName(string parameterName) + { + Command.CommandText = "SELECT $used::INT"; + Command.Parameters.Add(new DuckDBParameter(parameterName, 24)); + Command.Parameters.Add(new DuckDBParameter("used", 42)); + + Command.ExecuteScalar().Should().Be(42); + } + + [Fact] // EC16 + public void IgnoresAnEntryNamedDollarOnly() + { + Command.CommandText = "SELECT $foo::INT"; + Command.Parameters.Add(new DuckDBParameter("$", 24)); + Command.Parameters.Add(new DuckDBParameter("foo", 42)); + + Command.ExecuteScalar().Should().Be(42); + } + + [Fact] + public void IndexOfFindsAPrefixedEntryByItsBareName() + { + var parameters = new DuckDBParameterCollection { new DuckDBParameter("$id", 42) }; + + parameters.IndexOf("id").Should().Be(0); + } + + [Fact] + public void IndexOfPrefersTheExactMatch() + { + var parameters = new DuckDBParameterCollection + { + new DuckDBParameter("$id", 24), + new DuckDBParameter("id", 42) + }; + + parameters.IndexOf("id").Should().Be(1); + } + + [Fact] + public void IndexOfDoesNotFindAnAtPrefixedEntryByItsBareName() + { + var parameters = new DuckDBParameterCollection { new DuckDBParameter("@id", 42) }; + + parameters.IndexOf("id").Should().Be(-1); + } + + [Fact] + public void IndexOfReturnsMinusOneWhenNeitherFormIsPresent() + { + var parameters = new DuckDBParameterCollection { new DuckDBParameter("$id", 42) }; + + parameters.IndexOf("other").Should().Be(-1); + } + + // Contains, RemoveAt(string) and this[string] all resolve through IndexOf, so each inherits the + // prefix-stripping pass and each is asserted against a '$'-prefixed entry. + [Fact] + public void ContainsFindsAPrefixedEntryByItsBareName() + { + var parameters = new DuckDBParameterCollection { new DuckDBParameter("$id", 42) }; + + parameters.Contains("id").Should().BeTrue(); + parameters.Contains("other").Should().BeFalse(); + } + + [Fact] + public void RemoveAtRemovesAPrefixedEntryByItsBareName() + { + var parameters = new DuckDBParameterCollection { new DuckDBParameter("$id", 42) }; + + parameters.RemoveAt("id"); + + parameters.Count.Should().Be(0); + } + + [Fact] + public void IndexerReadsAPrefixedEntryByItsBareName() + { + var parameters = new DuckDBParameterCollection { new DuckDBParameter("$id", 42) }; + + parameters["id"].Value.Should().Be(42); + } + + [Fact] + public void RebindsTheSameCommandWhenAParameterValueChanges() + { + Command.CommandText = "SELECT $name::INT"; + Command.Parameters.Add(new DuckDBParameter("name", 42)); + + Command.ExecuteScalar().Should().Be(42); + + Command.Parameters[0].Value = 24; + + Command.ExecuteScalar().Should().Be(24); + } + + [Theory] // I7 — the positional branch is unchanged + [InlineData("SELECT ?::INT - ?::INT")] + [InlineData("SELECT $1::INT - $2::INT")] + public void BindsPositionallyFromUnnamedParameters(string query) + { + Command.CommandText = query; + Command.Parameters.Add(new DuckDBParameter(42)); + Command.Parameters.Add(new DuckDBParameter(24)); + + Command.ExecuteScalar().Should().Be(18); + } +} diff --git a/DuckDB.NET.Test/Parameters/ParameterCollectionTests.cs b/DuckDB.NET.Test/Parameters/ParameterCollectionTests.cs index c4c2f896..2efe7c9c 100644 --- a/DuckDB.NET.Test/Parameters/ParameterCollectionTests.cs +++ b/DuckDB.NET.Test/Parameters/ParameterCollectionTests.cs @@ -166,9 +166,10 @@ public void BindMultipleValuesInvalidOrderTest(string queryStatement) Command.ExecuteNonQuery(); Command.CommandText = queryStatement; + // Named entries matching nothing the statement declares now fail before execution. Command.Parameters.Add(new DuckDBParameter("param1", 42)); Command.Parameters.Add(new DuckDBParameter("param2", "hello")); - Command.Invoking(cmd => cmd.ExecuteNonQuery()).Should().ThrowExactly(); + Command.Invoking(cmd => cmd.ExecuteNonQuery()).Should().ThrowExactly(); Command.Parameters.Clear(); Command.Parameters.Add(new DuckDBParameter(42)); diff --git a/README.md b/README.md index 181d1832..f5ebb6ab 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ private static void PrintQueryResults(DbDataReader queryResult) } ``` +### Parameters + +A `DuckDBParameter` binds to a named statement parameter through its `ParameterName`. The name can +carry the `$` prefix, the comparison is case-sensitive, and a declared parameter that no +`DuckDBParameter` supplies throws an `InvalidOperationException`. + ### MotherDuck To connect to [MotherDuck](https://motherduck.com): From 62a46a4f05869d19d0bbd7175b4fa3c3c91fba65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Silveira?= Date: Mon, 31 Aug 2026 12:17:07 +0200 Subject: [PATCH 5/5] Merge the named parameter tests that differ only in data Five facts became two theories. The bodies were identical apart from one literal, and each case keeps the comment that ties it to the edge case it defends. --- .../Parameters/NamedParameterTests.cs | 46 +++++-------------- 1 file changed, 12 insertions(+), 34 deletions(-) diff --git a/DuckDB.NET.Test/Parameters/NamedParameterTests.cs b/DuckDB.NET.Test/Parameters/NamedParameterTests.cs index dfa56e16..a26c2f22 100644 --- a/DuckDB.NET.Test/Parameters/NamedParameterTests.cs +++ b/DuckDB.NET.Test/Parameters/NamedParameterTests.cs @@ -125,22 +125,13 @@ public void BindsEachStatementOfAMultiStatementCommandFromOneCollection() .Should().Equal(24, 42); } - [Fact] // EC11 — the defect being fixed: this used to bind nothing and report nothing - public void ThrowsWhenNoEntryMatchesADeclaredName() - { - Command.CommandText = "SELECT $name::INT"; - Command.Parameters.Add(new DuckDBParameter("other", 42)); - - Command.Invoking(command => command.ExecuteScalar()) - .Should().Throw() - .WithMessage("*name*"); - } - - [Fact] // EC12 - public void ThrowsWhenTheOnlyCandidateDiffersInCase() + [Theory] + [InlineData("other")] // EC11 — the defect being fixed: this used to bind nothing and report nothing + [InlineData("Name")] // EC12 — the only candidate differs in case + public void ThrowsWhenNoEntryMatchesTheDeclaredNameExactly(string parameterName) { Command.CommandText = "SELECT $name::INT"; - Command.Parameters.Add(new DuckDBParameter("Name", 42)); + Command.Parameters.Add(new DuckDBParameter(parameterName, 42)); Command.Invoking(command => command.ExecuteScalar()) .Should().Throw() @@ -190,12 +181,15 @@ public void IgnoresAnEntryNamedDollarOnly() Command.ExecuteScalar().Should().Be(42); } - [Fact] - public void IndexOfFindsAPrefixedEntryByItsBareName() + [Theory] + [InlineData("$id", "id", 0)] // '$' is stripped before matching + [InlineData("@id", "id", -1)] // '@' never is + [InlineData("$id", "other", -1)] // no candidate under either form + public void IndexOfMatchesABareNameOnlyAfterADollarIsStripped(string entryName, string lookupName, int expectedIndex) { - var parameters = new DuckDBParameterCollection { new DuckDBParameter("$id", 42) }; + var parameters = new DuckDBParameterCollection { new DuckDBParameter(entryName, 42) }; - parameters.IndexOf("id").Should().Be(0); + parameters.IndexOf(lookupName).Should().Be(expectedIndex); } [Fact] @@ -210,22 +204,6 @@ public void IndexOfPrefersTheExactMatch() parameters.IndexOf("id").Should().Be(1); } - [Fact] - public void IndexOfDoesNotFindAnAtPrefixedEntryByItsBareName() - { - var parameters = new DuckDBParameterCollection { new DuckDBParameter("@id", 42) }; - - parameters.IndexOf("id").Should().Be(-1); - } - - [Fact] - public void IndexOfReturnsMinusOneWhenNeitherFormIsPresent() - { - var parameters = new DuckDBParameterCollection { new DuckDBParameter("$id", 42) }; - - parameters.IndexOf("other").Should().Be(-1); - } - // Contains, RemoveAt(string) and this[string] all resolve through IndexOf, so each inherits the // prefix-stripping pass and each is asserted against a '$'-prefixed entry. [Fact]