Skip to content

Commit 04bc790

Browse files
author
machibuse
committed
Add support for [DateTime] and [DateTimeOffset] transformations in PropertyVisitor, update proto definitions, extend unit tests, and update documentation for Timestamp handling
1 parent f296227 commit 04bc790

5 files changed

Lines changed: 225 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
Porticle.Grpc.TypeMapper is a Roslyn-based post-processor shipped as a NuGet development dependency. It hooks into the build pipeline after `Protobuf_Compile` and rewrites protoc-generated C# classes to add support for Guids, decimals, nullable strings, and nullable enums — features not natively supported by Protocol Buffers.
66

7-
Proto field comments (`[GrpcGuid]`, `[Decimal]`, `[NullableString]`, `[NullableEnum]`) or global project properties control which transformations are applied.
7+
Proto field comments (`[GrpcGuid]`, `[Decimal]`, `[DateTime]`, `[DateTimeOffset]`, `[NullableString]`, `[NullableEnum]`) or global project properties control which transformations are applied.
88

99
## Solution Structure
1010

README.md

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Porticle.Grpc.TypeMapper
22

3-
A Roslyn-based post-processor for protoc-generated files that adds automatic mappings for `Guid`, `Guid?`, `decimal`, `decimal?`, `string?`, nullable enums and nullable reference
3+
A Roslyn-based post-processor for protoc-generated files that adds automatic mappings for `Guid`, `Guid?`, `decimal`, `decimal?`, `DateTime?`, `DateTimeOffset?`, `string?`,
4+
nullable enums and nullable reference
45
types. By simply adding this
56
package and adding
67
comments to
@@ -22,6 +23,8 @@ This library adds automatic conversion for:
2223
- Protobuf google.Protobuf.StringValue to C# Guid?
2324
- Protobuf string to C# decimal
2425
- Protobuf google.Protobuf.StringValue to C# decimal?
26+
- Protobuf google.protobuf.Timestamp to C# DateTime?
27+
- Protobuf google.protobuf.Timestamp to C# DateTimeOffset?
2528
- Protobuf google.Protobuf.StringValue to C# string?
2629
- Protobuf optional enum to C# nullable enum
2730
- Full nullable reference type support per message via `[NullableReferenceTypes]`
@@ -34,6 +37,8 @@ Code.
3437

