From 3f807ddc04656d9b4ef8a6a3661f299a8c042ad3 Mon Sep 17 00:00:00 2001 From: Tom Biddulph Date: Fri, 6 Feb 2026 10:08:56 +0000 Subject: [PATCH] Refactor PropertyResolverGenerator to handle nullable properties and update tests for resolver generation --- .../PropertyResolverGenerator.cs | 49 ++++---- .../PropertyResolverGeneratorTests.cs | 106 +++++++++++++++++- 2 files changed, 131 insertions(+), 24 deletions(-) diff --git a/src/PropertyResolvers.Generators/PropertyResolverGenerator.cs b/src/PropertyResolvers.Generators/PropertyResolverGenerator.cs index e20eaae..3ce9490 100644 --- a/src/PropertyResolvers.Generators/PropertyResolverGenerator.cs +++ b/src/PropertyResolvers.Generators/PropertyResolverGenerator.cs @@ -99,19 +99,17 @@ private static ImmutableArray GetResolverConfigs(Compilation com switch (namedArg.Key) { case "IncludeNamespaces": - config.IncludeNamespaces = namedArg.Value.Values + config.IncludeNamespaces = [.. namedArg.Value.Values .Select(v => v.Value as string) .Where(v => v != null) - .Cast() - .ToArray(); + .Cast()]; break; case "ExcludeNamespaces": - config.ExcludeNamespaces = namedArg.Value.Values + config.ExcludeNamespaces = [.. namedArg.Value.Values .Select(v => v.Value as string) .Where(v => v != null) - .Cast() - .ToArray(); + .Cast()]; break; } @@ -139,13 +137,13 @@ private static void CollectTypes(INamespaceSymbol ns, List types) { continue; } - - if (type.TypeKind is TypeKind.Class or TypeKind.Struct) + + if (type is {TypeKind: TypeKind.Class or TypeKind.Struct, DeclaredAccessibility: Accessibility.Public}) { var properties = type.GetMembers() .OfType() .Where(p => p.DeclaredAccessibility == Accessibility.Public && p.GetMethod != null) - .Select(p => new PropertyInfo(p.Name)) + .Select(p => new PropertyInfo(p.Name, IsNullableProperty(p))) .ToImmutableArray(); if (properties.Length > 0) @@ -183,7 +181,7 @@ private static void CollectNestedTypes(INamedTypeSymbol type, List typ var properties = type.GetMembers() .OfType() .Where(p => p.DeclaredAccessibility == Accessibility.Public && p.GetMethod != null) - .Select(p => new PropertyInfo(p.Name)) + .Select(p => new PropertyInfo(p.Name, IsNullableProperty(p))) .ToImmutableArray(); if (properties.Length > 0) @@ -238,7 +236,7 @@ private static void GenerateResolvers( .Where(t => ShouldIncludeType(t, config)) .SelectMany(t => t.Properties .Where(p => p.Name.Equals(config.PropertyName, StringComparison.OrdinalIgnoreCase)) - .Select(p => (TypeFullName: t.FullName, PropertyName: p.Name))) + .Select(p => (TypeFullName: t.FullName, PropertyName: p.Name, p.IsNullable))) .ToList(); if (matches.Count == 0) @@ -253,9 +251,10 @@ private static void GenerateResolvers( methods.AppendLine(" {"); - foreach (var (typeName, propertyName) in matches) + foreach (var (typeName, propertyName, isNullable) in matches) { - methods.AppendLine($" global::{typeName} x => x.{propertyName}.ToString(),"); + var toStringCall = isNullable ? "?.ToString()" : ".ToString()"; + methods.AppendLine($" global::{typeName} x => x.{propertyName}{toStringCall},"); } methods.AppendLine(" _ => null"); @@ -263,11 +262,6 @@ private static void GenerateResolvers( methods.AppendLine(); } - if (methods.Length == 0) - { - continue; - } - var code = $$""" // #nullable enable @@ -284,6 +278,23 @@ public static class {{className}} } } + private static bool IsNullableProperty(IPropertySymbol property) + { + // Nullable value type (e.g., int?, Guid?) + if (property.Type.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + return true; + } + + // Nullable reference type (e.g., string?, object?) + if (property.NullableAnnotation == NullableAnnotation.Annotated) + { + return true; + } + + return false; + } + private static bool ShouldIncludeType(TypeInfo type, ResolverConfig config) { var ns = type.Namespace; @@ -317,7 +328,7 @@ private sealed class ResolverConfig public string ResolverClassName => $"{PropertyName}Resolver"; } - private record struct PropertyInfo(string Name); + private record struct PropertyInfo(string Name, bool IsNullable); private record struct TypeInfo(string FullName, string Namespace, ImmutableArray Properties); } diff --git a/tests/PropertyResolvers.Tests/PropertyResolverGeneratorTests.cs b/tests/PropertyResolvers.Tests/PropertyResolverGeneratorTests.cs index c06c55b..d073d1e 100644 --- a/tests/PropertyResolvers.Tests/PropertyResolverGeneratorTests.cs +++ b/tests/PropertyResolvers.Tests/PropertyResolverGeneratorTests.cs @@ -191,7 +191,7 @@ public class Customer } [Fact] - public void GeneratorWithNoMatchingTypesDoesNotGenerateResolver() + public void GeneratorWithNoMatchingTypesGeneratesEmptyResolver() { const string source = """ @@ -213,7 +213,11 @@ public class Order var generatedFile = result.GeneratedTrees .FirstOrDefault(t => t.FilePath.EndsWith("NonExistentPropertyResolver.g.cs", StringComparison.Ordinal)); - Assert.Null(generatedFile); + Assert.NotNull(generatedFile); + + var generatedCode = generatedFile.GetText().ToString(); + Assert.Contains("public static class NonExistentPropertyResolver", generatedCode); + Assert.DoesNotContain("public static string?", generatedCode); } [Fact] @@ -414,7 +418,7 @@ public class NonGenericEntity } [Fact] - public void GeneratorWithOnlyGenericTypesGeneratesNoResolver() + public void GeneratorWithOnlyGenericTypesGeneratesEmptyResolver() { const string source = """ @@ -438,10 +442,102 @@ public class AnotherGeneric var result = RunGenerator(source); - // No resolver should be generated since all matching types are generic var generatedFile = result.GeneratedTrees .FirstOrDefault(t => t.FilePath.EndsWith("AccountIdResolver.g.cs", StringComparison.Ordinal)); - Assert.Null(generatedFile); + Assert.NotNull(generatedFile); + + var generatedCode = generatedFile.GetText().ToString(); + Assert.Contains("public static class AccountIdResolver", generatedCode); + Assert.DoesNotContain("public static string?", generatedCode); + } + + [Fact] + public void GeneratorWithNullableReferenceTypeUsesNullConditional() + { + const string source = """ + + #nullable enable + using PropertyResolvers.Attributes; + + [assembly: GeneratePropertyResolver("AccountId")] + + namespace TestNamespace + { + public class Order + { + public string? AccountId { get; set; } + } + } + """; + + var result = RunGenerator(source); + + var generatedFile = result.GeneratedTrees + .FirstOrDefault(t => t.FilePath.EndsWith("AccountIdResolver.g.cs", StringComparison.Ordinal)); + + Assert.NotNull(generatedFile); + + var generatedCode = generatedFile.GetText().ToString(); + Assert.Contains("x.AccountId?.ToString()", generatedCode); + } + + [Fact] + public void GeneratorWithNullableValueTypeUsesNullConditional() + { + const string source = """ + + using PropertyResolvers.Attributes; + + [assembly: GeneratePropertyResolver("Amount")] + + namespace TestNamespace + { + public class Order + { + public int? Amount { get; set; } + } + } + """; + + var result = RunGenerator(source); + + var generatedFile = result.GeneratedTrees + .FirstOrDefault(t => t.FilePath.EndsWith("AmountResolver.g.cs", StringComparison.Ordinal)); + + Assert.NotNull(generatedFile); + + var generatedCode = generatedFile.GetText().ToString(); + Assert.Contains("x.Amount?.ToString()", generatedCode); + } + + [Fact] + public void GeneratorWithNonNullableTypeUsesToString() + { + const string source = """ + + using PropertyResolvers.Attributes; + + [assembly: GeneratePropertyResolver("AccountId")] + + namespace TestNamespace + { + public class Order + { + public string AccountId { get; set; } + } + } + """; + + var result = RunGenerator(source); + + var generatedFile = result.GeneratedTrees + .FirstOrDefault(t => t.FilePath.EndsWith("AccountIdResolver.g.cs", StringComparison.Ordinal)); + + Assert.NotNull(generatedFile); + + var generatedCode = generatedFile.GetText().ToString(); + Assert.Contains("x.AccountId.ToString()", generatedCode); + Assert.DoesNotContain("x.AccountId?.ToString()", generatedCode); } } \ No newline at end of file