diff --git a/demo/Primify.Demo/Primify.Demo.csproj b/demo/Primify.Demo/Primify.Demo.csproj index 344c013..bd86cc4 100644 --- a/demo/Primify.Demo/Primify.Demo.csproj +++ b/demo/Primify.Demo/Primify.Demo.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/Primify.Generators/PrimifyGenerator.cs b/src/Primify.Generators/PrimifyGenerator.cs index b83c1e9..b8020f0 100644 --- a/src/Primify.Generators/PrimifyGenerator.cs +++ b/src/Primify.Generators/PrimifyGenerator.cs @@ -323,36 +323,134 @@ private static string GenerateExplicitCasting(string name, string argument) => public static explicit operator {argument}({name} value) => value.Value; """; - private static string GenerateImplicitCasting(string name, string argument) => - $""" - // Casting for BSON - public static implicit operator LiteDB.BsonValue({name} value) => - new LiteDB.BsonValue(value.Value); - public static implicit operator {name}(LiteDB.BsonValue value) => - From(({argument})System.Convert.ChangeType(value.RawValue, typeof({argument}))); - """; + private static string GenerateImplicitCasting(string name, string argument) + { + string toBson; + // We will generate the "from BsonValue" part differently based on complexity. + string fromBsonImplementation; + + switch (argument) + { + case "System.DateOnly": + toBson = "new LiteDB.BsonValue(value.Value.ToDateTime(System.TimeOnly.MinValue))"; + fromBsonImplementation = $"=> {name}.From(System.DateOnly.FromDateTime(value.AsDateTime));"; + break; + + case "System.TimeOnly": + toBson = "new LiteDB.BsonValue(value.Value.Ticks)"; + fromBsonImplementation = $"=> {name}.From(new System.TimeOnly(value.AsInt64));"; + break; + + case "System.DateTimeOffset": + // Create a BsonDocument for serialization + toBson = """ + new LiteDB.BsonDocument + { + ["DateTime"] = value.Value.UtcDateTime, + ["Offset"] = value.Value.Offset.Ticks + } + """; + + // Generate a full method body for deserialization + fromBsonImplementation = $$""" + { + var doc = value.AsDocument; + var utcDateTime = doc["DateTime"].AsDateTime; + var offset = new System.TimeSpan(doc["Offset"].AsInt64); + + // Create a UTC DateTimeOffset first, then convert to the original offset + var utcTime = new System.DateTimeOffset(utcDateTime); + var originalTime = utcTime.ToOffset(offset); + + return {{name}}.From(originalTime); + } + """; + break; + + default: + toBson = "new LiteDB.BsonValue(value.Value)"; + fromBsonImplementation = + $"=> {name}.From(({argument})System.Convert.ChangeType(value.RawValue, typeof({argument})));"; + break; + } + + return $""" + // Casting for BSON + public static implicit operator LiteDB.BsonValue({name} value) => + {toBson}; - private static string GenerateLiteDbInitializer(string name, string argument) => - $$""" - /// - /// Automatically registers the LiteDB BSON mapper for the {{name}} type. - /// - file static class {{name}}LiteDbInitializer - { - [ModuleInitializer] - internal static void Register() - { - BsonMapper.Global.RegisterType<{{name}}>( - serialize: wrapper => new LiteDB.BsonValue(wrapper.Value), - deserialize: bson => - { - var rawValue = bson.RawValue; - var typedValue = ({{argument}})System.Convert.ChangeType(rawValue, typeof({{argument}})); - - return {{name}}.From(typedValue); - } - ); - } - } - """; + public static implicit operator {name}(LiteDB.BsonValue value) + {fromBsonImplementation} + """; + } + + private static string GenerateLiteDbInitializer(string name, string argument) + { + string serializeCode; + string deserializeCode; + + switch (argument) + { + case "System.DateOnly": + serializeCode = "wrapper => new LiteDB.BsonValue(wrapper.Value.ToDateTime(System.TimeOnly.MinValue))"; + deserializeCode = $"bson => {name}.From(System.DateOnly.FromDateTime(bson.AsDateTime))"; + break; + + case "System.TimeOnly": + serializeCode = "wrapper => new LiteDB.BsonValue(wrapper.Value.Ticks)"; + deserializeCode = $"bson => {name}.From(new System.TimeOnly(bson.AsInt64))"; + break; + + case "System.DateTimeOffset": + serializeCode = """ + wrapper => + new LiteDB.BsonDocument + { + ["DateTime"] = wrapper.Value.UtcDateTime, + ["Offset"] = wrapper.Value.Offset.Ticks + } + """; + + deserializeCode = $$""" + bson => + { + var doc = bson.AsDocument; + var utcDateTime = doc["DateTime"].AsDateTime; + var offset = new System.TimeSpan(doc["Offset"].AsInt64); + + // 1. Create a DateTimeOffset from the UTC time (its offset will be zero). + var utcTime = new System.DateTimeOffset(utcDateTime); + + // 2. Convert it to the original offset. + var originalTime = utcTime.ToOffset(offset); + + return {{name}}.From(originalTime); + } + """; + break; + + default: + serializeCode = "wrapper => new LiteDB.BsonValue(wrapper.Value)"; + deserializeCode = + $"bson => {name}.From(({argument})System.Convert.ChangeType(bson.RawValue, typeof({argument})))"; + break; + } + + return $$""" + /// + /// Automatically registers the LiteDB BSON mapper for the {{name}} type. + /// + file static class {{name}}LiteDbInitializer + { + [System.Runtime.CompilerServices.ModuleInitializer] + internal static void Register() + { + LiteDB.BsonMapper.Global.RegisterType<{{name}}>( + serialize: {{serializeCode}}, + deserialize: {{deserializeCode}} + ); + } + } + """; + } } diff --git a/src/Primify/Primify.csproj b/src/Primify/Primify.csproj index 278c69a..ad1f416 100644 --- a/src/Primify/Primify.csproj +++ b/src/Primify/Primify.csproj @@ -3,7 +3,7 @@ net9.0 Primify - 1.4.22 + 1.4.23 true true diff --git a/tests/Primify.Tests/DateOnlyWrapperClassTests.cs b/tests/Primify.Tests/DateOnlyWrapperClassTests.cs index 4bab1c8..1b64ba3 100644 --- a/tests/Primify.Tests/DateOnlyWrapperClassTests.cs +++ b/tests/Primify.Tests/DateOnlyWrapperClassTests.cs @@ -15,8 +15,34 @@ public partial class DateOnlyPrimowrapClassWithPredefinedProperty public static DateOnlyPrimowrapClassWithPredefinedProperty Empty => new(DateOnly.MinValue); } +[Primify] +public partial record struct DateOnlyId; + +public class Foo +{ + public DateOnlyId Id { get; set; } +} + public class DateOnlyWrapperClassTests(ITestOutputHelper testOutputHelper) { + [Fact] + public void DateOnly_ReadWrite_ForLiteDb() + { + using var db = new LiteDatabase(":memory:"); + var collection = db.GetCollection(); + + var foo = new Foo() + { + Id = DateOnlyId.From(DateOnly.MaxValue) + }; + + var insert = collection.Insert(foo); + + var result = collection.FindById(foo.Id); + + Assert.Equal(foo.Id, result.Id); + } + [Fact] public void DateOnlyWrapperClass_CreatesType_WhenFromIsCalled() { diff --git a/tests/Primify.Tests/DateTimeOffsetWrapperClassTests.cs b/tests/Primify.Tests/DateTimeOffsetWrapperClassTests.cs index 7bfddf6..be4dac4 100644 --- a/tests/Primify.Tests/DateTimeOffsetWrapperClassTests.cs +++ b/tests/Primify.Tests/DateTimeOffsetWrapperClassTests.cs @@ -15,8 +15,34 @@ public partial class DateTimeOffsetPrimowrapClassWithPredefinedProperty public static DateTimeOffsetPrimowrapClassWithPredefinedProperty Empty => new(DateTimeOffset.MinValue); } +[Primify] +public partial record struct DateTimeOffsetId; + +public class DateTimeOffsetFoo +{ + public DateTimeOffsetId Id { get; set; } +} + public class DateTimeOffsetWrapperClassTests(ITestOutputHelper testOutputHelper) { + [Fact] + public void DateTimeOffset_ReadWrite_ForLiteDb() + { + using var db = new LiteDatabase(":memory:"); + var collection = db.GetCollection(); + + var foo = new DateTimeOffsetFoo() + { + Id = DateTimeOffsetId.From(DateTimeOffset.UtcNow) + }; + + var insert = collection.Insert(foo); + + var result = collection.FindById(foo.Id); + + Assert.Equal(foo.Id.ToString(), result.Id.ToString()); + } + [Fact] public void DateTimeOffsetWrapperClass_CreatesType_WhenFromIsCalled() { diff --git a/tests/Primify.Tests/TimeOnlyWrapperTests.cs b/tests/Primify.Tests/TimeOnlyWrapperTests.cs new file mode 100644 index 0000000..96b1d37 --- /dev/null +++ b/tests/Primify.Tests/TimeOnlyWrapperTests.cs @@ -0,0 +1,366 @@ +namespace Primify.Generator.Tests; + +[Primify] +public partial class TimeOnlyPrimowrapClass; + +// [Primify] +// public partial class TimeOnlyWrapperClassWithNormalize +// { +// private static TimeOnly Normalize(TimeOnly value) => value < 1 ? -1 : value; +// } + +[Primify] +public partial class TimeOnlyPrimowrapClassWithPredefinedProperty +{ + public static TimeOnlyPrimowrapClassWithPredefinedProperty Empty => new(TimeOnly.MinValue); +} + +[Primify] +public partial record struct TimeOnlyId; + +public class TimeOnlyFoo +{ + public TimeOnlyId Id { get; set; } +} + +public class TimeOnlyWrapperTests(ITestOutputHelper testOutputHelper) +{ + [Fact] + public void TimeOnly_ReadWrite_ForLiteDb() + { + using var db = new LiteDatabase(":memory:"); + var collection = db.GetCollection(); + + var foo = new TimeOnlyFoo() + { + Id = TimeOnlyId.From(TimeOnly.MaxValue) + }; + + var insert = collection.Insert(foo); + + var result = collection.FindById(foo.Id); + + Assert.Equal(foo.Id, result.Id); + } + + [Fact] + public void TimeOnlyWrapperClass_CreatesType_WhenFromIsCalled() + { + // Arrange + var expectedValue = TimeOnly.MinValue; + + // Act + var result = TimeOnlyPrimowrapClass.From(expectedValue); + testOutputHelper.WriteLine(result.ToString()); + + // Assert + Assert.Equal(expectedValue, result.Value); + Assert.Equal(expectedValue, result.Value); + } + + [Fact] + public void TimeOnlyWrapperClass_CreatesType_WhenExplicitlyCastToFromType() + { + var expectedValue = TimeOnly.MinValue; + + var result1 = (TimeOnlyPrimowrapClass)expectedValue; + var result2 = (TimeOnly)result1; + testOutputHelper.WriteLine(result1.ToString()); + + Assert.Equal(expectedValue, result1.Value); + Assert.Equal(expectedValue, result1.Value); + Assert.Equal(expectedValue, result2); + } + + // [Theory] + // [InlineData(1, 1)] + // [InlineData(10, 10)] + // [InlineData(0, -1)] + // [InlineData(-1, -1)] + // [InlineData(-100, -1)] + // public void TimeOnlyWrapperClassWithNormalize_ReturnsNormalizedValue_WhenCalledWithNonNormalizedValue(int value, int expected) + // { + // var result = TimeOnlyWrapperClassWithNormalize.From(value); + // testOutputHelper.WriteLine(result.ToString()); + // + // Assert.Equal(expected, result.Value); + // } + + [Fact] + public void TimeOnlyWrapperClassWithPredefinedProperty_IgnoresReadonly_WhenSerializedWithSystemTextJsonV1() + { + var expectedValue = TimeOnly.MaxValue; + var result = TimeOnlyPrimowrapClassWithPredefinedProperty.From(expectedValue); + + // Default value to string + testOutputHelper.WriteLine("result.Value:"); + testOutputHelper.WriteLine(result.Value.ToString()); + + Assert.Equal(expectedValue, result.Value); + + // System.Text.Json serialization + var json = System.Text.Json.JsonSerializer.Serialize(result); + testOutputHelper.WriteLine("\nSystem.Text.Json serialization:"); + testOutputHelper.WriteLine(json); + + // System.Text.Json deserialization + var stjDeserialized = + System.Text.Json.JsonSerializer.Deserialize(json); + testOutputHelper.WriteLine("\nSystem.Text.Json deserialized value:"); + testOutputHelper.WriteLine(stjDeserialized?.ToString() ?? "null"); + Assert.Equal(expectedValue, stjDeserialized?.Value); + } + + [Fact] + public void TimeOnlyWrapperClassWithPredefinedProperty_IgnoresReadonly_WhenSerializedWithSystemTextJson() + { + var expectedValue = TimeOnly.MinValue; + var result = TimeOnlyPrimowrapClassWithPredefinedProperty.Empty; + + // Default value to string + testOutputHelper.WriteLine("result.Value:"); + testOutputHelper.WriteLine(result.Value.ToString()); + + Assert.Equal(expectedValue, result.Value); + + // System.Text.Json serialization + var json = System.Text.Json.JsonSerializer.Serialize(result); + testOutputHelper.WriteLine("\nSystem.Text.Json serialization:"); + testOutputHelper.WriteLine(json); + + // System.Text.Json deserialization + var stjDeserialized = + System.Text.Json.JsonSerializer.Deserialize(json); + testOutputHelper.WriteLine("\nSystem.Text.Json deserialized value:"); + testOutputHelper.WriteLine(stjDeserialized?.ToString() ?? "null"); + Assert.Equal(expectedValue, stjDeserialized?.Value); + } + + [Fact] + public void TimeOnlyWrapperClassWithPredefinedProperty_IgnoresReadonly_WhenSerializedNewtonsoftJsonV1() + { + var expectedValue = TimeOnly.MaxValue; + var result = TimeOnlyPrimowrapClassWithPredefinedProperty.From(expectedValue); + + // Default value to string + testOutputHelper.WriteLine("result.Value:"); + testOutputHelper.WriteLine(result.Value.ToString()); + + Assert.Equal(expectedValue, result.Value); + + // Newtonsoft.Json serialization + var newtonsoftJson = Newtonsoft.Json.JsonConvert.SerializeObject(result); + testOutputHelper.WriteLine("\nNewtonsoft.Json serialization:"); + testOutputHelper.WriteLine(newtonsoftJson); + + // Newtonsoft.Json deserialization + var njsDeserialized = + Newtonsoft.Json.JsonConvert.DeserializeObject( + newtonsoftJson); + testOutputHelper.WriteLine("\nNewtonsoft.Json deserialized value:"); + testOutputHelper.WriteLine(njsDeserialized?.ToString() ?? "null"); + Assert.Equal(expectedValue, njsDeserialized?.Value); + } + + [Fact] + public void TimeOnlyWrapperClassWithPredefinedProperty_IgnoresReadonly_WhenSerializedNewtonsoftJson() + { + var expectedValue = TimeOnly.MinValue; + var result = TimeOnlyPrimowrapClassWithPredefinedProperty.From(expectedValue); + + // Default value to string + testOutputHelper.WriteLine("result.Value:"); + testOutputHelper.WriteLine(result.Value.ToString()); + + Assert.Equal(expectedValue, result.Value); + + // Newtonsoft.Json serialization + var newtonsoftJson = Newtonsoft.Json.JsonConvert.SerializeObject(result); + testOutputHelper.WriteLine("\nNewtonsoft.Json serialization:"); + testOutputHelper.WriteLine(newtonsoftJson); + + // Newtonsoft.Json deserialization + var njsDeserialized = + Newtonsoft.Json.JsonConvert.DeserializeObject( + newtonsoftJson); + testOutputHelper.WriteLine("\nNewtonsoft.Json deserialized value:"); + testOutputHelper.WriteLine(njsDeserialized?.ToString() ?? "null"); + Assert.Equal(expectedValue, njsDeserialized?.Value); + } + + [Fact] + public void TimeOnlyWrapperClassWithPredefinedProperty_IgnoresReadonly_WhenSerializedNewtonsoftBsonV1() + { + var expectedValue = TimeOnly.MaxValue; + var result = TimeOnlyPrimowrapClassWithPredefinedProperty.From(expectedValue); + + // Default value to string + testOutputHelper.WriteLine("result.Value:"); + testOutputHelper.WriteLine(result.Value.ToString()); + + Assert.Equal(expectedValue, result.Value); + + // BSON serialization + using var ms = new MemoryStream(); + using (var writer = new Newtonsoft.Json.Bson.BsonDataWriter(ms)) + { + var serializer = new Newtonsoft.Json.JsonSerializer(); + serializer.Serialize(writer, result); + } + + var bsonBytes = ms.ToArray(); + var bsonBase64 = Convert.ToBase64String(bsonBytes); + testOutputHelper.WriteLine("\nBSON serialization (Base64):"); + testOutputHelper.WriteLine(bsonBase64); + + // BSON deserialization + using var ms2 = new MemoryStream(bsonBytes); + using (var reader = new Newtonsoft.Json.Bson.BsonDataReader(ms2)) + { + var serializer = new Newtonsoft.Json.JsonSerializer(); + var bsonDeserialized = serializer.Deserialize(reader); + testOutputHelper.WriteLine("\nBSON deserialized value:"); + testOutputHelper.WriteLine(bsonDeserialized?.ToString() ?? "null"); + + // Compare the underlying DateTime values directly to avoid timezone/offset issues + var expectedDateTime = expectedValue; + var actualDateTime = bsonDeserialized?.Value; + Assert.Equal(expectedDateTime.ToString(), actualDateTime.Value.ToString()); + } + } + + [Fact] + public void TimeOnlyWrapperClassWithPredefinedProperty_IgnoresReadonly_WhenSerializedNewtonsoftBsonV3() + { + var expectedValue = TimeOnly.MaxValue; + var result = TimeOnlyPrimowrapClassWithPredefinedProperty.From(expectedValue); + + testOutputHelper.WriteLine($"Original Expected: {expectedValue:o}"); + Assert.Equal(expectedValue, result.Value); + + // BSON serialization + using var ms = new MemoryStream(); + using (var writer = new Newtonsoft.Json.Bson.BsonDataWriter(ms)) + { + var serializer = new Newtonsoft.Json.JsonSerializer(); + serializer.Serialize(writer, result); + } + + var bsonBytes = ms.ToArray(); + + // BSON deserialization + using var ms2 = new MemoryStream(bsonBytes); + using (var reader = new Newtonsoft.Json.Bson.BsonDataReader(ms2)) + { + var serializer = new Newtonsoft.Json.JsonSerializer(); + var bsonDeserialized = serializer.Deserialize(reader); + + testOutputHelper.WriteLine($"Deserialized Actual: {bsonDeserialized?.Value:o}"); + + // *** FIX FOR THE TEST *** + // BSON loses sub-millisecond precision. We must truncate the expected value + // to match the precision of the actual value before comparing. + + // 1. Get the Ticks of the original UTC DateTime + long originalTicks = expectedValue.Ticks; + + // 2. Truncate the ticks to the nearest millisecond + long truncatedTicks = originalTicks - (originalTicks % TimeSpan.TicksPerMillisecond); + + // 3. Create a new TimeOnly with the truncated value for comparison + var expectedValueTruncated = TimeOnly.FromDateTime(new DateTime(truncatedTicks)); + + testOutputHelper.WriteLine($"Truncated Expected: {expectedValueTruncated}"); + + Assert.Equal(expectedValueTruncated.ToString(), bsonDeserialized?.Value.ToString()); + } + } + + [Fact] + public void TimeOnlyWrapperClassWithPredefinedProperty_IgnoresReadonly_WhenSerializedNewtonsoftBson() + { + var expectedValue = TimeOnly.MinValue; + var result = TimeOnlyPrimowrapClassWithPredefinedProperty.From(expectedValue); + + // Default value to string + testOutputHelper.WriteLine("result.Value:"); + testOutputHelper.WriteLine(result.Value.ToString()); + + Assert.Equal(expectedValue, result.Value); + + // BSON serialization + using var ms = new MemoryStream(); + using (var writer = new Newtonsoft.Json.Bson.BsonDataWriter(ms)) + { + var serializer = new Newtonsoft.Json.JsonSerializer(); + serializer.Serialize(writer, result); + } + + var bsonBytes = ms.ToArray(); + var bsonBase64 = Convert.ToBase64String(bsonBytes); + testOutputHelper.WriteLine("\nBSON serialization (Base64):"); + testOutputHelper.WriteLine(bsonBase64); + + // BSON deserialization + using var ms2 = new MemoryStream(bsonBytes); + using (var reader = new Newtonsoft.Json.Bson.BsonDataReader(ms2)) + { + var serializer = new Newtonsoft.Json.JsonSerializer(); + var bsonDeserialized = serializer.Deserialize(reader); + testOutputHelper.WriteLine("\nBSON deserialized value:"); + testOutputHelper.WriteLine(bsonDeserialized?.ToString() ?? "null"); + + // Compare the underlying DateTime values directly to avoid timezone/offset issues + var expectedDateTime = expectedValue; + var actualDateTime = bsonDeserialized?.Value; + Assert.Equal(expectedDateTime, actualDateTime); + } + } + + [Fact] + public void TimeOnlyWrapperClassWithPredefinedProperty_IgnoresReadonly_WhenSerializedNewtonsoftBsonV2() + { + var expectedValue = TimeOnly.MinValue; + var result = TimeOnlyPrimowrapClassWithPredefinedProperty.From(expectedValue); + + // Default value to string + testOutputHelper.WriteLine("result.Value:"); + testOutputHelper.WriteLine(result.Value.ToString("o")); // Use ISO 8601 for clarity + + Assert.Equal(expectedValue, result.Value); + + // **SOLUTION: Configure the serializer to handle dates as UTC.** + var serializerSettings = new Newtonsoft.Json.JsonSerializerSettings + { + DateTimeZoneHandling = Newtonsoft.Json.DateTimeZoneHandling.Utc + }; + var serializer = Newtonsoft.Json.JsonSerializer.Create(serializerSettings); + + // BSON serialization + using var ms = new MemoryStream(); + using (var writer = new Newtonsoft.Json.Bson.BsonDataWriter(ms)) + { + serializer.Serialize(writer, result); + } + + var bsonBytes = ms.ToArray(); + var bsonBase64 = Convert.ToBase64String(bsonBytes); + testOutputHelper.WriteLine("\nBSON serialization (Base64):"); + testOutputHelper.WriteLine(bsonBase64); + + // BSON deserialization + using var ms2 = new MemoryStream(bsonBytes); + using (var reader = new Newtonsoft.Json.Bson.BsonDataReader(ms2)) + { + // Ensure the reader also respects the UTC setting + reader.DateTimeKindHandling = System.DateTimeKind.Utc; + + var bsonDeserialized = serializer.Deserialize(reader); + testOutputHelper.WriteLine("\nBSON deserialized value:"); + testOutputHelper.WriteLine(bsonDeserialized?.Value.ToString("o") ?? "null"); + + // The assertion should now pass without any special handling. + Assert.Equal(expectedValue, bsonDeserialized?.Value); + } + } +}