Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public BsonTimeOnlyOptionsAttribute(BsonType representation)
/// Initializes a new instance of the BsonTimeOnlyOptionsAttribute class.
/// </summary>
/// <param name="representation">The external representation.</param>
/// <param name="units">The TimeOnlyUnits.</param>
/// <param name="units">The TimeOnlyUnits. Ignored if representation is BsonType.Document.</param>
public BsonTimeOnlyOptionsAttribute(BsonType representation, TimeOnlyUnits units)
{
_representation = representation;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 113 additions & 4 deletions src/MongoDB.Bson/Serialization/Serializers/TimeOnlySerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/

using System;
using MongoDB.Bson.IO;
using MongoDB.Bson.Serialization.Options;

namespace MongoDB.Bson.Serialization.Serializers
Expand All @@ -32,7 +33,20 @@ public sealed class TimeOnlySerializer: StructSerializerBase<TimeOnly>, IReprese
/// </summary>
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;

Expand All @@ -58,11 +72,12 @@ public TimeOnlySerializer(BsonType representation)
/// Initializes a new instance of the <see cref="TimeOnlySerializer"/> class.
/// </summary>
/// <param name="representation">The representation.</param>
/// <param name="units">The units.</param>
/// <param name="units">The units. Ignored if representation is BsonType.Document.</param>
public TimeOnlySerializer(BsonType representation, TimeOnlyUnits units)
{
switch (representation)
{
case BsonType.Document:
case BsonType.Double:
case BsonType.Int32:
case BsonType.Int64:
Expand All @@ -75,6 +90,20 @@ public TimeOnlySerializer(BsonType representation, TimeOnlyUnits units)

_representation = representation;
_units = units;

_helper = new SerializerHelper
(
//TimeOnlySerializer was introduced in version 3.0.0 of the driver. Prior to that, TimeOnly was serialized
//as a POCO. Due to that, Microsecond and Nanosecond could be missing, as they were introduced in .NET 7.
//To avoid deserialization issues, we treat Microsecond and Nanosecond as optional members.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nits: we usually put a space after // in comments.

Also, instead of "was serialized as a POCO" say "was serialized as a class mapped POCO".

// TimeOnlySerializer was introduced in version 3.0.0 of the driver. Prior to that, TimeOnly was serialized
// as a class mapped POCO. Due to that, Microsecond and Nanosecond could be missing, as they were introduced in .NET 7.
// To avoid deserialization issues, we treat Microsecond and Nanosecond as optional members.                           

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deal.

new SerializerHelper.Member("Hour", Flags.Hour, isOptional: false),
new SerializerHelper.Member("Minute", Flags.Minute, isOptional: false),
new SerializerHelper.Member("Second", Flags.Second, isOptional: false),
new SerializerHelper.Member("Millisecond", Flags.Millisecond, isOptional: false),
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
Expand All @@ -98,10 +127,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)
};
}
Expand Down Expand Up @@ -129,6 +159,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;
Expand All @@ -145,6 +188,7 @@ public override void Serialize(BsonSerializationContext context, BsonSerializati
bsonWriter.WriteString(value.ToString("o"));
break;


Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove extra blank line.

default:
throw new BsonSerializationException($"'{_representation}' is not a valid TimeOnly representation.");
}
Expand Down Expand Up @@ -196,6 +240,59 @@ private TimeOnly FromInt64(long value, TimeOnlyUnits units)
: new TimeOnly(value * TicksPerUnit(units));
}

private TimeOnly FromDocument(BsonDeserializationContext context)
{
var bsonReader = context.Reader;
var hour = 0;
var minute = 0;
var second = 0;
var millisecond = 0;
int? microsecond = null;
int? nanosecond = null;
var ticks = 0L;

_helper.DeserializeMembers(context, (_, flag) =>
{
switch (flag)
{
case Flags.Hour:
hour = bsonReader.ReadInt32();
break;
case Flags.Minute:
minute = bsonReader.ReadInt32();
break;
case Flags.Second:
second = bsonReader.ReadInt32();
break;
case Flags.Millisecond:
millisecond = bsonReader.ReadInt32();
break;
case Flags.Microsecond:
microsecond = bsonReader.ReadInt32();
break;
case Flags.Nanosecond:
nanosecond = bsonReader.ReadInt32();
break;
case Flags.Ticks: ticks = bsonReader.ReadInt64();
break;
}
});

var deserializedTimeOnly = new TimeOnly(ticks);

if (deserializedTimeOnly.Hour != hour ||
deserializedTimeOnly.Minute != minute ||
deserializedTimeOnly.Second != second ||
deserializedTimeOnly.Millisecond != millisecond ||
(microsecond.HasValue && GetMicrosecondsComponent(deserializedTimeOnly.Ticks) != microsecond.Value) ||
(nanosecond.HasValue && GetNanosecondsComponent(deserializedTimeOnly.Ticks) != nanosecond.Value))
{
throw new BsonSerializationException("Deserialized TimeOnly components do not match the ticks value.");
}

return deserializedTimeOnly;
}

