Skip to content

Commit 65a982f

Browse files
author
machibuse
committed
Add support for [Decimal] property transformation in PropertyVisitor, update documentation and proto definitions, and add unit tests for decimal handling
1 parent 7ebc3f6 commit 65a982f

4 files changed

Lines changed: 190 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22

33
## Project Overview
44

5-
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, nullable strings, and nullable enums — features not natively supported by Protocol Buffers.
5+
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]`, `[NullableString]`, `[NullableEnum]`) or global project properties control which transformations are applied.
7+
Proto field comments (`[GrpcGuid]`, `[Decimal]`, `[NullableString]`, `[NullableEnum]`) or global project properties control which transformations are applied.
88

99
## Solution Structure
1010

@@ -23,7 +23,7 @@ Source/
2323
|------|------|
2424
| `ProtoPostProcessor.cs` | MSBuild Task entry point — finds generated files, runs visitors, writes output |
2525
| `ClassVisitor.cs` | CSharpSyntaxRewriter — orchestrates property and method transformations per class |
26-
| `PropertyVisitor.cs` | Rewrites property types/getters/setters (Guid, nullable string, nullable enum) |
26+
| `PropertyVisitor.cs` | Rewrites property types/getters/setters (Guid, decimal, nullable string, nullable enum) |
2727
| `MethodVisitor.cs` | Updates Equals/GetHashCode/WriteTo/MergeFrom etc. to use backing fields |
2828
| `PropertyToFieldRewriter.cs` | Replaces property identifiers with field names inside method bodies |
2929
| `ListWrappers.cs` | Source code strings for `RepeatedFieldGuidWrapper` and `IListWithRangeAdd<T>` |

Source/Porticle.Grpc.TypeMapper/PropertyVisitor.cs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ public class PropertyVisitor(TaskLoggingHelper log, bool wrapAllNonNullableStrin
2525
{
2626
if (property.GetLeadingTrivia().ToFullString().Contains("[GrpcGuid]")) return ConvertToGuidProperty(property);
2727

28+
if (property.GetLeadingTrivia().ToFullString().Contains("[Decimal]")) return ConvertToDecimalProperty(property);
29+
2830
if (wrapAllNullableStringValues || property.GetLeadingTrivia().ToFullString().Contains("[NullableString]"))
2931
return ConvertToNullableStringProperty(property, wrapAllNullableStringValues);
3032

@@ -81,6 +83,11 @@ public class PropertyVisitor(TaskLoggingHelper log, bool wrapAllNonNullableStrin
8183
}
8284
}
8385

86+
if (property.GetLeadingTrivia().ToFullString().Contains("[Decimal]"))
87+
{
88+
log.LogError("Decimal is not supported for repeated fields");
89+
}
90+
8491
if (property.GetLeadingTrivia().ToFullString().Contains("[NullableString]"))
8592
if (isNullable)
8693
log.LogError("Nullable string is not supported for repeated fields because protoc don't allow null for lists");
@@ -304,6 +311,116 @@ public class PropertyVisitor(TaskLoggingHelper log, bool wrapAllNonNullableStrin
304311
return property;
305312
}
306313

314+
private PropertyDeclarationSyntax? ConvertToDecimalProperty(PropertyDeclarationSyntax property)
315+
{
316+
var setter = property.GetSetter();
317+
318+
if (setter.Body == null)
319+
{
320+
log.LogError($"No setter found in property {property.Identifier}");
321+
return null;
322+
}
323+
324+
var isNullable = !setter.Body.ToFullString().Contains("ProtoPreconditions.CheckNotNull");
325+
326+
// Manipulate setter
327+
var assignment = GetAssignmentExpression(setter);
328+
329+
var originalRightHandSide = assignment.Right;
330+
331+
if (!isNullable)
332+
{
333+
if (originalRightHandSide is not InvocationExpressionSyntax invocationExpr || !invocationExpr.Expression.ToString().EndsWith("CheckNotNull"))
334+
{
335+
throw new TypeMapperException($"[Error] Can't find CheckNotNull call in setter of property {property.Identifier}");
336+
}
337+
338+
originalRightHandSide = invocationExpr.ArgumentList.Arguments.First().Expression;
339+
}
340+
341+
var toStringMethodName = SyntaxFactory.IdentifierName("ToString");
342+
var toStringArgument = SyntaxFactory.Argument(SyntaxFactory.ParseExpression("global::System.Globalization.CultureInfo.InvariantCulture"));
343+
var toStringArgumentList = SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList(toStringArgument));
344+
var parenthesizedOriginalRightHandSide = SyntaxFactory.ParenthesizedExpression(originalRightHandSide);
345+
346+
ExpressionSyntax newRightHandSide = isNullable
347+
? SyntaxFactory.ConditionalAccessExpression(parenthesizedOriginalRightHandSide,
348+
SyntaxFactory.InvocationExpression(SyntaxFactory.MemberBindingExpression(toStringMethodName), toStringArgumentList))
349+
: SyntaxFactory.InvocationExpression(
350+
SyntaxFactory.MemberAccessExpression(SyntaxKind.SimpleMemberAccessExpression, parenthesizedOriginalRightHandSide, toStringMethodName), toStringArgumentList);
351+
var newAssignment = assignment.WithRight(newRightHandSide);
352+
var newSetter = setter.ReplaceNode(assignment, newAssignment);
353+
354+
property = property.ReplaceNode(setter, newSetter);
355+
356+
// Manipulate getter
357+
var getter = property.GetGetter();
358+
359+
if (getter?.Body == null)
360+
{
361+
log.LogError($"No getter found in property {property.Identifier}");
362+
return null;
363+
}
364+
365+
var returnStatement = getter.Body?.Statements.OfType<ReturnStatementSyntax>().FirstOrDefault();
366+
367+
if (returnStatement?.Expression == null)
368+
{
369+
log.LogError($"Getter has no valid return statement in property {property.Identifier}");
370+
return null;
371+
}
372+
373+
var originalReturnExpression = returnStatement.Expression;
374+
375+
if (originalReturnExpression is not IdentifierNameSyntax identifierNameSyntax)
376+
{
377+
log.LogError($"Getter return statement should be a simple identifier in property {property.Identifier}");
378+
return null;
379+
}
380+
381+
ReplaceProps.Add(new PropertyToField(property.Identifier.ValueText, identifierNameSyntax.Identifier.ValueText));
382+
383+
var parseArguments = SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(new[]
384+
{
385+
SyntaxFactory.Argument(originalReturnExpression), SyntaxFactory.Argument(SyntaxFactory.ParseExpression("global::System.Globalization.CultureInfo.InvariantCulture"))
386+
}));
387+
388+
var newReturnExpression = SyntaxFactory.InvocationExpression(SyntaxFactory.ParseExpression("decimal.Parse"), parseArguments);
389+
390+
var newReturnStatement = returnStatement.WithExpression(newReturnExpression).WithTrailingTrivia(SyntaxFactory.Space);
391+
392+
BlockSyntax newGetterBody;
393+
if (isNullable)
394+
{
395+
var ifStatement = SyntaxFactory.IfStatement(
396+
SyntaxFactory.BinaryExpression(SyntaxKind.EqualsExpression, originalReturnExpression, SyntaxFactory.LiteralExpression(SyntaxKind.NullLiteralExpression)),
397+
SyntaxFactory.ReturnStatement(SyntaxFactory.LiteralExpression(SyntaxKind.DefaultLiteralExpression).WithLeadingTrivia(SyntaxFactory.Space)))
398+
.WithTrailingTrivia(SyntaxFactory.Space);
399+
400+
newGetterBody = SyntaxFactory.Block(ifStatement, newReturnStatement);
401+
}
402+
else
403+
{
404+
newGetterBody = SyntaxFactory.Block(newReturnStatement);
405+
}
406+
407+
var newGetter = getter.WithBody(newGetterBody.WithTrailingTrivia(SyntaxFactory.CarriageReturnLineFeed));
408+
409+
property = property.ReplaceNode(getter, newGetter);
410+
411+
// Change the type of the property
412+
if (isNullable)
413+
{
414+
property = property.WithType(SyntaxFactory.ParseTypeName("decimal?").WithTrailingTrivia(SyntaxFactory.Space));
415+
}
416+
else
417+
{
418+
property = property.WithType(SyntaxFactory.ParseTypeName("decimal").WithTrailingTrivia(SyntaxFactory.Space));
419+
}
420+
421+
return property;
422+
}
423+
307424
private static AssignmentExpressionSyntax GetAssignmentExpression(AccessorDeclarationSyntax setter)
308425
{
309426
var assignment = setter.Body!.Statements.OfType<ExpressionStatementSyntax>().Select(s => s.Expression as AssignmentExpressionSyntax).FirstOrDefault();

Source/Porticle.Grpc.UnitTests/Test.proto

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ message TestMessage {
1010
google.protobuf.StringValue single_nullable_guid = 2;
1111
google.protobuf.StringValue single_nullable_string = 4;
1212
repeated string list_of_guid = 5;
13+
string single_decimal = 8;
14+
google.protobuf.StringValue single_nullable_decimal = 9;
1315
}
1416

1517
message TestMessageMapped {
@@ -29,6 +31,12 @@ message TestMessageMapped {
2931

3032
// [NullableEnum]
3133
optional TestEnum enum_optional = 7;
34+
35+
// [Decimal]
36+
string single_decimal = 8;
37+
38+
// [Decimal]
39+
google.protobuf.StringValue single_nullable_decimal = 9;
3240
}
3341

3442
//////////////////////////////////////////////

Source/Porticle.Grpc.UnitTests/Tests.cs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Google.Protobuf;
1+
using System.Globalization;
2+
using Google.Protobuf;
23

34
namespace Porticle.Grpc.UnitTests;
45

@@ -11,6 +12,66 @@ public sealed class Tests
1112
private static readonly Guid Guid4 = Guid.Parse("D78E2E14-CC83-48A2-A782-E4A0807D25F4");
1213
private static readonly Guid Guid5 = Guid.Parse("666383AD-0637-4233-92FE-842072372FF7");
1314

15+
private static readonly decimal Decimal1 = 12345.6789m;
16+
private static readonly decimal Decimal2 = -99999.00001m;
17+
18+
[TestMethod]
19+
public void TestDecimalWithNull()
20+
{
21+
var message = new TestMessageMapped { SingleGuid = Guid4, SingleDecimal = Decimal1, SingleNullableDecimal = null };
22+
23+
var byteArray = message.ToByteArray();
24+
25+
var deserializedMessage = TestMessageMapped.Parser.ParseFrom(byteArray);
26+
27+
Assert.AreEqual(Decimal1, deserializedMessage.SingleDecimal);
28+
Assert.IsNull(deserializedMessage.SingleNullableDecimal);
29+
}
30+
31+
[TestMethod]
32+
public void TestDecimalWithoutNull()
33+
{
34+
var message = new TestMessageMapped { SingleGuid = Guid4, SingleDecimal = Decimal1, SingleNullableDecimal = Decimal2 };
35+
36+
var byteArray = message.ToByteArray();
37+
38+
var deserializedMessage = TestMessageMapped.Parser.ParseFrom(byteArray);
39+
40+
Assert.AreEqual(Decimal1, deserializedMessage.SingleDecimal);
41+
Assert.AreEqual(Decimal2, deserializedMessage.SingleNullableDecimal);
42+
}
43+
44+
[TestMethod]
45+
public void TestDecimalUnmappedToMapped()
46+
{
47+
var message = new TestMessage
48+
{
49+
SingleGuid = Guid4.ToString(),
50+
SingleDecimal = Decimal1.ToString(CultureInfo.InvariantCulture),
51+
SingleNullableDecimal = Decimal2.ToString(CultureInfo.InvariantCulture)
52+
};
53+
54+
var byteArray = message.ToByteArray();
55+
56+
var deserializedMessage = TestMessageMapped.Parser.ParseFrom(byteArray);
57+
58+
Assert.AreEqual(Decimal1, deserializedMessage.SingleDecimal);
59+
Assert.AreEqual(Decimal2, deserializedMessage.SingleNullableDecimal);
60+
}
61+
62+
[TestMethod]
63+
public void TestDecimalMappedToUnmapped()
64+
{
65+
var message = new TestMessageMapped { SingleGuid = Guid4, SingleDecimal = Decimal1, SingleNullableDecimal = Decimal2 };
66+
67+
var byteArray = message.ToByteArray();
68+
69+
var deserializedMessage = TestMessage.Parser.ParseFrom(byteArray);
70+
71+
Assert.AreEqual(Decimal1.ToString(CultureInfo.InvariantCulture), deserializedMessage.SingleDecimal);
72+
Assert.AreEqual(Decimal2.ToString(CultureInfo.InvariantCulture), deserializedMessage.SingleNullableDecimal);
73+
}
74+
1475
[TestMethod]
1576
public void TestWithNull()
1677
{

0 commit comments

Comments
 (0)