diff --git a/src/BlueWest.MapTo/EfGeneratorContext.cs b/src/BlueWest.MapTo/EfGeneratorContext.cs new file mode 100644 index 0000000..2619cfa --- /dev/null +++ b/src/BlueWest.MapTo/EfGeneratorContext.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using MapTo.Extensions; +using MapTo.Sources; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +#pragma warning disable CS8602 + +namespace MapTo +{ + internal class EfGeneratorContext + { + private readonly List _ignoredNamespaces; + + public EfGeneratorContext(Compilation compilation, SourceGenerationOptions sourceGenerationOptions, MemberDeclarationSyntax memberSyntax) + { + Compilation = compilation; + _ignoredNamespaces = new(); + Diagnostics = ImmutableArray.Empty; + Usings = ImmutableArray.Create("System", Constants.RootNamespace); + SourceGenerationOptions = sourceGenerationOptions; + MemberSyntax = memberSyntax; + + MappingContextTypeSymbol = compilation.GetTypeByMetadataNameOrThrow(MappingContextSource.FullyQualifiedName); + + AddUsingIfRequired(sourceGenerationOptions.SupportNullableStaticAnalysis, "System.Diagnostics.CodeAnalysis"); + } + + public ImmutableArray Diagnostics { get; private set; } + + public EfMethodsModel? Model { get; private set; } + + protected Compilation Compilation { get; } + + protected INamedTypeSymbol MappingContextTypeSymbol { get; } + + protected INamedTypeSymbol EfAddMethodsAttributeTypeSymbol { get; } + + protected SourceGenerationOptions SourceGenerationOptions { get; } + + protected MemberDeclarationSyntax MemberSyntax { get; } + + protected ImmutableArray Usings { get; private set; } + + public static EfGeneratorContext Create(Compilation compilation, SourceGenerationOptions sourceGenerationOptions, MemberDeclarationSyntax typeSyntax) + { + EfGeneratorContext context = typeSyntax switch + { + PropertyDeclarationSyntax => new EfGeneratorContext(compilation, sourceGenerationOptions, typeSyntax), + _ => throw new ArgumentOutOfRangeException() + }; + + context.Model = context.CreateMappingModel(); + + return context; + } + + protected void AddDiagnostic(Diagnostic diagnostic) + { + Diagnostics = Diagnostics.Add(diagnostic); + } + + protected void AddUsingIfRequired(ISymbol? namedTypeSymbol) => + AddUsingIfRequired(namedTypeSymbol?.ContainingNamespace.IsGlobalNamespace == false, namedTypeSymbol?.ContainingNamespace); + + protected void AddUsingIfRequired(bool condition, INamespaceSymbol? ns) => + AddUsingIfRequired(condition && ns is not null && !_ignoredNamespaces.Contains(ns.ToDisplayParts().First()), ns?.ToDisplayString()); + + protected void AddUsingIfRequired(bool condition, string? ns) + { + if (ns is not null && condition && ns != MemberSyntax.GetNamespace() && !Usings.Contains(ns)) + { + Usings = Usings.Add(ns); + } + } + + protected ImmutableArray GetEntityTypeSymbol(MemberDeclarationSyntax memberDeclarationSyntax, SemanticModel? semanticModel = null) + { + var attributeData = memberDeclarationSyntax.GetAttribute(EfAddMethodsAttributeSource.AttributeName); + var sourceSymbol = GetEntityTypeSymbols(attributeData, semanticModel); + return sourceSymbol; + } + + // we need two possible InamedTypeSymbol + protected ImmutableArray GetEntityTypeSymbols(SyntaxNode? attributeSyntax, SemanticModel? semanticModel = null) + { + if (attributeSyntax is null) + { + return new ImmutableArray(){}; + } + + semanticModel ??= Compilation.GetSemanticModel(attributeSyntax.SyntaxTree); + var descendentNodes = attributeSyntax + .DescendantNodes(); + + var sourceTypeExpressionSyntax = descendentNodes + .OfType() + .ToImmutableArray(); + + // cast + var resultList = new List(); + for (int i = 0; i < sourceTypeExpressionSyntax.Length; i++) + { + var sourceTypeExpression = sourceTypeExpressionSyntax[i]; + if (semanticModel.GetTypeInfo(sourceTypeExpression.Type).Type is INamedTypeSymbol namedTypeSymbol) + { + resultList.Add(namedTypeSymbol); + } + } + + return resultList.ToImmutableArray(); + } + + + protected bool IsEnumerable(ISymbol property, out INamedTypeSymbol? namedTypeSymbolResult) + { + + if (!property.TryGetTypeSymbol(out var propertyType)) + { + AddDiagnostic(DiagnosticsFactory.NoMatchingPropertyTypeFoundError(property)); + namedTypeSymbolResult = null; + return false; + } + + if ( + propertyType is INamedTypeSymbol namedTypeSymbol && + !propertyType.IsPrimitiveType() && + (Compilation.IsGenericEnumerable(propertyType) || propertyType.AllInterfaces.Any(i => Compilation.IsGenericEnumerable(i)))) + { + namedTypeSymbolResult = namedTypeSymbol; + return true; + } + namedTypeSymbolResult = null; + return false; + } + + private EfMethodsModel? CreateMappingModel() + { + var semanticModel = Compilation.GetSemanticModel(MemberSyntax.SyntaxTree); + /*if (semanticModel.GetDeclaredSymbol(MemberSyntax) is not INamedTypeSymbol typeSymbol) + { + AddDiagnostic(DiagnosticsFactory.TypeNotFoundError(MemberSyntax.GetLocation(), MemberSyntax.Identifier.ValueText)); + return null; + }*/ + + var attributeTypeSymbols = GetEntityTypeSymbol(MemberSyntax, semanticModel); + + // Main Entity + var entityData = GetEntityTypeData(MemberSyntax, semanticModel); + + string entityTypeName = entityData.Name, createTypeIdentifierName = entityData.Name, readTypeIdentifierName = entityData.Name; + + string entityTypeFullName = entityData.ToDisplayString(), createTypeFullName = entityData.ToDisplayString(), readTypeFullName = entityData.ToDisplayString(); + + + if (attributeTypeSymbols.Length > 0) + { + // Create DTO + createTypeIdentifierName = attributeTypeSymbols[0].Name; + createTypeFullName = attributeTypeSymbols[0].ToDisplayString(); + } + if (attributeTypeSymbols.Length > 1) + { + // Read DTO + readTypeIdentifierName = attributeTypeSymbols[1].Name; + readTypeFullName = attributeTypeSymbols[1].ToDisplayString(); + } + + + + // get containing class type information + ClassDeclarationSyntax classDeclarationSyntax = MemberSyntax.Parent as ClassDeclarationSyntax; + + // context name + var dbContextName = classDeclarationSyntax.Identifier.ValueText; + var namespaceDeclaration = classDeclarationSyntax.Parent as NamespaceDeclarationSyntax; + var namespaceFullName = namespaceDeclaration.Name.ToString(); + + var contextTypeFullName = $"{namespaceFullName}.{dbContextName}"; + + var propertyName = entityTypeName; + + if (MemberSyntax is PropertyDeclarationSyntax propertyDeclarationSyntax) + { + propertyName = propertyDeclarationSyntax.Identifier.ValueText; + } + if (MemberSyntax is FieldDeclarationSyntax fieldDeclaration) + { + // TODO: Test + propertyName = fieldDeclaration.ToString(); + } + + + if (attributeTypeSymbols.IsDefaultOrEmpty) + { + AddDiagnostic(DiagnosticsFactory.MapFromAttributeNotFoundError(MemberSyntax.GetLocation())); + return null; + } + + + //var typeIdentifierName = MemberSyntax.GetIdentifierName(); + //var isTypeInheritFromMappedBaseClass = IsTypeInheritFromMappedBaseClass(semanticModel); + + + return new EfMethodsModel( + SourceGenerationOptions, + namespaceFullName, + propertyName, + dbContextName, + contextTypeFullName, + entityTypeFullName, + entityTypeName, + createTypeFullName, + createTypeIdentifierName, + readTypeFullName, + readTypeIdentifierName, + Usings); + } + + private static ITypeSymbol GetEntityTypeData(MemberDeclarationSyntax memberDeclarationSyntax, SemanticModel? semanticModel = null) + { + var member = (memberDeclarationSyntax as PropertyDeclarationSyntax).Type; + var genericSyntax = member as GenericNameSyntax; + var typeInfo = semanticModel.GetTypeInfo(genericSyntax); + var firstTypeArgument = (typeInfo.ConvertedType as INamedTypeSymbol).TypeArguments[0]; + return firstTypeArgument; + } + + + + private string? ToQualifiedDisplayName(ISymbol? symbol) + { + if (symbol is null) + { + return null; + } + + var containingNamespace = MemberSyntax.GetNamespace(); + var symbolNamespace = symbol.ContainingNamespace.ToDisplayString(); + return containingNamespace != symbolNamespace && _ignoredNamespaces.Contains(symbol.ContainingNamespace.ToDisplayParts().First()) + ? symbol.ToDisplayString() + : symbol.Name; + } + } +} \ No newline at end of file diff --git a/src/BlueWest.MapTo/EfMethodsGenerator.cs b/src/BlueWest.MapTo/EfMethodsGenerator.cs new file mode 100644 index 0000000..7798385 --- /dev/null +++ b/src/BlueWest.MapTo/EfMethodsGenerator.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using MapTo.Extensions; +using MapTo.Sources; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace MapTo +{ + /*/// + /// Base source code generator typed class. + /// + public class SourceCodeGenerator : ISourceGenerator + { + public virtual void Initialize(GeneratorInitializationContext context) {} + public virtual void Execute(GeneratorExecutionContext context) {} + }*/ + + /// + /// Ef methods source generator + /// + [Generator] + public class EfMethodsGenerator: ISourceGenerator + { + /// + public void Initialize(GeneratorInitializationContext context) + { + context.RegisterForSyntaxNotifications(() => new EfMethodsSyntaxReceiver()); + } + + /// + public void Execute(GeneratorExecutionContext context) + { + try + { + var options = SourceGenerationOptions.From(context); + + var compilation = context.Compilation + .AddSource(ref context, EfAddMethodsAttributeSource.Generate(options)); + + if (context.SyntaxReceiver is EfMethodsSyntaxReceiver receiver && receiver.CandidateMembers.Any()) + { + AddGeneratedExtensions(context, compilation, receiver.CandidateMembers, options); + } + } + catch (Exception ex) + { + Console.WriteLine(ex); + throw; + } + } + + private static void AddGeneratedExtensions(GeneratorExecutionContext context, Compilation compilation, IEnumerable candidateTypes, SourceGenerationOptions options) + { + //SpinWait.SpinUntil(() => Debugger.IsAttached); + + foreach (var memberDeclarationSyntax in candidateTypes) + { + var mappingContext = EfGeneratorContext.Create(compilation, options, memberDeclarationSyntax); + mappingContext.Diagnostics.ForEach(context.ReportDiagnostic); + + if (mappingContext.Model is null) + { + continue; + } + + + var (source, hintName) = memberDeclarationSyntax switch + { + PropertyDeclarationSyntax => EfMethodsSource.Generate(mappingContext.Model), + _ => throw new ArgumentOutOfRangeException() + }; + + context.AddSource(hintName, source); + } + } + } +} \ No newline at end of file diff --git a/src/BlueWest.MapTo/EfMethodsSyntaxReceiver.cs b/src/BlueWest.MapTo/EfMethodsSyntaxReceiver.cs new file mode 100644 index 0000000..df28571 --- /dev/null +++ b/src/BlueWest.MapTo/EfMethodsSyntaxReceiver.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Linq; +using MapTo.Sources; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace MapTo +{ + internal class EfMethodsSyntaxReceiver : ISyntaxReceiver + { + public List CandidateMembers { get; } = new(); + + public void OnVisitSyntaxNode(SyntaxNode syntaxNode) + { + if (syntaxNode is not MemberDeclarationSyntax { AttributeLists: { Count: >= 1 } attributes } typeDeclarationSyntax) + { + return; + } + + var attributeSyntax = attributes + .SelectMany(a => a.Attributes) + .FirstOrDefault(a => a.Name is + IdentifierNameSyntax { Identifier: { ValueText: EfAddMethodsAttributeSource.AttributeName } } // For: [EfAddMethods] + or + QualifiedNameSyntax // For: [MapTo.EfAddMethods] + { + Left: IdentifierNameSyntax { Identifier: { ValueText: Constants.RootNamespace } }, + Right: IdentifierNameSyntax { Identifier: { ValueText: EfAddMethodsAttributeSource.AttributeName } } + } + ); + + if (attributeSyntax is not null) + { + CandidateMembers.Add(typeDeclarationSyntax); + } + } + } +} \ No newline at end of file diff --git a/src/BlueWest.MapTo/Extensions/EfGeneratorExtensions.cs b/src/BlueWest.MapTo/Extensions/EfGeneratorExtensions.cs new file mode 100644 index 0000000..a180751 --- /dev/null +++ b/src/BlueWest.MapTo/Extensions/EfGeneratorExtensions.cs @@ -0,0 +1,14 @@ +using MapTo.Sources; + +namespace MapTo.Extensions +{ + internal static class EfGeneratorExtensions + { + internal static SourceCode GenerateEfMethods(this EfMethodsModel model) + { + var builder = new SourceBuilder(); + + return new SourceCode(builder.ToString(), $"{model.Namespace}.{model.EntityTypeIdentifierName}.g.cs"); + } + } +} \ No newline at end of file diff --git a/src/BlueWest.MapTo/Extensions/RoslynExtensions.cs b/src/BlueWest.MapTo/Extensions/RoslynExtensions.cs index b38c6ff..036e330 100644 --- a/src/BlueWest.MapTo/Extensions/RoslynExtensions.cs +++ b/src/BlueWest.MapTo/Extensions/RoslynExtensions.cs @@ -31,7 +31,7 @@ namespace MapTo.Extensions public static string GetIdentifierName(this TypeDeclarationSyntax typeSyntax) => typeSyntax.Identifier.Text; - public static AttributeSyntax? GetAttribute(this TypeDeclarationSyntax typeDeclarationSyntax, string attributeName) + public static AttributeSyntax? GetAttribute(this MemberDeclarationSyntax typeDeclarationSyntax, string attributeName) { var attributeLists = typeDeclarationSyntax.AttributeLists; var selection = attributeLists @@ -51,7 +51,7 @@ namespace MapTo.Extensions public static AttributeData? GetAttribute(this ISymbol symbol, ITypeSymbol attributeSymbol) => symbol.GetAttributes(attributeSymbol).FirstOrDefault(); - public static string? GetNamespace(this TypeDeclarationSyntax typeDeclarationSyntax) => typeDeclarationSyntax + public static string? GetNamespace(this MemberDeclarationSyntax typeDeclarationSyntax) => typeDeclarationSyntax .Ancestors() .OfType() .FirstOrDefault() diff --git a/src/BlueWest.MapTo/MapToGenerator.cs b/src/BlueWest.MapTo/MapToGenerator.cs index 50b9771..77e605d 100644 --- a/src/BlueWest.MapTo/MapToGenerator.cs +++ b/src/BlueWest.MapTo/MapToGenerator.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; +using System.Threading; using MapTo.Extensions; using MapTo.Sources; using Microsoft.CodeAnalysis; @@ -17,7 +19,9 @@ namespace MapTo /// public void Initialize(GeneratorInitializationContext context) { + context.RegisterForSyntaxNotifications(() => new MapToSyntaxReceiver()); + } /// @@ -61,6 +65,7 @@ namespace MapTo { continue; } + var (source, hintName) = typeDeclarationSyntax switch { diff --git a/src/BlueWest.MapTo/MappingContext.cs b/src/BlueWest.MapTo/MappingContext.cs index 7d53c76..1cd5bfa 100644 --- a/src/BlueWest.MapTo/MappingContext.cs +++ b/src/BlueWest.MapTo/MappingContext.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics; using System.Linq; +using System.Threading; using MapTo.Extensions; using MapTo.Sources; using Microsoft.CodeAnalysis; @@ -71,7 +73,6 @@ namespace MapTo public static MappingContext Create(Compilation compilation, SourceGenerationOptions sourceGenerationOptions, TypeDeclarationSyntax typeSyntax) { - //SpinWait.SpinUntil(() => Debugger.IsAttached); MappingContext context = typeSyntax switch { diff --git a/src/BlueWest.MapTo/Models.cs b/src/BlueWest.MapTo/Models.cs index 8c8869b..de88e28 100644 --- a/src/BlueWest.MapTo/Models.cs +++ b/src/BlueWest.MapTo/Models.cs @@ -68,6 +68,26 @@ namespace MapTo ImmutableArray Usings ); + + internal record EfMethodsModel( + SourceGenerationOptions Options, + string Namespace, + string PropertyName, + string ContextTypeName, + string ContestFullType, + + string EntityTypeFullName, + string EntityTypeIdentifierName, + + string CreateTypeFullName, + string CreateTypeIdentifierName, + + string ReadTypeFullName, + string ReadTypeIdentifierName, + + ImmutableArray Usings + ); + internal record SourceGenerationOptions( AccessModifier ConstructorAccessModifier, AccessModifier GeneratedMethodsAccessModifier, diff --git a/src/BlueWest.MapTo/Sources/EfAddMethodsAttributeSource.cs b/src/BlueWest.MapTo/Sources/EfAddMethodsAttributeSource.cs new file mode 100644 index 0000000..9b66085 --- /dev/null +++ b/src/BlueWest.MapTo/Sources/EfAddMethodsAttributeSource.cs @@ -0,0 +1,47 @@ +using static MapTo.Sources.Constants; + + +namespace MapTo.Sources +{ + internal static class EfAddMethodsAttributeSource + { + internal const string AttributeName = "EfAddMethods"; + internal const string AttributeClassName = AttributeName + "Attribute"; + internal const string FullyQualifiedName = RootNamespace + "." + AttributeClassName; + + internal static SourceCode Generate(SourceGenerationOptions options) + { + using var builder = new SourceBuilder() + .WriteLine(GeneratedFilesHeader) + .WriteLine("using System;") + .WriteLine() + .WriteLine($"namespace {RootNamespace}") + .WriteOpeningBracket(); + + if (options.GenerateXmlDocument) + { + builder + .WriteLine("/// ") + .WriteLine("/// Generate Add methods for interacting with the entity") + .WriteLine("/// "); + } + + builder + .WriteLine("[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]") + .WriteLine($"public sealed class {AttributeName}Attribute : Attribute") + .WriteOpeningBracket(); + + builder + .WriteLine($"public {AttributeName}Attribute(Type createDto = null, Type returnType = null)") + .WriteOpeningBracket() + .WriteClosingBracket() + .WriteLine(); + + builder + .WriteClosingBracket() // class + .WriteClosingBracket(); // namespace + + return new(builder.ToString(), $"{AttributeName}Attribute.g.cs"); + } + } +} \ No newline at end of file diff --git a/src/BlueWest.MapTo/Sources/EfMethodsSource.cs b/src/BlueWest.MapTo/Sources/EfMethodsSource.cs new file mode 100644 index 0000000..2692f07 --- /dev/null +++ b/src/BlueWest.MapTo/Sources/EfMethodsSource.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using MapTo.Extensions; +using static MapTo.Sources.Constants; + +namespace MapTo.Sources +{ + internal static class EfMethodsSource + { + internal static SourceCode Generate(EfMethodsModel model) + { + const bool writeDebugInfo = false; + + List constructorHeaders = new List(); + + using var builder = new SourceBuilder() + .WriteLine(GeneratedFilesHeader) + .WriteUsings(model.Usings) + .WriteLine("using Microsoft.EntityFrameworkCore;") + .WriteLine() + // Namespace declaration + .WriteLine($"namespace {model.Namespace}") + .WriteOpeningBracket() + // Class declaration + .WriteLine($"public static partial class {model.EntityTypeIdentifierName}Extensions") + .WriteOpeningBracket() + .WriteLine() + .WriteLine($"public static (bool, {model.ReadTypeFullName}) Add{model.EntityTypeIdentifierName}(") + .WriteLine($"this {model.ContestFullType} dbContext,") + .WriteLine($"{model.CreateTypeFullName} {model.EntityTypeIdentifierName.ToLower()}toCreate)") + .WriteOpeningBracket() + .WriteLine($"var new{model.EntityTypeIdentifierName} = new {model.EntityTypeFullName}({model.EntityTypeIdentifierName.ToLower()}toCreate);") + .WriteLine($"dbContext.{model.PropertyName}.Add(new{model.EntityTypeIdentifierName});") + .WriteLine($"var success = dbContext.SaveChanges() >= 0;") + .WriteLine($"return (success, new {model.ReadTypeFullName}(new{model.EntityTypeIdentifierName}));") + .WriteClosingBracket(); + builder + .WriteLine() + // End class declaration + .WriteClosingBracket() + .WriteLine() + // End namespace declaration + .WriteClosingBracket(); + + return new(builder.ToString(), $"{model.Namespace}.{model.EntityTypeIdentifierName}Extensions.g.cs"); + } + } +} \ No newline at end of file diff --git a/test/BlueWest.MapTo.Tests/Infrastructure/CSharpGenerator.cs b/test/BlueWest.MapTo.Tests/Infrastructure/CSharpGenerator.cs index 872f37c..b43e274 100644 --- a/test/BlueWest.MapTo.Tests/Infrastructure/CSharpGenerator.cs +++ b/test/BlueWest.MapTo.Tests/Infrastructure/CSharpGenerator.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics; using System.Linq; +using System.Threading; using MapTo.Tests.Extensions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; @@ -46,9 +48,9 @@ namespace MapTo.Tests.Infrastructure // NB: fail tests when the injected program isn't valid _before_ running generators compilation.GetDiagnostics().ShouldBeSuccessful(); } - + var driver = CSharpGeneratorDriver.Create( - new[] { new MapToGenerator() }, + new List() { new MapToGenerator(), new EfMethodsGenerator() }, optionsProvider: new TestAnalyzerConfigOptionsProvider(analyzerConfigOptions), parseOptions: new CSharpParseOptions(languageVersion) );