Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
49 changes: 30 additions & 19 deletions src/PropertyResolvers.Generators/PropertyResolverGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,19 +99,17 @@ private static ImmutableArray<ResolverConfig> 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<string>()
.ToArray();
.Cast<string>()];
break;

case "ExcludeNamespaces":
config.ExcludeNamespaces = namedArg.Value.Values
config.ExcludeNamespaces = [.. namedArg.Value.Values
.Select(v => v.Value as string)
.Where(v => v != null)
.Cast<string>()
.ToArray();
.Cast<string>()];
break;

}
Expand Down Expand Up @@ -139,13 +137,13 @@ private static void CollectTypes(INamespaceSymbol ns, List<TypeInfo> 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<IPropertySymbol>()
.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)
Expand Down Expand Up @@ -183,7 +181,7 @@ private static void CollectNestedTypes(INamedTypeSymbol type, List<TypeInfo> typ
var properties = type.GetMembers()
.OfType<IPropertySymbol>()
.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)
Expand Down Expand Up @@ -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)
Expand All @@ -253,21 +251,17 @@ 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");
methods.AppendLine(" };");
methods.AppendLine();
}

if (methods.Length == 0)
{
continue;
}

var code = $$"""
// <auto-generated/>
#nullable enable
Expand All @@ -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;
Expand Down Expand Up @@ -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<PropertyInfo> Properties);
}
106 changes: 101 additions & 5 deletions tests/PropertyResolvers.Tests/PropertyResolverGeneratorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ public class Customer
}

[Fact]
public void GeneratorWithNoMatchingTypesDoesNotGenerateResolver()
public void GeneratorWithNoMatchingTypesGeneratesEmptyResolver()
{
const string source = """

Expand All @@ -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]
Expand Down Expand Up @@ -414,7 +418,7 @@ public class NonGenericEntity
}

[Fact]
public void GeneratorWithOnlyGenericTypesGeneratesNoResolver()
public void GeneratorWithOnlyGenericTypesGeneratesEmptyResolver()
{
const string source = """

Expand All @@ -438,10 +442,102 @@ public class AnotherGeneric<TKey, TValue>

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);
}
}