Skip to content

Commit

Permalink
Implement DateOnly and TimeOnly (#1100)
Browse files Browse the repository at this point in the history
* implement DateOnly and TimeOnly
fix #977

* use raw TimeOnly.Ticks

* release notes
  • Loading branch information
mgravell committed Sep 27, 2023
1 parent dbe3b45 commit 02c5dfe
Show file tree
Hide file tree
Showing 15 changed files with 385 additions and 11 deletions.
1 change: 1 addition & 0 deletions docs/releasenotes.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Packages are available on NuGet: [protobuf-net](https://www.nuget.org/packages/p

## unreleased

- support `DateOnly` and `TimeOnly` (#1100 by @mgravell, fixes #977)
- support Roslyn [DefaultValue] analyzer (#1040 by @deaglegross)

## 3.2.26
Expand Down
32 changes: 32 additions & 0 deletions src/protobuf-net.Core/BclHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,22 @@ public static DateTime ReadDateTime(ref ProtoReader.State state)
}
}

#if NET6_0_OR_GREATER
/// <summary>
/// Writes a DateOnly to a protobuf stream
/// </summary>
[MethodImpl(ProtoReader.HotPath)]
public static DateOnly ReadDateOnly(ref ProtoReader.State state)
=> DateOnly.FromDayNumber(state.ReadInt32());

/// <summary>
/// Writes a TimeOnly to a protobuf stream
/// </summary>
[MethodImpl(ProtoReader.HotPath)]
public static TimeOnly ReadTimeOnly(ref ProtoReader.State state)
=> new TimeOnly(state.ReadInt64());
#endif

/// <summary>
/// Writes a DateTime to a protobuf stream, excluding the <c>Kind</c>
/// </summary>
Expand All @@ -248,6 +264,22 @@ public static void WriteDateTime(ref ProtoWriter.State state, DateTime value)
WriteDateTimeImpl(ref state, value, false);
}

#if NET6_0_OR_GREATER
/// <summary>
/// Writes a DateOnly to a protobuf stream
/// </summary>
[MethodImpl(ProtoReader.HotPath)]
public static void WriteDateOnly(ref ProtoWriter.State state, DateOnly value)
=> state.WriteInt32(value.DayNumber);

/// <summary>
/// Writes a TimeOnly to a protobuf stream
/// </summary>
[MethodImpl(ProtoReader.HotPath)]
public static void WriteTimeOnly(ref ProtoWriter.State state, TimeOnly value)
=> state.WriteInt64(value.Ticks);
#endif

/// <summary>
/// Writes a DateTime to a protobuf stream, including the <c>Kind</c>
/// </summary>
Expand Down
8 changes: 8 additions & 0 deletions src/protobuf-net.Core/Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ public static ProtoTypeCode GetTypeCode(Type type)
if (type == typeof(Type)) return ProtoTypeCode.Type;
if (type == typeof(IntPtr)) return ProtoTypeCode.IntPtr;
if (type == typeof(UIntPtr)) return ProtoTypeCode.UIntPtr;
#if NET6_0_OR_GREATER
if (type == typeof(DateOnly)) return ProtoTypeCode.DateOnly;
if (type == typeof(TimeOnly)) return ProtoTypeCode.TimeOnly;
#endif

return ProtoTypeCode.Unknown;
}
Expand Down Expand Up @@ -187,5 +191,9 @@ internal enum ProtoTypeCode
ByteReadOnlyMemory = 107,
IntPtr = 108,
UIntPtr = 109,
#if NET6_0_OR_GREATER
DateOnly = 110,
TimeOnly = 111,
#endif
}
}
91 changes: 91 additions & 0 deletions src/protobuf-net.Core/Internal/PrimaryTypeProvider.DateTimeOnly.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using ProtoBuf.Serializers;
using System;

