Repository navigation
[Analyzer] Add analyzer for conflicting [ID] attribute usage #9828
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
224 changes: 224 additions & 0 deletions
224
src/HotChocolate/Core/src/Types.Analyzers/ConflictingIdAttributeAnalyzer.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| using System.Collections.Immutable; | ||
| using Microsoft.CodeAnalysis; | ||
| using Microsoft.CodeAnalysis.Diagnostics; | ||
|
|
||
| namespace HotChocolate.Types.Analyzers; | ||
|
|
||
| [DiagnosticAnalyzer(LanguageNames.CSharp)] | ||
| public sealed class ConflictingIdAttributeAnalyzer : DiagnosticAnalyzer | ||
| { | ||
| // Every [ID] member without a type name shares the declaring type's inferred GraphQL type name. | ||
| // Since each named type is analyzed in isolation, a single constant key is sufficient. | ||
| private const string InferredTypeNameKey = "inferred"; | ||
|
|
||
| public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = | ||
| [Errors.ConflictingIdAttribute]; | ||
|
|
||
| public override void Initialize(AnalysisContext context) | ||
| { | ||
| context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); | ||
| context.EnableConcurrentExecution(); | ||
| context.RegisterSymbolAction(AnalyzeNamedType, SymbolKind.NamedType); | ||
| } | ||
|
|
||
| private static void AnalyzeNamedType(SymbolAnalysisContext context) | ||
| { | ||
| var namedType = (INamedTypeSymbol)context.Symbol; | ||
|
|
||
| if (namedType.TypeKind is not (TypeKind.Class or TypeKind.Struct or TypeKind.Interface)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| // Group all [ID] members of the type by their effective GraphQL type-name key, | ||
| // preserving source order within each group. | ||
| Dictionary<string, List<IdMember>>? groups = null; | ||
|
|
||
| foreach (var member in namedType.GetMembers()) | ||
| { | ||
| ITypeSymbol? memberType = member switch | ||
| { | ||
| IPropertySymbol property => property.Type, | ||
| IMethodSymbol { MethodKind: MethodKind.Ordinary } method => method.ReturnType, | ||
| _ => null | ||
| }; | ||
|
|
||
| if (memberType is null) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| var idAttribute = GetIdAttribute(member); | ||
| if (idAttribute is null) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| var idMember = new IdMember(member, UnwrapRuntimeIdType(memberType), idAttribute.Value.Attribute); | ||
|
|
||
| groups ??= []; | ||
| if (!groups.TryGetValue(idAttribute.Value.TypeNameKey, out var members)) | ||
| { | ||
| members = []; | ||
| groups[idAttribute.Value.TypeNameKey] = members; | ||
| } | ||
|
|
||
| members.Add(idMember); | ||
| } | ||
|
|
||
| if (groups is null) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| foreach (var group in groups.Values) | ||
| { | ||
| AnalyzeGroup(context, group); | ||
| } | ||
| } | ||
|
|
||
| private static void AnalyzeGroup(SymbolAnalysisContext context, List<IdMember> group) | ||
| { | ||
| if (group.Count < 2) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| // The authoritative member is the [ID] member named "Id" (case-insensitive) | ||
| // if one exists, otherwise the first [ID] member in source order. | ||
| var authoritative = group[0]; | ||
| foreach (var candidate in group) | ||
| { | ||
| if (string.Equals(candidate.Member.Name, "Id", StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| authoritative = candidate; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| foreach (var idMember in group) | ||
| { | ||
| if (ReferenceEquals(idMember, authoritative)) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| if (SymbolEqualityComparer.Default.Equals(idMember.RuntimeIdType, authoritative.RuntimeIdType)) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| var syntaxReference = idMember.Attribute.ApplicationSyntaxReference; | ||
| if (syntaxReference is null) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| var diagnostic = Diagnostic.Create( | ||
| Errors.ConflictingIdAttribute, | ||
| syntaxReference.GetSyntax(context.CancellationToken).GetLocation(), | ||
| idMember.Member.Name, | ||
| authoritative.Member.Name, | ||
| authoritative.RuntimeIdType.ToDisplayString()); | ||
|
|
||
| context.ReportDiagnostic(diagnostic); | ||
| } | ||
| } | ||
|
|
||
| private static (string TypeNameKey, AttributeData Attribute)? GetIdAttribute(ISymbol member) | ||
| { | ||
| foreach (var attribute in member.GetAttributes()) | ||
| { | ||
| var attributeClass = attribute.AttributeClass; | ||
| if (attributeClass is null) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| if (attributeClass.Name != "IDAttribute" | ||
| || attributeClass.ContainingNamespace?.ToDisplayString() != "HotChocolate.Types.Relay") | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| // The generic IDAttribute<T> derives its type name from the type argument. | ||
| if (attributeClass is { IsGenericType: true, TypeArguments.Length: 1 }) | ||
| { | ||
| return ("generic:" + attributeClass.TypeArguments[0].ToDisplayString(), attribute); | ||
| } | ||
|
|
||
| // An explicit string type name forms its own group. | ||
| if (attribute.ConstructorArguments.Length > 0 | ||
| && attribute.ConstructorArguments[0].Value is string typeName) | ||
| { | ||
| return ("literal:" + typeName, attribute); | ||
| } | ||
|
|
||
| // No type name was supplied, so the GraphQL type name is inferred from the | ||
| // declaring type and shared across all such [ID] members of that type. | ||
| return (InferredTypeNameKey, attribute); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| private static ITypeSymbol UnwrapRuntimeIdType(ITypeSymbol type) | ||
| { | ||
| while (true) | ||
| { | ||
| if (type is IArrayTypeSymbol arrayType) | ||
| { | ||
| type = arrayType.ElementType; | ||
| continue; | ||
| } | ||
|
|
||
| if (type is INamedTypeSymbol { IsGenericType: true } namedType) | ||
| { | ||
| var constructedFrom = namedType.ConstructedFrom.ToDisplayString(); | ||
|
|
||
| if (constructedFrom is "System.Threading.Tasks.Task<TResult>" | ||
| or "System.Threading.Tasks.ValueTask<TResult>" | ||
| or "System.Nullable<T>") | ||
| { | ||
| type = namedType.TypeArguments[0]; | ||
| continue; | ||
| } | ||
|
|
||
| var elementType = GetEnumerableElementType(namedType); | ||
| if (elementType is not null) | ||
| { | ||
| type = elementType; | ||
| continue; | ||
| } | ||
| } | ||
|
|
||
| return type; | ||
| } | ||
| } | ||
|
|
||
| private static ITypeSymbol? GetEnumerableElementType(INamedTypeSymbol namedType) | ||
| { | ||
| // Strings implement IEnumerable<char> but must be treated as scalar IDs. | ||
| if (namedType.SpecialType == SpecialType.System_String) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| if (namedType.ConstructedFrom.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T) | ||
| { | ||
| return namedType.TypeArguments[0]; | ||
| } | ||
|
|
||
| foreach (var @interface in namedType.AllInterfaces) | ||
| { | ||
| if (@interface.ConstructedFrom.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T) | ||
| { | ||
| return @interface.TypeArguments[0]; | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| private sealed record IdMember(ISymbol Member, ITypeSymbol RuntimeIdType, AttributeData Attribute); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
9 changes: 9 additions & 0 deletions
9
src/HotChocolate/Core/src/Types.Analyzers/Properties/SourceGenResources.Designer.cs
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.