From c4eb84d31786fde0707d631595ad41ecb1024e4d Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 18 Aug 2026 11:46:56 +0100 Subject: [PATCH 1/4] Phase 2, increment 1: plain-data model for the TypeAccessor generator The cached pipeline values held Location + ITypeSymbol + IMethodSymbol (and MemberData an ITypeSymbol per member), and the raw Compilation fed the output step - so the incremental cache never hit (symbol equality does not hold across compilations) while the cached values pinned whole compilations in memory. See notes on the parity-notes branch (generator-audit.md) for the survey. New Dapper.CodeAnalysis.Model namespace with hand-written equatable plain-data types: LocationSnapshot (span values; reconstitutes a Location only at report time), EquatableArray (structural equality - deliberately not ImmutableArray, whose reference equality silently defeats caching), TypeAccessorModel/AccessorMember/ForwarderMethod, and GenerationEnvironment so the output step combines three compilation facts instead of the Compilation itself. All symbol projection moved into Parse; emit is byte-identical (the Accessors golden tests pass unchanged). ModelShapeTests gives the rule teeth: everything in the Model namespace is checked by reflection for Roslyn-typed fields and for IEquatable, so a symbol sneaking back into the cached model is a test failure, not a silent leak. PreGeneratedCodeWriter gains a bool-based ctor (the Compilation one remains for the interceptor generator until its turn). --- .../CodeAnalysis/Model/EquatableArray.cs | 69 ++++++ .../CodeAnalysis/Model/LocationSnapshot.cs | 55 +++++ .../CodeAnalysis/Model/TypeAccessorModel.cs | 156 +++++++++++++ .../TypeAccessorInterceptorGenerator.cs | 218 ++++++++---------- .../Writers/PreGeneratedCodeWriter.cs | 54 +++-- test/Dapper.AOT.Test/ModelShapeTests.cs | 87 +++++++ 6 files changed, 497 insertions(+), 142 deletions(-) create mode 100644 src/Dapper.AOT.Analyzers/CodeAnalysis/Model/EquatableArray.cs create mode 100644 src/Dapper.AOT.Analyzers/CodeAnalysis/Model/LocationSnapshot.cs create mode 100644 src/Dapper.AOT.Analyzers/CodeAnalysis/Model/TypeAccessorModel.cs create mode 100644 test/Dapper.AOT.Test/ModelShapeTests.cs diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/EquatableArray.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/EquatableArray.cs new file mode 100644 index 00000000..5f73817f --- /dev/null +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/EquatableArray.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Dapper.CodeAnalysis.Model; + +/// +/// An immutable array with structural equality, for use in cached generator model values. +/// +/// +/// Deliberately not , whose equality +/// is reference-based and silently defeats incremental caching. +/// +internal readonly struct EquatableArray : IEquatable>, IReadOnlyList + where T : IEquatable +{ + private readonly T[]? _items; + public static EquatableArray Empty => default; + + public EquatableArray(T[] items) => _items = items is { Length: 0 } ? null : items; + + public int Length => _items?.Length ?? 0; + public int Count => Length; + public bool IsEmpty => Length == 0; + public T this[int index] => _items![index]; + + public bool Equals(EquatableArray other) + { + var x = _items; + var y = other._items; + if (ReferenceEquals(x, y)) return true; + if (x is null || y is null || x.Length != y.Length) return false; + for (int i = 0; i < x.Length; i++) + { + if (!x[i].Equals(y[i])) return false; + } + return true; + } + + public override bool Equals(object? obj) => obj is EquatableArray other && Equals(other); + + public override int GetHashCode() + { + if (_items is null) return 0; + int hash = _items.Length; + foreach (var item in _items) + { + hash = (hash * -47) + item.GetHashCode(); + } + return hash; + } + + public Enumerator GetEnumerator() => new(_items); + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)(_items ?? [])).GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => (_items ?? []).GetEnumerator(); + + public struct Enumerator + { + private readonly T[]? _items; + private int _index; + internal Enumerator(T[]? items) + { + _items = items; + _index = -1; + } + public bool MoveNext() => _items is not null && ++_index < _items.Length; + public readonly T Current => _items![_index]; + } +} diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/LocationSnapshot.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/LocationSnapshot.cs new file mode 100644 index 00000000..9f957905 --- /dev/null +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/LocationSnapshot.cs @@ -0,0 +1,55 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using System; + +namespace Dapper.CodeAnalysis.Model; + +/// +/// Plain-data snapshot of a , so cached generator model values do not +/// hold Roslyn trees alive; reconstitute (for diagnostics) only at report time. +/// +/// +/// Only value data is stored (see the model shape test); a pins its +/// entire syntax tree, and a cached value that holds one keeps whole compilations alive. +/// +internal readonly struct LocationSnapshot : IEquatable +{ + public readonly string Path; // the source path as the tree knows it (not path-mapped) + public readonly int SpanStart, SpanLength; + public readonly int StartLine, StartChar, EndLine, EndChar; // zero-based, per LinePosition + + public LocationSnapshot(Location location) + { + var span = location.GetLineSpan(); + Path = span.Path; + SpanStart = location.SourceSpan.Start; + SpanLength = location.SourceSpan.Length; + StartLine = span.StartLinePosition.Line; + StartChar = span.StartLinePosition.Character; + EndLine = span.EndLinePosition.Line; + EndChar = span.EndLinePosition.Character; + } + + public bool IsDefault => Path is null; + + /// Reconstitute a location for diagnostics; only call at report time. + public Location AsLocation() => IsDefault ? Location.None : Location.Create(Path, + new TextSpan(SpanStart, SpanLength), + new LinePositionSpan(new LinePosition(StartLine, StartChar), new LinePosition(EndLine, EndChar))); + + public bool Equals(LocationSnapshot other) + => string.Equals(Path, other.Path, StringComparison.Ordinal) + && SpanStart == other.SpanStart && SpanLength == other.SpanLength + && StartLine == other.StartLine && StartChar == other.StartChar + && EndLine == other.EndLine && EndChar == other.EndChar; + + public override bool Equals(object? obj) => obj is LocationSnapshot other && Equals(other); + public override int GetHashCode() + { + var hash = Path is null ? 0 : StringComparer.Ordinal.GetHashCode(Path); + hash = (hash * -47) + SpanStart; + hash = (hash * -47) + SpanLength; + return hash; + } + public override string ToString() => $"{Path}({StartLine + 1},{StartChar + 1})"; +} diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/TypeAccessorModel.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/TypeAccessorModel.cs new file mode 100644 index 00000000..7e285d15 --- /dev/null +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/TypeAccessorModel.cs @@ -0,0 +1,156 @@ +using System; + +namespace Dapper.CodeAnalysis.Model; + +/// +/// Plain-data model for one TypeAccessor.CreateAccessor/CreateDataReader call-site: +/// everything the emit step needs, fully projected at parse time. No Roslyn reference types may +/// be stored here (see the model shape test) - a cached symbol pins its whole compilation. +/// +internal sealed class TypeAccessorModel : IEquatable +{ + public LocationSnapshot Location { get; } + public string ParameterTypeName { get; } // the grouping key; CodeWriter.GetTypeName form + public bool IsCollection { get; } + public bool IsPrimitive { get; } + public EquatableArray Members { get; } + public ForwarderMethod Method { get; } + + public TypeAccessorModel(LocationSnapshot location, string parameterTypeName, + bool isCollection, bool isPrimitive, EquatableArray members, ForwarderMethod method) + { + Location = location; + ParameterTypeName = parameterTypeName; + IsCollection = isCollection; + IsPrimitive = isPrimitive; + Members = members; + Method = method; + } + + public bool Equals(TypeAccessorModel? other) => other is not null + && Location.Equals(other.Location) + && string.Equals(ParameterTypeName, other.ParameterTypeName, StringComparison.Ordinal) + && IsCollection == other.IsCollection + && IsPrimitive == other.IsPrimitive + && Members.Equals(other.Members) + && Method.Equals(other.Method); + + public override bool Equals(object? obj) => Equals(obj as TypeAccessorModel); + public override int GetHashCode() + { + int hash = Location.GetHashCode(); + hash = (hash * -47) + StringComparer.Ordinal.GetHashCode(ParameterTypeName); + hash = (hash * -47) + Members.GetHashCode(); + hash = (hash * -47) + Method.GetHashCode(); + return hash; + } +} + +/// A gettable+settable member of the accessed type, as plain data. +internal readonly struct AccessorMember : IEquatable +{ + public int Number { get; } + public bool IsNullable { get; } + public string Name { get; } + public string Type { get; } // display form used in emitted code + public bool IsDBNull { get; } // the member type *is* System.DBNull + public bool IsSystemObject { get; } + public string? UnderlyingEnumTypeName { get; } // when the member type is an enum + + public AccessorMember(int number, bool isNullable, string name, string type, + bool isDbNull, bool isSystemObject, string? underlyingEnumTypeName) + { + Number = number; + IsNullable = isNullable; + Name = name; + Type = type; + IsDBNull = isDbNull; + IsSystemObject = isSystemObject; + UnderlyingEnumTypeName = underlyingEnumTypeName; + } + + public bool Equals(AccessorMember other) => Number == other.Number + && IsNullable == other.IsNullable + && string.Equals(Name, other.Name, StringComparison.Ordinal) + && string.Equals(Type, other.Type, StringComparison.Ordinal) + && IsDBNull == other.IsDBNull + && IsSystemObject == other.IsSystemObject + && string.Equals(UnderlyingEnumTypeName, other.UnderlyingEnumTypeName, StringComparison.Ordinal); + + public override bool Equals(object? obj) => obj is AccessorMember other && Equals(other); + public override int GetHashCode() => (Number * -47) + StringComparer.Ordinal.GetHashCode(Name); +} + +/// The intercepted method's shape, as needed to emit the forwarder. +internal readonly struct ForwarderMethod : IEquatable +{ + public string ReturnType { get; } + public string ContainingType { get; } + public string Name { get; } + public EquatableArray Parameters { get; } + + public ForwarderMethod(string returnType, string containingType, string name, EquatableArray parameters) + { + ReturnType = returnType; + ContainingType = containingType; + Name = name; + Parameters = parameters; + } + + public bool Equals(ForwarderMethod other) + => string.Equals(ReturnType, other.ReturnType, StringComparison.Ordinal) + && string.Equals(ContainingType, other.ContainingType, StringComparison.Ordinal) + && string.Equals(Name, other.Name, StringComparison.Ordinal) + && Parameters.Equals(other.Parameters); + + public override bool Equals(object? obj) => obj is ForwarderMethod other && Equals(other); + public override int GetHashCode() + => (StringComparer.Ordinal.GetHashCode(Name) * -47) + Parameters.GetHashCode(); +} + +internal readonly struct ForwarderParameter : IEquatable +{ + public string Type { get; } + public string Name { get; } + public bool IsTypeAccessorParam { get; } // Dapper.TypeAccessor: gets the ?? Instance fallback + + public ForwarderParameter(string type, string name, bool isTypeAccessorParam) + { + Type = type; + Name = name; + IsTypeAccessorParam = isTypeAccessorParam; + } + + public bool Equals(ForwarderParameter other) + => string.Equals(Type, other.Type, StringComparison.Ordinal) + && string.Equals(Name, other.Name, StringComparison.Ordinal) + && IsTypeAccessorParam == other.IsTypeAccessorParam; + + public override bool Equals(object? obj) => obj is ForwarderParameter other && Equals(other); + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Name); +} + +/// +/// The compilation-level facts the emit step needs, projected so the raw +/// Compilation never feeds the output step. +/// +internal readonly struct GenerationEnvironment : IEquatable +{ + public bool AllowUnsafe { get; } + public string? AssemblyName { get; } + public bool HasInterceptsLocationAttribute { get; } // already available to the consumer? + + public GenerationEnvironment(bool allowUnsafe, string? assemblyName, bool hasInterceptsLocationAttribute) + { + AllowUnsafe = allowUnsafe; + AssemblyName = assemblyName; + HasInterceptsLocationAttribute = hasInterceptsLocationAttribute; + } + + public bool Equals(GenerationEnvironment other) => AllowUnsafe == other.AllowUnsafe + && string.Equals(AssemblyName, other.AssemblyName, StringComparison.Ordinal) + && HasInterceptsLocationAttribute == other.HasInterceptsLocationAttribute; + + public override bool Equals(object? obj) => obj is GenerationEnvironment other && Equals(other); + public override int GetHashCode() => AssemblyName is null ? 0 : StringComparer.Ordinal.GetHashCode(AssemblyName); +} diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/TypeAccessorInterceptorGenerator.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/TypeAccessorInterceptorGenerator.cs index f8e7644b..57a0fdf7 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/TypeAccessorInterceptorGenerator.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/TypeAccessorInterceptorGenerator.cs @@ -1,4 +1,5 @@ -using Dapper.CodeAnalysis.Abstractions; +using Dapper.CodeAnalysis.Abstractions; +using Dapper.CodeAnalysis.Model; using Dapper.CodeAnalysis.Writers; using Dapper.Internal; using Dapper.Internal.Roslyn; @@ -28,13 +29,22 @@ public sealed partial class TypeAccessorInterceptorGenerator : InterceptorGenera public override void Initialize(IncrementalGeneratorInitializationContext context) { + // note the cached values are all plain data (see ModelShapeTests): symbols must be + // fully projected during parse, and the raw Compilation must not feed the output step var nodes = context.SyntaxProvider.CreateSyntaxProvider(PreFilter, Parse) .Where(x => x is not null) .Select((x, _) => x!); - var combined = context.CompilationProvider.Combine(nodes.Collect()); + var env = context.CompilationProvider.Select(static (c, _) => CreateEnvironment(c)); + var combined = env.Combine(nodes.Collect()); context.RegisterImplementationSourceOutput(combined, Generate); } + private static GenerationEnvironment CreateEnvironment(Compilation compilation) + => new( + allowUnsafe: compilation.Options is CSharpCompilationOptions cSharp && cSharp.AllowUnsafe, + assemblyName: compilation.AssemblyName, + hasInterceptsLocationAttribute: PreGeneratedCodeWriter.HasInterceptsLocationAttribute(compilation)); + private bool PreFilter(SyntaxNode node, CancellationToken cancellationToken) { if (node is InvocationExpressionSyntax invocation && invocation.ChildNodes().FirstOrDefault() is MemberAccessExpressionSyntax memberAccess) @@ -45,7 +55,7 @@ private bool PreFilter(SyntaxNode node, CancellationToken cancellationToken) return false; } - private SourceState? Parse(GeneratorSyntaxContext ctx, CancellationToken cancellationToken) + private TypeAccessorModel? Parse(GeneratorSyntaxContext ctx, CancellationToken cancellationToken) { if (ctx.Node is not InvocationExpressionSyntax ie || ctx.SemanticModel.GetOperation(ie, cancellationToken) is not IInvocationOperation op) { @@ -62,7 +72,13 @@ private bool PreFilter(SyntaxNode node, CancellationToken cancellationToken) return null; } - return new SourceState(loc!, parameterType!, op.TargetMethod); + return new TypeAccessorModel( + new LocationSnapshot(loc!), + CodeWriter.GetTypeName(parameterType!), + isCollection: Inspection.IsCollectionType(parameterType, out _), + isPrimitive: Inspection.IsPrimitiveType(parameterType), + members: ConstructTypeMembers(parameterType!), + method: ProjectMethod(op.TargetMethod)); bool TryParseParameterType(out ITypeSymbol? type) { @@ -88,9 +104,28 @@ bool TryParseLocation(out Location? loc) } } - private void Generate(SourceProductionContext context, (Compilation Compilation, ImmutableArray Nodes) state) + private static ForwarderMethod ProjectMethod(IMethodSymbol method) + { + var args = method.Parameters; + var parameters = new ForwarderParameter[args.Length]; + for (int i = 0; i < args.Length; i++) + { + var arg = args[i]; + bool isTypeAccessor = arg.Type is INamedTypeSymbol { IsGenericType: true, Arity: 1, Name: "TypeAccessor", ContainingType: null, ContainingNamespace: { Name: "Dapper", ContainingNamespace.IsGlobalNamespace: true } }; + parameters[i] = new ForwarderParameter(AppendedForm(arg.Type), arg.Name, isTypeAccessor); + } + return new ForwarderMethod(AppendedForm(method.ReturnType), AppendedForm(method.ContainingType), method.Name, + new EquatableArray(parameters)); + + // the string CodeWriter.Append(ITypeSymbol) would have produced + static string AppendedForm(ITypeSymbol type) => type.IsAnonymousType + ? type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + : CodeWriter.GetTypeName(type); + } + + private void Generate(SourceProductionContext context, (GenerationEnvironment Env, ImmutableArray Nodes) state) { - if (!IsGenerateInputValid(ref context, state)) + if (!IsGenerateInputValid(ref context, state.Nodes)) { Log?.Invoke(DiagnosticSeverity.Hidden, $"Generate input for '{nameof(TypeAccessorInterceptorGenerator)}' does not allow generation."); return; @@ -99,45 +134,45 @@ private void Generate(SourceProductionContext context, (Compilation Compilation, var codeWriter = new CodeWriter(); var sb = new TypeAccessorInterceptorCodeWriter(codeWriter); - sb.WriteFileHeader(state.Compilation); + sb.WriteFileHeader(state.Env.AllowUnsafe); sb.WriteInterceptorsClass(() => { int typeCounter = -1, methodCounter = 0; - foreach (var group in state.Nodes.GroupBy(x => x, SourceStateByTypeComparer.Instance)) + foreach (var group in state.Nodes.GroupBy(x => x.ParameterTypeName, StringComparer.Ordinal)) { typeCounter++; - var typeSymbol = group.Key.ParameterType; + var first = group.First(); // not allowing collections - if (Inspection.IsCollectionType(typeSymbol, out _)) + if (first.IsCollection) { ReportDiagnosticInUsages(Diagnostics.TypeAccessorCollectionTypeNotAllowed); continue; } // not allowing primitives - if (Inspection.IsPrimitiveType(typeSymbol)) + if (first.IsPrimitive) { ReportDiagnosticInUsages(Diagnostics.TypeAccessorPrimitiveTypeNotAllowed); continue; } - var typeSymbolName = CodeWriter.GetTypeName(typeSymbol); - var members = ConstructTypeMembers(typeSymbol!); + var typeSymbolName = group.Key; + var members = first.Members; if (members.Length == 0) { context.ReportDiagnostic(Diagnostic.Create(Diagnostics.TypeAccessorMembersNotParsed, null)); continue; } - foreach (var methodGroup in group.GroupBy(x => x.Method, SymbolEqualityComparer.Default)) + foreach (var methodGroup in group.GroupBy(x => x.Method)) { foreach (var usage in methodGroup) { sb.WriteInterceptorsLocationAttribute(usage.Location); } - sb.WriteMethodForwarder((IMethodSymbol)methodGroup.Key!, typeCounter, ref methodCounter); + sb.WriteMethodForwarder(methodGroup.Key, typeCounter, ref methodCounter); } var accessorSb = new CustomTypeAccessorClassCodeWriter(codeWriter); @@ -158,21 +193,21 @@ void ReportDiagnosticInUsages(DiagnosticDescriptor diagnosticDescriptor) { foreach (var usage in group) { - context.ReportDiagnostic(Diagnostic.Create(diagnosticDescriptor, usage.Location)); + context.ReportDiagnostic(Diagnostic.Create(diagnosticDescriptor, usage.Location.AsLocation())); } } } }); - var preGenerator = new PreGeneratedCodeWriter(codeWriter, state.Compilation); + var preGenerator = new PreGeneratedCodeWriter(codeWriter, state.Env.HasInterceptsLocationAttribute); preGenerator.Write(IncludedGeneration.InterceptsLocationAttribute); - context.AddSource((state.Compilation.AssemblyName ?? "package") + ".generated.cs", sb.GetSourceText()); + context.AddSource((state.Env.AssemblyName ?? "package") + ".generated.cs", sb.GetSourceText()); } - private static bool IsGenerateInputValid(ref SourceProductionContext ctx, (Compilation Compilation, ImmutableArray Nodes) state) + private static bool IsGenerateInputValid(ref SourceProductionContext ctx, ImmutableArray nodes) { - if (state.Nodes.IsDefaultOrEmpty) + if (nodes.IsDefaultOrEmpty) { // TODO report diagnostics return false; @@ -181,26 +216,6 @@ private static bool IsGenerateInputValid(ref SourceProductionContext ctx, (Compi return true; } - sealed class SourceState - { - public Location Location { get; } - public ITypeSymbol ParameterType { get; } - public IMethodSymbol Method { get; } - - public SourceState( - Location location, - ITypeSymbol parameterType, - IMethodSymbol method) - { - Location = location; - ParameterType = parameterType; - Method = method; - } - - public (ITypeSymbol ParameterType, Location? UniqueLocation) Group() - => new(ParameterType, Location); - } - [DebuggerDisplay("code: '{_sb.ToString()}'")] readonly struct TypeAccessorInterceptorCodeWriter { @@ -210,9 +225,8 @@ public TypeAccessorInterceptorCodeWriter(CodeWriter codeWriter) _sb = codeWriter; } - public void WriteFileHeader(Compilation compilation) + public void WriteFileHeader(bool allowUnsafe) { - bool allowUnsafe = compilation.Options is CSharpCompilationOptions cSharp && cSharp.AllowUnsafe; if (allowUnsafe) { _sb.Append("#nullable enable").NewLine() @@ -230,16 +244,14 @@ public void WriteInterceptorsClass(Action innerWriter) _sb.Outdent().Outdent(); } - public void WriteInterceptorsLocationAttribute(Location location) + public void WriteInterceptorsLocationAttribute(in LocationSnapshot location) { - var loc = location.GetLineSpan(); - var start = loc.StartLinePosition; _sb.Append("[global::System.Runtime.CompilerServices.InterceptsLocationAttribute(") - .AppendVerbatimLiteral(loc.Path).Append(", ").Append(start.Line + 1).Append(", ").Append(start.Character + 1).Append(")]") + .AppendVerbatimLiteral(location.Path).Append(", ").Append(location.StartLine + 1).Append(", ").Append(location.StartChar + 1).Append(")]") .NewLine(); } - public void WriteMethodForwarder(IMethodSymbol method, int customTypeNum, ref int methodNumber) + public void WriteMethodForwarder(in ForwarderMethod method, int customTypeNum, ref int methodNumber) { _sb.Append("internal static ").Append(method.ReturnType).Append(" ").Append("Forwarded").Append(methodNumber++).Append("("); int i = 0; @@ -255,22 +267,13 @@ public void WriteMethodForwarder(IMethodSymbol method, int customTypeNum, ref in foreach (var arg in method.Parameters) { _sb.Append(i == 0 ? "" : ", ").Append(arg.Name); - if (arg.Type is INamedTypeSymbol { IsGenericType: true, Arity: 1, Name: "TypeAccessor", ContainingType: null, ContainingNamespace: { Name: "Dapper", ContainingNamespace.IsGlobalNamespace: true } }) + if (arg.IsTypeAccessorParam) { _sb.Append(" ?? ").Append(GetCustomTypeAccessorClassName(customTypeNum)).Append(".Instance"); } i++; } _sb.Append(");").Outdent(false).NewLine().NewLine(); - - //_sb.Append("public static global::Dapper.ObjectAccessor<").Append(userTypeName).Append("> ") - // .Append("CreateAccessor(").Append(userTypeName).Append(" obj, ") - // .Append("global::Dapper.TypeAccessor<").Append(userTypeName).Append(">? accessor = null)") - // .Indent().NewLine(); - - //_sb.Append("return new global::Dapper.ObjectAccessor<").Append(userTypeName).Append(">") - // .Append("(obj, accessor ?? ").Append(GetCustomTypeAccessorClassName(customTypeNum)).Append(".Instance);") - // .Outdent().NewLine().NewLine(); } public SourceText GetSourceText() => SourceText.From(_sb.ToString(), Encoding.UTF8); @@ -300,7 +303,7 @@ public void WriteClass(int customTypeNum, string userType, Action innerWriter) public void WriteMemberCount(int memberCount) => _sb.Append("public override int MemberCount => ").Append(memberCount).Append(";").NewLine(); - public void WriteTryIndex(string userTypeName, MemberData[] members) + public void WriteTryIndex(string userTypeName, EquatableArray members) { var sb = _sb; sb.Append("public override int? TryIndex(string name, bool exact = false)") @@ -342,7 +345,7 @@ void WriteHashVersionImplementation() } } - public void WriteGetName(string userTypeName, MemberData[] members) + public void WriteGetName(string userTypeName, EquatableArray members) { _sb.Append("public override string GetName(int index) => index switch") .Indent().NewLine(); @@ -356,7 +359,7 @@ public void WriteGetName(string userTypeName, MemberData[] members) .Outdent().Append(";").NewLine(); } - public void WriteIndexer(string userTypeName, MemberData[] members) + public void WriteIndexer(string userTypeName, EquatableArray members) { _sb.Append("public override object? this[").Append(userTypeName).Append(" obj, int index]") .Indent().NewLine(); @@ -382,15 +385,15 @@ public void WriteIndexer(string userTypeName, MemberData[] members) _sb.Outdent().NewLine(); } - public void WriteIsNullable(MemberData[] members) + public void WriteIsNullable(EquatableArray members) { _sb.Append("public override bool IsNullable(int index) => index switch") .Indent().NewLine(); var strBuilder = new StringBuilder(); - foreach (var item in members.Where(x => x.IsNullable)) + foreach (var item in members) { - strBuilder.Append(item.Number).Append(" or "); + if (item.IsNullable) strBuilder.Append(item.Number).Append(" or "); } if (strBuilder.Length > 0) { @@ -399,9 +402,9 @@ public void WriteIsNullable(MemberData[] members) } strBuilder.Clear(); - foreach (var item in members.Where(x => !x.IsNullable)) + foreach (var item in members) { - strBuilder.Append(item.Number).Append(" or "); + if (!item.IsNullable) strBuilder.Append(item.Number).Append(" or "); } if (strBuilder.Length > 0) { @@ -413,15 +416,15 @@ public void WriteIsNullable(MemberData[] members) .Outdent().Append(";").NewLine(); } - public void WriteIsNull(string userTypeName, MemberData[] members) + public void WriteIsNull(string userTypeName, EquatableArray members) { _sb.Append("public override bool IsNull(").Append(userTypeName).Append(" obj, int index) => index switch") .Indent().NewLine(); var strBuilder = new StringBuilder(); - foreach (var item in members.Where(x => !x.IsNullable)) + foreach (var item in members) { - strBuilder.Append(item.Number).Append(" or "); + if (!item.IsNullable) strBuilder.Append(item.Number).Append(" or "); } if (strBuilder.Length > 0) { @@ -429,9 +432,10 @@ public void WriteIsNull(string userTypeName, MemberData[] members) _sb.Append(strBuilder.ToString()).Append(" => false,").NewLine(); } - foreach (var member in members.Where(x => x.IsNullable)) + foreach (var member in members) { - if (IsDBNull(member.TypeSymbol)) + if (!member.IsNullable) continue; + if (member.IsDBNull) { // if member is of type DBNull, then it is always null => simply return true _sb.Append(member.Number).Append(" => true,").NewLine(); @@ -439,7 +443,7 @@ public void WriteIsNull(string userTypeName, MemberData[] members) } _sb.Append(member.Number).Append(" => obj.").Append(member.Name).Append(" is null"); - if (member.TypeSymbol.IsSystemObject()) + if (member.IsSystemObject) { _sb.Append(" or global::System.DBNull"); } @@ -448,16 +452,9 @@ public void WriteIsNull(string userTypeName, MemberData[] members) _sb.Append("_ => base.IsNull(obj, index)") .Outdent().Append(";").NewLine(); - - static bool IsDBNull(ITypeSymbol typeSymbol) - { - return typeSymbol.ContainingNamespace.ContainingNamespace?.IsGlobalNamespace == true - && typeSymbol.ContainingNamespace.Name == "System" - && typeSymbol.Name == "DBNull"; - } } - public void WriteGetType(MemberData[] members) + public void WriteGetType(EquatableArray members) { _sb.Append("public override global::System.Type GetType(int index) => index switch") .Indent().NewLine(); @@ -478,7 +475,7 @@ public void WriteGetType(MemberData[] members) .Outdent().Append(";").NewLine(); } - public void WriteGetValue(string userTypeName, MemberData[] members) + public void WriteGetValue(string userTypeName, EquatableArray members) { _sb.Append("public override TValue GetValue(").Append(userTypeName).Append(" obj, int index) => index switch") .Indent().NewLine(); @@ -488,7 +485,7 @@ public void WriteGetValue(string userTypeName, MemberData[] members) _sb.Append(member.Number).Append(" when typeof(TValue) == typeof(").Append(member.Type).Append(")"); // if memberType is enum, we need to figure out an underlying type and check on it - var underlyingType = member.TypeSymbol.GetUnderlyingEnumTypeName(); + var underlyingType = member.UnderlyingEnumTypeName; if (underlyingType is not null) { _sb.Append(" || typeof(TValue) == typeof(").Append(underlyingType).Append(")"); @@ -501,7 +498,7 @@ public void WriteGetValue(string userTypeName, MemberData[] members) .Outdent().Append(";").NewLine(); } - public void WriteSetValue(string userTypeName, MemberData[] members) + public void WriteSetValue(string userTypeName, EquatableArray members) { _sb.Append("public override void SetValue(").Append(userTypeName).Append(" obj, int index, TValue value)") .Indent().NewLine() @@ -513,7 +510,7 @@ public void WriteSetValue(string userTypeName, MemberData[] members) _sb.Append("case ").Append(member.Number).Append(" when typeof(TValue) == typeof(").Append(member.Type).Append(")"); // if memberType is enum, we need to figure out an underlying type and check on it - var underlyingType = member.TypeSymbol.GetUnderlyingEnumTypeName(); + var underlyingType = member.UnderlyingEnumTypeName; if (underlyingType is not null) { _sb.Append(" || typeof(TValue) == typeof(").Append(underlyingType).Append(")"); @@ -531,9 +528,9 @@ public void WriteSetValue(string userTypeName, MemberData[] members) private static string GetCustomTypeAccessorClassName(int num) => "DapperCustomTypeAccessor" + num; - private static MemberData[] ConstructTypeMembers(ITypeSymbol typeSymbol) + private static EquatableArray ConstructTypeMembers(ITypeSymbol typeSymbol) { - var members = new List(); + var members = new List(); int memberNumber = 0; HashSet seenNames = new(StringComparer.Ordinal); @@ -552,51 +549,30 @@ private static MemberData[] ConstructTypeMembers(ITypeSymbol typeSymbol) if (type is IPropertySymbol property) { - members.Add(new() - { - Name = property.Name, - Type = member.ToDisplayString(), - TypeSymbol = property.Type, - Number = memberNumber++, - IsNullable = property.Type.IsNullable() - }); + members.Add(Create(property.Name, member, property.Type, property.Type.IsNullable())); } if (type is IFieldSymbol field) { - members.Add(new() - { - Name = field.Name, - Type = member.ToDisplayString(), - TypeSymbol = field.Type, - Number = memberNumber++, - IsNullable = field.NullableAnnotation == NullableAnnotation.Annotated - }); + members.Add(Create(field.Name, member, field.Type, field.NullableAnnotation == NullableAnnotation.Annotated)); } + + AccessorMember Create(string name, ITypeSymbol displayType, ITypeSymbol memberType, bool isNullable) + => new(memberNumber++, isNullable, name, displayType.ToDisplayString(), + isDbNull: IsDBNull(memberType), + isSystemObject: memberType.IsSystemObject(), + underlyingEnumTypeName: memberType.GetUnderlyingEnumTypeName()); } tier = tier.BaseType; } -#pragma warning disable IDE0305 // Simplify collection initialization - return members.ToArray(); -#pragma warning restore IDE0305 // Simplify collection initialization - } + return new EquatableArray(members.ToArray()); - [DebuggerDisplay("{TypeSymbol} {Name}")] - struct MemberData - { - public int Number; - public bool IsNullable; - public string Name; - public string Type; - public ITypeSymbol TypeSymbol; - } - - sealed class SourceStateByTypeComparer : IEqualityComparer - { - public static readonly SourceStateByTypeComparer Instance = new(); - - public bool Equals(SourceState x, SourceState y) => SymbolEqualityComparer.Default.Equals(x.ParameterType, y.ParameterType); - public int GetHashCode(SourceState obj) => SymbolEqualityComparer.Default.GetHashCode(obj.ParameterType); + static bool IsDBNull(ITypeSymbol typeSymbol) + { + return typeSymbol.ContainingNamespace.ContainingNamespace?.IsGlobalNamespace == true + && typeSymbol.ContainingNamespace.Name == "System" + && typeSymbol.Name == "DBNull"; + } } } diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Writers/PreGeneratedCodeWriter.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Writers/PreGeneratedCodeWriter.cs index 94f7891d..8de835cf 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/Writers/PreGeneratedCodeWriter.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Writers/PreGeneratedCodeWriter.cs @@ -6,37 +6,28 @@ namespace Dapper.CodeAnalysis.Writers { internal struct PreGeneratedCodeWriter { - readonly Compilation _compilation; + readonly bool _hasInterceptsLocationAttribute; readonly CodeWriter _codeWriter; public PreGeneratedCodeWriter( CodeWriter codeWriter, Compilation compilation) - { - _codeWriter = codeWriter; - _compilation = compilation; - } + : this(codeWriter, HasInterceptsLocationAttribute(compilation)) + { } - public void Write(IncludedGeneration includedGenerations) + public PreGeneratedCodeWriter( + CodeWriter codeWriter, + bool hasInterceptsLocationAttribute) { - if (includedGenerations.HasAny(IncludedGeneration.InterceptsLocationAttribute)) - { - WriteInterceptsLocationAttribute(); - } - - if (includedGenerations.HasAny(IncludedGeneration.DbStringHelpers)) - { - _codeWriter.NewLine().Append(Resources.ReadString("Dapper.InGeneration.DapperHelpers.cs")); - } + _codeWriter = codeWriter; + _hasInterceptsLocationAttribute = hasInterceptsLocationAttribute; } - void WriteInterceptsLocationAttribute() + /// Is InterceptsLocationAttribute already available to the consumer's compilation? + internal static bool HasInterceptsLocationAttribute(Compilation compilation) { - var attrib = _compilation.GetTypeByMetadataName("System.Runtime.CompilerServices.InterceptsLocationAttribute"); - if (!IsAvailable(attrib, _compilation)) - { - _codeWriter.NewLine().Append(Resources.ReadString("Dapper.InGeneration.InterceptsLocationAttribute.cs")); - } + var attrib = compilation.GetTypeByMetadataName("System.Runtime.CompilerServices.InterceptsLocationAttribute"); + return IsAvailable(attrib, compilation); static bool IsAvailable(INamedTypeSymbol? type, Compilation compilation) { @@ -57,5 +48,26 @@ static bool IsAvailable(INamedTypeSymbol? type, Compilation compilation) } } } + + public void Write(IncludedGeneration includedGenerations) + { + if (includedGenerations.HasAny(IncludedGeneration.InterceptsLocationAttribute)) + { + WriteInterceptsLocationAttribute(); + } + + if (includedGenerations.HasAny(IncludedGeneration.DbStringHelpers)) + { + _codeWriter.NewLine().Append(Resources.ReadString("Dapper.InGeneration.DapperHelpers.cs")); + } + } + + void WriteInterceptsLocationAttribute() + { + if (!_hasInterceptsLocationAttribute) + { + _codeWriter.NewLine().Append(Resources.ReadString("Dapper.InGeneration.InterceptsLocationAttribute.cs")); + } + } } } diff --git a/test/Dapper.AOT.Test/ModelShapeTests.cs b/test/Dapper.AOT.Test/ModelShapeTests.cs new file mode 100644 index 00000000..a02e0edc --- /dev/null +++ b/test/Dapper.AOT.Test/ModelShapeTests.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Xunit; + +namespace Dapper.AOT.Test; + +/// +/// Everything in the Dapper.CodeAnalysis.Model namespace is cached by the incremental +/// generator driver, so it must be plain data: a stored ISymbol/SyntaxNode/ +/// Location/Compilation both pins entire compilations in memory (a serious leak +/// in a long-running IDE session) and defeats the cache (symbol equality does not hold across +/// compilations). Roslyn value types would be acceptable in principle, but the model +/// currently stores none, so this test forbids Roslyn types outright; loosen deliberately if +/// that ever changes. +/// +public class ModelShapeTests +{ + const string ModelNamespace = "Dapper.CodeAnalysis.Model"; + + public static IEnumerable ModelTypes() + => typeof(Dapper.CodeAnalysis.DapperAnalyzer).Assembly.GetTypes() + .Where(t => t.Namespace == ModelNamespace && !t.IsEnum && !IsCompilerGenerated(t)) + .Select(t => new object[] { t }); + + static bool IsCompilerGenerated(Type type) => type.Name.StartsWith("<"); + + [Fact] + public void ModelNamespaceIsNotEmpty() => Assert.NotEmpty(ModelTypes()); + + [Theory, MemberData(nameof(ModelTypes))] + public void ModelTypeHoldsNoRoslynReferences(Type type) + { + List failures = []; + foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + Visit(field.FieldType, $"{type.Name}.{field.Name}", failures, []); + } + Assert.Empty(failures); + } + + static void Visit(Type fieldType, string path, List failures, HashSet seen) + { + if (!seen.Add(fieldType)) return; + + if (fieldType.IsArray) + { + Visit(fieldType.GetElementType()!, path + "[]", failures, seen); + return; + } + if (fieldType.IsGenericParameter) return; // open generic (e.g. EquatableArray): checked at each closed usage + + var assemblyName = fieldType.Assembly.GetName().Name ?? ""; + if (assemblyName.StartsWith("Microsoft.CodeAnalysis", StringComparison.Ordinal)) + { + failures.Add($"{path} stores Roslyn type {fieldType.Name}"); + return; + } + + if (fieldType.IsGenericType) + { + foreach (var arg in fieldType.GetGenericArguments()) + { + Visit(arg, $"{path}<{arg.Name}>", failures, seen); + } + } + + // follow nested model/user types (but not framework primitives) + if (fieldType.Namespace == ModelNamespace) + { + foreach (var field in fieldType.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) + { + Visit(field.FieldType, $"{path}.{field.Name}", failures, seen); + } + } + } + + [Theory, MemberData(nameof(ModelTypes))] + public void ModelTypeIsEquatable(Type type) + { + if (type.IsInterface || type.IsAbstract || type.IsNested) return; // (nested helpers like enumerators are not cached values) + // structural equality is what makes the incremental cache work at all + var equatable = typeof(IEquatable<>).MakeGenericType(type); + Assert.True(equatable.IsAssignableFrom(type), $"{type.Name} should implement IEquatable<{type.Name}>"); + } +} From e34382ed7fb6f54df65486dfaed0b59261d1b6a7 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 18 Aug 2026 11:53:48 +0100 Subject: [PATCH 2/4] Phase 2, increment 3a: Location out of the interceptor generator's cached states SourceState (and all three subclasses) now carry a LocationSnapshot - plain span/path values - instead of a Location, which pins its whole syntax tree. Two facts that emit used to pull from the Location's tree are projected at parse instead: the normalized interceptor file path (SourceReferenceResolver.NormalizePath) and the language version (for CheckPrerequisites); the IncludeLocation SQL comment now uses the snapshot's mapped path/line. CommonComparer compares snapshots with the same path/start/end semantics the old Location-based comparer had, and the group key's UniqueLocation becomes LocationSnapshot? (value equality rather than reference - each call-site still groups distinctly). Byte-identical verified two ways: all golden fixtures pass unchanged (net10/net48), and the Dapper test-suite's generated file hashes equal before and after (f8d3a61f). --- .../DapperInterceptorGenerator.cs | 81 +++++++++++++------ .../CodeAnalysis/Model/LocationSnapshot.cs | 11 ++- 2 files changed, 65 insertions(+), 27 deletions(-) diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs index 9ad7cffd..f769bca3 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs @@ -1,4 +1,5 @@ using Dapper.CodeAnalysis.Abstractions; +using Dapper.CodeAnalysis.Model; using Dapper.CodeAnalysis.Writers; using Dapper.Internal; using Dapper.Internal.Roslyn; @@ -90,6 +91,14 @@ internal bool PreFilter(SyntaxNode node, CancellationToken cancellationToken) return null; } } + // see https://github.com/dotnet/roslyn/blob/main/docs/features/interceptors.md#file-paths + // (the parse-time projection of GenerateState.GetInterceptorFilePath) + private static string InterceptorFilePath(in ParseState ctx, Location location) + { + if (location.SourceTree is not { } tree) return ""; + return ctx.SemanticModel.Compilation.Options.SourceReferenceResolver?.NormalizePath(tree.FilePath, baseFilePath: null) ?? tree.FilePath; + } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Chosen API")] internal SourceState? Parse(ParseState ctx) { @@ -106,14 +115,14 @@ internal bool PreFilter(SyntaxNode node, CancellationToken cancellationToken) if (flags.HasAny(OperationFlags.NotAotSupported)) { // not our API (yet); count it, so the scorecard stays honest - return new SkippedSourceState(ie.GetLocation(), flags); + return new SkippedSourceState(new LocationSnapshot(ie.GetLocation()), flags); } var location = DapperAnalyzer.SharedParseArgsAndFlags(ctx, op, ref flags, out var sql, out var argExpression, reportDiagnostic: null, out var resultType, exitFirstFailure: true); if (flags.HasAny(OperationFlags.DoNotGenerate)) { // diagnostics (from the analyzer's identical pass) told us to leave it alone - return new SkippedSourceState(location, flags); + return new SkippedSourceState(new LocationSnapshot(location), flags); } @@ -139,14 +148,16 @@ internal bool PreFilter(SyntaxNode node, CancellationToken cancellationToken) var additionalState = AdditionalCommandState.Parse(Inspection.GetSymbol(ctx, op), map, null); Debug.Assert(!flags.HasAny(OperationFlags.DoNotGenerate), "should have already exited"); - return new SuccessSourceState(location, op.TargetMethod, flags, sql, resultType, argExpression?.Type, parameterMap, additionalState); + int languageVersion = ctx.Node.SyntaxTree.Options is CSharpParseOptions csOptions ? (int)csOptions.LanguageVersion : -1; + return new SuccessSourceState(new LocationSnapshot(location), InterceptorFilePath(ctx, location), languageVersion, + op.TargetMethod, flags, sql, resultType, argExpression?.Type, parameterMap, additionalState); } catch (Exception ex) { - Location? loc = null; + LocationSnapshot loc = default; try { - loc = ctx.Node.GetLocation(); + loc = new LocationSnapshot(ctx.Node.GetLocation()); } catch { } // best effort only return new FaultSourceState(loc, ex); @@ -231,11 +242,12 @@ private static bool CheckPrerequisites(in GenerateState ctx) if (ctx.Nodes.IsDefaultOrEmpty) return false; // nothing to do // find the first enabled thing with a C# parse options - if (ctx.Nodes.OfType().FirstOrDefault()?.Location?.SourceTree?.Options is not CSharpParseOptions options) return false; // not C# + var firstSuccess = ctx.Nodes.OfType().FirstOrDefault(); + if (firstSuccess is null || firstSuccess.LanguageVersion < 0) return false; // not C# bool success = true; - var version = options.LanguageVersion; + var version = (LanguageVersion)firstSuccess.LanguageVersion; if (version != LanguageVersion.Default && version < LanguageVersion.CSharp11) { ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.LanguageVersionTooLow, null)); @@ -265,7 +277,7 @@ internal void Generate(in GenerateState ctx) foreach (var fault in ctx.Nodes.OfType()) { var ex = fault.Fault; - ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.UnknownError, fault.Location, ex.Message, ex.StackTrace)); + ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.UnknownError, fault.Location.AsLocation(), ex.Message, ex.StackTrace)); } int unsupported = 0, skippedViaDiagnostics = 0; @@ -309,10 +321,9 @@ internal void Generate(in GenerateState ctx) foreach (var op in grp.OrderBy(row => row.Location, CommonComparer.Instance)) { - var loc = op.Location.GetLineSpan(); - var start = loc.StartLinePosition; + var loc = op.Location; sb.Append("[global::System.Runtime.CompilerServices.InterceptsLocationAttribute(") - .AppendVerbatimLiteral(ctx.GetInterceptorFilePath(op.Location.SourceTree)).Append(", ").Append(start.Line + 1).Append(", ").Append(start.Character + 1).Append(")]").NewLine(); + .AppendVerbatimLiteral(op.InterceptorFilePath).Append(", ").Append(loc.StartLine + 1).Append(", ").Append(loc.StartChar + 1).Append(")]").NewLine(); usageCount++; } @@ -370,8 +381,7 @@ internal void Generate(in GenerateState ctx) fixedSql = origin.Sql; // expect exactly one SQL sb.Append("global::System.Diagnostics.Debug.Assert(sql == ") .AppendVerbatimLiteral(fixedSql).Append(");").NewLine(); - var path = origin.Location.GetMappedLineSpan(); - fixedSql = $"-- {path.Path}#{path.StartLinePosition.Line + 1}\r\n{fixedSql}"; + fixedSql = $"-- {origin.Location.MappedPath}#{origin.Location.MappedStartLine + 1}\r\n{fixedSql}"; } else { @@ -1511,8 +1521,8 @@ static bool IsDerived(ITypeSymbol? type, ITypeSymbol baseType) internal abstract class SourceState { - public Location? Location { get; } - protected SourceState(Location? location) => Location = location; + public LocationSnapshot Location { get; } + protected SourceState(in LocationSnapshot location) => Location = location; } internal sealed class SkippedSourceState : SourceState @@ -1521,7 +1531,7 @@ internal sealed class SkippedSourceState : SourceState // all, or diagnostics made us leave it alone; retained so the DAP000 scorecard can // count honestly rather than quietly shrinking the denominator public OperationFlags Flags { get; } - public SkippedSourceState(Location? location, OperationFlags flags) : base(location) + public SkippedSourceState(in LocationSnapshot location, OperationFlags flags) : base(location) => Flags = flags; } @@ -1529,13 +1539,14 @@ internal sealed class FaultSourceState : SourceState { public Exception Fault { get; } - public FaultSourceState(Location? location, Exception fault) : base(location) + public FaultSourceState(in LocationSnapshot location, Exception fault) : base(location) => Fault = fault; } internal sealed class SuccessSourceState : SourceState { - public new Location Location => base.Location!; // assert non-null + public string InterceptorFilePath { get; } // normalized per the interceptors spec + public int LanguageVersion { get; } // raw LanguageVersion value; -1 when not C# public OperationFlags Flags { get; } public string? Sql { get; } @@ -1545,10 +1556,13 @@ internal sealed class SuccessSourceState : SourceState public ITypeSymbol? ParameterType { get; } public AdditionalCommandState? AdditionalCommandState { get; } - public SuccessSourceState(Location location, IMethodSymbol method, OperationFlags flags, string? sql, + public SuccessSourceState(in LocationSnapshot location, string interceptorFilePath, int languageVersion, + IMethodSymbol method, OperationFlags flags, string? sql, ITypeSymbol? resultType, ITypeSymbol? parameterType, string parameterMap, AdditionalCommandState? additionalCommandState) : base(location) { + InterceptorFilePath = interceptorFilePath; + LanguageVersion = languageVersion; Flags = flags; Sql = sql; ResultType = resultType; @@ -1558,25 +1572,42 @@ public SuccessSourceState(Location location, IMethodSymbol method, OperationFlag AdditionalCommandState = additionalCommandState; } - public (OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, Location? UniqueLocation, AdditionalCommandState? AdditionalCommandState) Group() + public (OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState) Group() => new(Flags, Method, ParameterType, ParameterMap, (Flags & (OperationFlags.CacheCommand | OperationFlags.IncludeLocation)) == 0 ? null : Location, AdditionalCommandState); } - private sealed class CommonComparer : LocationComparer, IEqualityComparer<(OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, Location? UniqueLocation, AdditionalCommandState? AdditionalCommandState)> + private sealed class CommonComparer : + IComparer, + IEqualityComparer<(OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState)> { public static readonly CommonComparer Instance = new(); private CommonComparer() { } + public int Compare(LocationSnapshot x, LocationSnapshot y) + { + // same semantics as the old Location-based LocationComparer: path, then start, then end + var delta = StringComparer.InvariantCulture.Compare(x.Path, y.Path); + if (delta == 0) + { + delta = (x.StartLine, x.StartChar).CompareTo((y.StartLine, y.StartChar)); + } + if (delta == 0) + { + delta = (x.EndLine, x.EndChar).CompareTo((y.EndLine, y.EndChar)); + } + return delta; + } + public bool Equals( - (OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, Location? UniqueLocation, AdditionalCommandState? AdditionalCommandState) x, - (OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, Location? UniqueLocation, AdditionalCommandState? AdditionalCommandState) y) => x.Flags == y.Flags + (OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState) x, + (OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState) y) => x.Flags == y.Flags && x.ParameterMap == y.ParameterMap && SymbolEqualityComparer.Default.Equals(x.Method, y.Method) && SymbolEqualityComparer.Default.Equals(x.ParameterType, y.ParameterType) - && x.UniqueLocation == y.UniqueLocation + && Nullable.Equals(x.UniqueLocation, y.UniqueLocation) && Equals(x.AdditionalCommandState, y.AdditionalCommandState); - public int GetHashCode((OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, Location? UniqueLocation, AdditionalCommandState? AdditionalCommandState) obj) + public int GetHashCode((OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState) obj) { var hash = (int)obj.Flags; hash *= -47; diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/LocationSnapshot.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/LocationSnapshot.cs index 9f957905..71469359 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/LocationSnapshot.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/LocationSnapshot.cs @@ -1,4 +1,4 @@ -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; using System; @@ -15,8 +15,10 @@ namespace Dapper.CodeAnalysis.Model; internal readonly struct LocationSnapshot : IEquatable { public readonly string Path; // the source path as the tree knows it (not path-mapped) + public readonly string MappedPath; // per GetMappedLineSpan: honors #line and path-mapping public readonly int SpanStart, SpanLength; public readonly int StartLine, StartChar, EndLine, EndChar; // zero-based, per LinePosition + public readonly int MappedStartLine; public LocationSnapshot(Location location) { @@ -28,6 +30,9 @@ public LocationSnapshot(Location location) StartChar = span.StartLinePosition.Character; EndLine = span.EndLinePosition.Line; EndChar = span.EndLinePosition.Character; + var mapped = location.GetMappedLineSpan(); + MappedPath = mapped.Path; + MappedStartLine = mapped.StartLinePosition.Line; } public bool IsDefault => Path is null; @@ -39,9 +44,11 @@ public Location AsLocation() => IsDefault ? Location.None : Location.Create(Path public bool Equals(LocationSnapshot other) => string.Equals(Path, other.Path, StringComparison.Ordinal) + && string.Equals(MappedPath, other.MappedPath, StringComparison.Ordinal) && SpanStart == other.SpanStart && SpanLength == other.SpanLength && StartLine == other.StartLine && StartChar == other.StartChar - && EndLine == other.EndLine && EndChar == other.EndChar; + && EndLine == other.EndLine && EndChar == other.EndChar + && MappedStartLine == other.MappedStartLine; public override bool Equals(object? obj) => obj is LocationSnapshot other && Equals(other); public override int GetHashCode() From 10a9aef14b604af713a722ca4cc2e46cd36c0282 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 18 Aug 2026 11:57:01 +0100 Subject: [PATCH 3/4] Phase 2, increment 3b: the intercepted method symbol projected at parse SuccessSourceState.Method becomes InterceptedMethod - plain data with what signature emission and parameter forwarding actually consume: Append-form return/parameter type strings, name, extension-ness, arity, and the precomputed NRT-shim fact (the IsAsync/annotation dance moves to parse). Grouping equality moves from SymbolEqualityComparer to the model's structural equality; HasParam/Forward walk MethodParam names. CodeWriter.GetAppendTypeName is the one canonical 'what Append(ITypeSymbol) would emit' helper so projections cannot drift from emission. Byte-identical verified: goldens unchanged (net10/net48), harness hash equal (f8d3a61f). The model shape test picks up the new types automatically. --- .../DapperInterceptorGenerator.Multi.cs | 5 +- .../DapperInterceptorGenerator.Single.cs | 21 ++---- .../DapperInterceptorGenerator.cs | 38 ++++++++--- .../CodeAnalysis/Model/InterceptedMethod.cs | 67 +++++++++++++++++++ .../Internal/CodeWriter.cs | 5 ++ 5 files changed, 109 insertions(+), 27 deletions(-) create mode 100644 src/Dapper.AOT.Analyzers/CodeAnalysis/Model/InterceptedMethod.cs diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.Multi.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.Multi.cs index df84eb6f..928ee3bc 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.Multi.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.Multi.cs @@ -1,4 +1,5 @@ -using Dapper.Internal; +using Dapper.CodeAnalysis.Model; +using Dapper.Internal; using Microsoft.CodeAnalysis; using System.Collections.Immutable; @@ -12,7 +13,7 @@ private static bool TryWriteMultiExecImplementation( OperationFlags commandTypeMode, ITypeSymbol? parameterType, string map, bool cache, - ImmutableArray methodParameters, + EquatableArray methodParameters, CommandFactoryState factories, string? fixedSql, AdditionalCommandState? additionalCommandState) diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.Single.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.Single.cs index 58c3f806..44e26202 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.Single.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.Single.cs @@ -1,4 +1,5 @@ -using Dapper.Internal; +using Dapper.CodeAnalysis.Model; +using Dapper.Internal; using Dapper.Internal.Roslyn; using Microsoft.CodeAnalysis; using System.Collections.Immutable; @@ -9,13 +10,13 @@ public sealed partial class DapperInterceptorGenerator { static void WriteSingleImplementation( CodeWriter sb, - IMethodSymbol method, + InterceptedMethod method, ITypeSymbol? resultType, OperationFlags flags, OperationFlags commandTypeMode, ITypeSymbol? parameterType, string map, bool cache, - in ImmutableArray methodParameters, + in EquatableArray methodParameters, in CommandFactoryState factories, in RowReaderState readers, string? fixedSql, @@ -137,15 +138,7 @@ static void WriteSingleImplementation( { // there are some NRT oddities in Dapper itself; shim over everything // (we know that DapperAOT has "T? {First|Single}OrDefault[Async]" and "T? ExecuteScalar[Async]") - bool addNullForgiving; - if (method.ReturnType.IsAsync(out var t)) - { - addNullForgiving = t is not null && t.NullableAnnotation != NullableAnnotation.Annotated; - } - else - { - addNullForgiving = method.ReturnType.NullableAnnotation != NullableAnnotation.Annotated; - } + bool addNullForgiving = method.ReturnValueNeedsNullForgiving; if (addNullForgiving) { sb.Append("!"); @@ -167,7 +160,7 @@ static CodeWriter WriteTypedArg(CodeWriter sb, ITypeSymbol? parameterType) } } - private static bool HasParam(ImmutableArray methodParameters, string name) + private static bool HasParam(in EquatableArray methodParameters, string name) { foreach (var p in methodParameters) { @@ -179,6 +172,6 @@ private static bool HasParam(ImmutableArray methodParameters, return false; } - private static string Forward(ImmutableArray methodParameters, string name) + private static string Forward(in EquatableArray methodParameters, string name) => HasParam(methodParameters, name) ? name : "default"; } \ No newline at end of file diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs index f769bca3..79b8c94d 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs @@ -99,6 +99,22 @@ private static string InterceptorFilePath(in ParseState ctx, Location location) return ctx.SemanticModel.Compilation.Options.SourceReferenceResolver?.NormalizePath(tree.FilePath, baseFilePath: null) ?? tree.FilePath; } + private static InterceptedMethod ProjectMethod(IMethodSymbol method) + { + var args = method.Parameters; + var parameters = new MethodParam[args.Length]; + for (int i = 0; i < args.Length; i++) + { + parameters[i] = new MethodParam(CodeWriter.GetAppendTypeName(args[i].Type), args[i].Name); + } + // the NRT shim over Dapper oddities: is the (awaited) return value annotated? + bool needsNullForgiving = method.ReturnType.IsAsync(out var awaited) + ? awaited is not null && awaited.NullableAnnotation != NullableAnnotation.Annotated + : method.ReturnType.NullableAnnotation != NullableAnnotation.Annotated; + return new InterceptedMethod(CodeWriter.GetAppendTypeName(method.ReturnType), method.Name, + method.IsExtensionMethod, method.Arity, needsNullForgiving, new EquatableArray(parameters)); + } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Chosen API")] internal SourceState? Parse(ParseState ctx) { @@ -150,7 +166,7 @@ private static string InterceptorFilePath(in ParseState ctx, Location location) Debug.Assert(!flags.HasAny(OperationFlags.DoNotGenerate), "should have already exited"); int languageVersion = ctx.Node.SyntaxTree.Options is CSharpParseOptions csOptions ? (int)csOptions.LanguageVersion : -1; return new SuccessSourceState(new LocationSnapshot(location), InterceptorFilePath(ctx, location), languageVersion, - op.TargetMethod, flags, sql, resultType, argExpression?.Type, parameterMap, additionalState); + ProjectMethod(op.TargetMethod), flags, sql, resultType, argExpression?.Type, parameterMap, additionalState); } catch (Exception ex) { @@ -343,7 +359,7 @@ internal void Generate(in GenerateState ctx) for (int i = 0; i < parameters.Length; i++) { if (i != 0) sb.Append(", "); - else if (method.IsExtensionMethod) sb.Append("this "); + else if (method.IsExtension) sb.Append("this "); sb.Append(parameters[i].Type).Append(" ").Append(parameters[i].Name); } sb.Append(")").Indent().NewLine(); @@ -1551,13 +1567,13 @@ internal sealed class SuccessSourceState : SourceState public OperationFlags Flags { get; } public string? Sql { get; } public string ParameterMap { get; } - public IMethodSymbol Method { get; } + public InterceptedMethod Method { get; } public ITypeSymbol? ResultType { get; } public ITypeSymbol? ParameterType { get; } public AdditionalCommandState? AdditionalCommandState { get; } public SuccessSourceState(in LocationSnapshot location, string interceptorFilePath, int languageVersion, - IMethodSymbol method, OperationFlags flags, string? sql, + InterceptedMethod method, OperationFlags flags, string? sql, ITypeSymbol? resultType, ITypeSymbol? parameterType, string parameterMap, AdditionalCommandState? additionalCommandState) : base(location) { @@ -1572,12 +1588,12 @@ public SuccessSourceState(in LocationSnapshot location, string interceptorFilePa AdditionalCommandState = additionalCommandState; } - public (OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState) Group() + public (OperationFlags Flags, InterceptedMethod Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState) Group() => new(Flags, Method, ParameterType, ParameterMap, (Flags & (OperationFlags.CacheCommand | OperationFlags.IncludeLocation)) == 0 ? null : Location, AdditionalCommandState); } private sealed class CommonComparer : IComparer, - IEqualityComparer<(OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState)> + IEqualityComparer<(OperationFlags Flags, InterceptedMethod Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState)> { public static readonly CommonComparer Instance = new(); private CommonComparer() { } @@ -1599,21 +1615,21 @@ public int Compare(LocationSnapshot x, LocationSnapshot y) public bool Equals( - (OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState) x, - (OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState) y) => x.Flags == y.Flags + (OperationFlags Flags, InterceptedMethod Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState) x, + (OperationFlags Flags, InterceptedMethod Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState) y) => x.Flags == y.Flags && x.ParameterMap == y.ParameterMap - && SymbolEqualityComparer.Default.Equals(x.Method, y.Method) + && x.Method.Equals(y.Method) && SymbolEqualityComparer.Default.Equals(x.ParameterType, y.ParameterType) && Nullable.Equals(x.UniqueLocation, y.UniqueLocation) && Equals(x.AdditionalCommandState, y.AdditionalCommandState); - public int GetHashCode((OperationFlags Flags, IMethodSymbol Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState) obj) + public int GetHashCode((OperationFlags Flags, InterceptedMethod Method, ITypeSymbol? ParameterType, string ParameterMap, LocationSnapshot? UniqueLocation, AdditionalCommandState? AdditionalCommandState) obj) { var hash = (int)obj.Flags; hash *= -47; hash += obj.ParameterMap.GetHashCode(); hash *= -47; - hash += SymbolEqualityComparer.Default.GetHashCode(obj.Method); + hash += obj.Method.GetHashCode(); hash *= -47; if (obj.ParameterType is not null) { diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/InterceptedMethod.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/InterceptedMethod.cs new file mode 100644 index 00000000..1a9a162b --- /dev/null +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/InterceptedMethod.cs @@ -0,0 +1,67 @@ +using System; + +namespace Dapper.CodeAnalysis.Model; + +/// +/// The intercepted Dapper method's shape as plain data: everything signature emission and +/// parameter forwarding need, projected at parse time (no symbols may be cached - see the +/// model shape test). +/// +internal sealed class InterceptedMethod : IEquatable +{ + public string ReturnType { get; } // in emitted (Append) form + public string Name { get; } + public bool IsExtension { get; } + public int Arity { get; } + /// Per the NRT shim: the (awaited) return value is not nullable-annotated. + public bool ReturnValueNeedsNullForgiving { get; } + public EquatableArray Parameters { get; } + + public InterceptedMethod(string returnType, string name, bool isExtension, int arity, + bool returnValueNeedsNullForgiving, EquatableArray parameters) + { + ReturnType = returnType; + Name = name; + IsExtension = isExtension; + Arity = arity; + ReturnValueNeedsNullForgiving = returnValueNeedsNullForgiving; + Parameters = parameters; + } + + public bool Equals(InterceptedMethod? other) => other is not null + && string.Equals(ReturnType, other.ReturnType, StringComparison.Ordinal) + && string.Equals(Name, other.Name, StringComparison.Ordinal) + && IsExtension == other.IsExtension + && Arity == other.Arity + && ReturnValueNeedsNullForgiving == other.ReturnValueNeedsNullForgiving + && Parameters.Equals(other.Parameters); + + public override bool Equals(object? obj) => Equals(obj as InterceptedMethod); + public override int GetHashCode() + { + int hash = StringComparer.Ordinal.GetHashCode(Name); + hash = (hash * -47) + StringComparer.Ordinal.GetHashCode(ReturnType); + hash = (hash * -47) + Parameters.GetHashCode(); + return hash; + } + public override string ToString() => $"{ReturnType} {Name}"; +} + +internal readonly struct MethodParam : IEquatable +{ + public string Type { get; } // in emitted (Append) form + public string Name { get; } + + public MethodParam(string type, string name) + { + Type = type; + Name = name; + } + + public bool Equals(MethodParam other) + => string.Equals(Type, other.Type, StringComparison.Ordinal) + && string.Equals(Name, other.Name, StringComparison.Ordinal); + + public override bool Equals(object? obj) => obj is MethodParam other && Equals(other); + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Name); +} diff --git a/src/Dapper.AOT.Analyzers/Internal/CodeWriter.cs b/src/Dapper.AOT.Analyzers/Internal/CodeWriter.cs index 4cca6b57..2310c956 100644 --- a/src/Dapper.AOT.Analyzers/Internal/CodeWriter.cs +++ b/src/Dapper.AOT.Analyzers/Internal/CodeWriter.cs @@ -67,6 +67,11 @@ public CodeWriter Append(string? value) return this; } + /// The string would emit for this type. + internal static string GetAppendTypeName(ITypeSymbol value) => value.IsAnonymousType + ? value.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + : GetTypeName(value); + public static string GetTypeName(ITypeSymbol? value) { static bool IsNonNullableValueType(ITypeSymbol type) From 9d6ec9a66ad8fe6376398d91901f719ba79143b6 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 18 Aug 2026 12:03:10 +0100 Subject: [PATCH 4/4] Phase 2, increment 3c-i: AdditionalCommandState becomes plain data CommandProperty held an INamedTypeSymbol and a Location in the cached model; it now stores the emitted type name, the short name (for diagnostics), the precomputed is-DbCommand and member-exists facts, and a LocationSnapshot - the symbol probes run at construction (parse time) instead of at emit. QueryColumns and CommandProperties move from ImmutableArray to EquatableArray, which needed default-vs-empty to be distinguishable (QueryColumns semantics depend on it), so EquatableArray no longer collapses empty to default. AdditionalCommandState moves into the Model namespace, putting it under the shape test's enforcement; the type-name grouping in WriteCommandProperties replaces the symbol grouping. Byte-identical: goldens unchanged (net10/net48), harness hash equal (f8d3a61f). --- .../CodeAnalysis/DapperAnalyzer.cs | 19 +- .../DapperInterceptorGenerator.cs | 52 +---- .../Model/AdditionalCommandState.cs | 175 +++++++++++++++++ .../CodeAnalysis/Model/EquatableArray.cs | 10 +- .../Internal/AdditionalCommandState.cs | 179 ------------------ .../Internal/CodeWriter.cs | 4 +- .../Internal/CommandFactoryState.cs | 3 +- .../Internal/MemberMap.cs | 5 +- .../Internal/RowReaderState.cs | 21 +- 9 files changed, 218 insertions(+), 250 deletions(-) create mode 100644 src/Dapper.AOT.Analyzers/CodeAnalysis/Model/AdditionalCommandState.cs delete mode 100644 src/Dapper.AOT.Analyzers/Internal/AdditionalCommandState.cs diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs index 970b83f9..dc7362d6 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperAnalyzer.cs @@ -1,4 +1,5 @@ using Dapper.Internal; +using Dapper.CodeAnalysis.Model; using Dapper.Internal.Roslyn; using Dapper.SqlAnalysis; using Microsoft.CodeAnalysis; @@ -872,10 +873,10 @@ enum ParameterMode } } - ImmutableArray cmdProps; + EquatableArray cmdProps = default; if (cmdPropsCount != 0) { - var builder = ImmutableArray.CreateBuilder(cmdPropsCount); + var builder = new List(cmdPropsCount); foreach (var attrib in methodAttribs) { if (IsDapperAttribute(attrib) && attrib.AttributeClass!.Name == Types.CommandPropertyAttribute @@ -885,19 +886,15 @@ enum ParameterMode && attrib.ConstructorArguments[0].Value is string name && attrib.ConstructorArguments[1].Value is object value) { - builder.Add(new(cmdType, name, value, location)); + builder.Add(CommandProperty.Create(cmdType, name, value, location)); } } - cmdProps = builder.ToImmutable(); + cmdProps = new(builder.ToArray()); } - else - { - cmdProps = ImmutableArray.Empty; - } - - return cmdProps.IsDefaultOrEmpty && rowCountHint <= 0 && rowCountHintMember is null && batchSize is null && queryColumns.IsDefault - ? null : new(rowCountHint, rowCountHintMember?.Member?.Name, batchSize, cmdProps, queryColumns); + var queryColumnsModel = queryColumns.IsDefault ? default : new EquatableArray(queryColumns.AsSpan().ToArray()); + return cmdProps.IsEmpty && rowCountHint <= 0 && rowCountHintMember is null && batchSize is null && queryColumnsModel.IsDefault + ? null : new(rowCountHint, rowCountHintMember?.Member?.Name, batchSize, cmdProps, queryColumnsModel); } static void ValidateParameters(MemberMap? parameters, OperationFlags flags, Action onDiagnostic) diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs index 79b8c94d..1337b597 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/DapperInterceptorGenerator.cs @@ -518,7 +518,7 @@ internal void Generate(in GenerateState ctx) methodIndex, factories.Count(), readers.Count())); } - private static void WriteGetRowParser(CodeWriter sb, ITypeSymbol? resultType, in RowReaderState readers, OperationFlags flags, ImmutableArray queryColumns) + private static void WriteGetRowParser(CodeWriter sb, ITypeSymbol? resultType, in RowReaderState readers, OperationFlags flags, in EquatableArray queryColumns) { sb.Append("return ").AppendReader(resultType, readers, flags, queryColumns) .Append(".GetRowParser(reader, startIndex, length, returnNullIfFirstMissing);").NewLine(); @@ -675,44 +675,27 @@ static CodeWriter WriteGetCommandHeader(CodeWriter sb, string declaredType) => s } [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0066:Convert switch statement to expression", Justification = "Readability")] - private static void WriteCommandProperties(in GenerateState ctx, CodeWriter sb, string source, ImmutableArray properties, int index = 0) + private static void WriteCommandProperties(in GenerateState ctx, CodeWriter sb, string source, in EquatableArray properties, int index = 0) { - foreach (var grp in properties.GroupBy(x => x.CommandType, SymbolEqualityComparer.Default)) + foreach (var grp in properties.GroupBy(x => x.CommandTypeName, StringComparer.Ordinal)) { - var type = (INamedTypeSymbol)grp.Key!; - bool isDbCmd = type is - { - Name: "DbCommand", ContainingType: null, Arity: 0, TypeKind: TypeKind.Class, ContainingNamespace: - { - Name: "Common", - ContainingNamespace: - { - Name: "Data", - ContainingNamespace: - { - Name: "System", - ContainingNamespace.IsGlobalNamespace: true - } - } - } - }; - - bool firstForType = true; // defer starting the if-test in case all invalid + bool isDbCmd = false, firstForType = true; // defer starting the if-test in case all invalid foreach (var prop in grp) { + isDbCmd = prop.IsDbCommand; if (IsReserved(prop.Name)) { - ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.CommandPropertyReserved, prop.Location, prop.Name)); + ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.CommandPropertyReserved, prop.Location.AsLocation(), prop.Name)); continue; } - else if (!HasPublicSettableInstanceMember(type, prop.Name)) + else if (!prop.MemberExists) { - ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.CommandPropertyNotFound, prop.Location, type.Name, prop.Name)); + ctx.ReportDiagnostic(Diagnostic.Create(Diagnostics.CommandPropertyNotFound, prop.Location.AsLocation(), prop.CommandTypeShortName, prop.Name)); continue; } if (firstForType && !isDbCmd) { - sb.NewLine().Append("if (cmd is ").Append(type).Append(" cmd").Append(index).Append(")").Indent(); + sb.NewLine().Append("if (cmd is ").Append(grp.Key).Append(" cmd").Append(index).Append(")").Indent(); firstForType = false; } @@ -747,21 +730,6 @@ private static void WriteCommandProperties(in GenerateState ctx, CodeWriter sb, } } - static bool HasPublicSettableInstanceMember(ITypeSymbol type, string name) - { - foreach (var member in type.GetMembers()) - { - if (member.IsStatic || member.Name != name || member.DeclaredAccessibility != Accessibility.Public) continue; - return member.Kind switch - { - SymbolKind.Field when member is IFieldSymbol field => field.IsReadOnly, - SymbolKind.Property when member is IPropertySymbol prop => prop.SetMethod is not null, - _ => false, - }; - } - return false; - } - static bool IsReserved(string name) { switch (name) @@ -785,7 +753,7 @@ static bool IsReserved(string name) } } - private static void WriteRowFactory(in GenerateState context, CodeWriter sb, ITypeSymbol type, int index, OperationFlags flags, ImmutableArray queryColumns, Location? location) + private static void WriteRowFactory(in GenerateState context, CodeWriter sb, ITypeSymbol type, int index, OperationFlags flags, EquatableArray queryColumns, Location? location) { var map = MemberMap.CreateForResults(type, location); if (map is null) return; diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/AdditionalCommandState.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/AdditionalCommandState.cs new file mode 100644 index 00000000..862e5d72 --- /dev/null +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/AdditionalCommandState.cs @@ -0,0 +1,175 @@ +using Dapper.CodeAnalysis; +using Dapper.Internal; +using Microsoft.CodeAnalysis; +using System; +using System.Collections.Immutable; + +namespace Dapper.CodeAnalysis.Model; + +/// +/// A [CommandProperty] declaration as plain data (this rides in the cached generator +/// model, so no symbols may be stored - see the model shape test); the validity probes that +/// need the command-type symbol run at construction. +/// +internal readonly struct CommandProperty : IEquatable +{ + public readonly string CommandTypeName; // emitted (Append) form, for the "cmd is X" test + public readonly string CommandTypeShortName; // for diagnostics + public readonly bool IsDbCommand; // System.Data.Common.DbCommand itself: no type test needed + public readonly bool MemberExists; // the named member probe, evaluated against the symbol + public readonly string Name; + public readonly object Value; // attribute constant: string/int/bool etc + public readonly LocationSnapshot Location; + + private CommandProperty(string commandTypeName, string commandTypeShortName, bool isDbCommand, + bool memberExists, string name, object value, in LocationSnapshot location) + { + CommandTypeName = commandTypeName; + CommandTypeShortName = commandTypeShortName; + IsDbCommand = isDbCommand; + MemberExists = memberExists; + Name = name; + Value = value; + Location = location; + } + + public static CommandProperty Create(INamedTypeSymbol commandType, string name, object value, Location? location) + { + bool isDbCmd = commandType is + { + Name: "DbCommand", ContainingType: null, Arity: 0, TypeKind: TypeKind.Class, ContainingNamespace: + { + Name: "Common", + ContainingNamespace: + { + Name: "Data", + ContainingNamespace: + { + Name: "System", + ContainingNamespace.IsGlobalNamespace: true + } + } + } + }; + return new(CodeWriter.GetAppendTypeName(commandType), commandType.Name, isDbCmd, + HasPublicSettableInstanceMember(commandType, name), name, value, + location is null ? default : new LocationSnapshot(location)); + } + + // note: preserved exactly from the emit-time check it replaces, quirks and all + private static bool HasPublicSettableInstanceMember(ITypeSymbol type, string name) + { + foreach (var member in type.GetMembers()) + { + if (member.IsStatic || member.Name != name || member.DeclaredAccessibility != Accessibility.Public) continue; + return member.Kind switch + { + SymbolKind.Field when member is IFieldSymbol field => field.IsReadOnly, + SymbolKind.Property when member is IPropertySymbol prop => prop.SetMethod is not null, + _ => false, + }; + } + return false; + } + + public override string ToString() => $"{CommandTypeShortName}.{Name}={Value}"; + + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(CommandTypeName) & Name.GetHashCode() ^ (Value?.GetHashCode() ?? 0) ^ Location.GetHashCode(); + + public override bool Equals(object obj) => obj is CommandProperty other && Equals(in other); + + bool IEquatable.Equals(CommandProperty other) => Equals(in other); + public bool Equals(in CommandProperty other) + => string.Equals(CommandTypeName, other.CommandTypeName, StringComparison.Ordinal) + && IsDbCommand == other.IsDbCommand + && MemberExists == other.MemberExists + && string.Equals(Name, other.Name) + && Equals(Value, other.Value) + && Location.Equals(other.Location); +} + +internal sealed class AdditionalCommandState : IEquatable +{ + public readonly int RowCountHint; + public readonly int? BatchSize; + public readonly string? RowCountHintMemberName; + public readonly EquatableArray CommandProperties; + public readonly EquatableArray QueryColumns; // default (unset) is distinct from empty + + public bool HasRowCountHint => RowCountHint > 0 || RowCountHintMemberName is not null; + + public bool HasCommandProperties => !CommandProperties.IsEmpty; + + public static AdditionalCommandState? Parse(ISymbol? target, MemberMap? map, Action? reportDiagnostic) + { + if (target is null) return null; + + var inherited = target is IAssemblySymbol ? null : Parse(target.ContainingSymbol, null, reportDiagnostic); + var local = DapperAnalyzer.SharedGetAdditionalCommandState(target, map, reportDiagnostic); + if (inherited is null) return local; + if (local is null) return inherited; + return Combine(inherited, local); + } + + private static AdditionalCommandState Combine(AdditionalCommandState inherited, AdditionalCommandState overrides) + { + if (inherited is null) return overrides; + if (overrides is null) return inherited; + + var count = inherited.RowCountHint; + var countMember = inherited.RowCountHintMemberName; + + if (overrides.RowCountHintMemberName is not null) + { + count = 0; + countMember = overrides.RowCountHintMemberName; + } + else if (overrides.RowCountHint > 0) + { + count = overrides.RowCountHint; + countMember = null; + } + + return new(count, countMember, inherited.BatchSize ?? overrides.BatchSize, + Concat(inherited.CommandProperties, overrides.CommandProperties), + overrides.QueryColumns.IsDefault ? inherited.QueryColumns : overrides.QueryColumns); + } + + static EquatableArray Concat(in EquatableArray x, in EquatableArray y) + { + if (x.IsEmpty) return y; + if (y.IsEmpty) return x; + var arr = new CommandProperty[x.Length + y.Length]; + int index = 0; + foreach (var item in x) arr[index++] = item; + foreach (var item in y) arr[index++] = item; + return new(arr); + } + + internal AdditionalCommandState( + int rowCountHint, string? rowCountHintMemberName, int? batchSize, + in EquatableArray commandProperties, in EquatableArray queryColumns) + { + RowCountHint = rowCountHint; + RowCountHintMemberName = rowCountHintMemberName; + BatchSize = batchSize; + CommandProperties = commandProperties; + QueryColumns = queryColumns; + } + + public override bool Equals(object obj) => obj is AdditionalCommandState other && Equals(in other); + + bool IEquatable.Equals(AdditionalCommandState other) => Equals(in other); + + public bool Equals(in AdditionalCommandState other) + => RowCountHint == other.RowCountHint + && BatchSize == other.BatchSize + && RowCountHintMemberName == other.RowCountHintMemberName + && CommandProperties.Equals(other.CommandProperties) + && QueryColumns.Equals(other.QueryColumns); + + public override int GetHashCode() + => (RowCountHint + BatchSize.GetValueOrDefault() + + (RowCountHintMemberName is null ? 0 : StringComparer.Ordinal.GetHashCode(RowCountHintMemberName))) + ^ CommandProperties.GetHashCode() ^ QueryColumns.GetHashCode(); +} diff --git a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/EquatableArray.cs b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/EquatableArray.cs index 5f73817f..43360d0c 100644 --- a/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/EquatableArray.cs +++ b/src/Dapper.AOT.Analyzers/CodeAnalysis/Model/EquatableArray.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; @@ -15,13 +15,15 @@ namespace Dapper.CodeAnalysis.Model; where T : IEquatable { private readonly T[]? _items; - public static EquatableArray Empty => default; + public static EquatableArray Empty => new([]); - public EquatableArray(T[] items) => _items = items is { Length: 0 } ? null : items; + public EquatableArray(T[]? items) => _items = items; public int Length => _items?.Length ?? 0; public int Count => Length; public bool IsEmpty => Length == 0; + /// Distinct from empty, mirroring ImmutableArray: "not specified at all". + public bool IsDefault => _items is null; public T this[int index] => _items![index]; public bool Equals(EquatableArray other) @@ -41,7 +43,7 @@ public bool Equals(EquatableArray other) public override int GetHashCode() { - if (_items is null) return 0; + if (_items is null) return -1; // default is distinct from empty int hash = _items.Length; foreach (var item in _items) { diff --git a/src/Dapper.AOT.Analyzers/Internal/AdditionalCommandState.cs b/src/Dapper.AOT.Analyzers/Internal/AdditionalCommandState.cs deleted file mode 100644 index 171755b1..00000000 --- a/src/Dapper.AOT.Analyzers/Internal/AdditionalCommandState.cs +++ /dev/null @@ -1,179 +0,0 @@ -using Dapper.CodeAnalysis; -using Microsoft.CodeAnalysis; -using System; -using System.Collections.Immutable; -using System.Linq; - -namespace Dapper.Internal; - -internal readonly struct CommandProperty : IEquatable -{ - public readonly INamedTypeSymbol CommandType; - public readonly string Name; - public readonly object Value; - public readonly Location? Location; - - public CommandProperty(INamedTypeSymbol commandType, string name, object value, Location? location) - { - CommandType = commandType; - Name = name; - Value = value; - Location = location; - } - - public override string ToString() => $"{CommandType.Name}.{Name}={Value}"; - - public override int GetHashCode() => SymbolEqualityComparer.Default.GetHashCode(CommandType) & Name.GetHashCode() ^ (Value?.GetHashCode() ?? 0) ^ (Location?.GetHashCode() ?? 0); - - public override bool Equals(object obj) => obj is CommandProperty other && Equals(in other); - - bool IEquatable.Equals(CommandProperty other) => Equals(in other); - public bool Equals(in CommandProperty other) - => SymbolEqualityComparer.Default.Equals(CommandType, other.CommandType) && string.Equals(Name, other.Name) && Equals(Value, other.Value) && Equals(Location, other.Location); -} - -internal sealed class AdditionalCommandState : IEquatable -{ - public readonly int RowCountHint; - public readonly int? BatchSize; - public readonly string? RowCountHintMemberName; - public readonly ImmutableArray CommandProperties; - public readonly ImmutableArray QueryColumns; - - public bool HasRowCountHint => RowCountHint > 0 || RowCountHintMemberName is not null; - - public bool HasCommandProperties => !CommandProperties.IsDefaultOrEmpty; - - public static AdditionalCommandState? Parse(ISymbol? target, MemberMap? map, Action? reportDiagnostic) - { - if (target is null) return null; - - var inherited = target is IAssemblySymbol ? null : Parse(target.ContainingSymbol, null, reportDiagnostic); - var local = DapperAnalyzer.SharedGetAdditionalCommandState(target, map, reportDiagnostic); - if (inherited is null) return local; - if (local is null) return inherited; - return Combine(inherited, local); - } - - private static AdditionalCommandState Combine(AdditionalCommandState inherited, AdditionalCommandState overrides) - { - if (inherited is null) return overrides; - if (overrides is null) return inherited; - - var count = inherited.RowCountHint; - var countMember = inherited.RowCountHintMemberName; - - if (overrides.RowCountHintMemberName is not null) - { - count = 0; - countMember = overrides.RowCountHintMemberName; - } - else if (overrides.RowCountHint > 0) - { - count = overrides.RowCountHint; - countMember = null; - } - - return new(count, countMember, inherited.BatchSize ?? overrides.BatchSize, - Concat(inherited.CommandProperties, overrides.CommandProperties), - overrides.QueryColumns.IsDefault ? inherited.QueryColumns : overrides.QueryColumns); - } - - static ImmutableArray Concat(ImmutableArray x, ImmutableArray y) - { - if (x.IsDefaultOrEmpty) return y; - if (y.IsDefaultOrEmpty) return x; - var builder = ImmutableArray.CreateBuilder(x.Length + y.Length); - builder.AddRange(x); - builder.AddRange(y); - return builder.ToImmutable(); - } - - internal AdditionalCommandState( - int rowCountHint, string? rowCountHintMemberName, int? batchSize, - ImmutableArray commandProperties, ImmutableArray queryColumns) - { - RowCountHint = rowCountHint; - RowCountHintMemberName = rowCountHintMemberName; - BatchSize = batchSize; - CommandProperties = commandProperties; - QueryColumns = queryColumns; - } - - - public override bool Equals(object obj) => obj is AdditionalCommandState other && Equals(in other); - - bool IEquatable.Equals(AdditionalCommandState other) => Equals(in other); - - public bool Equals(in AdditionalCommandState other) - => RowCountHint == other.RowCountHint - && BatchSize == other.BatchSize - && RowCountHintMemberName == other.RowCountHintMemberName - && ((CommandProperties.IsDefaultOrEmpty && other.CommandProperties.IsDefaultOrEmpty) || Equals(CommandProperties, other.CommandProperties)) - && Equals(QueryColumns, other.QueryColumns); - - private static bool Equals(in ImmutableArray x, in ImmutableArray y) - { - if (x.IsDefaultOrEmpty) - { - return y.IsDefaultOrEmpty; - } - if (y.IsDefaultOrEmpty || x.Length != y.Length) - { - return false; - } - var ySpan = y.AsSpan(); - int index = 0; - foreach (ref readonly CommandProperty xVal in x.AsSpan()) - { - if (!xVal.Equals(in ySpan[index++])) - { - return false; - } - } - return true; - } - - static int GetHashCode(in ImmutableArray x) - { - if (x.IsDefaultOrEmpty) return 0; - - var value = 0; - foreach (ref readonly CommandProperty xVal in x.AsSpan()) - { - value = (value * -42) + xVal.GetHashCode(); - } - return value; - } - - internal static bool Equals(in ImmutableArray x, in ImmutableArray y) - { - if (x.IsDefaultOrEmpty) - { - return x.IsDefault ? y.IsDefault : y.IsEmpty; - } - if (y.IsDefaultOrEmpty || x.Length != y.Length) - { - return false; - } - return x.AsSpan().SequenceEqual(y.AsSpan()); - } - - internal static int GetHashCode(in ImmutableArray x) - { - if (x.IsDefaultOrEmpty) return x.IsDefault ? -1 : 0; - - int value = x.Length; - foreach (string xVal in x.AsSpan()) - { - value = (value * -42) + xVal.GetHashCode(); - } - return value; - } - - public override int GetHashCode() - => (RowCountHint + BatchSize.GetValueOrDefault() - + (RowCountHintMemberName is null ? 0 : RowCountHintMemberName.GetHashCode())) - ^ (CommandProperties.IsDefaultOrEmpty ? 0 : GetHashCode(in CommandProperties)) - ^ GetHashCode(QueryColumns); -} diff --git a/src/Dapper.AOT.Analyzers/Internal/CodeWriter.cs b/src/Dapper.AOT.Analyzers/Internal/CodeWriter.cs index 2310c956..5e5821db 100644 --- a/src/Dapper.AOT.Analyzers/Internal/CodeWriter.cs +++ b/src/Dapper.AOT.Analyzers/Internal/CodeWriter.cs @@ -8,6 +8,8 @@ namespace Dapper.Internal; +using Dapper.CodeAnalysis.Model; + internal sealed class CodeWriter { static CodeWriter? s_Spare; @@ -333,7 +335,7 @@ public string ToStringRecycle() return s; } - internal CodeWriter AppendReader(ITypeSymbol? resultType, RowReaderState readers, OperationFlags flags, ImmutableArray queryColumns) + internal CodeWriter AppendReader(ITypeSymbol? resultType, RowReaderState readers, OperationFlags flags, in EquatableArray queryColumns) { if (IsInbuiltResultType(resultType, out var helper)) { diff --git a/src/Dapper.AOT.Analyzers/Internal/CommandFactoryState.cs b/src/Dapper.AOT.Analyzers/Internal/CommandFactoryState.cs index 836968bb..4c162251 100644 --- a/src/Dapper.AOT.Analyzers/Internal/CommandFactoryState.cs +++ b/src/Dapper.AOT.Analyzers/Internal/CommandFactoryState.cs @@ -1,4 +1,5 @@ -using Microsoft.CodeAnalysis; +using Dapper.CodeAnalysis.Model; +using Microsoft.CodeAnalysis; using System; using System.Collections; using System.Collections.Generic; diff --git a/src/Dapper.AOT.Analyzers/Internal/MemberMap.cs b/src/Dapper.AOT.Analyzers/Internal/MemberMap.cs index 273b5c42..a2b632df 100644 --- a/src/Dapper.AOT.Analyzers/Internal/MemberMap.cs +++ b/src/Dapper.AOT.Analyzers/Internal/MemberMap.cs @@ -1,4 +1,5 @@ -using Dapper.CodeAnalysis; +using Dapper.CodeAnalysis.Model; +using Dapper.CodeAnalysis; using Microsoft.CodeAnalysis; using System; using System.Collections.Immutable; @@ -85,7 +86,7 @@ private MemberMap(bool forParameters, Location? location, ITypeSymbol declaredTy Members = GetMembers(forParameters, ElementType, Constructor, FactoryMethod); } - public ImmutableArray MapQueryColumns(ImmutableArray queryColumns) + public ImmutableArray MapQueryColumns(in EquatableArray queryColumns) { if (queryColumns.IsDefault) return Members; // not bound diff --git a/src/Dapper.AOT.Analyzers/Internal/RowReaderState.cs b/src/Dapper.AOT.Analyzers/Internal/RowReaderState.cs index a7ba4f29..2af186c7 100644 --- a/src/Dapper.AOT.Analyzers/Internal/RowReaderState.cs +++ b/src/Dapper.AOT.Analyzers/Internal/RowReaderState.cs @@ -1,4 +1,5 @@ -using Microsoft.CodeAnalysis; +using Dapper.CodeAnalysis.Model; +using Microsoft.CodeAnalysis; using System.Collections; using System.Collections.Generic; using System.Collections.Immutable; @@ -6,14 +7,14 @@ namespace Dapper.Internal; -internal readonly struct RowReaderState : IEnumerable<(ITypeSymbol Type, OperationFlags Flags, ImmutableArray QueryColumns, int Index)> +internal readonly struct RowReaderState : IEnumerable<(ITypeSymbol Type, OperationFlags Flags, EquatableArray QueryColumns, int Index)> { public RowReaderState() { } - private readonly Dictionary<(ITypeSymbol Type, OperationFlags Flags, ImmutableArray QueryColumns), int> resultTypes = new (KeyComparer.Instance); + private readonly Dictionary<(ITypeSymbol Type, OperationFlags Flags, EquatableArray QueryColumns), int> resultTypes = new (KeyComparer.Instance); public int Count() => resultTypes.Count; - public IEnumerator<(ITypeSymbol Type, OperationFlags Flags, ImmutableArray QueryColumns, int Index)> GetEnumerator() + public IEnumerator<(ITypeSymbol Type, OperationFlags Flags, EquatableArray QueryColumns, int Index)> GetEnumerator() { // retain discovery order return resultTypes.OrderBy(x => x.Value).Select(x => (x.Key.Type, x.Key.Flags, x.Key.QueryColumns, x.Value)).GetEnumerator(); @@ -21,7 +22,7 @@ public RowReaderState() { } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - public int GetIndex(ITypeSymbol type, OperationFlags flags, ImmutableArray queryColumns) + public int GetIndex(ITypeSymbol type, OperationFlags flags, EquatableArray queryColumns) { const OperationFlags SIGNIFICANT_FLAGS = OperationFlags.StrictTypes; // restrict to flags that impact the reader var key = (type, flags & SIGNIFICANT_FLAGS, queryColumns); @@ -32,15 +33,15 @@ public int GetIndex(ITypeSymbol type, OperationFlags flags, ImmutableArray QueryColumns)> + private sealed class KeyComparer : IEqualityComparer<(ITypeSymbol Type, OperationFlags Flags, EquatableArray QueryColumns)> { private KeyComparer() { } public static readonly KeyComparer Instance = new(); - bool IEqualityComparer<(ITypeSymbol Type, OperationFlags Flags, ImmutableArray QueryColumns)>.Equals((ITypeSymbol Type, OperationFlags Flags, ImmutableArray QueryColumns) x, (ITypeSymbol Type, OperationFlags Flags, ImmutableArray QueryColumns) y) - => SymbolEqualityComparer.Default.Equals(x.Type, y.Type) && x.Flags == y.Flags && AdditionalCommandState.Equals(x.QueryColumns, y.QueryColumns); + bool IEqualityComparer<(ITypeSymbol Type, OperationFlags Flags, EquatableArray QueryColumns)>.Equals((ITypeSymbol Type, OperationFlags Flags, EquatableArray QueryColumns) x, (ITypeSymbol Type, OperationFlags Flags, EquatableArray QueryColumns) y) + => SymbolEqualityComparer.Default.Equals(x.Type, y.Type) && x.Flags == y.Flags && x.QueryColumns.Equals(y.QueryColumns); - int IEqualityComparer<(ITypeSymbol Type, OperationFlags Flags, ImmutableArray QueryColumns)>.GetHashCode((ITypeSymbol Type, OperationFlags Flags, ImmutableArray QueryColumns) obj) - => SymbolEqualityComparer.Default.GetHashCode(obj.Type) ^ (int)obj.Flags ^ AdditionalCommandState.GetHashCode(obj.QueryColumns); + int IEqualityComparer<(ITypeSymbol Type, OperationFlags Flags, EquatableArray QueryColumns)>.GetHashCode((ITypeSymbol Type, OperationFlags Flags, EquatableArray QueryColumns) obj) + => SymbolEqualityComparer.Default.GetHashCode(obj.Type) ^ (int)obj.Flags ^ obj.QueryColumns.GetHashCode(); } }