From fc066357a2888023677b85856a305fcfbab35dc7 Mon Sep 17 00:00:00 2001 From: Sergey Vershinin Date: Thu, 11 Jun 2026 19:58:50 +0200 Subject: [PATCH 1/4] support list and null parameter binding --- src/LadybugDB/Interop/Native.DllImport.cs | 75 ++++++ src/LadybugDB/Interop/Native.LibraryImport.cs | 69 +++++ src/LadybugDB/PreparedStatement.cs | 254 +++++++++++++++++- .../LadybugDB.Tests/PreparedStatementTests.cs | 47 ++++ 4 files changed, 443 insertions(+), 2 deletions(-) diff --git a/src/LadybugDB/Interop/Native.DllImport.cs b/src/LadybugDB/Interop/Native.DllImport.cs index a7b20ad..97fcee3 100644 --- a/src/LadybugDB/Interop/Native.DllImport.cs +++ b/src/LadybugDB/Interop/Native.DllImport.cs @@ -179,6 +179,12 @@ internal static LbugState PreparedStatementBindTimestampTz(ref LbugPreparedState internal static LbugState PreparedStatementBindInterval(ref LbugPreparedStatement preparedStatement, string paramName, LbugInterval value) => PreparedStatementBindIntervalRaw(ref preparedStatement, ToUtf8(paramName), value); + [DllImport(LibraryName, EntryPoint = "lbug_prepared_statement_bind_value", CallingConvention = Conv)] + private static extern LbugState PreparedStatementBindValueRaw(ref LbugPreparedStatement preparedStatement, byte[] paramName, IntPtr value); + + internal static LbugState PreparedStatementBindValue(ref LbugPreparedStatement preparedStatement, string paramName, IntPtr value) + => PreparedStatementBindValueRaw(ref preparedStatement, ToUtf8(paramName), value); + // ---- QueryResult ----------------------------------------------------------------------------- [DllImport(LibraryName, EntryPoint = "lbug_query_result_destroy", CallingConvention = Conv)] internal static extern void QueryResultDestroy(ref LbugQueryResult queryResult); @@ -223,6 +229,12 @@ internal static LbugState PreparedStatementBindInterval(ref LbugPreparedStatemen [DllImport(LibraryName, EntryPoint = "lbug_data_type_get_id", CallingConvention = Conv)] internal static extern LbugDataTypeId DataTypeGetId(ref LbugLogicalType dataType); + [DllImport(LibraryName, EntryPoint = "lbug_data_type_create", CallingConvention = Conv)] + internal static extern void DataTypeCreate(LbugDataTypeId id, IntPtr childType, ulong numElementsInArray, out LbugLogicalType outType); + + [DllImport(LibraryName, EntryPoint = "lbug_data_type_create", CallingConvention = Conv)] + internal static extern void DataTypeCreateWithChild(LbugDataTypeId id, ref LbugLogicalType childType, ulong numElementsInArray, out LbugLogicalType outType); + [DllImport(LibraryName, EntryPoint = "lbug_data_type_destroy", CallingConvention = Conv)] internal static extern void DataTypeDestroy(ref LbugLogicalType dataType); @@ -230,6 +242,69 @@ internal static LbugState PreparedStatementBindInterval(ref LbugPreparedStatemen [DllImport(LibraryName, EntryPoint = "lbug_value_destroy", CallingConvention = Conv)] internal static extern void ValueDestroy(ref LbugValue value); + [DllImport(LibraryName, EntryPoint = "lbug_value_destroy", CallingConvention = Conv)] + internal static extern void ValueDestroy(IntPtr value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_null", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateNull(); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_default", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateDefault(ref LbugLogicalType dataType); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_bool", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateBool([MarshalAs(UnmanagedType.U1)] bool value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_int8", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateInt8(sbyte value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_int16", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateInt16(short value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_int32", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateInt32(int value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_int64", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateInt64(long value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_uint8", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateUInt8(byte value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_uint16", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateUInt16(ushort value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_uint32", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateUInt32(uint value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_uint64", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateUInt64(ulong value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_float", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateFloat(float value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_double", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateDouble(double value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_string", CallingConvention = Conv)] + private static extern IntPtr ValueCreateStringRaw(byte[] value); + + internal static IntPtr ValueCreateString(string value) + => ValueCreateStringRaw(ToUtf8(value)); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_date", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateDate(LbugDate value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_timestamp", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateTimestamp(LbugTimestamp value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_timestamp_tz", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateTimestampTz(LbugTimestamp value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_interval", CallingConvention = Conv)] + internal static extern IntPtr ValueCreateInterval(LbugInterval value); + + [DllImport(LibraryName, EntryPoint = "lbug_value_create_list", CallingConvention = Conv)] + internal static extern LbugState ValueCreateList(ulong numElements, [In] IntPtr[] elements, out IntPtr outValue); + [DllImport(LibraryName, EntryPoint = "lbug_value_is_null", CallingConvention = Conv)] [return: MarshalAs(UnmanagedType.U1)] internal static extern bool ValueIsNull(ref LbugValue value); diff --git a/src/LadybugDB/Interop/Native.LibraryImport.cs b/src/LadybugDB/Interop/Native.LibraryImport.cs index a1e5598..31a3d19 100644 --- a/src/LadybugDB/Interop/Native.LibraryImport.cs +++ b/src/LadybugDB/Interop/Native.LibraryImport.cs @@ -111,6 +111,9 @@ internal static partial class Native [LibraryImport(LibraryName, EntryPoint = "lbug_prepared_statement_bind_interval", StringMarshalling = StringMarshalling.Utf8)] internal static partial LbugState PreparedStatementBindInterval(ref LbugPreparedStatement preparedStatement, string paramName, LbugInterval value); + [LibraryImport(LibraryName, EntryPoint = "lbug_prepared_statement_bind_value", StringMarshalling = StringMarshalling.Utf8)] + internal static partial LbugState PreparedStatementBindValue(ref LbugPreparedStatement preparedStatement, string paramName, IntPtr value); + // ---- QueryResult ----------------------------------------------------------------------------- [LibraryImport(LibraryName, EntryPoint = "lbug_query_result_destroy")] internal static partial void QueryResultDestroy(ref LbugQueryResult queryResult); @@ -155,6 +158,12 @@ internal static partial class Native [LibraryImport(LibraryName, EntryPoint = "lbug_data_type_get_id")] internal static partial LbugDataTypeId DataTypeGetId(ref LbugLogicalType dataType); + [LibraryImport(LibraryName, EntryPoint = "lbug_data_type_create")] + internal static partial void DataTypeCreate(LbugDataTypeId id, IntPtr childType, ulong numElementsInArray, out LbugLogicalType outType); + + [LibraryImport(LibraryName, EntryPoint = "lbug_data_type_create")] + internal static partial void DataTypeCreateWithChild(LbugDataTypeId id, ref LbugLogicalType childType, ulong numElementsInArray, out LbugLogicalType outType); + [LibraryImport(LibraryName, EntryPoint = "lbug_data_type_destroy")] internal static partial void DataTypeDestroy(ref LbugLogicalType dataType); @@ -162,6 +171,66 @@ internal static partial class Native [LibraryImport(LibraryName, EntryPoint = "lbug_value_destroy")] internal static partial void ValueDestroy(ref LbugValue value); + [LibraryImport(LibraryName, EntryPoint = "lbug_value_destroy")] + internal static partial void ValueDestroy(IntPtr value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_null")] + internal static partial IntPtr ValueCreateNull(); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_default")] + internal static partial IntPtr ValueCreateDefault(ref LbugLogicalType dataType); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_bool")] + internal static partial IntPtr ValueCreateBool([MarshalAs(UnmanagedType.U1)] bool value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_int8")] + internal static partial IntPtr ValueCreateInt8(sbyte value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_int16")] + internal static partial IntPtr ValueCreateInt16(short value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_int32")] + internal static partial IntPtr ValueCreateInt32(int value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_int64")] + internal static partial IntPtr ValueCreateInt64(long value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_uint8")] + internal static partial IntPtr ValueCreateUInt8(byte value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_uint16")] + internal static partial IntPtr ValueCreateUInt16(ushort value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_uint32")] + internal static partial IntPtr ValueCreateUInt32(uint value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_uint64")] + internal static partial IntPtr ValueCreateUInt64(ulong value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_float")] + internal static partial IntPtr ValueCreateFloat(float value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_double")] + internal static partial IntPtr ValueCreateDouble(double value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_string", StringMarshalling = StringMarshalling.Utf8)] + internal static partial IntPtr ValueCreateString(string value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_date")] + internal static partial IntPtr ValueCreateDate(LbugDate value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_timestamp")] + internal static partial IntPtr ValueCreateTimestamp(LbugTimestamp value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_timestamp_tz")] + internal static partial IntPtr ValueCreateTimestampTz(LbugTimestamp value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_interval")] + internal static partial IntPtr ValueCreateInterval(LbugInterval value); + + [LibraryImport(LibraryName, EntryPoint = "lbug_value_create_list")] + internal static partial LbugState ValueCreateList(ulong numElements, [In] IntPtr[] elements, out IntPtr outValue); + [LibraryImport(LibraryName, EntryPoint = "lbug_value_is_null")] [return: MarshalAs(UnmanagedType.U1)] internal static partial bool ValueIsNull(ref LbugValue value); diff --git a/src/LadybugDB/PreparedStatement.cs b/src/LadybugDB/PreparedStatement.cs index faf125c..ab6222f 100644 --- a/src/LadybugDB/PreparedStatement.cs +++ b/src/LadybugDB/PreparedStatement.cs @@ -1,4 +1,6 @@ using System; +using System.Collections; +using System.Collections.Generic; using System.Threading; using LadybugDB.Interop; @@ -109,8 +111,7 @@ public PreparedStatement Bind(string name, object? value) { switch (value) { - case null: - throw new ArgumentNullException(nameof(value), "Binding null parameters is not supported."); + case null: return BindValue(name, CreateNativeValue(null)); case bool v: return Bind(name, v); case sbyte v: return Bind(name, v); case short v: return Bind(name, v); @@ -130,6 +131,7 @@ public PreparedStatement Bind(string name, object? value) #if NET7_0_OR_GREATER case DateOnly v: return Bind(name, v); #endif + case IEnumerable v: return BindValue(name, CreateNativeValue(v)); default: throw new NotSupportedException($"Cannot bind a parameter of type {value.GetType()}."); } @@ -168,6 +170,254 @@ private static long ToUtcTicks(DateTime value) ? DateTime.SpecifyKind(value, DateTimeKind.Utc).Ticks : value.ToUniversalTime().Ticks; + private static IntPtr CreateNativeValue(object? value) + { + IntPtr handle = value switch + { + null => Native.ValueCreateNull(), + bool v => Native.ValueCreateBool(v), + sbyte v => Native.ValueCreateInt8(v), + short v => Native.ValueCreateInt16(v), + int v => Native.ValueCreateInt32(v), + long v => Native.ValueCreateInt64(v), + byte v => Native.ValueCreateUInt8(v), + ushort v => Native.ValueCreateUInt16(v), + uint v => Native.ValueCreateUInt32(v), + ulong v => Native.ValueCreateUInt64(v), + float v => Native.ValueCreateFloat(v), + double v => Native.ValueCreateDouble(v), + string v => Native.ValueCreateString(v), + Guid v => Native.ValueCreateString(v.ToString()), + DateTimeOffset v => Native.ValueCreateTimestampTz(new LbugTimestamp { Value = (v.UtcDateTime.Ticks - UnixEpochTicks) / 10L }), + DateTime v => Native.ValueCreateTimestamp(new LbugTimestamp { Value = (ToUtcTicks(v) - UnixEpochTicks) / 10L }), + Interval v => Native.ValueCreateInterval(new LbugInterval { Months = v.Months, Days = v.Days, Micros = v.Micros }), +#if NET7_0_OR_GREATER + DateOnly v => Native.ValueCreateDate(new LbugDate { Days = v.DayNumber - new DateOnly(1970, 1, 1).DayNumber }), +#endif + IEnumerable v => CreateNativeList(v), + _ => throw new NotSupportedException($"Cannot bind a parameter of type {value.GetType()}.") + }; + + if (handle == IntPtr.Zero) + { + throw new LadybugException("Failed to create a native parameter value."); + } + + return handle; + } + + private static IntPtr CreateNativeList(IEnumerable values) + { + var elementHandles = new List(); + try + { + foreach (object? value in values) + { + elementHandles.Add(CreateNativeValue(value)); + } + + if (elementHandles.Count == 0) + { + return CreateNativeEmptyList(values.GetType()); + } + + IntPtr[] elements = elementHandles.ToArray(); + LbugState state = Native.ValueCreateList((ulong)elements.Length, elements, out IntPtr listHandle); + if (state != LbugState.Success || listHandle == IntPtr.Zero) + { + throw new LadybugException("Failed to create a LIST parameter value."); + } + + return listHandle; + } + finally + { + foreach (IntPtr handle in elementHandles) + { + Native.ValueDestroy(handle); + } + } + } + + private static IntPtr CreateNativeEmptyList(Type sequenceType) + { + if (!TryGetElementDataTypeId(sequenceType, out LbugDataTypeId childTypeId)) + { + throw new NotSupportedException( + $"Cannot bind empty enumerable parameter type {sequenceType} because its element type is unknown or unsupported."); + } + + Native.DataTypeCreate(childTypeId, IntPtr.Zero, 0, out LbugLogicalType childType); + try + { + Native.DataTypeCreateWithChild(LbugDataTypeId.List, ref childType, 0, out LbugLogicalType listType); + try + { + IntPtr handle = Native.ValueCreateDefault(ref listType); + if (handle == IntPtr.Zero) + { + throw new LadybugException("Failed to create an empty LIST parameter value."); + } + + return handle; + } + finally + { + Native.DataTypeDestroy(ref listType); + } + } + finally + { + Native.DataTypeDestroy(ref childType); + } + } + + private static bool TryGetElementDataTypeId(Type sequenceType, out LbugDataTypeId dataTypeId) + { + Type? elementType = GetEnumerableElementType(sequenceType); + if (elementType is not null && Nullable.GetUnderlyingType(elementType) is { } nullableType) + { + elementType = nullableType; + } + + if (elementType == typeof(bool)) + { + dataTypeId = LbugDataTypeId.Bool; + return true; + } + + if (elementType == typeof(sbyte)) + { + dataTypeId = LbugDataTypeId.Int8; + return true; + } + + if (elementType == typeof(short)) + { + dataTypeId = LbugDataTypeId.Int16; + return true; + } + + if (elementType == typeof(int)) + { + dataTypeId = LbugDataTypeId.Int32; + return true; + } + + if (elementType == typeof(long)) + { + dataTypeId = LbugDataTypeId.Int64; + return true; + } + + if (elementType == typeof(byte)) + { + dataTypeId = LbugDataTypeId.UInt8; + return true; + } + + if (elementType == typeof(ushort)) + { + dataTypeId = LbugDataTypeId.UInt16; + return true; + } + + if (elementType == typeof(uint)) + { + dataTypeId = LbugDataTypeId.UInt32; + return true; + } + + if (elementType == typeof(ulong)) + { + dataTypeId = LbugDataTypeId.UInt64; + return true; + } + + if (elementType == typeof(float)) + { + dataTypeId = LbugDataTypeId.Float; + return true; + } + + if (elementType == typeof(double)) + { + dataTypeId = LbugDataTypeId.Double; + return true; + } + + if (elementType == typeof(string) || elementType == typeof(Guid)) + { + dataTypeId = LbugDataTypeId.String; + return true; + } + + if (elementType == typeof(DateTime)) + { + dataTypeId = LbugDataTypeId.Timestamp; + return true; + } + + if (elementType == typeof(DateTimeOffset)) + { + dataTypeId = LbugDataTypeId.TimestampTz; + return true; + } + + if (elementType == typeof(Interval)) + { + dataTypeId = LbugDataTypeId.Interval; + return true; + } + +#if NET7_0_OR_GREATER + if (elementType == typeof(DateOnly)) + { + dataTypeId = LbugDataTypeId.Date; + return true; + } +#endif + + dataTypeId = default; + return false; + } + + private static Type? GetEnumerableElementType(Type sequenceType) + { + if (sequenceType.IsArray) + { + return sequenceType.GetElementType(); + } + + if (sequenceType.IsGenericType && sequenceType.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + return sequenceType.GetGenericArguments()[0]; + } + + if (sequenceType.IsGenericType) + { + Type[] genericArguments = sequenceType.GetGenericArguments(); + if (genericArguments.Length == 1) + { + return genericArguments[0]; + } + } + + return null; + } + + private PreparedStatement BindValue(string name, IntPtr value) + { + try + { + return Do(name, Native.PreparedStatementBindValue(ref _handle, Name(name), value)); + } + finally + { + Native.ValueDestroy(value); + } + } + private PreparedStatement Do(string name, LbugState state) { if (state != LbugState.Success) diff --git a/test/LadybugDB.Tests/PreparedStatementTests.cs b/test/LadybugDB.Tests/PreparedStatementTests.cs index e476324..4a31394 100644 --- a/test/LadybugDB.Tests/PreparedStatementTests.cs +++ b/test/LadybugDB.Tests/PreparedStatementTests.cs @@ -73,6 +73,53 @@ public void Execute_with_parameter_dictionary() } } + [SkippableFact] + public void Execute_with_parameter_dictionary_binds_lists_empty_lists_and_nulls() + { + Skip.IfNot(TestEnvironment.NativeAvailable, "Native Ladybug library is not available."); + + string dbPath = TestEnvironment.NewTempDbPath(); + try + { + using var db = new Database(dbPath); + using var conn = new Connection(db); + + conn.Query("CREATE NODE TABLE Person(name STRING, PRIMARY KEY(name))").Dispose(); + conn.Query("CREATE (:Person {name: 'Alice'})").Dispose(); + conn.Query("CREATE (:Person {name: 'Bob'})").Dispose(); + conn.Query("CREATE (:Person {name: 'Mallory'})").Dispose(); + + var parameters = new Dictionary + { + ["names"] = new List { "Alice", "Bob" }, + ["scores"] = new[] { 0.1f, 0.2f }, + ["empty"] = Array.Empty(), + ["missing"] = null + }; + using QueryResult result = conn.Execute( + """ + MATCH (p:Person) + WHERE p.name IN $names + RETURN collect(p.name) AS names, $scores AS scores, $empty AS empty, $missing AS missing + """, + parameters); + + object?[] row = result.Rows().Single(); + var names = Assert.IsType(row[0]); + var scores = Assert.IsType(row[1]); + var empty = Assert.IsType(row[2]); + + Assert.Equal(new object?[] { "Alice", "Bob" }, names.OrderBy(value => value).ToArray()); + Assert.Equal(new object?[] { 0.1f, 0.2f }, scores); + Assert.Empty(empty); + Assert.Null(row[3]); + } + finally + { + TestEnvironment.TryDelete(dbPath); + } + } + [SkippableFact] public void Prepare_failure_throws_query_exception() { From bdd9b4dcc97adb830ec8df3fcec130de6c602887 Mon Sep 17 00:00:00 2001 From: Sergey Vershinin Date: Fri, 10 Jul 2026 10:03:44 +0200 Subject: [PATCH 2/4] test: guard interop declaration parity --- test/LadybugDB.Tests/StructLayoutTests.cs | 48 +++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/test/LadybugDB.Tests/StructLayoutTests.cs b/test/LadybugDB.Tests/StructLayoutTests.cs index 4e9559e..c1a325e 100644 --- a/test/LadybugDB.Tests/StructLayoutTests.cs +++ b/test/LadybugDB.Tests/StructLayoutTests.cs @@ -96,3 +96,51 @@ public void SystemConfig_FieldOffsets_MatchNativeAbi() static int Offset(string field) => (int)Marshal.OffsetOf(field); } } + +/// +/// Cross-TFM guard: Native.LibraryImport.cs (net7+) and Native.DllImport.cs (ns2.0) +/// are hand-kept identical. On net10.0 only the LibraryImport partial compiles, so reflection over +/// the loaded assembly cannot see the ns2.0 declarations. Parse both source files and assert that +/// they declare the same set of native entry points. This does not prove signature equivalence. +/// +public sealed class InteropDeclarationParity +{ + private static readonly System.Text.RegularExpressions.Regex EntryPointPattern = + new(@"EntryPoint\s*=\s*""(?[A-Za-z0-9_]+)"""); + + private static string InteropDir([System.Runtime.CompilerServices.CallerFilePath] string? thisFile = null) + { + string testDir = System.IO.Path.GetDirectoryName(thisFile!)!; + string root = System.IO.Path.GetFullPath(System.IO.Path.Combine(testDir, "..", "..")); + return System.IO.Path.Combine(root, "src", "LadybugDB", "Interop"); + } + + private static System.Collections.Generic.HashSet EntryPoints(string fileName) + { + string path = System.IO.Path.Combine(InteropDir(), fileName); + string text = System.IO.File.ReadAllText(path); + var set = new System.Collections.Generic.HashSet(System.StringComparer.Ordinal); + foreach (System.Text.RegularExpressions.Match match in EntryPointPattern.Matches(text)) + { + set.Add(match.Groups["ep"].Value); + } + + return set; + } + + [Fact] + public void LibraryImport_And_DllImport_DeclareTheSameEntryPoints() + { + var libraryImport = EntryPoints("Native.LibraryImport.cs"); + var dllImport = EntryPoints("Native.DllImport.cs"); + + var onlyInLibraryImport = new System.Collections.Generic.SortedSet(libraryImport); + onlyInLibraryImport.ExceptWith(dllImport); + var onlyInDllImport = new System.Collections.Generic.SortedSet(dllImport); + onlyInDllImport.ExceptWith(libraryImport); + + Assert.True( + onlyInLibraryImport.Count == 0 && onlyInDllImport.Count == 0, + $"Interop declaration drift.\n Only in LibraryImport: {string.Join(", ", onlyInLibraryImport)}\n Only in DllImport: {string.Join(", ", onlyInDllImport)}"); + } +} From 7594a8ccbb599efa729b74384c56d543a26661d4 Mon Sep 17 00:00:00 2001 From: Sergey Vershinin Date: Fri, 10 Jul 2026 10:04:53 +0200 Subject: [PATCH 3/4] docs: document stable engine upgrades --- MAINTAINING.md | 77 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 18 deletions(-) diff --git a/MAINTAINING.md b/MAINTAINING.md index 8c36507..4b6e093 100644 --- a/MAINTAINING.md +++ b/MAINTAINING.md @@ -5,9 +5,10 @@ the development-era working notes. ## Repository Shape -This repository contains the hand-written C# binding for the native Ladybug C API. It is also used as the -`tools/csharp_api` submodule inside the main `LadybugDB/ladybug` monorepo, where the engine source and -`src/include/c_api/lbug.h` are available at `../../`. +This is the standalone, hand-written C# binding for the native Ladybug C API. It can be mounted locally +at `tools/csharp_api` inside a `LadybugDB/ladybug` checkout, where the engine source and +`src/include/c_api/lbug.h` are available at `../../`. The repositories advance independently: a binding +release pins a published engine tag rather than a parent-repository submodule commit. Main areas: @@ -102,25 +103,65 @@ dotnet test tools/csharp_api/test/LadybugDB.Tests/LadybugDB.Tests.csproj -c Rele PowerShell can mangle unquoted `-D` arguments; pass CMake flags as quoted strings or via an explicit PowerShell array when scripting. -## Release Flow +## Adopting an Upstream Engine Release -1. Decide the package version and update `version.txt`. -2. Ensure the native engine release exists in `LadybugDB/ladybug` for the first three numeric package - version segments, or pass `--engine-version` / workflow `engine_version`. -3. Run local validation where practical: +Use a fresh binding worktree so `lib/` and `download/` cannot contain a native or release archive from +the previous engine. Set `LADYBUG_ENGINE_REPO` to a local `LadybugDB/ladybug` checkout, then fetch and +inspect the old and new release tags: - ```powershell - ./build.ps1 --target Test - ./build.ps1 --target Pack - ``` +```powershell +$engine = $env:LADYBUG_ENGINE_REPO +if (-not $engine -or -not (Test-Path (Join-Path $engine 'src/include/c_api/lbug.h'))) { + throw 'Set LADYBUG_ENGINE_REPO to a LadybugDB/ladybug checkout.' +} +$old = 'v0.17.0' +$new = 'v0.18.1' + +git -C $engine fetch origin --tags --prune +gh release view $new --repo LadybugDB/ladybug +git -C $engine diff --exit-code $old $new -- src/include/c_api/ +git -C $engine log --oneline "$old..$new" -- src/include/c_api/ src/c_api/ +``` -4. Merge through CI. -5. Tag the package version: +The header diff is the ABI gate. An empty diff means no release-driven interop change. A non-empty diff +requires the ABI checklist below, including updates to both declaration files and native-gated tests. +Implementation-only changes can still affect behavior, so review the `src/c_api/` log even when the +header is unchanged. + +Confirm that the new release contains every asset named by `cake/BuildContext.cs`: + +```powershell +$required = @( + 'liblbug-windows-x86_64.zip', + 'liblbug-linux-x86_64.tar.gz', + 'liblbug-linux-aarch64.tar.gz', + 'liblbug-osx-x86_64.tar.gz', + 'liblbug-osx-arm64.tar.gz' +) +$assets = @(gh release view $new --repo LadybugDB/ladybug --json assets --jq '.assets[].name') +$missing = @($required | Where-Object { $_ -notin $assets }) +if ($missing.Count -ne 0) { throw "Missing release assets: $($missing -join ', ')" } +``` - ```bash - git tag v0.17.0.1 - git push origin v0.17.0.1 - ``` +Set `version.txt` to the exact stable package version, update active version references in the README +and examples, then validate the real native and the full package family: + +```powershell +.\build.ps1 --target Test --engine-version $new +.\build.ps1 --target Pack --package-version ($new.TrimStart('v')) --engine-version $new +``` + +`Test` must run with native skips disabled. `Pack` must verify the managed package, all five per-RID +native packages, and the native meta-package. Merge through CI before creating and pushing the matching +`vX.Y.Z` binding tag; only a tag push publishes to NuGet. + +## Release Flow + +1. Follow **Adopting an Upstream Engine Release** when the first three version segments change. +2. Update `version.txt`; for a binding-only release, increment only the optional fourth segment. +3. Run Cake `Test` and `Pack` against the intended engine tag. +4. Merge through CI. +5. Tag the merged package version and push that tag; the tag-triggered workflow publishes. The release workflow gates on linux-x64 against the real engine, packs the full package family, verifies contents, and publishes all packages to NuGet through trusted publishing. From e684e705b794dd7e638f19295aa28a13a674530a Mon Sep 17 00:00:00 2001 From: Sergey Vershinin Date: Fri, 10 Jul 2026 10:06:13 +0200 Subject: [PATCH 4/4] chore: prepare 0.18.1 release --- README.md | 6 +++--- examples/Directory.Build.props | 2 +- examples/native-loading/Dockerfile.bundled | 2 +- examples/native-loading/Dockerfile.system | 4 ++-- examples/native-loading/scripts/build-deb.sh | 2 +- examples/quickstart/README.md | 2 +- version.txt | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 48d13f3..8ba7f13 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Official C# binding for the [Ladybug](https://github.com/ladybugdb/ladybug) embe It wraps the native Ladybug C API via P/Invoke and ships prebuilt native libraries for supported platforms, so you can run Cypher queries against an embedded graph database directly from .NET. -> Current package family: `0.17.0.1`, built against the Ladybug `v0.17.0` engine. +> Current package family: `0.18.1`, built against the Ladybug `v0.18.1` engine. ## Target frameworks @@ -78,8 +78,8 @@ project under [`cake/`](cake) (run it with the `build.ps1` / `build.sh` bootstra All packages in the family share one version. The first three numeric segments track the upstream engine release, and the optional fourth segment is the .NET package revision for binding-only releases. For -example, package `0.17.0.1` wraps the Ladybug `v0.17.0` engine; a future binding-only fix over the same -engine would be `0.17.0.2`. Prerelease suffixes are reserved for preview builds. +example, package `0.18.1` wraps the Ladybug `v0.18.1` engine; a future binding-only fix over the same +engine would be `0.18.1.1`. Prerelease suffixes are reserved for preview builds. The package version is defined once in `version.txt` at the repo root. Override it with `--package-version ` (the release workflow uses the git tag). The engine release defaults to the first diff --git a/examples/Directory.Build.props b/examples/Directory.Build.props index 7e99895..7c4bfd2 100644 --- a/examples/Directory.Build.props +++ b/examples/Directory.Build.props @@ -3,7 +3,7 @@ - 0.17.0.1 + 0.18.1 diff --git a/examples/native-loading/Dockerfile.bundled b/examples/native-loading/Dockerfile.bundled index 02373a8..bf278b8 100644 --- a/examples/native-loading/Dockerfile.bundled +++ b/examples/native-loading/Dockerfile.bundled @@ -3,7 +3,7 @@ # (liblbug.so) is published next to the app and loaded from the app's own deployment directory. FROM mcr.microsoft.com/dotnet/sdk:10.0 -ARG LADYBUG_VERSION=0.17.0-alpha.1 +ARG LADYBUG_VERSION=0.18.1 WORKDIR /src COPY nuget.config ./nuget.config diff --git a/examples/native-loading/Dockerfile.system b/examples/native-loading/Dockerfile.system index 1c07a99..7a3c7ef 100644 --- a/examples/native-loading/Dockerfile.system +++ b/examples/native-loading/Dockerfile.system @@ -3,7 +3,7 @@ # installed system-wide via a Debian package; the app must locate it through the OS dynamic linker. FROM mcr.microsoft.com/dotnet/sdk:10.0 -ARG LADYBUG_VERSION=0.17.0-alpha.1 +ARG LADYBUG_VERSION=0.18.1 # unzip: extract liblbug.so from the native nupkg for the .deb. binutils: readelf (SONAME inspection). RUN apt-get update \ @@ -21,6 +21,6 @@ RUN dotnet publish app/ConsumerApp.csproj -c Release -r linux-x64 --self-contain -p:LadybugVersion=${LADYBUG_VERSION} -o /app/publish # Build the .deb at image-build time; installation happens at run time (after the negative control). -RUN bash scripts/build-deb.sh /src/feed /tmp 0.17.0 +RUN bash scripts/build-deb.sh /src/feed /tmp 0.18.1 ENTRYPOINT ["bash", "/src/scripts/run-system.sh"] diff --git a/examples/native-loading/scripts/build-deb.sh b/examples/native-loading/scripts/build-deb.sh index 6fb7cb3..8ff10a2 100644 --- a/examples/native-loading/scripts/build-deb.sh +++ b/examples/native-loading/scripts/build-deb.sh @@ -10,7 +10,7 @@ set -euo pipefail FEED="${1:?feed dir required}" OUT="${2:?output dir required}" -VER="${3:-0.17.0}" +VER="${3:-0.18.1}" work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT diff --git a/examples/quickstart/README.md b/examples/quickstart/README.md index b023037..4754027 100644 --- a/examples/quickstart/README.md +++ b/examples/quickstart/README.md @@ -18,7 +18,7 @@ dotnet run Expected output: ``` -LadybugDB 0.17.0 (storage v41) +LadybugDB 0.18.1 (storage v42) name | age Alice is 25 years old diff --git a/version.txt b/version.txt index b558c6a..249afd5 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.17.0.1 +0.18.1