private long TicksPerUnit(TimeOnlyUnits units)
{
return units switch
Expand Down Expand Up @@ -231,6 +328,18 @@ private long ToInt64(TimeOnly timeOnly, TimeOnlyUnits units)
: timeOnly.Ticks / TicksPerUnit(units);
}

private int GetNanosecondsComponent(long ticks)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put in alphabetical order.

{
// ticks % 10 * 100
return (int)(ticks % TicksPerUnit(TimeOnlyUnits.Microseconds) * 100);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure that computation is correct. I suggest:

var nanosecondsPerTick = 100;
return (int)(ticks * nanosecondsPerTick % 1000);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The computation is correct, it takes the last digit of the ticks and multiplies it by 100, but I can change it to make it clearer.

}

private int GetMicrosecondsComponent(long ticks)
{
// ticks / 10 % 1000
return (int)(ticks / TicksPerUnit(TimeOnlyUnits.Microseconds) % 1000);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's introduce an intermediate variable to make the computation easier to understand:

var ticksPerMicrosecond = TimeSpan.TicksPerMillisecond / 1000;
return (int)(ticks / ticksPerMicrosecond % 1000);             

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add an intermediate variable, but I'll keep the call to TicksPerUnit so we don't have code duplication.

}

// explicit interface implementations
IBsonSerializer IRepresentationConfigurable.WithRepresentation(BsonType representation)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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)]
Expand All @@ -81,6 +85,60 @@ public void Constructor_with_representation_should_return_expected_result(
subject.Units.Should().Be(units);
}

[Theory]
[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" )]
[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" )]
[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" )]
public void Deserialize_with_document_should_have_expected_result(string json, string expectedResult)
{
var subject = new TimeOnlySerializer();
TestDeserialize(subject, json, expectedResult);
}

[Theory]
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "8" }, "Minute" : { "$numberInt" : "32" }, "Second" : { "$numberInt" : "5" }, "Millisecond" : { "$numberInt" : "594" }, "Microsecond" : { "$numberInt" : "658" }, "Ticks" : { "$numberLong" : "307255946583" } } }""","08:32:05.5946583" )]
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "8" }, "Minute" : { "$numberInt" : "32" }, "Second" : { "$numberInt" : "5" }, "Millisecond" : { "$numberInt" : "594" }, "Nanosecond" : { "$numberInt" : "300" }, "Ticks" : { "$numberLong" : "307255946583" } } }""","08:32:05.5946583" )]
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "8" }, "Minute" : { "$numberInt" : "32" }, "Second" : { "$numberInt" : "5" }, "Millisecond" : { "$numberInt" : "594" }, "Ticks" : { "$numberLong" : "307255946583" } } }""","08:32:05.5946583" )]
public void Deserialize_with_document_should_work_with_missing_microsecond_or_nanosecond(string json, string expectedResult)
{
var subject = new TimeOnlySerializer();
TestDeserialize(subject, json, expectedResult);
}

[Theory]
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "7" }, "Minute" : { "$numberInt" : "32" }, "Second" : { "$numberInt" : "5" }, "Millisecond" : { "$numberInt" : "594" }, "Microsecond" : { "$numberInt" : "658" }, "Nanosecond" : { "$numberInt" : "300" }, "Ticks" : { "$numberLong" : "307255946583" } } }""")]
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "8" }, "Minute" : { "$numberInt" : "33" }, "Second" : { "$numberInt" : "5" }, "Millisecond" : { "$numberInt" : "594" }, "Microsecond" : { "$numberInt" : "658" }, "Nanosecond" : { "$numberInt" : "300" }, "Ticks" : { "$numberLong" : "307255946583" } } }""")]
[InlineData("""{ "x" : { "Hour" : { "$numberInt" : "8" }, "Minute" : { "$numberInt" : "32" }, "Second" : { "$numberInt" : "6" }, "Millisecond" : { "$numberInt" : "594" }, "Microsecond" : { "$numberInt" : "658" }, "Nanosecond" : { "$numberInt" : "300" }, "Ticks" : { "$numberLong" : "307255946583" } } }""")]
public void Deserialize_with_document_should_throw_when_component_is_not_correct(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<BsonSerializationException>();
exception.Message.Should().Be("Deserialized TimeOnly components do not match the ticks value.");
}

[Fact]
public void Deserialize_with_document_should_throw_when_field_is_unknown()
{
const string json = """{ "x" : { "Unknown": "test", Ticks: { "$numberDouble" : "307255946583" } } }""";
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<BsonSerializationException>();
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")]
Expand Down Expand Up @@ -273,6 +331,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" }""")]
Expand Down Expand Up @@ -407,8 +476,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);

Expand Down Expand Up @@ -473,6 +542,9 @@ private class TestClass

[BsonTimeOnlyOptions(BsonType.Int64, TimeOnlyUnits.Nanoseconds )]
public TimeOnly Nanoseconds { get; set; }

[BsonTimeOnlyOptions(BsonType.Document)]
public TimeOnly Document { get; set; }
}
}
#endif
Expand Down