From 1f58c3c13c46c5542a9aa06b565c67fb6685d3ab Mon Sep 17 00:00:00 2001 From: MrDevRobot <12503462+mrdevrobot@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:03:14 +0200 Subject: [PATCH 1/2] Support byte[] BSON binary and add tests Add full byte[] (BSON Binary) support to the source generator and tests. Map byte[] to WriteBinary/ReadBinary in CodeGenerator, handle ReadBinary's ReadOnlySpan by calling .ToArray(), and add null handling for nullable byte[] properties. Exclude byte[] from being treated as a sequence in SyntaxHelper. Add BinaryEntity model and register the collection in TestDbContext, and introduce comprehensive BinaryPropertyTests covering round-trips, empty arrays, nullable fields, updates, persistence across reopen, large payloads, BLiteEngine (dynamic/BSON) interop, and JSON base64 behavior. --- src/BLite.SourceGenerators/CodeGenerator.cs | 27 +- .../Helpers/SyntaxHelper.cs | 3 + tests/BLite.Shared/MockEntities.cs | 10 + tests/BLite.Shared/TestDbContext.cs | 6 + tests/BLite.Tests/BinaryPropertyTests.cs | 276 ++++++++++++++++++ 5 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 tests/BLite.Tests/BinaryPropertyTests.cs diff --git a/src/BLite.SourceGenerators/CodeGenerator.cs b/src/BLite.SourceGenerators/CodeGenerator.cs index e5036cf..9228c9e 100644 --- a/src/BLite.SourceGenerators/CodeGenerator.cs +++ b/src/BLite.SourceGenerators/CodeGenerator.cs @@ -333,7 +333,7 @@ private static void GenerateWriteProperty(StringBuilder sb, PropertyInfo prop, s var writeMethod = GetPrimitiveWriteMethod(prop, allowKey: false); if (writeMethod != null) { - if (prop.IsNullable || prop.TypeName == "string" || prop.TypeName == "String") + if (prop.IsNullable || prop.TypeName == "string" || prop.TypeName == "String" || prop.TypeName == "byte[]") { sb.AppendLine($" if (entity.{prop.Name} != null)"); sb.AppendLine($" {{"); @@ -718,6 +718,27 @@ private static void GenerateReadPropertyToLocal(StringBuilder sb, PropertyInfo p var readMethod = GetPrimitiveReadMethod(prop); if (readMethod != null) { + if (readMethod == "ReadBinary") + { + // ReadBinary returns ReadOnlySpan — must call .ToArray() to materialise + if (prop.IsNullable) + { + sb.AppendLine($" if ({bsonTypeVar} == global::BLite.Bson.BsonType.Null)"); + sb.AppendLine($" {{"); + sb.AppendLine($" {localVar} = null;"); + sb.AppendLine($" }}"); + sb.AppendLine($" else"); + sb.AppendLine($" {{"); + sb.AppendLine($" {localVar} = reader.ReadBinary(out _).ToArray();"); + sb.AppendLine($" }}"); + } + else + { + sb.AppendLine($" {localVar} = reader.ReadBinary(out _).ToArray();"); + } + } + else + { var cast = (prop.TypeName == "float" || prop.TypeName == "Single") ? "(float)" : ""; // Handle nullable types - check for null in BSON stream @@ -738,6 +759,7 @@ private static void GenerateReadPropertyToLocal(StringBuilder sb, PropertyInfo p var readArgs = IsCoercedReadMethod(readMethod) ? $"({bsonTypeVar})" : "()"; sb.AppendLine($" {localVar} = {cast}reader.{readMethod}{readArgs};"); } + } } else if (prop.ConverterTypeName != null) { @@ -931,6 +953,7 @@ private static string GetBaseMapperClass(PropertyInfo? keyProp, EntityInfo entit if (cleanType.EndsWith("TimeOnly")) return "WriteTimeOnly"; if (cleanType.EndsWith("Guid")) return "WriteGuid"; if (cleanType.EndsWith("ObjectId")) return "WriteObjectId"; + if (cleanType == "byte[]") return "WriteBinary"; return null; } @@ -964,6 +987,7 @@ private static string GetBaseMapperClass(PropertyInfo? keyProp, EntityInfo entit if (cleanType.EndsWith("TimeOnly")) return "ReadTimeOnly"; if (cleanType.EndsWith("Guid")) return "ReadGuid"; if (cleanType.EndsWith("ObjectId")) return "ReadObjectId"; + if (cleanType == "byte[]") return "ReadBinary"; return null; } @@ -1025,6 +1049,7 @@ private static string QualifyType(string typeName) case "object": case "dynamic": case "void": + case "byte[]": return baseType + (isNullable ? "?" : ""); case "Guid": return "global::System.Guid" + (isNullable ? "?" : ""); case "DateTime": return "global::System.DateTime" + (isNullable ? "?" : ""); diff --git a/src/BLite.SourceGenerators/Helpers/SyntaxHelper.cs b/src/BLite.SourceGenerators/Helpers/SyntaxHelper.cs index cf42860..29a4bfd 100644 --- a/src/BLite.SourceGenerators/Helpers/SyntaxHelper.cs +++ b/src/BLite.SourceGenerators/Helpers/SyntaxHelper.cs @@ -143,6 +143,9 @@ public static bool IsCollectionType(ITypeSymbol type, out ITypeSymbol? itemType) // Handle arrays if (type is IArrayTypeSymbol arrayType) { + // Exclude byte[] — it maps to BSON Binary, not a sequence of integers + if (arrayType.ElementType.SpecialType == SpecialType.System_Byte) + return false; itemType = arrayType.ElementType; return true; } diff --git a/tests/BLite.Shared/MockEntities.cs b/tests/BLite.Shared/MockEntities.cs index a26d023..601a1f5 100644 --- a/tests/BLite.Shared/MockEntities.cs +++ b/tests/BLite.Shared/MockEntities.cs @@ -199,6 +199,16 @@ public class DddLineItem public int Quantity { get; set; } } + // --- Binary Property Tests --- + + public class BinaryEntity + { + public ObjectId Id { get; set; } + public string Label { get; set; } = ""; + public byte[] Data { get; set; } = Array.Empty(); + public byte[]? OptionalData { get; set; } + } + public record OrderId(string Value) { public OrderId() : this(string.Empty) { } diff --git a/tests/BLite.Shared/TestDbContext.cs b/tests/BLite.Shared/TestDbContext.cs index d10efe9..d26feda 100644 --- a/tests/BLite.Shared/TestDbContext.cs +++ b/tests/BLite.Shared/TestDbContext.cs @@ -80,6 +80,9 @@ public partial class TestDbContext : DocumentDbContext // Device – HasConversion on a non-ID property (ulong → long) public DocumentCollection Devices { get; set; } = null!; + // Binary Property Tests + public DocumentCollection BinaryEntities { get; set; } = null!; + public TestDbContext(string databasePath) : base(databasePath) { InitializeCollections(); @@ -183,6 +186,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) // Benchmark entities modelBuilder.Entity().ToCollection("customer_orders").HasKey(e => e.Id); + + // Binary Property Tests + modelBuilder.Entity().ToCollection("binary_entities"); } public void ForceCheckpoint() diff --git a/tests/BLite.Tests/BinaryPropertyTests.cs b/tests/BLite.Tests/BinaryPropertyTests.cs new file mode 100644 index 0000000..7cad340 --- /dev/null +++ b/tests/BLite.Tests/BinaryPropertyTests.cs @@ -0,0 +1,276 @@ +// BLite — byte[] / BSON Binary round-trip tests via DocumentDbContext typed path + +using BLite.Bson; +using BLite.Core; +using BLite.Shared; +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Xunit; + +namespace BLite.Tests; + +public class BinaryPropertyTests : IDisposable +{ + private readonly string _dbPath; + private readonly TestDbContext _db; + + public BinaryPropertyTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"test_binary_{Guid.NewGuid()}.db"); + _db = new TestDbContext(_dbPath); + } + + public void Dispose() + { + _db.Dispose(); + if (File.Exists(_dbPath)) File.Delete(_dbPath); + } + + [Fact] + public async Task BinaryProperty_NonNull_RoundTrips() + { + var entity = new BinaryEntity + { + Label = "payload", + Data = new byte[] { 0x01, 0x02, 0x03, 0xAA, 0xFF } + }; + + var id = await _db.BinaryEntities.InsertAsync(entity); + await _db.SaveChangesAsync(); + + var result = await _db.BinaryEntities.FindByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal(entity.Label, result!.Label); + Assert.Equal(entity.Data, result.Data); + } + + [Fact] + public async Task BinaryProperty_EmptyArray_RoundTrips() + { + var entity = new BinaryEntity + { + Label = "empty", + Data = Array.Empty() + }; + + var id = await _db.BinaryEntities.InsertAsync(entity); + await _db.SaveChangesAsync(); + + var result = await _db.BinaryEntities.FindByIdAsync(id); + + Assert.NotNull(result); + Assert.Empty(result!.Data); + } + + [Fact] + public async Task BinaryProperty_NullableNull_RoundTrips() + { + var entity = new BinaryEntity + { + Label = "no-optional", + Data = new byte[] { 1, 2 }, + OptionalData = null + }; + + var id = await _db.BinaryEntities.InsertAsync(entity); + await _db.SaveChangesAsync(); + + var result = await _db.BinaryEntities.FindByIdAsync(id); + + Assert.NotNull(result); + Assert.Null(result!.OptionalData); + } + + [Fact] + public async Task BinaryProperty_NullableNonNull_RoundTrips() + { + var entity = new BinaryEntity + { + Label = "with-optional", + Data = new byte[] { 10, 20 }, + OptionalData = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF } + }; + + var id = await _db.BinaryEntities.InsertAsync(entity); + await _db.SaveChangesAsync(); + + var result = await _db.BinaryEntities.FindByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal(entity.OptionalData, result!.OptionalData); + } + + [Fact] + public async Task BinaryProperty_Update_RoundTrips() + { + var entity = new BinaryEntity + { + Label = "original", + Data = new byte[] { 1, 2, 3 } + }; + + var id = await _db.BinaryEntities.InsertAsync(entity); + await _db.SaveChangesAsync(); + + entity.Data = new byte[] { 9, 8, 7, 6 }; + entity.Label = "updated"; + Assert.True(await _db.BinaryEntities.UpdateAsync(entity)); + + var result = await _db.BinaryEntities.FindByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal(new byte[] { 9, 8, 7, 6 }, result!.Data); + Assert.Equal("updated", result.Label); + } + + [Fact] + public async Task BinaryProperty_PersistsAcrossReopen() + { + var path = Path.Combine(Path.GetTempPath(), $"test_binary_reopen_{Guid.NewGuid()}.db"); + var expected = new byte[] { 0x11, 0x22, 0x33, 0x44 }; + ObjectId savedId; + + using (var db = new TestDbContext(path)) + { + var entity = new BinaryEntity { Label = "persist", Data = expected }; + savedId = await db.BinaryEntities.InsertAsync(entity); + await db.SaveChangesAsync(); + db.ForceCheckpoint(); + } + + using (var db = new TestDbContext(path)) + { + var result = await db.BinaryEntities.FindByIdAsync(savedId); + Assert.NotNull(result); + Assert.Equal(expected, result!.Data); + } + + if (File.Exists(path)) File.Delete(path); + } + + [Fact] + public async Task BinaryProperty_LargePayload_RoundTrips() + { + var data = new byte[4096]; + for (var i = 0; i < data.Length; i++) + data[i] = (byte)(i % 256); + + var entity = new BinaryEntity { Label = "large", Data = data }; + var id = await _db.BinaryEntities.InsertAsync(entity); + await _db.SaveChangesAsync(); + + var result = await _db.BinaryEntities.FindByIdAsync(id); + + Assert.NotNull(result); + Assert.Equal(data, result!.Data); + } + + // ── BLiteEngine (dynamic/BSON) cross-path ──────────────────────────────── + + /// + /// Write a binary field via BLiteEngine (BsonValue.FromBinary), + /// then read it back via the typed DocumentDbContext mapper. + /// Verifies that the generated deserializer correctly converts BsonType.Binary → byte[]. + /// + [Fact] + public async Task BinaryProperty_BLiteEngine_WritesBsonBinary_TypedReaderGetsBytes() + { + var data = new byte[] { 0xCA, 0xFE, 0xBA, 0xBE }; + var label = "engine-to-typed"; + var path = Path.Combine(Path.GetTempPath(), $"test_binary_engine_{Guid.NewGuid()}.db"); + ObjectId savedId; + + // Write via BLiteEngine — stores the field as BsonType.Binary + using (var engine = new BLiteEngine(path)) + { + engine.RegisterKeys(["label", "data", "optionaldata"]); + var bsonId = await engine.InsertAsync("binary_entities", + engine.CreateDocument(["label", "data", "optionaldata"], b => + { + b.AddString("label", label); + b.Add("data", BsonValue.FromBinary(data)); + b.Add("optionaldata", BsonValue.Null); + })); + savedId = bsonId.AsObjectId(); + } + + // Read back via typed DocumentDbContext mapper + using (var db = new TestDbContext(path)) + { + var result = await db.BinaryEntities.FindByIdAsync(savedId); + + Assert.NotNull(result); + Assert.Equal(label, result!.Label); + Assert.Equal(data, result.Data); + Assert.Null(result.OptionalData); + } + + if (File.Exists(path)) File.Delete(path); + } + + // ── JSON serialization ──────────────────────────────────────────────────── + + /// + /// BsonDocument → ToJson encodes Binary as base64 string. + /// FromJson parses that string back as BsonType.String (not Binary) because + /// JSON has no native binary type — this is a known limitation. + /// The raw bytes are still recoverable via Convert.FromBase64String. + /// + [Fact] + public void BinaryProperty_Json_Binary_SerializesAsBase64_TypeNotPreserved() + { + var data = new byte[] { 0x01, 0x02, 0x03, 0xAA }; + + // ── Build an in-memory BsonDocument containing a Binary field ───────── + var keyMap = new ConcurrentDictionary(); + keyMap["_id"] = 0; + keyMap["data"] = 1; + var reverseMap = new ConcurrentDictionary(); + reverseMap[0] = "_id"; + reverseMap[1] = "data"; + + var doc = BsonDocument.Create(keyMap, reverseMap, b => + { + b.AddId(new BsonId(ObjectId.NewObjectId())); + b.Add("data", BsonValue.FromBinary(data)); + }); + + // Verify the field is BsonType.Binary before serialization + Assert.True(doc.TryGetValue("data", out var beforeJson)); + Assert.True(beforeJson.IsBinary, "Field must be BsonType.Binary before serialization"); + + // ── BSON → JSON ────────────────────────────────────────────────────── + var json = BsonJsonConverter.ToJson(doc, indented: false); + + // The JSON must contain the base64 encoding of the data bytes + var expectedBase64 = Convert.ToBase64String(data); + Assert.Contains(expectedBase64, json); + Assert.True(IsValidJson(json), "ToJson must produce valid JSON"); + + // ── JSON → BSON ────────────────────────────────────────────────────── + var roundTripped = BsonJsonConverter.FromJson(json, keyMap, reverseMap); + + Assert.True(roundTripped.TryGetValue("data", out var reparsed)); + + // Known limitation: JSON has no binary type. The base64 string comes back + // as BsonType.String, not BsonType.Binary. + Assert.True(reparsed.IsString, + "After JSON round-trip the field type is String (base64), not Binary"); + Assert.False(reparsed.IsBinary, + "BsonType.Binary is NOT preserved through JSON serialization"); + + // The data bytes are still recoverable by decoding the base64 string + var recovered = Convert.FromBase64String(reparsed.AsString!); + Assert.Equal(data, recovered); + } + + private static bool IsValidJson(string json) + { + try { JsonDocument.Parse(json); return true; } + catch { return false; } + } +} From a466b22e2cedbf731b5b980b71af0ff8f3e20a1a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 09:36:10 +0000 Subject: [PATCH 2/2] Fix test cleanup: delete WAL sidecar files and dispose JsonDocument Agent-Logs-Url: https://github.com/EntglDb/BLite/sessions/29b1ef90-8d5e-487d-b058-6b6ca476f085 Co-authored-by: mrdevrobot <12503462+mrdevrobot@users.noreply.github.com> --- tests/BLite.Tests/BinaryPropertyTests.cs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/BLite.Tests/BinaryPropertyTests.cs b/tests/BLite.Tests/BinaryPropertyTests.cs index 7cad340..ebdc13e 100644 --- a/tests/BLite.Tests/BinaryPropertyTests.cs +++ b/tests/BLite.Tests/BinaryPropertyTests.cs @@ -26,7 +26,16 @@ public BinaryPropertyTests() public void Dispose() { _db.Dispose(); - if (File.Exists(_dbPath)) File.Delete(_dbPath); + DeleteFileIfExists(_dbPath); + DeleteFileIfExists(Path.ChangeExtension(_dbPath, ".wal")); + } + + private static void DeleteFileIfExists(string path) + { + if (!string.IsNullOrEmpty(path) && File.Exists(path)) + { + File.Delete(path); + } } [Fact] @@ -149,7 +158,8 @@ public async Task BinaryProperty_PersistsAcrossReopen() Assert.Equal(expected, result!.Data); } - if (File.Exists(path)) File.Delete(path); + DeleteFileIfExists(path); + DeleteFileIfExists(Path.ChangeExtension(path, ".wal")); } [Fact] @@ -209,7 +219,8 @@ public async Task BinaryProperty_BLiteEngine_WritesBsonBinary_TypedReaderGetsByt Assert.Null(result.OptionalData); } - if (File.Exists(path)) File.Delete(path); + DeleteFileIfExists(path); + DeleteFileIfExists(Path.ChangeExtension(path, ".wal")); } // ── JSON serialization ──────────────────────────────────────────────────── @@ -270,7 +281,11 @@ public void BinaryProperty_Json_Binary_SerializesAsBase64_TypeNotPreserved() private static bool IsValidJson(string json) { - try { JsonDocument.Parse(json); return true; } + try + { + using var _ = JsonDocument.Parse(json); + return true; + } catch { return false; } } }