From 88d31383cd906a0fcb5db9c15452676c9db16bbe Mon Sep 17 00:00:00 2001 From: Mike Pringle Date: Mon, 6 Oct 2025 22:21:47 -0400 Subject: [PATCH 1/4] Updates to source generation --- .gitignore | 3 ++ .../Emitter/QueryEmitter.cs | 54 +++++++------------ .../Emitter/SourceBuilder.cs | 52 +++++++++++++----- src/QueryByShape.Analyzer/NamedTypeSymbols.cs | 52 +++++------------- .../Parser/QueryParser.cs | 10 ++-- src/QueryByShape.Analyzer/QueryMetadata.cs | 4 +- ...cludesFieldsWhenNotConfigured.verified.txt | 2 +- ...shotTests.GeneratesBasicQuery.verified.txt | 2 +- ...GeneratesFieldsWhenConfigured.verified.txt | 2 +- ...apshotTests.GeneratesFragment.verified.txt | 2 +- ...hotTests.GeneratesRecordQuery.verified.txt | 2 +- ...GeneratesWithJsonPropertyName.verified.txt | 2 +- ...apshotTests.SupportsQueryName.verified.txt | 2 +- 13 files changed, 90 insertions(+), 99 deletions(-) diff --git a/.gitignore b/.gitignore index 8a30d25..cca999b 100644 --- a/.gitignore +++ b/.gitignore @@ -396,3 +396,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml + +# Snapshot tests +*.received.txt \ No newline at end of file diff --git a/src/QueryByShape.Analyzer/Emitter/QueryEmitter.cs b/src/QueryByShape.Analyzer/Emitter/QueryEmitter.cs index 7a7dd38..b17f140 100644 --- a/src/QueryByShape.Analyzer/Emitter/QueryEmitter.cs +++ b/src/QueryByShape.Analyzer/Emitter/QueryEmitter.cs @@ -1,9 +1,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -using QueryByShape.Analyzer.Diagnostics; using System.Collections.Generic; using System.Linq; -using System.Net.Http.Headers; using System.Text; using System.Text.Json; @@ -28,14 +26,13 @@ public static void EmitSource(SourceProductionContext ctx, ParseResult result) } private string EmitQuery() { - // variables dynamic or static set? use case - // variables with object type (sorting) - _sb.Append("query "); _sb.Append(FormatName(query.Name ?? query.TypeName)); - EmitParameters(query.Variables?.Select(v => v.DefaultValue == null ? $"{v.Name}:{v.GraphType}" : $"{v.Name}:{v.GraphType} = {JsonSerializer.Serialize(v.DefaultValue)}")); - EmitType(query.Type!, query.Options, 1); + var variables = query.Variables?.Select(v => v.DefaultValue == null ? $"{v.Name}:{v.GraphType}" : $"{v.Name}:{v.GraphType} = {JsonSerializer.Serialize(v.DefaultValue)}").ToArray(); + _sb.AppendParentheses(variables); + + EmitType(query.Type, query.Options); return _sb.ToString(); } @@ -50,7 +47,7 @@ private string FormatName(string name) return name; } - private void EmitType(TypeMetadata typeMetadata, QueryOptions options, int depth) + private void EmitType(TypeMetadata typeMetadata, QueryOptions options) { if (typeMetadata.Members is null) { @@ -59,9 +56,9 @@ private void EmitType(TypeMetadata typeMetadata, QueryOptions options, int depth var (members, fragmentMembers) = typeMetadata.Members.Value.Partition(m => m.On?.Count is not > 0); - _sb.AppendLine("{"); + _sb.AppendStartBlock(); - EmitMembers(members, options, depth); + EmitMembers(members, options); if (fragmentMembers.Count > 0) { @@ -71,22 +68,24 @@ private void EmitType(TypeMetadata typeMetadata, QueryOptions options, int depth foreach (var fragment in fragments) { - _sb.AppendLine($"... on {fragment.Key} {{", depth); - EmitMembers(fragment, options, depth + 1); - _sb.AppendLine("}", depth); + _sb.AppendLine(); + _sb.Append($"... on {fragment.Key}"); + _sb.AppendStartBlock(); + EmitMembers(fragment, options); + _sb.AppendEndBlock(); } } - _sb.AppendLine("}", depth - 1); + _sb.AppendEndBlock(); } - private void EmitMembers(IEnumerable members, QueryOptions options, int depth) + private void EmitMembers(IEnumerable members, QueryOptions options) { var filtered = options.IncludeFields == false ? members.Where(m => m.Kind == SymbolKind.Property) : members; foreach (var member in filtered) { - _sb.AppendIndent(depth); + _sb.AppendLine(); _sb.Append(member.OverrideName ?? FormatName(member.Name)); if (member.AliasOf != null) @@ -95,29 +94,14 @@ private void EmitMembers(IEnumerable members, QueryOptions optio _sb.Append(member.AliasOf); } - EmitParameters(member.Arguments?.Select(a => $"{a.Name}:{a.VariableName}")); - + var arguments = member.Arguments?.Select(a => $"{a.Name}:{a.VariableName}").ToArray(); + _sb.AppendParentheses(arguments); + if (member.ChildrenType?.Members?.Count > 0) { - EmitType(member.ChildrenType, options, depth + 1); + EmitType(member.ChildrenType, options); } - else - { - _sb.AppendLine(string.Empty); - } - } - } - - internal void EmitParameters(IEnumerable? items) - { - if (items == null || !items.Any()) - { - return; } - - _sb.Append("("); - _sb.Append(string.Join(",", items)); - _sb.Append(")"); } } } diff --git a/src/QueryByShape.Analyzer/Emitter/SourceBuilder.cs b/src/QueryByShape.Analyzer/Emitter/SourceBuilder.cs index 2224aa8..2ec6815 100644 --- a/src/QueryByShape.Analyzer/Emitter/SourceBuilder.cs +++ b/src/QueryByShape.Analyzer/Emitter/SourceBuilder.cs @@ -1,31 +1,41 @@ -using System.Text; +using System; +using System.Text; +using static System.Net.Mime.MediaTypeNames; namespace QueryByShape.Analyzer { internal class SourceBuilder(SourceFormatting formatting) { - private StringBuilder sb = new StringBuilder(); - - public void Append(string text) + private readonly StringBuilder sb = new StringBuilder(); + private int depth = 0; + + internal void AppendStartBlock() { - sb.Append(text); + sb.Append(" {"); + depth++; } - public void AppendLine(string text, int depth = 0) + internal void AppendEndBlock() + { + depth--; + AppendLine(); + sb.Append("}"); + } + + internal void AppendLine() { - AppendIndent(depth); - if (formatting == SourceFormatting.Minified) { - sb.Append(text); + sb.Append(" "); } else { - sb.AppendLine(text); + sb.AppendLine(); + AppendIndent(); } } - public void AppendIndent(int depth) + internal void AppendIndent() { if (formatting == SourceFormatting.Minified) { @@ -33,10 +43,28 @@ public void AppendIndent(int depth) } else if (depth > 0) { - sb.Append(' ', depth * 4); + sb.Append(' ', depth * 4); + } + } + + internal void Append(string text) + { + sb.Append(text); + } + + internal void AppendParentheses(string[]? values) + { + if (values == null || values.Length == 0) + { + return; } + + sb.Append("("); + sb.AppendJoin(",", values); + sb.Append(")"); } public override string ToString() => sb.ToString(); } + } diff --git a/src/QueryByShape.Analyzer/NamedTypeSymbols.cs b/src/QueryByShape.Analyzer/NamedTypeSymbols.cs index 632dbba..3f2cf03 100644 --- a/src/QueryByShape.Analyzer/NamedTypeSymbols.cs +++ b/src/QueryByShape.Analyzer/NamedTypeSymbols.cs @@ -1,54 +1,30 @@ using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Editing; -using System; using System.Collections.Generic; using System.Reflection; namespace QueryByShape.Analyzer { - public struct LazySymbol(Func creator) + public class NamedTypeSymbols(Compilation compilation) { - private INamedTypeSymbol? _value = null; - public INamedTypeSymbol Value => _value ?? (_value = creator()); - } - public class NamedTypeSymbols - { - private readonly Compilation _compilation; - - public NamedTypeSymbols(Compilation compilation) - { - _compilation = compilation; - _delegate = new (() => ResolveNamedType(typeof(Delegate).FullName)); - _iEnumerableOfT = new(() => ResolveNamedType(typeof(IEnumerable<>).FullName)); - _iDictionaryOfKV = new (() => ResolveNamedType(typeof(IDictionary<,>).FullName)); - _intPtr = new (() => ResolveNamedType(typeof(IntPtr).FullName)); - _memberInfo = new (() => ResolveNamedType(typeof(MemberInfo).FullName)); - _uIntPtr = new (() => ResolveNamedType(typeof(UIntPtr).FullName)); - } + public INamedTypeSymbol Delegate => _delegate ??= compilation.GetSpecialType(SpecialType.System_Delegate); + private INamedTypeSymbol? _delegate; - public INamedTypeSymbol Delegate => _delegate.Value; - private readonly LazySymbol _delegate; + public INamedTypeSymbol IEnumerableOfT => _iEnumerableOfT ??= compilation.GetSpecialType(SpecialType.System_Collections_Generic_IEnumerable_T); + private INamedTypeSymbol? _iEnumerableOfT; - public INamedTypeSymbol IEnumerableOfT => _iEnumerableOfT.Value; - private readonly LazySymbol _iEnumerableOfT; + public INamedTypeSymbol IDictionaryOfKV => _iDictionaryOfKV ??= compilation.GetTypeByMetadataName(typeof(IDictionary<,>).FullName)!; + private INamedTypeSymbol? _iDictionaryOfKV; - public INamedTypeSymbol IDictionaryOfKV => _iDictionaryOfKV.Value; - private readonly LazySymbol _iDictionaryOfKV; + public INamedTypeSymbol IntPtr => _intPtr ??= compilation.GetSpecialType(SpecialType.System_IntPtr); + private INamedTypeSymbol? _intPtr; - public INamedTypeSymbol IntPtr => _intPtr.Value; - private readonly LazySymbol _intPtr; + public INamedTypeSymbol MemberInfo => _memberInfo ??= compilation.GetTypeByMetadataName(typeof(MemberInfo).FullName)!; + private INamedTypeSymbol? _memberInfo; - public INamedTypeSymbol MemberInfo => _memberInfo.Value; - private readonly LazySymbol _memberInfo; - - public INamedTypeSymbol UIntPtr => _uIntPtr.Value; - private readonly LazySymbol _uIntPtr; - - private INamedTypeSymbol ResolveNamedType(string name) - { - return _compilation.GetTypeByMetadataName(name)!; - } + public INamedTypeSymbol UIntPtr => _uIntPtr ??= compilation.GetSpecialType(SpecialType.System_UIntPtr); + private INamedTypeSymbol? _uIntPtr; + public bool IsPropertySerializable(IPropertySymbol property) { return ( diff --git a/src/QueryByShape.Analyzer/Parser/QueryParser.cs b/src/QueryByShape.Analyzer/Parser/QueryParser.cs index eb6942a..316cbbb 100644 --- a/src/QueryByShape.Analyzer/Parser/QueryParser.cs +++ b/src/QueryByShape.Analyzer/Parser/QueryParser.cs @@ -47,8 +47,10 @@ public ParseResult Parse(TypeDeclarationSyntax typeDeclaration, INamedTypeSymbol //_diagnostics.Add(QueryMustBePartialDiagnostic.Create(declaredSymbol.Name, typeDeclaration.GetLocation())); } - var query = new QueryMetadata(declaredSymbol.Name, declaredSymbol.GetNamespace()); - query.Type = ParseTypeMetadata(declaredSymbol, query.Options, out var queryArguments); + var types = ParseTypeMetadata(declaredSymbol, out var queryArguments); + + var query = new QueryMetadata(declaredSymbol.Name, declaredSymbol.GetNamespace(), types); + UpdateQueryFromAttributes(query, declaredSymbol.GetAttributes()); ValidateVariables(query.Variables, queryArguments); @@ -124,7 +126,7 @@ private void UpdateQueryFromAttributes(QueryMetadata query, ImmutableArray childArguments) + private TypeMetadata ParseTypeMetadata(INamedTypeSymbol type, out List childArguments) { INamedTypeSymbol? current = type; var name = type.ToDisplayString(); @@ -157,7 +159,7 @@ private TypeMetadata ParseTypeMetadata(INamedTypeSymbol type, QueryOptions optio if (symbols.TryGetChildrenType(memberType!, out var childrenType)) { - metadata.ChildrenType = ParseTypeMetadata(childrenType!, options, out var innerChildArguments); + metadata.ChildrenType = ParseTypeMetadata(childrenType!, out var innerChildArguments); childArguments.AddRange(innerChildArguments); } diff --git a/src/QueryByShape.Analyzer/QueryMetadata.cs b/src/QueryByShape.Analyzer/QueryMetadata.cs index 17a1e40..0151560 100644 --- a/src/QueryByShape.Analyzer/QueryMetadata.cs +++ b/src/QueryByShape.Analyzer/QueryMetadata.cs @@ -15,7 +15,7 @@ internal record QueryOptions() internal record DiagnosticMetadata(DiagnosticDescriptor Descriptor, Location Location, EquatableArray? MessageArguments = null); - internal record QueryMetadata(string TypeName, string NamespaceName) + internal record QueryMetadata(string TypeName, string NamespaceName, TypeMetadata Type) { public string? Name { get; set; } @@ -23,8 +23,6 @@ internal record QueryMetadata(string TypeName, string NamespaceName) public EquatableArray? Variables { get; set; } - public TypeMetadata? Type { get; set; } - public QueryOptions Options { get; set; } = new QueryOptions(); } diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.ExcludesFieldsWhenNotConfigured.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.ExcludesFieldsWhenNotConfigured.verified.txt index 0299e01..546d854 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.ExcludesFieldsWhenNotConfigured.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.ExcludesFieldsWhenNotConfigured.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query nameQuery { people { preferredName } }"; + public static string ToGraphQLQuery() => @"query nameQuery { people { preferredName } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesBasicQuery.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesBasicQuery.verified.txt index 75adf4f..e4e6225 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesBasicQuery.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesBasicQuery.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query nameQuery { people { name } }"; + public static string ToGraphQLQuery() => @"query nameQuery { people { name } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesFieldsWhenConfigured.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesFieldsWhenConfigured.verified.txt index b33dfc3..ab6ebed 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesFieldsWhenConfigured.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesFieldsWhenConfigured.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query nameQuery { people { firstName lastName middleName } }"; + public static string ToGraphQLQuery() => @"query nameQuery { people { firstName lastName middleName } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesFragment.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesFragment.verified.txt index d10a4c2..dd7c7b4 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesFragment.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesFragment.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query nameQuery { people { firstName lastName middleName ... on Employee { employeeId } } }"; + public static string ToGraphQLQuery() => @"query nameQuery { people { firstName lastName middleName ... on Employee { employeeId } } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesRecordQuery.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesRecordQuery.verified.txt index 990a152..74398a6 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesRecordQuery.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesRecordQuery.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query nameQuery { people { name preferredName } }"; + public static string ToGraphQLQuery() => @"query nameQuery { people { name preferredName } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithJsonPropertyName.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithJsonPropertyName.verified.txt index 5185d14..58b2566 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithJsonPropertyName.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithJsonPropertyName.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query nameQuery { people { id firstName lastName middle_name } }"; + public static string ToGraphQLQuery() => @"query nameQuery { people { id firstName lastName middle_name } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.SupportsQueryName.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.SupportsQueryName.verified.txt index 63f1b6b..ebbe24f 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.SupportsQueryName.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.SupportsQueryName.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query renamed { people { name } }"; + public static string ToGraphQLQuery() => @"query renamed { people { name } }"; } } \ No newline at end of file From c8dd7fde3ad71217cb93b6c91e9659df7fb7d4e7 Mon Sep 17 00:00:00 2001 From: Mike Pringle Date: Wed, 22 Oct 2025 21:05:44 -0400 Subject: [PATCH 2/4] Update Verified test results for source generator updates --- .../Snapshots/SnapshotTests.GeneratesAlias.verified.txt | 2 +- .../SnapshotTests.GeneratesArgumentsAndVariables.verified.txt | 2 +- ...ts.GeneratesArgumentsAndVariablesWithDefaults.verified.txt | 2 +- ...napshotTests.GeneratesComplexIEnumarableQuery.verified.txt | 2 +- .../SnapshotTests.GeneratesKitchenSinkQuery.verified.txt | 2 +- ....GeneratesMultipleQueriesWithDifferentOptions.verified.txt | 4 ++-- ...s.GeneratesMultipleQueriesWithSharedVariables.verified.txt | 4 ++-- .../SnapshotTests.GeneratesWithJsonIgnore.verified.txt | 2 +- .../SnapshotTests.GeneratesWithStructQuery.verified.txt | 2 +- 9 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesAlias.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesAlias.verified.txt index f989364..ab5917c 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesAlias.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesAlias.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query nameQuery { people:customers { firstName lastName middleName:middle } }"; + public static string ToGraphQLQuery() => @"query nameQuery { people:customers { firstName lastName middleName:middle } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesArgumentsAndVariables.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesArgumentsAndVariables.verified.txt index bab5ee6..fa899ac 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesArgumentsAndVariables.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesArgumentsAndVariables.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query nameQuery($id:UUID!) { people(id:$id) { customerId firstName lastName middleName } }"; + public static string ToGraphQLQuery() => @"query nameQuery($id:UUID!) { people(id:$id) { customerId firstName lastName middleName } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesArgumentsAndVariablesWithDefaults.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesArgumentsAndVariablesWithDefaults.verified.txt index d47ac14..812cb61 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesArgumentsAndVariablesWithDefaults.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesArgumentsAndVariablesWithDefaults.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query nameQuery($id:UUID!,$isActive:Boolean = true) { people(id:$id,isActive:$isActive) { customerId firstName lastName middleName } }"; + public static string ToGraphQLQuery() => @"query nameQuery($id:UUID!,$isActive:Boolean = true) { people(id:$id,isActive:$isActive) { customerId firstName lastName middleName } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesComplexIEnumarableQuery.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesComplexIEnumarableQuery.verified.txt index 75adf4f..e4e6225 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesComplexIEnumarableQuery.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesComplexIEnumarableQuery.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query nameQuery { people { name } }"; + public static string ToGraphQLQuery() => @"query nameQuery { people { name } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesKitchenSinkQuery.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesKitchenSinkQuery.verified.txt index a48a033..48c9f0b 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesKitchenSinkQuery.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesKitchenSinkQuery.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query renamed($id:UUID!) { people(id:$id) { id:identity firstName lastName addressLine1 city state zip } }"; + public static string ToGraphQLQuery() => @"query renamed($id:UUID!) { people(id:$id) { id:identity firstName lastName addressLine1 city state zip } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithDifferentOptions.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithDifferentOptions.verified.txt index c69a50f..2ecf52d 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithDifferentOptions.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithDifferentOptions.verified.txt @@ -9,7 +9,7 @@ namespace Tests { public partial class FirstQuery { - public static string ToGraphQLQuery() => @"query firstQuery($id:UUID!) { people(id:$id) { customerId firstName lastName middleName addressLine1 city state zipCode } }"; + public static string ToGraphQLQuery() => @"query firstQuery($id:UUID!) { people(id:$id) { customerId firstName lastName middleName addressLine1 city state zipCode } }"; } } @@ -23,6 +23,6 @@ namespace Tests { public partial class SecondQuery { - public static string ToGraphQLQuery() => @"query secondQuery($id:UUID!) { people(id:$id) { customerId firstName lastName middleName } }"; + public static string ToGraphQLQuery() => @"query secondQuery($id:UUID!) { people(id:$id) { customerId firstName lastName middleName } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedVariables.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedVariables.verified.txt index ae4068b..b279aa1 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedVariables.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesMultipleQueriesWithSharedVariables.verified.txt @@ -9,7 +9,7 @@ namespace Tests { public partial class FirstQuery { - public static string ToGraphQLQuery() => @"query firstQuery($id:UUID!,$orderId:UUID!) { people(id:$id) { customerId orders(orderId:$orderId) { orderId orderDate } firstName lastName middleName } }"; + public static string ToGraphQLQuery() => @"query firstQuery($id:UUID!,$orderId:UUID!) { people(id:$id) { customerId orders(orderId:$orderId) { orderId orderDate } firstName lastName middleName } }"; } } @@ -23,6 +23,6 @@ namespace Tests { public partial class SecondQuery { - public static string ToGraphQLQuery() => @"query secondQuery($id:UUID!,$orderId:UUID!) { people(id:$id) { customerId orders(orderId:$orderId) { orderId orderDate } firstName lastName middleName } }"; + public static string ToGraphQLQuery() => @"query secondQuery($id:UUID!,$orderId:UUID!) { people(id:$id) { customerId orders(orderId:$orderId) { orderId orderDate } firstName lastName middleName } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithJsonIgnore.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithJsonIgnore.verified.txt index b7ae5c4..12b1448 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithJsonIgnore.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithJsonIgnore.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query nameQuery { people { firstName lastName } }"; + public static string ToGraphQLQuery() => @"query nameQuery { people { firstName lastName } }"; } } \ No newline at end of file diff --git a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithStructQuery.verified.txt b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithStructQuery.verified.txt index 9159aad..1d04066 100644 --- a/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithStructQuery.verified.txt +++ b/tests/QueryByShape.Analyzer.Tests/SourceGenerator/Snapshots/SnapshotTests.GeneratesWithStructQuery.verified.txt @@ -9,6 +9,6 @@ namespace Tests { public partial class NameQuery { - public static string ToGraphQLQuery() => @"query nameQuery { people { age } }"; + public static string ToGraphQLQuery() => @"query nameQuery { people { age } }"; } } \ No newline at end of file From 3cd363abb9e019c8953d085db0c04037c60f2268 Mon Sep 17 00:00:00 2001 From: Mike Pringle Date: Wed, 22 Oct 2025 21:07:21 -0400 Subject: [PATCH 3/4] Parser updates for perfrmance --- .../Parser/QueryParser.cs | 87 ++++++++++--------- 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/src/QueryByShape.Analyzer/Parser/QueryParser.cs b/src/QueryByShape.Analyzer/Parser/QueryParser.cs index 316cbbb..941967e 100644 --- a/src/QueryByShape.Analyzer/Parser/QueryParser.cs +++ b/src/QueryByShape.Analyzer/Parser/QueryParser.cs @@ -17,7 +17,7 @@ namespace QueryByShape.Analyzer internal class QueryParser(NamedTypeSymbols symbols) { private readonly List _diagnostics = []; - private readonly Dictionary)> _typeCache = []; + private readonly Dictionary)> _typeCache = new(SymbolEqualityComparer.Default); public static EquatableArray Process(ImmutableArray<(TypeDeclarationSyntax declaration, SemanticModel semanticModel)> contexts, NamedTypeSymbols symbols, CancellationToken cancellationToken) { @@ -26,10 +26,7 @@ public static EquatableArray Process(ImmutableArray<(TypeDeclaratio for (int i = 0; i < contexts.Length; i++) { - if (cancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException(); - } + cancellationToken.ThrowIfCancellationRequested(); var (declaration, semanticModel) = contexts[i]; var declaredSymbol = semanticModel.GetDeclaredSymbol(declaration)!; @@ -47,7 +44,7 @@ public ParseResult Parse(TypeDeclarationSyntax typeDeclaration, INamedTypeSymbol //_diagnostics.Add(QueryMustBePartialDiagnostic.Create(declaredSymbol.Name, typeDeclaration.GetLocation())); } - var types = ParseTypeMetadata(declaredSymbol, out var queryArguments); + var (types, queryArguments) = ParseTypeMetadata(declaredSymbol); var query = new QueryMetadata(declaredSymbol.Name, declaredSymbol.GetNamespace(), types); @@ -59,31 +56,41 @@ public ParseResult Parse(TypeDeclarationSyntax typeDeclaration, INamedTypeSymbol public void ValidateVariables(IEnumerable? variables, IList arguments) { - var variableLookup = variables?.ToDictionary(v => v.Name) ?? []; - var variableUsage = variableLookup.Keys.ToHashSet(); + // Nothing to validate + if (variables?.Any() != true && arguments.Count == 0) + { + return; + } + + var variableLookup = variables?.ToDictionary(v => v.Name, StringComparer.Ordinal) ?? new Dictionary(StringComparer.Ordinal); + var unusedVariables = new HashSet(variableLookup.Keys, StringComparer.Ordinal); foreach (var argument in arguments) { - if (variableLookup.ContainsKey(argument.VariableName) is false) - { - _diagnostics.Add(MissingVariableDiagnostic.CreateMetadata(argument.Name, argument.VariableName, argument.Reference.GetLocation())); - } - else if (variableUsage.Contains(argument.VariableName)) + var varName = argument.VariableName; + + if (variableLookup.TryGetValue(varName, out var variable) is false) { - variableUsage.Remove(argument.VariableName); + _diagnostics.Add(MissingVariableDiagnostic.CreateMetadata(argument.Name, varName, argument.Reference.GetLocation())); + continue; } + + // Mark as used + unusedVariables.Remove(varName); } - foreach (var variableName in variableUsage) + // Any remaining variables were not used by any argument + foreach (var variableName in unusedVariables) { - _diagnostics.Add(UnusedVariableDiagnostic.CreateMetadata(variableName, variableLookup[variableName].Reference.GetLocation())); + var variable = variableLookup[variableName]; + _diagnostics.Add(UnusedVariableDiagnostic.CreateMetadata(variableName, variable.Reference.GetLocation())); } } private void UpdateQueryFromAttributes(QueryMetadata query, ImmutableArray attributes) { - Dictionary variables = []; + Dictionary variables = new(StringComparer.Ordinal); foreach (var attribute in attributes) { @@ -97,14 +104,14 @@ private void UpdateQueryFromAttributes(QueryMetadata query, ImmutableArray childArguments) + private (TypeMetadata, List) ParseTypeMetadata(INamedTypeSymbol type) { - INamedTypeSymbol? current = type; - var name = type.ToDisplayString(); - - if (_typeCache.ContainsKey(name)) + if (_typeCache.ContainsKey(type)) { - (var metadata, childArguments) = _typeCache[name]; - return metadata; + return _typeCache[type]; } - Dictionary members = []; - childArguments = []; + INamedTypeSymbol? current = type; + + Dictionary members = new(StringComparer.Ordinal); + List arguments = []; while (current?.Name is not null or "Object" or "ValueType") { @@ -159,15 +164,16 @@ private TypeMetadata ParseTypeMetadata(INamedTypeSymbol type, out List 0) { - childArguments.AddRange(metadata.Arguments); + arguments.AddRange(metadata.Arguments); } } @@ -178,10 +184,11 @@ private TypeMetadata ParseTypeMetadata(INamedTypeSymbol type, out List m.IsSerializable && m.Ignore is not true).ToArray(); - var typeMetadata = new TypeMetadata(name, [.. typeMembers]); + var typeMetadata = new TypeMetadata(type.ToDisplayString(), [.. typeMembers]); - _typeCache[name] = (typeMetadata, childArguments); - return typeMetadata; + var result = (typeMetadata, arguments); + _typeCache[type] = result; + return result; } private MemberMetadata ParseMemberMetadata(ISymbol member, out INamedTypeSymbol? memberType) @@ -201,8 +208,8 @@ IFieldSymbol field when symbols.IsFieldSerializable(field) => field.Type, private void UpdateMemberFromBaseAttributes(MemberMetadata metadata, ImmutableArray attributes) { - var arguments = new Dictionary(); - var inlineFragments = new HashSet(); + Dictionary arguments = new(StringComparer.Ordinal); + HashSet inlineFragments = new(StringComparer.Ordinal); foreach (var attribute in attributes) { From a3b6094c05207be606b3ca2ef1c85128c6fae23e Mon Sep 17 00:00:00 2001 From: Mike Pringle Date: Sun, 2 Nov 2025 16:33:10 -0500 Subject: [PATCH 4/4] Performance updates --- .../Analyzers/ArgumentAnalyzer.cs | 6 +- .../Analyzers/VariableAnalyzer.cs | 6 +- src/QueryByShape.Analyzer/AttributeNames.cs | 1 + .../CodeAnalysisExtensions.cs | 87 +++++--- .../EnumerableExtensions.cs | 12 -- src/QueryByShape.Analyzer/NamedTypeSymbols.cs | 27 ++- .../Parser/QueryParser.cs | 187 ++++++++++-------- src/QueryByShape.Analyzer/ReferenceSet.cs | 69 +++++++ 8 files changed, 271 insertions(+), 124 deletions(-) create mode 100644 src/QueryByShape.Analyzer/ReferenceSet.cs diff --git a/src/QueryByShape.Analyzer/Analyzers/ArgumentAnalyzer.cs b/src/QueryByShape.Analyzer/Analyzers/ArgumentAnalyzer.cs index e1188ee..90091a5 100644 --- a/src/QueryByShape.Analyzer/Analyzers/ArgumentAnalyzer.cs +++ b/src/QueryByShape.Analyzer/Analyzers/ArgumentAnalyzer.cs @@ -40,12 +40,12 @@ private static void AnalyzeNode(SyntaxNodeAnalysisContext context) var attributes = context.ContainingSymbol!.GetAttributes(); var activeAttribute = attributes.Single(a => a.ApplicationSyntaxReference?.SyntaxTree == attributeSyntax.SyntaxTree && a.ApplicationSyntaxReference?.Span == attributeSyntax.Span); - if (activeAttribute.AttributeClass?.Equals(attributeNamedType, SymbolEqualityComparer.Default) != true) + if (activeAttribute.IsAttributeType(attributeNamedType) is false + || activeAttribute.TryGetConstructorArgument(out string? activeName) is false) { return; } - var activeName = activeAttribute.GetConstructorArgument(); ReportInvalidName(activeName, context); ReportDuplicateNames(activeName, activeAttribute, attributeNamedType, attributes, context); @@ -63,7 +63,7 @@ private static void ReportInvalidName(string name, SyntaxNodeAnalysisContext con private static void ReportDuplicateNames(string name, AttributeData activeAttribute, INamedTypeSymbol attributeNamedType, ImmutableArray attributes, SyntaxNodeAnalysisContext context) { - var dupes = attributes.Where(a => a.AttributeClass?.Equals(attributeNamedType, SymbolEqualityComparer.Default) == true && a.GetConstructorArgument() == name); + var dupes = attributes.Where(a => a.IsAttributeType(attributeNamedType) && a.TryGetConstructorArgument(out string? argument) && argument == name); if (dupes.First() != activeAttribute) { diff --git a/src/QueryByShape.Analyzer/Analyzers/VariableAnalyzer.cs b/src/QueryByShape.Analyzer/Analyzers/VariableAnalyzer.cs index 1dbc67f..84de7e4 100644 --- a/src/QueryByShape.Analyzer/Analyzers/VariableAnalyzer.cs +++ b/src/QueryByShape.Analyzer/Analyzers/VariableAnalyzer.cs @@ -40,12 +40,12 @@ private static void AnalyzeNode(SyntaxNodeAnalysisContext context) var attributes = context.ContainingSymbol!.GetAttributes(); var activeAttribute = attributes.Single(a => a.ApplicationSyntaxReference?.SyntaxTree == attributeSyntax.SyntaxTree && a.ApplicationSyntaxReference?.Span == attributeSyntax.Span); - if (activeAttribute.AttributeClass?.Equals(attributeNamedType, SymbolEqualityComparer.Default) != true) + if (activeAttribute.IsAttributeType(attributeNamedType) is false + || activeAttribute.TryGetConstructorArgument(out string? activeName) is false) { return; } - var activeName = activeAttribute.GetConstructorArgument(); ReportInvalidName(activeName, context); ReportDuplicateNames(activeName, activeAttribute, attributeNamedType, attributes, context); } @@ -73,7 +73,7 @@ private static void ReportInvalidName(string name, SyntaxNodeAnalysisContext con private static void ReportDuplicateNames(string name, AttributeData activeAttribute, INamedTypeSymbol attributeNamedType, ImmutableArray attributes, SyntaxNodeAnalysisContext context) { - var dupes = attributes.Where(a => a.AttributeClass?.Equals(attributeNamedType, SymbolEqualityComparer.Default) == true && a.GetConstructorArgument() == name); + var dupes = attributes.Where(a => a.IsAttributeType(attributeNamedType) && a.TryGetConstructorArgument(out string? argument) && argument == name); if (dupes.First() != activeAttribute) { diff --git a/src/QueryByShape.Analyzer/AttributeNames.cs b/src/QueryByShape.Analyzer/AttributeNames.cs index fabe4ed..ebf23a7 100644 --- a/src/QueryByShape.Analyzer/AttributeNames.cs +++ b/src/QueryByShape.Analyzer/AttributeNames.cs @@ -5,6 +5,7 @@ internal static class AttributeNames public const string JSON_PROPERTY = "System.Text.Json.Serialization.JsonPropertyNameAttribute"; public const string JSON_IGNORE = "System.Text.Json.Serialization.JsonIgnoreAttribute"; public const string QUERY = $"QueryByShape.{nameof(QueryAttribute)}"; + public const string MUTATION = $"QueryByShape.{nameof(MutationAttribute)}"; public const string VARIABLE = $"QueryByShape.{nameof(VariableAttribute)}"; public const string ARGUMENT = $"QueryByShape.{nameof(ArgumentAttribute)}"; public const string ALIAS_OF = $"QueryByShape.{nameof(AliasOfAttribute)}"; diff --git a/src/QueryByShape.Analyzer/CodeAnalysisExtensions.cs b/src/QueryByShape.Analyzer/CodeAnalysisExtensions.cs index 43972a1..8560125 100644 --- a/src/QueryByShape.Analyzer/CodeAnalysisExtensions.cs +++ b/src/QueryByShape.Analyzer/CodeAnalysisExtensions.cs @@ -2,6 +2,7 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json.Serialization; using System.Xml.Linq; @@ -12,8 +13,10 @@ internal static class Extensions { public static string ToFullName(this AttributeData attribute) { - var attributeClass = attribute.AttributeClass ?? throw new NullReferenceException(); - return $"{attributeClass.GetNamespace()}.{attributeClass.Name}"; + return attribute.AttributeClass? + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + .Replace("global::", string.Empty) + ?? throw new ArgumentNullException(nameof(attribute)); } public static string ExtractName(this NameSyntax nameSyntax) @@ -47,42 +50,69 @@ public static Location GetLocation(this AttributeData attribute) public static INamedTypeSymbol ResolveNamedType(this Compilation compilation) { - return compilation.GetTypeByMetadataName(typeof(T).FullName)!; + var name = typeof(T).FullName ?? throw new InvalidOperationException("No metadata name for type"); + var symbol = compilation.GetTypeByMetadataName(name); + return symbol ?? throw new InvalidOperationException($"Type '{name}' not found in compilation"); } - public static string GetConstructorArgument(this AttributeData attribute) => GetConstructorArguments(attribute)[0]; - public static string[] GetConstructorArguments(this AttributeData attribute) => attribute.ConstructorArguments.Select(c => c.Value?.ToString() ?? "").ToArray(); - - public static bool TryGetNamedArgument(this AttributeData attribute, string name, out T? value) - { + public static bool TryGetConstructorArgument(this AttributeData attribute, [NotNullWhen(true)] out T? value) + { + if (attribute?.ConstructorArguments.Length > 0 && attribute.ConstructorArguments[0].Value is T argument) + { + value = argument; + return true; + } + value = default; + return false; + } - var arguments = attribute.NamedArguments.Where(a => a.Key == name); - - if (arguments.Any()) + public static bool TryGetConstructorArguments(this AttributeData attribute, [NotNullWhen(true)] out TFirst? first, [NotNullWhen(true)] out TSecond? second) + { + if (attribute?.ConstructorArguments.Length > 1 && attribute.ConstructorArguments[0].Value is TFirst firstArg && attribute.ConstructorArguments[1].Value is TSecond secondArg) { - value = (T?)arguments.First().Value.Value; + first = firstArg; + second = secondArg; return true; } + first = default; + second = default; return false; } - public static string GetNamespace(this INamedTypeSymbol symbol) + public static bool TryGetNamedArgument(this AttributeData attribute, string name, out T? value) { - return string.Join(".", GetNamespace_Internal(symbol.ContainingNamespace)); + foreach (var kv in attribute.NamedArguments) + { + if (kv.Key == name && kv.Value.Value is T t) + { + value = t; + return true; + } + } - static string[] GetNamespace_Internal(INamespaceSymbol symbol, int index = 0) + value = default; + return false; + } + + public static bool IsAttributeType(this AttributeData? attribute, INamedTypeSymbol? targetType) + { + var sourceType = attribute?.AttributeClass; + + if (sourceType is null || targetType is null) { - if (symbol.ContainingNamespace == null) - { - return new string[index]; - } - - var result = GetNamespace_Internal(symbol.ContainingNamespace, index + 1); - result[result.Length - index - 1] = symbol.Name; - return result; + return false; } + + return SymbolEqualityComparer.Default.Equals(sourceType, targetType); + } + + public static string GetNamespace(this INamedTypeSymbol symbol) + { + return symbol.ContainingNamespace + .ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + .Replace("global::", string.Empty); } public static bool TryGetCompatibleGenericBaseType(this ITypeSymbol type, INamedTypeSymbol? baseType, out INamedTypeSymbol? result) @@ -150,6 +180,15 @@ public static bool IsAssignableFrom(this ITypeSymbol? baseType, ITypeSymbol? typ return false; } - public static Location ToTrimmedLocation(this Location location) => Location.Create(location.SourceTree!.FilePath, location.SourceSpan, location.GetLineSpan().Span); + public static Location ToTrimmedLocation(this Location location) + { + if (location == Location.None || location.SourceTree is null) + { + return location; + } + + return Location.Create(location.SourceTree.FilePath, location.SourceSpan, location.GetLineSpan().Span); + } + } } diff --git a/src/QueryByShape.Analyzer/EnumerableExtensions.cs b/src/QueryByShape.Analyzer/EnumerableExtensions.cs index 8624958..8923233 100644 --- a/src/QueryByShape.Analyzer/EnumerableExtensions.cs +++ b/src/QueryByShape.Analyzer/EnumerableExtensions.cs @@ -8,18 +8,6 @@ namespace QueryByShape.Analyzer { public static class EnumerableExtensions { - internal static void Deconstruct(this IList list, out T first, out T second) - { - first = list.Count > 0 ? list[0] : throw new IndexOutOfRangeException(); - second = list.Count > 1 ? list[1] : throw new IndexOutOfRangeException(); - } - - public static void Deconstruct(this IList list, out T first, out T second, out T third) - { - (first, second) = list; - third = list.Count > 2 ? list[2] : throw new IndexOutOfRangeException(); - } - public static (List, List) Partition(this IEnumerable source, Func predicate) { var left = new List(); diff --git a/src/QueryByShape.Analyzer/NamedTypeSymbols.cs b/src/QueryByShape.Analyzer/NamedTypeSymbols.cs index 3f2cf03..e986ee6 100644 --- a/src/QueryByShape.Analyzer/NamedTypeSymbols.cs +++ b/src/QueryByShape.Analyzer/NamedTypeSymbols.cs @@ -1,6 +1,7 @@ using Microsoft.CodeAnalysis; using System.Collections.Generic; using System.Reflection; +using System.Text.Json.Serialization; namespace QueryByShape.Analyzer { @@ -24,7 +25,31 @@ public class NamedTypeSymbols(Compilation compilation) public INamedTypeSymbol UIntPtr => _uIntPtr ??= compilation.GetSpecialType(SpecialType.System_UIntPtr); private INamedTypeSymbol? _uIntPtr; - + public INamedTypeSymbol QueryAttribute => _queryAttribute ??= compilation.GetTypeByMetadataName(typeof(QueryAttribute).FullName)!; + private INamedTypeSymbol? _queryAttribute; + + public INamedTypeSymbol VariableAttribute => _variableAttribute ??= compilation.GetTypeByMetadataName(typeof(VariableAttribute).FullName)!; + private INamedTypeSymbol? _variableAttribute; + + public INamedTypeSymbol ArgumentAttribute => _argumentAttribute ??= compilation.GetTypeByMetadataName(typeof(ArgumentAttribute).FullName)!; + private INamedTypeSymbol? _argumentAttribute; + + public INamedTypeSymbol AliasOfAttribute => _aliasOfAttribute ??= compilation.GetTypeByMetadataName(typeof(AliasOfAttribute).FullName)!; + private INamedTypeSymbol? _aliasOfAttribute; + + public INamedTypeSymbol OnAttribute => _onAttribute ??= compilation.GetTypeByMetadataName(typeof(OnAttribute).FullName)!; + private INamedTypeSymbol? _onAttribute; + + public INamedTypeSymbol MutationAttribute => _mutationAttribute ??= compilation.GetTypeByMetadataName(typeof(MutationAttribute).FullName)!; + private INamedTypeSymbol? _mutationAttribute; + + public INamedTypeSymbol JsonIgnoreAttribute => _jsonIgnoreAttribute ??= compilation.GetTypeByMetadataName(typeof(JsonIgnoreAttribute).FullName)!; + private INamedTypeSymbol? _jsonIgnoreAttribute; + + public INamedTypeSymbol JsonPropertyAttribute => _jsonPropertyAttribute ??= compilation.GetTypeByMetadataName(typeof(JsonPropertyNameAttribute).FullName)!; + private INamedTypeSymbol? _jsonPropertyAttribute; + + public bool IsPropertySerializable(IPropertySymbol property) { return ( diff --git a/src/QueryByShape.Analyzer/Parser/QueryParser.cs b/src/QueryByShape.Analyzer/Parser/QueryParser.cs index 941967e..4c0df8c 100644 --- a/src/QueryByShape.Analyzer/Parser/QueryParser.cs +++ b/src/QueryByShape.Analyzer/Parser/QueryParser.cs @@ -7,17 +7,24 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.InteropServices; using System.Text.Json.Serialization; using System.Threading; +using System.Xml.Linq; namespace QueryByShape.Analyzer { internal class QueryParser(NamedTypeSymbols symbols) { - private readonly List _diagnostics = []; - private readonly Dictionary)> _typeCache = new(SymbolEqualityComparer.Default); + private static readonly SymbolEqualityComparer _symbolComparer = SymbolEqualityComparer.Default; + private static readonly StringComparer _stringComparer = StringComparer.Ordinal; + + private readonly List _diagnostics = new(); + private readonly Dictionary)> _typeCache = + new(_symbolComparer); public static EquatableArray Process(ImmutableArray<(TypeDeclarationSyntax declaration, SemanticModel semanticModel)> contexts, NamedTypeSymbols symbols, CancellationToken cancellationToken) { @@ -54,79 +61,73 @@ public ParseResult Parse(TypeDeclarationSyntax typeDeclaration, INamedTypeSymbol return (query, [.. _diagnostics]); } - public void ValidateVariables(IEnumerable? variables, IList arguments) + public void ValidateVariables(EquatableArray? variables, IList arguments) { // Nothing to validate - if (variables?.Any() != true && arguments.Count == 0) + if ((variables is null or { Count: 0 }) && arguments is { Count: 0 }) { return; } - var variableLookup = variables?.ToDictionary(v => v.Name, StringComparer.Ordinal) ?? new Dictionary(StringComparer.Ordinal); - var unusedVariables = new HashSet(variableLookup.Keys, StringComparer.Ordinal); + var vars = variables?.GetArray() ?? Array.Empty(); + + var variableRefs = new ReferenceSet(vars, v => v.Name, StringComparer.Ordinal); foreach (var argument in arguments) { var varName = argument.VariableName; - if (variableLookup.TryGetValue(varName, out var variable) is false) + if (variableRefs.TryMarkReferenced(varName) is false) { _diagnostics.Add(MissingVariableDiagnostic.CreateMetadata(argument.Name, varName, argument.Reference.GetLocation())); continue; } - - // Mark as used - unusedVariables.Remove(varName); } // Any remaining variables were not used by any argument - foreach (var variableName in unusedVariables) + foreach (var variable in variableRefs.GetUnreferenced()) { - var variable = variableLookup[variableName]; - _diagnostics.Add(UnusedVariableDiagnostic.CreateMetadata(variableName, variable.Reference.GetLocation())); + _diagnostics.Add(UnusedVariableDiagnostic.CreateMetadata(variable.Name, variable.Reference.GetLocation())); } } private void UpdateQueryFromAttributes(QueryMetadata query, ImmutableArray attributes) { - Dictionary variables = new(StringComparer.Ordinal); + Dictionary variables = new(_stringComparer); foreach (var attribute in attributes) { - switch (attribute.ToFullName()) + if (attribute.IsAttributeType(symbols.QueryAttribute)) { - case AttributeNames.QUERY: - foreach (var argument in attribute.NamedArguments) + foreach (var argument in attribute.NamedArguments) + { + switch (argument.Key) { - switch (argument.Key) - { - case nameof(QueryAttribute.OperationName): - query.Name = (string)argument.Value.Value!; - break; - case nameof(QueryAttribute.IncludeFields) when argument.Value.Value is bool includeFields: - query.Options.IncludeFields = includeFields; - break; - case nameof(QueryAttribute.PropertyNamingPolicy) when argument.Value.Value is JsonPropertyNaming jsonPropertyNaming: - query.Options.PropertyNamingPolicy =jsonPropertyNaming; - break; - case nameof(QueryAttribute.Formatting) when argument.Value.Value is SourceFormatting sourceFormatting: - query.Options.Formatting = sourceFormatting; - break; - } + case nameof(QueryAttribute.OperationName): + query.Name = (string)argument.Value.Value!; + break; + case nameof(QueryAttribute.IncludeFields) when argument.Value.Value is bool includeFields: + query.Options.IncludeFields = includeFields; + break; + case nameof(QueryAttribute.PropertyNamingPolicy) when argument.Value.Value is JsonPropertyNaming jsonPropertyNaming: + query.Options.PropertyNamingPolicy = jsonPropertyNaming; + break; + case nameof(QueryAttribute.Formatting) when argument.Value.Value is SourceFormatting sourceFormatting: + query.Options.Formatting = sourceFormatting; + break; } - break; - - case AttributeNames.VARIABLE: - var (variablName, graphType) = attribute.GetConstructorArguments(); + } + + } + else if (attribute.IsAttributeType(symbols.VariableAttribute)) + { + if (attribute.TryGetConstructorArguments(out string? variableName, out string? graphType) + && variables.ContainsKey(variableName) is false) + { var defaultValue = attribute.TryGetNamedArgument(nameof(VariableAttribute.DefaultValue), out var value) ? value : null; - - if (variables.ContainsKey(variablName) == false) - { - variables.Add(variablName, new VariableMetadata(variablName, graphType, defaultValue, attribute.ApplicationSyntaxReference)); - } - - break; + variables[variableName] = new VariableMetadata(variableName, graphType, defaultValue, attribute.ApplicationSyntaxReference); + } } } @@ -134,24 +135,30 @@ private void UpdateQueryFromAttributes(QueryMetadata query, ImmutableArray) ParseTypeMetadata(INamedTypeSymbol type) - { - if (_typeCache.ContainsKey(type)) + { + if (_typeCache.TryGetValue(type, out var cachedType)) { - return _typeCache[type]; + return cachedType; } INamedTypeSymbol? current = type; - Dictionary members = new(StringComparer.Ordinal); + Dictionary members = new(_stringComparer); + List arguments = []; - while (current?.Name is not null or "Object" or "ValueType") + while (current is not null && current.SpecialType is not SpecialType.System_Object and not SpecialType.System_ValueType) { foreach (var member in current.GetMembers()) { + if ((member.Kind != SymbolKind.Property && member.Kind != SymbolKind.Field) || member.IsImplicitlyDeclared) + { + continue; + } + var memberName = member.Name; var attributes = member.GetAttributes(); - + if (members.TryGetValue(memberName, out var metadata) is false) { metadata = ParseMemberMetadata(member, out var memberType); @@ -170,14 +177,22 @@ private void UpdateQueryFromAttributes(QueryMetadata query, ImmutableArray 0) { arguments.AddRange(metadata.Arguments); } } - UpdateMemberFromInheritedAttributes(metadata, attributes); + if (metadata != null) + { + UpdateMemberFromInheritedAttributes(metadata, attributes); + + if (metadata.Ignore is true) + { + members[memberName] = null; + } + } } current = current.BaseType; @@ -191,6 +206,19 @@ private void UpdateQueryFromAttributes(QueryMetadata query, ImmutableArray property.Type, + IFieldSymbol field when symbols.IsFieldSerializable(field) => field.Type, + _ => null + } as INamedTypeSymbol; + + return memberType is not null; + } + + private MemberMetadata ParseMemberMetadata(ISymbol member, out INamedTypeSymbol? memberType) { memberType = member switch @@ -208,31 +236,30 @@ IFieldSymbol field when symbols.IsFieldSerializable(field) => field.Type, private void UpdateMemberFromBaseAttributes(MemberMetadata metadata, ImmutableArray attributes) { - Dictionary arguments = new(StringComparer.Ordinal); - HashSet inlineFragments = new(StringComparer.Ordinal); + Dictionary arguments = new(_stringComparer); + HashSet inlineFragments = new(_stringComparer); foreach (var attribute in attributes) { - switch (attribute.ToFullName()) + if (attribute.IsAttributeType(symbols.ArgumentAttribute)) { - case AttributeNames.ARGUMENT: - var (name, variableName) = attribute.GetConstructorArguments(); - - if (arguments.ContainsKey(name) == false) - { - var argMetadata = new ArgumentMetadata(name, variableName, attribute.ApplicationSyntaxReference); - arguments.Add(name, argMetadata); - } - break; - - case AttributeNames.ALIAS_OF: - metadata.AliasOf = attribute.GetConstructorArgument(); - break; - - case AttributeNames.ON: - inlineFragments.Add(attribute.GetConstructorArgument()); - break; - + if (attribute.TryGetConstructorArguments(out string? name, out string? variableName) + && arguments.ContainsKey(name) is false) + { + var argMetadata = new ArgumentMetadata(name, variableName, attribute.ApplicationSyntaxReference); + arguments[name] = argMetadata; + } + } + else if (attribute.IsAttributeType(symbols.AliasOfAttribute)) + { + metadata.AliasOf = attribute.TryGetConstructorArgument(out string? alias) ? alias : null; + } + else if (attribute.IsAttributeType(symbols.OnAttribute)) + { + if (attribute.TryGetConstructorArgument(out string? on)) + { + inlineFragments.Add(on); + } } } @@ -244,19 +271,17 @@ private void UpdateMemberFromInheritedAttributes(MemberMetadata metadata, Immuta { foreach (var attribute in attributes) { - switch (attribute.ToFullName()) + if (attribute.IsAttributeType(symbols.JsonPropertyAttribute) && metadata.OverrideName is null) { - case AttributeNames.JSON_PROPERTY when metadata.OverrideName is null: - metadata.OverrideName = attribute.GetConstructorArgument(); - break; - - case AttributeNames.JSON_IGNORE when metadata.Ignore is null: - var ignoreCondition = attribute.TryGetNamedArgument(nameof(JsonIgnoreAttribute.Condition), out var condition) + metadata.OverrideName = attribute.TryGetConstructorArgument(out string? overrideName) ? overrideName : null; + } + else if (attribute.IsAttributeType(symbols.JsonIgnoreAttribute) && metadata.Ignore is null) + { + var ignoreCondition = attribute.TryGetNamedArgument(nameof(JsonIgnoreAttribute.Condition), out var condition) ? (JsonIgnoreCondition)condition : JsonIgnoreCondition.Always; - metadata.Ignore = ignoreCondition is not JsonIgnoreCondition.Never; - break; + metadata.Ignore = ignoreCondition is not JsonIgnoreCondition.Never; } } } diff --git a/src/QueryByShape.Analyzer/ReferenceSet.cs b/src/QueryByShape.Analyzer/ReferenceSet.cs new file mode 100644 index 0000000..eeac431 --- /dev/null +++ b/src/QueryByShape.Analyzer/ReferenceSet.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; + +namespace QueryByShape.Analyzer +{ + /// + /// Tracks a fixed set of values and allows marking keys as referenced. + /// First occurrence of a duplicate key is preserved (first-win). + /// + internal sealed class ReferenceSet + { + private readonly TValue[] _items; + private readonly Dictionary _unreferenced; + private readonly HashSet _initialKeys; + + public ReferenceSet(TValue[] items, Func keySelector, IEqualityComparer comparer) + { + _items = items ?? Array.Empty(); + _unreferenced = new Dictionary(_items.Length, comparer); + _initialKeys = new HashSet(comparer); + + for (var i = 0; i < _items.Length; i++) + { + var key = keySelector(_items[i]); + + // First-win: ignore duplicate keys + if (_initialKeys.Add(key)) + { + _unreferenced[key] = i; + } + } + } + + /// + /// Mark a key as referenced. Returns true if the key existed in the original set; false otherwise. + /// + public bool TryMarkReferenced(TKey key) + { + if (_initialKeys.Contains(key) is false) + { + return false; + } + + _unreferenced.Remove(key); + return true; + } + + /// + /// Returns the items that were not referenced. + /// + public TValue[] GetUnreferenced() + { + if (_unreferenced.Count == 0) + { + return Array.Empty(); + } + + var result = new TValue[_unreferenced.Count]; + var idx = 0; + + foreach (var kv in _unreferenced) + { + result[idx++] = _items[kv.Value]; + } + + return result; + } + } +}