Skip to content

Commit e694805

Browse files
authored
CSHARP-5737: Add legacy representation for TimeOnly (#1783)
1 parent b73979a commit e694805

File tree

4 files changed

+196
-16
lines changed

4 files changed

+196
-16
lines changed

src/MongoDB.Bson/Serialization/Attributes/BsonTimeOnlyOptionsAttribute.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public BsonTimeOnlyOptionsAttribute(BsonType representation)
4444
/// Initializes a new instance of the BsonTimeOnlyOptionsAttribute class.
4545
/// </summary>
4646
/// <param name="representation">The external representation.</param>
47-
/// <param name="units">The TimeOnlyUnits.</param>
47+
/// <param name="units">The TimeOnlyUnits. Ignored if representation is BsonType.Document.</param>
4848
public BsonTimeOnlyOptionsAttribute(BsonType representation, TimeOnlyUnits units)
4949
{
5050
_representation = representation;

src/MongoDB.Bson/Serialization/Serializers/DateOnlySerializer.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515

1616
using System;
1717
using MongoDB.Bson.IO;
18-
using MongoDB.Bson.Serialization.Attributes;
1918
using MongoDB.Bson.Serialization.Options;
2019

2120
namespace MongoDB.Bson.Serialization.Serializers

src/MongoDB.Bson/Serialization/Serializers/TimeOnlySerializer.cs

Lines changed: 113 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
*/
1515

1616
using System;
17+
using MongoDB.Bson.IO;
1718
using MongoDB.Bson.Serialization.Options;
1819

1920
namespace MongoDB.Bson.Serialization.Serializers
@@ -32,7 +33,20 @@ public sealed class TimeOnlySerializer: StructSerializerBase<TimeOnly>, IReprese
3233
/// </summary>
3334
public static TimeOnlySerializer Instance => __instance;
3435

36+
// private constants
37+
private static class Flags
38+
{
39+
public const long Hour = 1;
40+
public const long Minute = 2;
41+
public const long Second = 4;
42+
public const long Millisecond = 8;
43+
public const long Microsecond = 16;
44+
public const long Nanosecond = 32;
45+
public const long Ticks = 64;
46+
}
47+
3548
// private fields
49+
private readonly SerializerHelper _helper;
3650
private readonly BsonType _representation;
3751
private readonly TimeOnlyUnits _units;
3852

@@ -58,11 +72,12 @@ public TimeOnlySerializer(BsonType representation)
5872
/// Initializes a new instance of the <see cref="TimeOnlySerializer"/> class.
5973
/// </summary>
6074
/// <param name="representation">The representation.</param>
61-
/// <param name="units">The units.</param>
75+
/// <param name="units">The units. Ignored if representation is BsonType.Document.</param>
6276
public TimeOnlySerializer(BsonType representation, TimeOnlyUnits units)
6377
{
6478
switch (representation)
6579
{
80+
case BsonType.Document:
6681
case BsonType.Double:
6782
case BsonType.Int32:
6883
case BsonType.Int64:
@@ -75,6 +90,20 @@ public TimeOnlySerializer(BsonType representation, TimeOnlyUnits units)
7590

7691
_representation = representation;
7792
_units = units;
93+
94+
_helper = new SerializerHelper
95+
(
96+
// TimeOnlySerializer was introduced in version 3.0.0 of the driver. Prior to that, TimeOnly was serialized
97+
// as a class mapped POCO. Due to that, Microsecond and Nanosecond could be missing, as they were introduced in .NET 7.
98+
// To avoid deserialization issues, we treat Microsecond and Nanosecond as optional members.
99+
new SerializerHelper.Member("Hour", Flags.Hour, isOptional: false),
100+
new SerializerHelper.Member("Minute", Flags.Minute, isOptional: false),
101+
new SerializerHelper.Member("Second", Flags.Second, isOptional: false),
102+
new SerializerHelper.Member("Millisecond", Flags.Millisecond, isOptional: false),
103+
new SerializerHelper.Member("Microsecond", Flags.Microsecond, isOptional: true),
104+
new SerializerHelper.Member("Nanosecond", Flags.Nanosecond, isOptional: true),
105+
new SerializerHelper.Member("Ticks", Flags.Ticks, isOptional: false)
106+
);
78107
}
79108

80109
// public properties
@@ -98,10 +127,11 @@ public override TimeOnly Deserialize(BsonDeserializationContext context, BsonDes
98127

99128
return bsonType switch
100129
{
101-
BsonType.String => TimeOnly.ParseExact(bsonReader.ReadString(), "o"),
102-
BsonType.Int64 => FromInt64(bsonReader.ReadInt64(), _units),
103-
BsonType.Int32 => FromInt32(bsonReader.ReadInt32(), _units),
130+
BsonType.Document => FromDocument(context),
104131
BsonType.Double => FromDouble(bsonReader.ReadDouble(), _units),
132+
BsonType.Int32 => FromInt32(bsonReader.ReadInt32(), _units),
133+
BsonType.Int64 => FromInt64(bsonReader.ReadInt64(), _units),
134+
BsonType.String => TimeOnly.ParseExact(bsonReader.ReadString(), "o"),
105135
_ => throw CreateCannotDeserializeFromBsonTypeException(bsonType)
106136
};
107137
}
@@ -129,6 +159,19 @@ public override void Serialize(BsonSerializationContext context, BsonSerializati
129159

130160
switch (_representation)
131161
{
162+
case BsonType.Document:
163+
bsonWriter.WriteStartDocument();
164+
bsonWriter.WriteInt32("Hour", value.Hour);
165+
bsonWriter.WriteInt32("Minute", value.Minute);
166+
bsonWriter.WriteInt32("Second", value.Second);
167+
bsonWriter.WriteInt32("Millisecond", value.Millisecond);
168+
// Microsecond and Nanosecond properties were added in .NET 7
169+
bsonWriter.WriteInt32("Microsecond", GetMicrosecondsComponent(value.Ticks));
170+
bsonWriter.WriteInt32("Nanosecond", GetNanosecondsComponent(value.Ticks));
171+
bsonWriter.WriteInt64("Ticks", value.Ticks);
172+
bsonWriter.WriteEndDocument();
173+
break;
174+
132175
case BsonType.Double:
133176
bsonWriter.WriteDouble(ToDouble(value, _units));
134177
break;
@@ -175,6 +218,60 @@ public TimeOnlySerializer WithRepresentation(BsonType representation, TimeOnlyUn
175218
}
176219

177220
// private methods
221+
private TimeOnly FromDocument(BsonDeserializationContext context)
222+
{
223+
var bsonReader = context.Reader;
224+
var hour = 0;
225+
var minute = 0;
226+
var second = 0;
227+
var millisecond = 0;
228+
int? microsecond = null;
229+
int? nanosecond = null;
230+
var ticks = 0L;
231+
232+
_helper.DeserializeMembers(context, (_, flag) =>
233+
{
234+
switch (flag)
235+
{
236+
case Flags.Hour:
237+
hour = bsonReader.ReadInt32();
238+
break;
239+
case Flags.Minute:
240+
minute = bsonReader.ReadInt32();
241+
break;
242+
case Flags.Second:
243+
second = bsonReader.ReadInt32();
244+
break;
245+
case Flags.Millisecond:
246+
millisecond = bsonReader.ReadInt32();
247+
break;
248+
case Flags.Microsecond:
249+
microsecond = bsonReader.ReadInt32();
250+
break;
251+
case Flags.Nanosecond:
252+
nanosecond = bsonReader.ReadInt32();
253+
break;
254+
case Flags.Ticks:
255+
ticks = bsonReader.ReadInt64();
256+
break;
257+
}
258+
});
259+
260+
var deserializedTimeOnly = new TimeOnly(ticks);
261+
262+
if (deserializedTimeOnly.Hour != hour ||
263+
deserializedTimeOnly.Minute != minute ||
264+
deserializedTimeOnly.Second != second ||
265+
deserializedTimeOnly.Millisecond != millisecond ||
266+
(microsecond.HasValue && GetMicrosecondsComponent(deserializedTimeOnly.Ticks) != microsecond.Value) ||
267+
(nanosecond.HasValue && GetNanosecondsComponent(deserializedTimeOnly.Ticks) != nanosecond.Value))
268+
{
269+
throw new BsonSerializationException("Deserialized TimeOnly components do not match the ticks value.");
270+
}
271+
272+
return deserializedTimeOnly;
273+
}
274+
178275
private TimeOnly FromDouble(double value, TimeOnlyUnits units)
179276
{
180277
return units is TimeOnlyUnits.Nanoseconds
@@ -196,6 +293,18 @@ private TimeOnly FromInt64(long value, TimeOnlyUnits units)
196293
: new TimeOnly(value * TicksPerUnit(units));
197294
}
198295

296+
private int GetMicrosecondsComponent(long ticks)
297+
{
298+
var ticksPerMicrosecond = TicksPerUnit(TimeOnlyUnits.Microseconds);
299+
return (int)(ticks / ticksPerMicrosecond % 1000);
300+
}
301+
302+
private int GetNanosecondsComponent(long ticks)
303+
{
304+
var nanosecondsPerTick = 100;
305+
return (int)(ticks * nanosecondsPerTick % 1000);
306+
}
307+
199308
private long TicksPerUnit(TimeOnlyUnits units)
200309
{
201310
return units switch

tests/MongoDB.Bson.Tests/Serialization/Serializers/TimeOnlySerializerTests.cs

Lines changed: 82 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,17 +43,21 @@ public void Attribute_should_set_correct_units()
4343
Microseconds = timeOnly,
4444
Ticks = timeOnly,
4545
Nanoseconds = timeOnly,
46+
Document = timeOnly
4647
};
4748

4849
var json = testObj.ToJson();
4950

50-
var expected = "{ \"Hours\" : 13, "
51-
+ "\"Minutes\" : 804, "
52-
+ "\"Seconds\" : 48293, "
53-
+ "\"Milliseconds\" : 48293000, "
54-
+ "\"Microseconds\" : 48293000000, "
55-
+ "\"Ticks\" : 482930000000, "
56-
+ "\"Nanoseconds\" : 48293000000000 }";
51+
var baseString = """
52+
{ "Hours" : 13, "Minutes" : 804, "Seconds" : 48293, "Milliseconds" : 48293000, "Microseconds" : 48293000000, "Ticks" : 482930000000, "Nanoseconds" : 48293000000000
53+
""";
54+
55+
var documentString = """
56+
{ "Hour" : 13, "Minute" : 24, "Second" : 53, "Millisecond" : 0, "Microsecond" : 0, "Nanosecond" : 0, "Ticks" : 482930000000 }
57+
""";
58+
59+
60+
var expected = baseString + """, "Document" : """ + documentString + " }";
5761
Assert.Equal(expected, json);
5862
}
5963

@@ -69,7 +73,7 @@ public void Constructor_with_no_arguments_should_return_expected_result()
6973
[Theory]
7074
[ParameterAttributeData]
7175
public void Constructor_with_representation_should_return_expected_result(
72-
[Values(BsonType.String, BsonType.Int64, BsonType.Int32, BsonType.Double)]
76+
[Values(BsonType.String, BsonType.Int64, BsonType.Int32, BsonType.Double, BsonType.Document)]
7377
BsonType representation,
7478
[Values(TimeOnlyUnits.Ticks, TimeOnlyUnits.Hours, TimeOnlyUnits.Minutes, TimeOnlyUnits.Seconds,
7579
TimeOnlyUnits.Milliseconds, TimeOnlyUnits.Microseconds, TimeOnlyUnits.Nanoseconds)]
@@ -81,6 +85,60 @@ public void Constructor_with_representation_should_return_expected_result(
8185
subject.Units.Should().Be(units);
8286
}
8387

88+
[Theory]
89+
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "8" }, "Minute" : { "$numberInt" : "32" }, "Second" : { "$numberInt" : "5" }, "Millisecond" : { "$numberInt" : "594" }, "Microsecond" : { "$numberInt" : "658" }, "Nanosecond" : { "$numberInt" : "300" }, "Ticks" : { "$numberLong" : "307255946583" } } }""","08:32:05.5946583" )]
90+
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "0" }, "Minute" : { "$numberInt" : "0" }, "Second" : { "$numberInt" : "0" }, "Millisecond" : { "$numberInt" : "0" }, "Microsecond" : { "$numberInt" : "0" }, "Nanosecond" : { "$numberInt" : "0" }, "Ticks" : { "$numberLong" : "0" } } }""","00:00:00.0000000" )]
91+
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "23" }, "Minute" : { "$numberInt" : "59" }, "Second" : { "$numberInt" : "59" }, "Millisecond" : { "$numberInt" : "999" }, "Microsecond" : { "$numberInt" : "999" }, "Nanosecond" : { "$numberInt" : "900" }, "Ticks" : { "$numberLong" : "863999999999" } } }""","23:59:59.9999999" )]
92+
public void Deserialize_with_document_should_have_expected_result(string json, string expectedResult)
93+
{
94+
var subject = new TimeOnlySerializer();
95+
TestDeserialize(subject, json, expectedResult);
96+
}
97+
98+
[Theory]
99+
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "8" }, "Minute" : { "$numberInt" : "32" }, "Second" : { "$numberInt" : "5" }, "Millisecond" : { "$numberInt" : "594" }, "Microsecond" : { "$numberInt" : "658" }, "Ticks" : { "$numberLong" : "307255946583" } } }""","08:32:05.5946583" )]
100+
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "8" }, "Minute" : { "$numberInt" : "32" }, "Second" : { "$numberInt" : "5" }, "Millisecond" : { "$numberInt" : "594" }, "Nanosecond" : { "$numberInt" : "300" }, "Ticks" : { "$numberLong" : "307255946583" } } }""","08:32:05.5946583" )]
101+
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "8" }, "Minute" : { "$numberInt" : "32" }, "Second" : { "$numberInt" : "5" }, "Millisecond" : { "$numberInt" : "594" }, "Ticks" : { "$numberLong" : "307255946583" } } }""","08:32:05.5946583" )]
102+
public void Deserialize_with_document_should_work_with_missing_microsecond_or_nanosecond(string json, string expectedResult)
103+
{
104+
var subject = new TimeOnlySerializer();
105+
TestDeserialize(subject, json, expectedResult);
106+
}
107+
108+
[Theory]
109+
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "7" }, "Minute" : { "$numberInt" : "32" }, "Second" : { "$numberInt" : "5" }, "Millisecond" : { "$numberInt" : "594" }, "Microsecond" : { "$numberInt" : "658" }, "Nanosecond" : { "$numberInt" : "300" }, "Ticks" : { "$numberLong" : "307255946583" } } }""")]
110+
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "8" }, "Minute" : { "$numberInt" : "33" }, "Second" : { "$numberInt" : "5" }, "Millisecond" : { "$numberInt" : "594" }, "Microsecond" : { "$numberInt" : "658" }, "Nanosecond" : { "$numberInt" : "300" }, "Ticks" : { "$numberLong" : "307255946583" } } }""")]
111+
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "8" }, "Minute" : { "$numberInt" : "32" }, "Second" : { "$numberInt" : "6" }, "Millisecond" : { "$numberInt" : "594" }, "Microsecond" : { "$numberInt" : "658" }, "Nanosecond" : { "$numberInt" : "300" }, "Ticks" : { "$numberLong" : "307255946583" } } }""")]
112+
public void Deserialize_with_document_should_throw_when_component_is_not_correct(string json)
113+
{
114+
var subject = new TimeOnlySerializer();
115+
116+
using var reader = new JsonReader(json);
117+
reader.ReadStartDocument();
118+
reader.ReadName("x");
119+
var context = BsonDeserializationContext.CreateRoot(reader);
120+
121+
var exception = Record.Exception(() => subject.Deserialize(context));
122+
exception.Should().BeOfType<BsonSerializationException>();
123+
exception.Message.Should().Be("Deserialized TimeOnly components do not match the ticks value.");
124+
}
125+
126+
[Fact]
127+
public void Deserialize_with_document_should_throw_when_field_is_unknown()
128+
{
129+
const string json = """{ "x" : { "Unknown": "test", Ticks: { "$numberDouble" : "307255946583" } } }""";
130+
var subject = new TimeOnlySerializer();
131+
132+
using var reader = new JsonReader(json);
133+
reader.ReadStartDocument();
134+
reader.ReadName("x");
135+
var context = BsonDeserializationContext.CreateRoot(reader);
136+
137+
var exception = Record.Exception(() => subject.Deserialize(context));
138+
exception.Should().BeOfType<BsonSerializationException>();
139+
exception.Message.Should().Be("Invalid element: 'Unknown'.");
140+
}
141+
84142
[Theory]
85143
[InlineData("""{ "x" : "08:32:05.5946583" }""","08:32:05.5946583" )]
86144
[InlineData("""{ "x" : "00:00:00.0000000" }""","00:00:00.0000000")]
@@ -273,6 +331,17 @@ public void GetHashCode_should_return_zero()
273331
result.Should().Be(0);
274332
}
275333

334+
[Theory]
335+
[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" } } }""")]
336+
[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" } } }""")]
337+
[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" } } }""")]
338+
public void Serialize_with_document_representation_should_have_expected_result(string valueString, string expectedResult)
339+
{
340+
var subject = new TimeOnlySerializer(BsonType.Document);
341+
342+
TestSerialize(subject, valueString, expectedResult);
343+
}
344+
276345
[Theory]
277346
[InlineData(BsonType.String, "08:32:05.5946583", """{ "x" : "08:32:05.5946583" }""")]
278347
[InlineData(BsonType.String, "00:00:00.0000000", """{ "x" : "00:00:00.0000000" }""")]
@@ -407,8 +476,8 @@ public void Serializer_should_be_registered()
407476
[Theory]
408477
[ParameterAttributeData]
409478
public void WithRepresentation_should_return_expected_result(
410-
[Values(BsonType.String, BsonType.Int64, BsonType.Int32, BsonType.Double)] BsonType oldRepresentation,
411-
[Values(BsonType.String, BsonType.Int64, BsonType.Int32, BsonType.Double)] BsonType newRepresentation)
479+
[Values(BsonType.String, BsonType.Int64, BsonType.Int32, BsonType.Double, BsonType.Document)] BsonType oldRepresentation,
480+
[Values(BsonType.String, BsonType.Int64, BsonType.Int32, BsonType.Double, BsonType.Document)] BsonType newRepresentation)
412481
{
413482
var subject = new TimeOnlySerializer(oldRepresentation);
414483

@@ -473,6 +542,9 @@ private class TestClass
473542

474543
[BsonTimeOnlyOptions(BsonType.Int64, TimeOnlyUnits.Nanoseconds )]
475544
public TimeOnly Nanoseconds { get; set; }
545+
546+
[BsonTimeOnlyOptions(BsonType.Document)]
547+
public TimeOnly Document { get; set; }
476548
}
477549
}
478550
#endif

0 commit comments

Comments
 (0)