3538
- Add `// [GrpcGuid]` as comment to a string or StringValue proto field to get Guid/Guid? in generated c# code
3639
- Add `// [Decimal]` as comment to a string or StringValue proto field to get decimal/decimal? in generated c# code
40+
- Add `// [DateTime]` as comment to a google.protobuf.Timestamp proto field to get DateTime? in generated c# code (UTC only - assigning a non-UTC DateTime throws)
41+
- Add `// [DateTimeOffset]` as comment to a google.protobuf.Timestamp proto field to get DateTimeOffset? in generated c# code
3742
- Add `// [NullableString]` as comment to a string or StringValue proto field to get string? in generated c# code
3843
- Add `// [NullableEnum]` as comment to an optional enum proto field to get MyEnum? in generated c# code
3944
- Add `// [NullableReferenceTypes]` as comment above a message to wrap all reference type properties with `#nullable enable/disable` and make nullable ones `string?` / `TypeName?`
@@ -74,6 +79,8 @@ There are several things you can do in your .proto files:
7479
- Add `// [GrpcGuid]` as comment to a StringValue field - Converts the corresponding c# string property to Guid?
7580
- Add `// [Decimal]` as comment to a string field - Converts the corresponding c# string property to decimal
7681
- Add `// [Decimal]` as comment to a StringValue field - Converts the corresponding c# string property to decimal?
82+
- Add `// [DateTime]` as comment to a google.protobuf.Timestamp field - Converts the corresponding c# Timestamp property to DateTime?
83+
- Add `// [DateTimeOffset]` as comment to a google.protobuf.Timestamp field - Converts the corresponding c# Timestamp property to DateTimeOffset?
7784
- Add `// [NullableString]` as comment to a StringValue field - Converts the corresponding c# string property to string?
7885
- Add `// [NullableEnum]` as comment to a optional enum field - Converts the corresponding optional proto enum to a C# nullable Enum
7986
- Add `// [NullableReferenceTypes]` as comment above a message definition - Enables full nullable reference type support for the entire message (see below)
@@ -284,6 +291,59 @@ public global::Porticle.Grpc.UnitTests.TestEnum? FooBar {
284291
}
285292
```
286293

294+
## `[DateTime]` / `[DateTimeOffset]` - Mapping google.protobuf.Timestamp
295+
296+
Add `// [DateTime]` or `// [DateTimeOffset]` as comment to a `google.protobuf.Timestamp` field to get a `DateTime?` or `DateTimeOffset?` property in the generated C# code.
297+
Since message fields are always optional in proto3, the resulting property is always nullable.
298+
299+
The conversion uses the original functions provided by Google.Protobuf: `Timestamp.FromDateTime`/`ToDateTime` and `Timestamp.FromDateTimeOffset`/`ToDateTimeOffset`.
300+
301+
**Note:** `Timestamp.FromDateTime` only accepts `DateTime` values with `DateTimeKind.Utc`. Assigning a `DateTime` with kind `Local` or `Unspecified` throws an `ArgumentException`.
302+
`DateTimeOffset` values can have any offset - they are converted to UTC internally, so reading the property back always returns offset zero.
303+
304+
```protobuf
305+
syntax = "proto3";
306+
307+
import "google/protobuf/timestamp.proto";
308+
309+
message Order {
310+
// [DateTime] Creation time (UTC)
311+
google.protobuf.Timestamp created_at = 1;
312+
313+
// [DateTimeOffset] Delivery time
314+
google.protobuf.Timestamp delivered_at = 2;
315+
}
316+
```
317+
318+
Will result in generated code like this:
319+
320+
```csharp
321+
/// <summary>[DateTime] Creation time (UTC)</summary>
322+
public global::System.DateTime? CreatedAt {
323+
get {
324+
// return null when the underlying Timestamp is null
325+
if (createdAt_ == null) return default;
326+
// convert using the original Google.Protobuf function
327+
return createdAt_.ToDateTime();
328+
}
329+
set {
330+
// Timestamp.FromDateTime throws an ArgumentException when value.Kind is not Utc
331+
createdAt_ = value == null ? null : global::Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(value.Value);
332+
}
333+
}
334+
335+
/// <summary>[DateTimeOffset] Delivery time</summary>
336+
public global::System.DateTimeOffset? DeliveredAt {
337+
get {
338+
if (deliveredAt_ == null) return default;
339+
return deliveredAt_.ToDateTimeOffset();
340+
}
341+
set {
342+
deliveredAt_ = value == null ? null : global::Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(value.Value);
343+
}
344+
}
345+
```
346+
287347
## `[NullableReferenceTypes]` - Per-Message Nullable Reference Types
288348

289349
Add `// [NullableReferenceTypes]` as a comment above a `message` definition to automatically apply nullable reference type annotations to **all** properties in that message:

Source/Porticle.Grpc.TypeMapper/PropertyVisitor.cs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ public class PropertyVisitor(TaskLoggingHelper log, bool wrapAllNonNullableStrin
121121
return null;
122122
}
123123

124+
if (property.Type.ToString() == "global::Google.Protobuf.WellKnownTypes.Timestamp")
125+
{
126+
if (property.GetLeadingTrivia().ToFullString().Contains("[DateTimeOffset]")) return ConvertToTimestampProperty(property, true);
127+
128+
if (property.GetLeadingTrivia().ToFullString().Contains("[DateTime]")) return ConvertToTimestampProperty(property, false);
129+
}
130+
124131
if (property.GetLeadingTrivia().ToFullString().Contains("[NullableEnum]")) return ConvertOptionalToNullableEnum(property);
125132

126133
if (nullableReferenceTypes) return ConvertToNullableMessageProperty(property);
@@ -484,6 +491,83 @@ public class PropertyVisitor(TaskLoggingHelper log, bool wrapAllNonNullableStrin
484491
return property;
485492
}
486493

