Search code examples
c#roslynsourcegeneratorscsharp-source-generator

Can't access arguments of attribute from system library using source generator


I am trying to make a source generator for mapping columns from the google bigquery api client to class properties. I'm having trouble getting custom column names from a ColumnAttribute on the properties. ConstructorArguments is always empty and columnAttribute.AttributeClass in this sample is always an ErrorTypeSymbol. If I try to load that type using compilation.GetTypeByMetadataName("System.ComponentModel.DataAnnotations.Schema.ColumnAttribute") the result is always null.

using System.Collections.Immutable;
using System.Diagnostics;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace BigQueryMapping;

[Generator]
public class BigQueryMapperGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // add marker attribute
        context.RegisterPostInitializationOutput(ctx =>
            ctx.AddSource("BigQueryMappedAttribute.g.cs", SourceText.From(Attribute, Encoding.UTF8)));

        // add static interface
        context.RegisterPostInitializationOutput(ctx =>
            ctx.AddSource("BigQueryMappedInterface.g.cs", SourceText.From(Interface, Encoding.UTF8)));

        // get classes
        IncrementalValuesProvider<ClassDeclarationSyntax> classDeclarations = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (s, _) => s is ClassDeclarationSyntax c && c.AttributeLists.Any(),
                transform: static (ctx, _) => GetSemanticTargetForGeneration(ctx)
            )
            .Where(static m => m is not null)!;

        IncrementalValueProvider<(Compilation Compilation, ImmutableArray<ClassDeclarationSyntax>Syntaxes)>
            compilationAndClasses = context.CompilationProvider.Combine(classDeclarations.Collect());

        context.RegisterSourceOutput(compilationAndClasses,
            static (spc, source) => Execute(source.Compilation, source.Syntaxes, spc));

        static ClassDeclarationSyntax? GetSemanticTargetForGeneration(GeneratorSyntaxContext context)
        {
            var classDeclarationSyntax = (ClassDeclarationSyntax)context.Node;

            foreach (var attributeListSyntax in classDeclarationSyntax.AttributeLists)
            {
                foreach (var attributeSyntax in attributeListSyntax.Attributes)
                {
                    var fullName = context.SemanticModel.GetTypeInfo(attributeSyntax).Type?.ToDisplayString();

                    if (fullName == "BigQueryMapping.BigQueryMappedAttribute")
                        return classDeclarationSyntax;
                }
            }

            return null;
        }

        static void Execute(Compilation compilation, ImmutableArray<ClassDeclarationSyntax> classes,
            SourceProductionContext context)
        {
            try
            {
                if (classes.IsDefaultOrEmpty)
                    return;

                var distinctClasses = classes.Distinct();

                var classesToGenerate = GetTypesToGenerate(compilation, distinctClasses, context.CancellationToken);

                foreach (var classToGenerate in classesToGenerate)
                {
                    var result = GeneratePartialClass(classToGenerate);
                    context.AddSource($"{classToGenerate.RowClass.Name}.g.cs", SourceText.From(result, Encoding.UTF8));
                }
            }
            catch (Exception e)
            {
                var descriptor = new DiagnosticDescriptor(id: "BQD001",
                    title: "Error creating bigquery mapper",
                    messageFormat: "{0} {1}",
                    category: "BigQueryMapperGenerator",
                    DiagnosticSeverity.Error,
                    isEnabledByDefault: true);

                context.ReportDiagnostic(Diagnostic.Create(descriptor, null, e.Message, e.StackTrace));
            }
        }
    }

    static IEnumerable<ClassToGenerate> GetTypesToGenerate(Compilation compilation,
        IEnumerable<ClassDeclarationSyntax> classes,
        CancellationToken ct)
    {
        var columnAttributeSymbol =
            compilation.GetTypeByMetadataName("System.ComponentModel.DataAnnotations.Schema.ColumnAttribute");

        foreach (var @class in classes)
        {
            Debug.WriteLine($"Checking class {@class}");
            ct.ThrowIfCancellationRequested();
            var semanticModel = compilation.GetSemanticModel(@class.SyntaxTree);
            if (semanticModel.GetDeclaredSymbol(@class) is not INamedTypeSymbol classSymbol)
                continue;

            var info = new ClassToGenerate(classSymbol, new());
            foreach (var member in classSymbol.GetMembers())
            {
                if (member is IPropertySymbol propertySymbol)
                {
                    if (propertySymbol.DeclaredAccessibility == Accessibility.Public)
                    {
                        if (propertySymbol.SetMethod is not null)
                        {
                            var columnName = propertySymbol.Name;
                            var columnAttribute = propertySymbol.GetAttributes().FirstOrDefault(a =>
                                a.AttributeClass!.ToDisplayString() == "Column");
                            if (columnAttribute is not null)
                            {
                                if (!columnAttribute.ConstructorArguments.IsDefaultOrEmpty)
                                {
                                    var nameArg = columnAttribute.ConstructorArguments.First();
                                    if (nameArg.Value is string name)
                                    {
                                        columnName = name;
                                    }
                                }
                            }

                            info.Properties.Add((columnName, propertySymbol));
                        }
                    }
                }
            }

            yield return info;
        }
    }


    static string GeneratePartialClass(ClassToGenerate c)
    {
        var sb = new StringBuilder();
        sb.Append($@"// <auto-generated/>
namespace {c.RowClass.ContainingNamespace.ToDisplayString()}
{{
    public partial class {c.RowClass.Name} : BigQueryMapping.IBigQueryGenerated<{c.RowClass.Name}>
    {{
        public static {c.RowClass.Name} FromBigQueryRow(Google.Cloud.BigQuery.V2.BigQueryRow row)
        {{
            return new {c.RowClass.Name}
            {{");

        foreach (var (columnName, property) in c.Properties)
        {
            // would like to check if key exists but don't see any sort of ContainsKey implemented on BigQueryRow
            var tempName = $"___{property.Name}";
            var basePropertyType = property.Type.WithNullableAnnotation(NullableAnnotation.None).ToDisplayString();
            if (basePropertyType.EndsWith("?"))
            {
                basePropertyType = basePropertyType.Substring(default, basePropertyType.Length - 1);
            }

            sb.Append($@"
                {property.Name} = row[""{columnName}""] is {basePropertyType} {tempName} ? {tempName} : default,");
        }

        sb.Append($@"
            }};
        }}
    }}
}}");
        return sb.ToString();
    }

    private record struct ClassToGenerate(INamedTypeSymbol RowClass,
        List<(string ColumnName, IPropertySymbol Property)> Properties);

    public const string Attribute = /* lang=csharp */ @"// <auto-generated/>
namespace BigQueryMapping {
    [System.AttributeUsage(System.AttributeTargets.Class)]
    public class BigQueryMappedAttribute : System.Attribute
    {
    }
}";

    public const string Interface = /* lang=csharp */ @"// <auto-generated/>
namespace BigQueryMapping {
    public interface IBigQueryGenerated<TRow> {
        static TRow FromBigQueryRow(Google.Cloud.BigQuery.V2.BigQueryRow row) => throw new System.NotImplementedException();
    }
}";
}

I have tried this with both System.ComponentModel.DataAnnotations.Schema.ColumnAttribute and a custom attribute injected via context.RegisterPostInitializationOutput to similar results. I have also tried rewriting this to use ISourceGenerator instead of IIncrementalGenerator and gotten the same behavior. Am wondering what I need to do to get columnAttribute loading correctly.

Thanks for any help in advance


Solution

  • It's hard to psychic debug the code but this does jump out:

                            var columnAttribute = propertySymbol.GetAttributes().FirstOrDefault(a =>
                               a.AttributeClass!.ToDisplayString() == "Column");
    

    I'd guess the class here would be the fully qualified name. You already fetched the ColumnAttribute type earlier, so what'd be even better is to compare the AttributeClass to that type rather than doing string checks like this.

    As a semi-related comment, if you're looking for types/members that are annotated with a specific attribute, rather than doing it yourself we have SyntaxValueProvider.ForAttributeWithMetadataName which is pretty heavily optimized to reduce the performance impact on your Visual Studio. It requires 17.3 or higher, but as long as you're OK with that it'll generally help performance.