diff --git a/src/MongoDB.Bson/Serialization/Attributes/BsonTimeOnlyOptionsAttribute.cs b/src/MongoDB.Bson/Serialization/Attributes/BsonTimeOnlyOptionsAttribute.cs index 23f327f3290..0593b3661db 100644 --- a/src/MongoDB.Bson/Serialization/Attributes/BsonTimeOnlyOptionsAttribute.cs +++ b/src/MongoDB.Bson/Serialization/Attributes/BsonTimeOnlyOptionsAttribute.cs @@ -44,7 +44,7 @@ public BsonTimeOnlyOptionsAttribute(BsonType representation) /// Initializes a new instance of the BsonTimeOnlyOptionsAttribute class. /// /// The external representation. - /// The TimeOnlyUnits. + /// The TimeOnlyUnits. Ignored if representation is BsonType.Document. public BsonTimeOnlyOptionsAttribute(BsonType representation, TimeOnlyUnits units) { _representation = representation; diff --git a/src/MongoDB.Bson/Serialization/Serializers/DateOnlySerializer.cs b/src/MongoDB.Bson/Serialization/Serializers/DateOnlySerializer.cs index 6dceaf65831..bb571238b86 100644 --- a/src/MongoDB.Bson/Serialization/Serializers/DateOnlySerializer.cs +++ b/src/MongoDB.Bson/Serialization/Serializers/DateOnlySerializer.cs @@ -15,7 +15,6 @@ using System; using MongoDB.Bson.IO; -using MongoDB.Bson.Serialization.Attributes; using MongoDB.Bson.Serialization.Options; namespace MongoDB.Bson.Serialization.Serializers diff --git a/src/MongoDB.Bson/Serialization/Serializers/TimeOnlySerializer.cs b/src/MongoDB.Bson/Serialization/Serializers/TimeOnlySerializer.cs index 8b765217005..cd6f0e21361 100644 --- a/src/MongoDB.Bson/Serialization/Serializers/TimeOnlySerializer.cs +++ b/src/MongoDB.Bson/Serialization/Serializers/TimeOnlySerializer.cs @@ -14,6 +14,7 @@ */ using System; +using MongoDB.Bson.IO; using MongoDB.Bson.Serialization.Options; namespace MongoDB.Bson.Serialization.Serializers @@ -32,7 +33,20 @@ public sealed class TimeOnlySerializer: StructSerializerBase, IReprese /// public static TimeOnlySerializer Instance => __instance; + // private constants + private static class Flags + { + public const long Hour = 1; + public const long Minute = 2; + public const long Second = 4; + public const long Millisecond = 8; + public const long Microsecond = 16; + public const long Nanosecond = 32; + public const long Ticks = 64; + } + // private fields + private readonly SerializerHelper _helper; private readonly BsonType _representation; private readonly TimeOnlyUnits _units; @@ -58,11 +72,12 @@ public TimeOnlySerializer(BsonType representation) /// Initializes a new instance of the class. /// /// The representation. - /// The units. + /// The units. Ignored if representation is BsonType.Document. public TimeOnlySerializer(BsonType representation, TimeOnlyUnits units) { switch (representation) { + case BsonType.Document: case BsonType.Double: case BsonType.Int32: case BsonType.Int64: @@ -75,6 +90,17 @@ public TimeOnlySerializer(BsonType representation, TimeOnlyUnits units) _representation = representation; _units = units; + + _helper = new SerializerHelper + ( + new SerializerHelper.Member("Hour", Flags.Hour, isOptional: true), + new SerializerHelper.Member("Minute", Flags.Minute, isOptional: true), + new SerializerHelper.Member("Second", Flags.Second, isOptional: true), + new SerializerHelper.Member("Millisecond", Flags.Millisecond, isOptional: true), + new SerializerHelper.Member("Microsecond", Flags.Microsecond, isOptional: true), + new SerializerHelper.Member("Nanosecond", Flags.Nanosecond, isOptional: true), + new SerializerHelper.Member("Ticks", Flags.Ticks, isOptional: false) + ); } // public properties @@ -98,10 +124,11 @@ public override TimeOnly Deserialize(BsonDeserializationContext context, BsonDes return bsonType switch { - BsonType.String => TimeOnly.ParseExact(bsonReader.ReadString(), "o"), - BsonType.Int64 => FromInt64(bsonReader.ReadInt64(), _units), - BsonType.Int32 => FromInt32(bsonReader.ReadInt32(), _units), + BsonType.Document => FromDocument(context), BsonType.Double => FromDouble(bsonReader.ReadDouble(), _units), + BsonType.Int32 => FromInt32(bsonReader.ReadInt32(), _units), + BsonType.Int64 => FromInt64(bsonReader.ReadInt64(), _units), + BsonType.String => TimeOnly.ParseExact(bsonReader.ReadString(), "o"), _ => throw CreateCannotDeserializeFromBsonTypeException(bsonType) }; } @@ -129,6 +156,19 @@ public override void Serialize(BsonSerializationContext context, BsonSerializati switch (_representation) { + case BsonType.Document: + bsonWriter.WriteStartDocument(); + bsonWriter.WriteInt32("Hour", value.Hour); + bsonWriter.WriteInt32("Minute", value.Minute); + bsonWriter.WriteInt32("Second", value.Second); + bsonWriter.WriteInt32("Millisecond", value.Millisecond); + // Microsecond and Nanosecond properties were added in .NET 7 + bsonWriter.WriteInt32("Microsecond", GetMicrosecondsComponent(value.Ticks)); + bsonWriter.WriteInt32("Nanosecond", GetNanosecondsComponent(value.Ticks)); + bsonWriter.WriteInt64("Ticks", value.Ticks); + bsonWriter.WriteEndDocument(); + break; + case BsonType.Double: bsonWriter.WriteDouble(ToDouble(value, _units)); break; @@ -145,6 +185,7 @@ public override void Serialize(BsonSerializationContext context, BsonSerializati bsonWriter.WriteString(value.ToString("o")); break; + default: throw new BsonSerializationException($"'{_representation}' is not a valid TimeOnly representation."); } @@ -196,6 +237,29 @@ private TimeOnly FromInt64(long value, TimeOnlyUnits units) : new TimeOnly(value * TicksPerUnit(units)); } + private TimeOnly FromDocument(BsonDeserializationContext context) + { + var bsonReader = context.Reader; + var ticks = 0L; + + _helper.DeserializeMembers(context, (_, flag) => + { + switch (flag) + { + case Flags.Hour: + case Flags.Minute: + case Flags.Second: + case Flags.Millisecond: + case Flags.Microsecond: + case Flags.Nanosecond: + bsonReader.SkipValue(); break; // ignore value (use Ticks instead) + case Flags.Ticks: ticks = Int64Serializer.Instance.Deserialize(context); break; + } + }); + + return FromInt64(ticks, TimeOnlyUnits.Ticks); + } + private long TicksPerUnit(TimeOnlyUnits units) { return units switch @@ -231,6 +295,18 @@ private long ToInt64(TimeOnly timeOnly, TimeOnlyUnits units) : timeOnly.Ticks / TicksPerUnit(units); } + private int GetNanosecondsComponent(long ticks) + { + // ticks % 10 * 100 + return (int)(ticks % TicksPerUnit(TimeOnlyUnits.Microseconds) * 100); + } + + private int GetMicrosecondsComponent(long ticks) + { + // ticks / 10 % 1000 + return (int)(ticks / TicksPerUnit(TimeOnlyUnits.Microseconds) % 1000); + } + // explicit interface implementations IBsonSerializer IRepresentationConfigurable.WithRepresentation(BsonType representation) { diff --git a/tests/MongoDB.Bson.Tests/Serialization/Serializers/TimeOnlySerializerTests.cs b/tests/MongoDB.Bson.Tests/Serialization/Serializers/TimeOnlySerializerTests.cs index ea344347f56..54d781a27bd 100644 --- a/tests/MongoDB.Bson.Tests/Serialization/Serializers/TimeOnlySerializerTests.cs +++ b/tests/MongoDB.Bson.Tests/Serialization/Serializers/TimeOnlySerializerTests.cs @@ -43,17 +43,21 @@ public void Attribute_should_set_correct_units() Microseconds = timeOnly, Ticks = timeOnly, Nanoseconds = timeOnly, + Document = timeOnly }; var json = testObj.ToJson(); - var expected = "{ \"Hours\" : 13, " - + "\"Minutes\" : 804, " - + "\"Seconds\" : 48293, " - + "\"Milliseconds\" : 48293000, " - + "\"Microseconds\" : 48293000000, " - + "\"Ticks\" : 482930000000, " - + "\"Nanoseconds\" : 48293000000000 }"; + var baseString = """ + { "Hours" : 13, "Minutes" : 804, "Seconds" : 48293, "Milliseconds" : 48293000, "Microseconds" : 48293000000, "Ticks" : 482930000000, "Nanoseconds" : 48293000000000 + """; + + var documentString = """ + { "Hour" : 13, "Minute" : 24, "Second" : 53, "Millisecond" : 0, "Microsecond" : 0, "Nanosecond" : 0, "Ticks" : 482930000000 } + """; + + + var expected = baseString + """, "Document" : """ + documentString + " }"; Assert.Equal(expected, json); } @@ -69,7 +73,7 @@ public void Constructor_with_no_arguments_should_return_expected_result() [Theory] [ParameterAttributeData] public void Constructor_with_representation_should_return_expected_result( - [Values(BsonType.String, BsonType.Int64, BsonType.Int32, BsonType.Double)] + [Values(BsonType.String, BsonType.Int64, BsonType.Int32, BsonType.Double, BsonType.Document)] BsonType representation, [Values(TimeOnlyUnits.Ticks, TimeOnlyUnits.Hours, TimeOnlyUnits.Minutes, TimeOnlyUnits.Seconds, TimeOnlyUnits.Milliseconds, TimeOnlyUnits.Microseconds, TimeOnlyUnits.Nanoseconds)] @@ -81,6 +85,53 @@ public void Constructor_with_representation_should_return_expected_result( subject.Units.Should().Be(units); } + [Theory] + [InlineData("""{ "x" : { Ticks: { "$numberLong" : "307255946583" } } }""","08:32:05.5946583" )] + [InlineData("""{ "x" : { Ticks: { "$numberLong" : "0" } } }""","00:00:00.0000000" )] + [InlineData("""{ "x" : { Ticks: { "$numberLong" : "863999999999" } } }""","23:59:59.9999999" )] + public void Deserialize_with_document_should_have_expected_result(string json, string expectedResult) + { + var subject = new TimeOnlySerializer(); + TestDeserialize(subject, json, expectedResult); + } + + [Theory] + [InlineData("""{ "x" : { Ticks: { "$numberDouble" : "307255946583" } } }""","08:32:05.5946583" )] + [InlineData("""{ "x" : { Ticks: { "$numberDecimal" : "307255946583" } } }""","08:32:05.5946583" )] + public void Deserialize_with_document_should_be_forgiving_of_actual_numeric_type(string json, string expectedResult) + { + var subject = new TimeOnlySerializer(); + TestDeserialize(subject, json, expectedResult); + } + + [Theory] + [InlineData(""" + { "x" : { Hour: { "$numberInt": 0 }, Minute: { "$numberInt": 0 }, Second: { "$numberInt": 0 }, + Millisecond: { "$numberInt": 0 }, Microsecond: { "$numberInt": 0 }, Nanosecond: { "$numberInt": 0 }, + Ticks: { "$numberDouble" : "307255946583" } } } + ""","08:32:05.5946583" )] + public void Deserialize_with_document_should_ignore_other_time_components(string json, string expectedResult) + { + var subject = new TimeOnlySerializer(); + TestDeserialize(subject, json, expectedResult); + } + + [Theory] + [InlineData("""{ "x" : { "Unknown": "test", Ticks: { "$numberDouble" : "307255946583" } } }""" )] + public void Deserialize_with_document_should_throw_when_field_is_unknown(string json) + { + var subject = new TimeOnlySerializer(); + + using var reader = new JsonReader(json); + reader.ReadStartDocument(); + reader.ReadName("x"); + var context = BsonDeserializationContext.CreateRoot(reader); + + var exception = Record.Exception(() => subject.Deserialize(context)); + exception.Should().BeOfType(); + exception.Message.Should().Be("Invalid element: 'Unknown'."); + } + [Theory] [InlineData("""{ "x" : "08:32:05.5946583" }""","08:32:05.5946583" )] [InlineData("""{ "x" : "00:00:00.0000000" }""","00:00:00.0000000")] @@ -273,6 +324,17 @@ public void GetHashCode_should_return_zero() result.Should().Be(0); } + [Theory] + [InlineData("08:32:05.5946583", """{ "x" : { "Hour" : { "$numberInt" : "8" }, "Minute" : { "$numberInt" : "32" }, "Second" : { "$numberInt" : "5" }, "Millisecond" : { "$numberInt" : "594" }, "Microsecond" : { "$numberInt" : "658" }, "Nanosecond" : { "$numberInt" : "300" }, "Ticks" : { "$numberLong" : "307255946583" } } }""")] + [InlineData("00:00:00.0000000", """{ "x" : { "Hour" : { "$numberInt" : "0" }, "Minute" : { "$numberInt" : "0" }, "Second" : { "$numberInt" : "0" }, "Millisecond" : { "$numberInt" : "0" }, "Microsecond" : { "$numberInt" : "0" }, "Nanosecond" : { "$numberInt" : "0" }, "Ticks" : { "$numberLong" : "0" } } }""")] + [InlineData("23:59:59.9999999", """{ "x" : { "Hour" : { "$numberInt" : "23" }, "Minute" : { "$numberInt" : "59" }, "Second" : { "$numberInt" : "59" }, "Millisecond" : { "$numberInt" : "999" }, "Microsecond" : { "$numberInt" : "999" }, "Nanosecond" : { "$numberInt" : "900" }, "Ticks" : { "$numberLong" : "863999999999" } } }""")] + public void Serialize_with_document_representation_should_have_expected_result(string valueString, string expectedResult) + { + var subject = new TimeOnlySerializer(BsonType.Document); + + TestSerialize(subject, valueString, expectedResult); + } + [Theory] [InlineData(BsonType.String, "08:32:05.5946583", """{ "x" : "08:32:05.5946583" }""")] [InlineData(BsonType.String, "00:00:00.0000000", """{ "x" : "00:00:00.0000000" }""")] @@ -407,8 +469,8 @@ public void Serializer_should_be_registered() [Theory] [ParameterAttributeData] public void WithRepresentation_should_return_expected_result( - [Values(BsonType.String, BsonType.Int64, BsonType.Int32, BsonType.Double)] BsonType oldRepresentation, - [Values(BsonType.String, BsonType.Int64, BsonType.Int32, BsonType.Double)] BsonType newRepresentation) + [Values(BsonType.String, BsonType.Int64, BsonType.Int32, BsonType.Double, BsonType.Document)] BsonType oldRepresentation, + [Values(BsonType.String, BsonType.Int64, BsonType.Int32, BsonType.Double, BsonType.Document)] BsonType newRepresentation) { var subject = new TimeOnlySerializer(oldRepresentation); @@ -473,6 +535,9 @@ private class TestClass [BsonTimeOnlyOptions(BsonType.Int64, TimeOnlyUnits.Nanoseconds )] public TimeOnly Nanoseconds { get; set; } + + [BsonTimeOnlyOptions(BsonType.Document)] + public TimeOnly Document { get; set; } } } #endif