494+
/// <summary>
495+
/// Converts a google.protobuf.Timestamp property to DateTime? or DateTimeOffset? using the
496+
/// conversion methods provided by Google.Protobuf (Timestamp.FromDateTime/ToDateTime and
497+
/// Timestamp.FromDateTimeOffset/ToDateTimeOffset). Timestamp.FromDateTime throws an
498+
/// ArgumentException when the assigned DateTime is not of kind Utc (e.g. Local).
499+
/// </summary>
500+
private PropertyDeclarationSyntax? ConvertToTimestampProperty(PropertyDeclarationSyntax property, bool asDateTimeOffset)
501+
{
502+
var targetType = asDateTimeOffset ? "global::System.DateTimeOffset" : "global::System.DateTime";
503+
var fromMethod = asDateTimeOffset ? "FromDateTimeOffset" : "FromDateTime";
504+
var toMethod = asDateTimeOffset ? "ToDateTimeOffset" : "ToDateTime";
505+
506+
var setter = property.GetSetter();
507+
508+
if (setter.Body == null)
509+
{
510+
log.LogError($"No setter found in property {property.Identifier}");
511+
return null;
512+
}
513+
514+
// Manipulate setter - message fields are always nullable, so null must be passed through
515+
var assignment = GetAssignmentExpression(setter);
516+
517+
var newRightHandSide = SyntaxFactory.ParseExpression($"value == null ? null : global::Google.Protobuf.WellKnownTypes.Timestamp.{fromMethod}(value.Value)");
518+
519+
var newSetter = setter.ReplaceNode(assignment, assignment.WithRight(newRightHandSide));
520+
521+
property = property.ReplaceNode(setter, newSetter);
522+
523+
// Manipulate getter
524+
var getter = property.GetGetter();
525+
526+
if (getter?.Body == null)
527+
{
528+
log.LogError($"No getter found in property {property.Identifier}");
529+
return null;
530+
}
531+
532+
var returnStatement = getter.Body?.Statements.OfType<ReturnStatementSyntax>().FirstOrDefault();
533+
534+
if (returnStatement?.Expression == null)
535+
{
536+
log.LogError($"Getter has no valid return statement in property {property.Identifier}");
537+
return null;
538+
}
539+
540+
var originalReturnExpression = returnStatement.Expression;
541+
542+
if (originalReturnExpression is not IdentifierNameSyntax identifierNameSyntax)
543+
{
544+
log.LogError($"Getter return statement should be a simple identifier in property {property.Identifier}");
545+
return null;
546+
}
547+
548+
ReplaceProps.Add(new PropertyToField(property.Identifier.ValueText, identifierNameSyntax.Identifier.ValueText));
549+
550+
var newReturnExpression = SyntaxFactory.InvocationExpression(SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, originalReturnExpression,
551+
SyntaxFactory.IdentifierName(toMethod)));
552+
553+
var newReturnStatement = returnStatement.WithExpression(newReturnExpression).WithTrailingTrivia(SyntaxFactory.Space);
554+
555+
// Statement: if (field == null) return default;
556+
var ifStatement = SyntaxFactory.IfStatement(
557+
SyntaxFactory.BinaryExpression(SyntaxKind.EqualsExpression, originalReturnExpression, SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)),
558+
SyntaxFactory.ReturnStatement(SyntaxFactory.LiteralExpression(SyntaxKind.DefaultLiteralExpression).WithLeadingTrivia(SyntaxFactory.Space)))
559+
.WithTrailingTrivia(SyntaxFactory.Space);
560+
561+
var newGetterBody = SyntaxFactory.Block(ifStatement, newReturnStatement);
562+
563+
var newGetter = getter.WithBody(newGetterBody.WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed));
564+
565+
property = property.ReplaceNode(getter, newGetter);
566+
567+
// Change the type of the property - always nullable because message fields can be null
568+
return property.WithType(SyntaxFactory.ParseTypeName(targetType + "?").WithTrailingTrivia(SyntaxFactory.Space));
569+
}
570+
487571
private static AssignmentExpressionSyntax GetAssignmentExpression(AccessorDeclarationSyntax setter)
488572
{
489573
var assignment = setter.Body!.Statements.OfType<ExpressionStatementSyntax>().Select(s => s.Expression as AssignmentExpressionSyntax).FirstOrDefault();

Source/Porticle.Grpc.UnitTests/Test.proto

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
option csharp_namespace = "Porticle.Grpc.UnitTests";
44

55
import "google/protobuf/wrappers.proto";
6+
import "google/protobuf/timestamp.proto";
67

78

89
message TestMessage {
@@ -13,6 +14,8 @@ message TestMessage {
1314
string single_decimal = 8;
1415
google.protobuf.StringValue single_nullable_decimal = 9;
1516
repeated string list_of_decimal = 10;
17+
google.protobuf.Timestamp single_date_time = 11;
18+
google.protobuf.Timestamp single_date_time_offset = 12;
1619
}
1720

1821
message TestMessageMapped {
@@ -41,6 +44,12 @@ message TestMessageMapped {
4144

4245
// [Decimal]
4346
repeated string list_of_decimal = 10;
47+
48+
// [DateTime]
49+
google.protobuf.Timestamp single_date_time = 11;
50+
51+
// [DateTimeOffset]
52+
google.protobuf.Timestamp single_date_time_offset = 12;
4453
}
4554

4655
//////////////////////////////////////////////

Source/Porticle.Grpc.UnitTests/Tests.cs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Globalization;
22
using System.Reflection;
33
using Google.Protobuf;
4+
using Google.Protobuf.WellKnownTypes;
45

56
namespace Porticle.Grpc.UnitTests;
67

@@ -16,6 +17,9 @@ public sealed class Tests
1617
private static readonly decimal Decimal1 = 12345.6789m;
1718
private static readonly decimal Decimal2 = -99999.00001m;
1819

20+
private static readonly DateTime UtcDateTime1 = new DateTime(2024, 5, 17, 13, 45, 30, 123, DateTimeKind.Utc).AddTicks(4567);
21+
private static readonly DateTimeOffset DateTimeOffset1 = new DateTimeOffset(2024, 5, 17, 13, 45, 30, 123, TimeSpan.FromHours(2)).AddTicks(4567);
22+
1923
[TestMethod]
2024
public void TestDecimalWithNull()
2125
{
@@ -106,6 +110,72 @@ public void TestRepeatedDecimalUnmappedToMapped()
106110
Assert.AreEqual(0m, deserializedMessage.ListOfDecimal[2]);
107111
}
108112

113+
[TestMethod]
114+
public void TestDateTimeRoundtrip()
115+
{
116+
var message = new TestMessageMapped { SingleDateTime = UtcDateTime1, SingleDateTimeOffset = DateTimeOffset1 };
117+
118+
var byteArray = message.ToByteArray();
119+
120+
var deserializedMessage = TestMessageMapped.Parser.ParseFrom(byteArray);
121+
122+
Assert.AreEqual(UtcDateTime1, deserializedMessage.SingleDateTime);
123+
Assert.AreEqual(DateTimeKind.Utc, deserializedMessage.SingleDateTime!.Value.Kind);
124+
125+
// DateTimeOffset equality compares the instant - the original offset is not preserved, Timestamp always returns UTC (offset zero)
126+
Assert.AreEqual(DateTimeOffset1, deserializedMessage.SingleDateTimeOffset);
127+
Assert.AreEqual(TimeSpan.Zero, deserializedMessage.SingleDateTimeOffset!.Value.Offset);
128+
}
129+
130+
[TestMethod]
131+
public void TestDateTimeWithNull()
132+
{
133+
var message = new TestMessageMapped { SingleDateTime = null, SingleDateTimeOffset = null };
134+
135+
var byteArray = message.ToByteArray();
136+
137+
var deserializedMessage = TestMessageMapped.Parser.ParseFrom(byteArray);
138+
139+
Assert.IsNull(deserializedMessage.SingleDateTime);
140+
Assert.IsNull(deserializedMessage.SingleDateTimeOffset);
141+
}
142+
143+
[TestMethod]
144+
public void TestDateTimeLocalThrows()
145+
{
146+
var message = new TestMessageMapped();
147+
148+
// Timestamp.FromDateTime only accepts DateTimeKind.Utc
149+
Assert.ThrowsException<ArgumentException>(() => message.SingleDateTime = DateTime.SpecifyKind(UtcDateTime1, DateTimeKind.Local));
150+
Assert.ThrowsException<ArgumentException>(() => message.SingleDateTime = DateTime.SpecifyKind(UtcDateTime1, DateTimeKind.Unspecified));
151+
}
152+
153+
[TestMethod]
154+
public void TestDateTimeUnmappedToMapped()
155+
{
156+
var message = new TestMessage { SingleDateTime = Timestamp.FromDateTime(UtcDateTime1), SingleDateTimeOffset = Timestamp.FromDateTimeOffset(DateTimeOffset1) };
157+
158+
var byteArray = message.ToByteArray();
159+
160+
var deserializedMessage = TestMessageMapped.Parser.ParseFrom(byteArray);
161+
162+
Assert.AreEqual(UtcDateTime1, deserializedMessage.SingleDateTime);
163+
Assert.AreEqual(DateTimeOffset1, deserializedMessage.SingleDateTimeOffset);
164+
}
165+
166+
[TestMethod]
167+
public void TestDateTimeMappedToUnmapped()
168+
{
169+
var message = new TestMessageMapped { SingleDateTime = UtcDateTime1, SingleDateTimeOffset = DateTimeOffset1 };
170+
171+
var byteArray = message.ToByteArray();
172+
173+
var deserializedMessage = TestMessage.Parser.ParseFrom(byteArray);
174+
175+
Assert.AreEqual(Timestamp.FromDateTime(UtcDateTime1), deserializedMessage.SingleDateTime);
176+
Assert.AreEqual(Timestamp.FromDateTimeOffset(DateTimeOffset1), deserializedMessage.SingleDateTimeOffset);
177+
}
178+
109179
[TestMethod]
110180
public void TestWithNull()
111181
{

0 commit comments

Comments
 (0)