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