#if NET6_0_OR_GREATER
namespace ProtoBuf.Internal
{
// map DateOnly as "int32" (days since January 1, 0001 in the Proleptic Gregorian calendar),
// and TimeOnly as "int64" (ticks into day, where a tick is 100ns)
//
// it was tempting to map to Date and TimeOfDay respectively, but this has problems:
// - Date allows dates a date without a year to be expressed, which DateOnly does not
// - TimeOfDay allows 24:00 to be expressed, which TimeOnly does not
// likewise, there is Timestamp. but that is ... awkward and heavy for pure dates,
// and Duration has larger range which will explode TimeOnly - it would be artificial
// to pretend that they can interop with either of these
//
// either way, there's also a minor precision issue, since the google types go to
// nanosecond precision, and TimeOfDay only allows ticks (100ns), but in reality this
// is unlikely to be a problem; anyone requiring better accuracy should probably handle
// it manually, or (simpler) use WellKnownTypes.Duration / WellKnownTypes.Timestamp directly
//
// refs:
// https://github.com/protocolbuffers/protobuf/blob/main/src/google/protobuf/timestamp.proto
// https://github.com/protocolbuffers/protobuf/blob/main/src/google/protobuf/duration.proto
// https://github.com/googleapis/googleapis/blob/master/google/type/timeofday.proto
// https://github.com/googleapis/googleapis/blob/master/google/type/date.proto

partial class PrimaryTypeProvider :
ISerializer<DateOnly>, ISerializer<DateOnly?>,
IValueChecker<DateOnly>, IValueChecker<DateOnly?>,
IValueChecker<TimeOnly>, IValueChecker<TimeOnly?>,
IMeasuringSerializer<DateOnly>, IMeasuringSerializer<DateOnly?>,
IMeasuringSerializer<TimeOnly>, IMeasuringSerializer<TimeOnly?>,

ISerializer<TimeOnly>, ISerializer<TimeOnly?>
{
SerializerFeatures ISerializer<DateOnly>.Features => SerializerFeatures.WireTypeVarint | SerializerFeatures.CategoryScalar;
SerializerFeatures ISerializer<DateOnly?>.Features => SerializerFeatures.WireTypeVarint | SerializerFeatures.CategoryScalar;

SerializerFeatures ISerializer<TimeOnly>.Features => SerializerFeatures.WireTypeVarint | SerializerFeatures.CategoryScalar;
SerializerFeatures ISerializer<TimeOnly?>.Features => SerializerFeatures.WireTypeVarint | SerializerFeatures.CategoryScalar;

TimeOnly ISerializer<TimeOnly>.Read(ref ProtoReader.State state, TimeOnly value)
=> new TimeOnly(state.ReadInt64());

void ISerializer<TimeOnly>.Write(ref ProtoWriter.State state, TimeOnly value)
=> state.WriteInt64(value.Ticks);

TimeOnly? ISerializer<TimeOnly?>.Read(ref ProtoReader.State state, TimeOnly? value)
=> new TimeOnly(state.ReadInt64());

void ISerializer<TimeOnly?>.Write(ref ProtoWriter.State state, TimeOnly? value)
=> state.WriteInt64(value.GetValueOrDefault().Ticks);

DateOnly ISerializer<DateOnly>.Read(ref ProtoReader.State state, DateOnly value)
=> DateOnly.FromDayNumber(state.ReadInt32());

void ISerializer<DateOnly>.Write(ref ProtoWriter.State state, DateOnly value)
=> state.WriteInt32(value.DayNumber);

DateOnly? ISerializer<DateOnly?>.Read(ref ProtoReader.State state, DateOnly? value)
=> DateOnly.FromDayNumber(state.ReadInt32());

void ISerializer<DateOnly?>.Write(ref ProtoWriter.State state, DateOnly? value)
=> state.WriteInt32(value.GetValueOrDefault().DayNumber);


bool IValueChecker<DateOnly>.HasNonTrivialValue(DateOnly value) => value.DayNumber != 0;
bool IValueChecker<DateOnly>.IsNull(DateOnly value) => false;

bool IValueChecker<DateOnly?>.HasNonTrivialValue(DateOnly? value) => value.GetValueOrDefault().DayNumber != 0;
bool IValueChecker<DateOnly?>.IsNull(DateOnly? value) => value is null;
int IMeasuringSerializer<DateOnly>.Measure(ISerializationContext context, WireType wireType, DateOnly value)
=> ProtoWriter.MeasureInt32(value.DayNumber);

int IMeasuringSerializer<DateOnly?>.Measure(ISerializationContext context, WireType wireType, DateOnly? value)
=> ProtoWriter.MeasureInt32(value.Value.DayNumber);

bool IValueChecker<TimeOnly>.HasNonTrivialValue(TimeOnly value) => value.Ticks != 0;
bool IValueChecker<TimeOnly>.IsNull(TimeOnly value) => false;

bool IValueChecker<TimeOnly?>.HasNonTrivialValue(TimeOnly? value) => value.GetValueOrDefault().Ticks != 0;
bool IValueChecker<TimeOnly?>.IsNull(TimeOnly? value) => value is null;
int IMeasuringSerializer<TimeOnly>.Measure(ISerializationContext context, WireType wireType, TimeOnly value)
=> ProtoWriter.MeasureInt64(value.Ticks);

int IMeasuringSerializer<TimeOnly?>.Measure(ISerializationContext context, WireType wireType, TimeOnly? value)
=> ProtoWriter.MeasureInt64(value.Value.Ticks);
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ int IMeasuringSerializer<int>.Measure(ISerializationContext context, WireType wi
{
WireType.Fixed32 => 4,
WireType.Fixed64 => 8,
WireType.Varint => value < 0 ? 10 : ProtoWriter.MeasureUInt32((uint)value),
WireType.Varint => ProtoWriter.MeasureInt32(value),
WireType.SignedVarint => ProtoWriter.MeasureUInt32(ProtoWriter.Zig(value)),
_ => -1,
};
Expand Down Expand Up @@ -251,7 +251,7 @@ int IMeasuringSerializer<sbyte>.Measure(ISerializationContext context, WireType
{
WireType.Fixed32 => 4,
WireType.Fixed64 => 8,
WireType.Varint => value < 0 ? 10 : ProtoWriter.MeasureUInt32((uint)value),
WireType.Varint => ProtoWriter.MeasureInt32(value),
WireType.SignedVarint => ProtoWriter.MeasureUInt32(ProtoWriter.Zig(value)),
_ => -1,
};
Expand All @@ -264,7 +264,7 @@ int IMeasuringSerializer<short>.Measure(ISerializationContext context, WireType
{
WireType.Fixed32 => 4,
WireType.Fixed64 => 8,
WireType.Varint => value < 0 ? 10 : ProtoWriter.MeasureUInt32((uint)value),
WireType.Varint => ProtoWriter.MeasureInt32(value),
WireType.SignedVarint => ProtoWriter.MeasureUInt32(ProtoWriter.Zig(value)),
_ => -1,
};
Expand Down
8 changes: 8 additions & 0 deletions src/protobuf-net.Core/ProtoWriter.Null.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ private protected override void ImplEndLengthPrefixedSubItem(ref State state, Su
private protected override bool TryFlush(ref State state) => true;
}

[MethodImpl(ProtoReader.HotPath)]
internal static int MeasureInt32(int value)
=> value < 0 ? 10 : MeasureUInt32((uint)value);

[MethodImpl(ProtoReader.HotPath)]
internal static int MeasureUInt32(uint value)
{
Expand All @@ -208,6 +212,10 @@ internal static int MeasureUInt32(uint value)
#endif
}

[MethodImpl(ProtoReader.HotPath)]
internal static int MeasureInt64(long value)
=> value < 0 ? 10 : MeasureUInt64((ulong)value);

[MethodImpl(ProtoReader.HotPath)]
internal static int MeasureUInt64(ulong value)
{
Expand Down
16 changes: 10 additions & 6 deletions src/protobuf-net.Core/WellKnownTypes/Duration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,11 @@ void ISerializer<Duration>.Write(ref ProtoWriter.State state, Duration value)
internal static void WriteDuration(ref ProtoWriter.State state, Duration value)
=> WriteSecondsNanos(ref state, value.Seconds, value.Nanoseconds, false);

internal static long ToDurationSeconds(TimeSpan value, out int nanos, bool isTimestamp)
internal static long ToDurationSeconds(long ticks, out int nanos, bool isTimestamp)
{
nanos = (int)(((value.Ticks % TimeSpan.TicksPerSecond) * 1000000)
nanos = (int)(((ticks % TimeSpan.TicksPerSecond) * 1000000)
/ TimeSpan.TicksPerMillisecond);
var seconds = value.Ticks / TimeSpan.TicksPerSecond;
var seconds = ticks / TimeSpan.TicksPerSecond;
NormalizeSecondsNanoseconds(ref seconds, ref nanos, isTimestamp);
return seconds;
}
Expand Down Expand Up @@ -194,14 +194,18 @@ public Duration(long seconds, int nanoseconds)
}

/// <summary>Converts a TimeSpan to a Duration</summary>
public Duration(TimeSpan value)
public Duration(TimeSpan value) : this(value.Ticks) { }

internal Duration(long ticks)
{
Seconds = PrimaryTypeProvider.ToDurationSeconds(value, out var nanoseconds, false);
Seconds = PrimaryTypeProvider.ToDurationSeconds(ticks, out var nanoseconds, false);
Nanoseconds = nanoseconds;
}

/// <summary>Converts a Duration to a TimeSpan</summary>
public TimeSpan AsTimeSpan() => TimeSpan.FromTicks(PrimaryTypeProvider.ToTicks(Seconds, Nanoseconds));
public TimeSpan AsTimeSpan() => TimeSpan.FromTicks(ToTicks());

internal long ToTicks() => PrimaryTypeProvider.ToTicks(Seconds, Nanoseconds);

/// <summary>Converts a Duration to a TimeSpan</summary>
public static implicit operator TimeSpan(Duration value) => value.AsTimeSpan();
Expand Down
2 changes: 1 addition & 1 deletion src/protobuf-net.Core/WellKnownTypes/Timestamp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public Timestamp(long seconds, int nanoseconds)
/// <summary>Converts a DateTime to a Timestamp</summary>
public Timestamp(DateTime value)
{
Seconds = PrimaryTypeProvider.ToDurationSeconds(value - TimestampEpoch, out var nanoseconds, true);
Seconds = PrimaryTypeProvider.ToDurationSeconds((value - TimestampEpoch).Ticks, out var nanoseconds, true);
Nanoseconds = nanoseconds;
}

Expand Down
133 changes: 133 additions & 0 deletions src/protobuf-net.Test/DateTimeOnlyTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using ProtoBuf.Meta;
using ProtoBuf.unittest;
using System;
using System.IO;
using Xunit;

#if NET6_0_OR_GREATER
namespace ProtoBuf.Test
{
public class DateTimeOnlyTests
{
[Fact]
public void SchemaFlat()
{
var model = RuntimeTypeModel.Create();
Assert.Equal(@"syntax = ""proto3"";
package ProtoBuf.Test;
message SomeType {
int32 Date = 1;
int64 Time = 2;
}
", model.GetSchema(typeof(SomeType)), ignoreLineEndingDifferences: true);
}

[Fact]
public void SchemaArrays()
{
var model = RuntimeTypeModel.Create();
Assert.Equal(@"syntax = ""proto3"";
package ProtoBuf.Test;
message SomeTypeWithArrays {
repeated int32 Dates = 1;
repeated int64 Times = 2;
}
", model.GetSchema(typeof(SomeTypeWithArrays)), ignoreLineEndingDifferences: true);
}

[Fact]
public void CanRoundtripSimpleAndArrays()
{
var model = RuntimeTypeModel.Create();
model.AutoCompile = false;
model.Add<SomeType>();
model.Add<SomeTypeWithArrays>();

ExecuteSimple(model);
ExecuteArrays(model);

model.CompileInPlace();
ExecuteSimple(model);
ExecuteArrays(model);

var compiled = model.Compile();
ExecuteSimple(compiled);
ExecuteArrays(compiled);

compiled = PEVerify.CompileAndVerify(model);
ExecuteSimple(compiled);
ExecuteArrays(compiled);
}

void ExecuteSimple(TypeModel model)
{
var obj = new SomeType {
Date = new DateOnly(2023, 09, 27),
Time = new TimeOnly(11, 55, 32, 904),
};

using var ms = new MemoryStream();
model.Serialize(ms, obj);
var hex = BitConverter.ToString(ms.GetBuffer(), 0, (int)ms.Length);
Assert.Equal("08-E5-8B-2D-10-80-85-85-B0-BF-0C", hex);
/*
Field #1: 08 Varint Value = 738789, Hex = E5-8B-2D
Field #2: 10 Varint Value = 429329040000, Hex = 80-85-85-B0-BF-0C
*/


ms.Position = 0;
var clone = model.Deserialize<SomeType>(ms);
Assert.NotNull(clone);
Assert.NotSame(obj, clone);
Assert.Equal(obj.Date, clone.Date);
Assert.Equal(obj.Time, clone.Time);
}

void ExecuteArrays(TypeModel model)
{
var obj = new SomeTypeWithArrays
{
Dates = new[] { new DateOnly(2023, 09, 27) },
Times = new[] { new TimeOnly(11, 55, 32, 904) },
};

using var ms = new MemoryStream();
model.Serialize(ms, obj);
var hex = BitConverter.ToString(ms.GetBuffer(), 0, (int)ms.Length);
Assert.Equal("08-E5-8B-2D-10-80-85-85-B0-BF-0C", hex);
// same payload

ms.Position = 0;
var clone = model.Deserialize<SomeTypeWithArrays>(ms);
Assert.NotNull(clone);
Assert.NotSame(obj, clone);
Assert.Equal(obj.Dates, clone.Dates);
Assert.Equal(obj.Times, clone.Times);
}

[ProtoContract]
public class SomeType
{
[ProtoMember(1)]
public DateOnly Date { get; set; }

[ProtoMember(2)]
public TimeOnly Time { get; set; }
}

[ProtoContract]
public class SomeTypeWithArrays
{
[ProtoMember(1)]
public DateOnly[] Dates { get; set; }

[ProtoMember(2)]
public TimeOnly[] Times { get; set; }
}
}
}

#endif
Loading

0 comments on commit 02c5dfe

Please sign in to comment.