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.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.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/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.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.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/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.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.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()));
}
diff --git a/DuckDB.NET.Test/Parameters/NamedParameterTests.cs b/DuckDB.NET.Test/Parameters/NamedParameterTests.cs
new file mode 100644
index 00000000..a26c2f22
--- /dev/null
+++ b/DuckDB.NET.Test/Parameters/NamedParameterTests.cs
@@ -0,0 +1,260 @@
+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);
+ }
+
+ [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(parameterName, 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);
+ }
+
+ [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(entryName, 42) };
+
+ parameters.IndexOf(lookupName).Should().Be(expectedIndex);
+ }
+
+ [Fact]
+ public void IndexOfPrefersTheExactMatch()
+ {
+ var parameters = new DuckDBParameterCollection
+ {
+ new DuckDBParameter("$id", 24),
+ new DuckDBParameter("id", 42)
+ };
+
+ parameters.IndexOf("id").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/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)
-
-
-
diff --git a/README.md b/README.md
index ea5f8b02..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):
@@ -80,6 +86,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