From 3c6284322512a1d49cfe0bdb1bc1be9814ec5222 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Mon, 11 May 2026 11:47:30 -0500 Subject: [PATCH 01/14] fix(codegen): skip same-type augmentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...eratedContractCompilationAugmentorTests.cs | 43 ++++++ .../GeneratedContractCompilationAugmentor.cs | 133 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractCompilationAugmentorTests.cs create mode 100644 src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/GeneratedContractCompilationAugmentor.cs diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractCompilationAugmentorTests.cs b/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractCompilationAugmentorTests.cs new file mode 100644 index 000000000..d0752f214 --- /dev/null +++ b/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractCompilationAugmentorTests.cs @@ -0,0 +1,43 @@ +using IntelliTect.Coalesce.CodeGeneration.Analysis.Roslyn; +using IntelliTect.Coalesce.Testing; +using IntelliTect.Coalesce.Testing.Util; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; + +namespace IntelliTect.Coalesce.CodeGeneration.Tests; + +public class GeneratedContractCompilationAugmentorTests +{ + [Test] + public async Task SameProjectGeneratedClassShapes_ForTheSourceType_DoNotDuplicateMembers() + { + var source = $$""" + #nullable enable + using IntelliTect.Coalesce.DataAnnotations; + + namespace IntelliTect.Coalesce.Testing.GeneratedContracts; + + [GeneratedContractShape( + "same-project-generated-class", + GeneratedContractOutputKind.Class, + "{{ReflectionRepositoryFactory.SymbolDiscoveryAssemblyName}}", + "IntelliTect.Coalesce.Testing.GeneratedContracts", + "SameProjectGeneratedClass", + Members = [nameof(Name)])] + public partial class SameProjectGeneratedClass + { + public string Name { get; set; } = null!; + } + """; + + var compilation = ReflectionRepositoryFactory.GetCompilation( + [CSharpSyntaxTree.ParseText(SourceText.From(source), path: "SameProjectGeneratedClass.cs")], + assertSuccess: true); + + var augmentedCompilation = (CSharpCompilation)GeneratedContractCompilationAugmentor + .AugmentWithSameProjectGeneratedContracts(compilation, "/tmp/coalesce-generated-contracts-test"); + + ReflectionRepositoryFactory.AssertCompilationSuccess(augmentedCompilation); + await Task.CompletedTask; + } +} diff --git a/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/GeneratedContractCompilationAugmentor.cs b/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/GeneratedContractCompilationAugmentor.cs new file mode 100644 index 000000000..b99e09755 --- /dev/null +++ b/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/GeneratedContractCompilationAugmentor.cs @@ -0,0 +1,133 @@ +#nullable enable + +using IntelliTect.Coalesce.DataAnnotations; +using IntelliTect.Coalesce.CodeGeneration.Api.Generators; +using IntelliTect.Coalesce.TypeDefinition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace IntelliTect.Coalesce.CodeGeneration.Analysis.Roslyn; + +internal static class GeneratedContractCompilationAugmentor +{ + public static Compilation AugmentWithSameProjectGeneratedContracts( + Compilation compilation, + string projectDirectory, + CSharpParseOptions? parseOptions = null) + { + if (compilation is null) + { + throw new ArgumentNullException(nameof(compilation)); + } + + if (string.IsNullOrWhiteSpace(projectDirectory) || string.IsNullOrWhiteSpace(compilation.AssemblyName)) + { + return compilation; + } + + var targetAssemblyName = NormalizeAssemblyName(compilation.AssemblyName); + var outputPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + var generatedTrees = new List(); + var emittedTypes = new HashSet(StringComparer.Ordinal); + + foreach (var sourceType in GetDeclaredTypes(compilation)) + { + var shapes = GeneratedContracts.GetShapes(sourceType) + .Where(shape => ShouldAugmentShape(sourceType, shape)) + .Where(shape => string.Equals( + NormalizeAssemblyName(shape.TargetAssemblyName), + targetAssemblyName, + StringComparison.Ordinal)) + .ToList(); + + foreach (var shape in shapes) + { + var typeKey = $"{shape.TargetNamespace}.{shape.TypeName}"; + if (!emittedTypes.Add(typeKey)) + { + throw new InvalidOperationException( + $"Generated contract '{typeKey}' is declared more than once. " + + $"The duplicate declaration was found on '{sourceType.ToDisplayString()}'."); + } + + var model = new GeneratedContracts.GeneratedContractFileModel( + shape, + GeneratedContracts.ResolveProperties(sourceType, shape), + sourceType.ToDisplayString(SymbolTypeViewModel.DefaultDisplayFormat)); + + var outputPath = Path.Combine(projectDirectory, GeneratedContracts.GeneratedContractsRelativePath, $"{shape.TypeName}.g.cs"); + outputPaths.Add(Path.GetFullPath(outputPath)); + generatedTrees.Add(GeneratedContractFile.CreateSyntaxTree( + model, + parseOptions ?? compilation.SyntaxTrees.FirstOrDefault()?.Options as CSharpParseOptions, + outputPath)); + } + } + + if (generatedTrees.Count == 0) + { + return compilation; + } + + var existingTrees = compilation.SyntaxTrees + .Where(tree => + !string.IsNullOrWhiteSpace(tree.FilePath) && + outputPaths.Contains(Path.GetFullPath(tree.FilePath))) + .ToList(); + + if (existingTrees.Count > 0) + { + compilation = compilation.RemoveSyntaxTrees(existingTrees); + } + + return compilation.AddSyntaxTrees(generatedTrees); + } + + private static bool ShouldAugmentShape(INamedTypeSymbol sourceType, GeneratedContracts.ContractShape shape) + { + if (shape.OutputKind != (int)GeneratedContractOutputKind.Class) + { + return true; + } + + return shape.TypeName != sourceType.Name + || shape.TargetNamespace != sourceType.ContainingNamespace.ToDisplayString(); + } + + private static string NormalizeAssemblyName(string assemblyName) + { + if (assemblyName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || + assemblyName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + { + return Path.GetFileNameWithoutExtension(assemblyName); + } + + return assemblyName; + } + + private static IEnumerable GetDeclaredTypes(Compilation compilation) + { + var discovered = new HashSet(SymbolEqualityComparer.Default); + + foreach (var tree in compilation.SyntaxTrees) + { + var semanticModel = compilation.GetSemanticModel(tree); + foreach (var declaration in tree.GetRoot().DescendantNodes().OfType()) + { + if (semanticModel.GetDeclaredSymbol(declaration) is INamedTypeSymbol symbol && + symbol.ContainingAssembly is not null && + SymbolEqualityComparer.Default.Equals(symbol.ContainingAssembly, compilation.Assembly)) + { + discovered.Add(symbol); + } + } + } + + return discovered; + } +} From e1745d528852fd1974a16a240eee16630149adc2 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Mon, 11 May 2026 12:13:13 -0500 Subject: [PATCH 02/14] fix(codegen): wire same-project augmentation on top branch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Analysis/Roslyn/RoslynTypeLocator.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/RoslynTypeLocator.cs b/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/RoslynTypeLocator.cs index 0083d0cde..7477c47cd 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/RoslynTypeLocator.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/RoslynTypeLocator.cs @@ -58,6 +58,11 @@ private Compilation GetProjectCompilation() .WithMetadataReferences(_projectContext.GetMetadataReferences()) .GetCompilationAsync().Result; + _compilation = GeneratedContractCompilationAugmentor.AugmentWithSameProjectGeneratedContracts( + _compilation, + _projectContext.ProjectPath, + parseOptions); + return _compilation; } From 18c6c7d967e12f90bbbe8433074e435a0dcd3792 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Mon, 11 May 2026 13:32:13 -0500 Subject: [PATCH 03/14] docs: describe same-project generated contract analysis Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/topics/dto-shapes-and-read-models.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/topics/dto-shapes-and-read-models.md b/docs/topics/dto-shapes-and-read-models.md index 159c6190d..0f8622e24 100644 --- a/docs/topics/dto-shapes-and-read-models.md +++ b/docs/topics/dto-shapes-and-read-models.md @@ -63,3 +63,9 @@ This is the layer that makes richer read DTOs practical without forcing every re Generated contract shapes are not limited to a single nullability policy. A single source type can now describe stricter read-facing contracts and more permissive write- or patch-facing contracts from the same model. That keeps generated interfaces and DTO classes aligned with their actual API semantics instead of forcing one nullability story onto every generated output. + +## Same-project generated contracts are available during analysis + +When generated contract shapes target the same project that declares the source type, Coalesce now materializes those generated contracts early enough for analysis and validation to see them consistently. + +That keeps same-project generated interfaces and classes usable without introducing duplicate-member diagnostics or forcing consumers to move those shapes into a different project. From 833b0567636e98e6f98917b7b082727d7767c9a7 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Mon, 11 May 2026 23:13:28 -0500 Subject: [PATCH 04/14] feat: prototype contract source generation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...eratedContractShapeSourceGeneratorTests.cs | 173 ++++ .../IntelliTect.Coalesce.Analyzer.csproj | 6 +- .../GeneratedContractShapeSourceGenerator.cs | 834 ++++++++++++++++++ ...eratedContractCompilationAugmentorTests.cs | 70 ++ .../GeneratedContractCompilationAugmentor.cs | 71 +- 5 files changed, 1146 insertions(+), 8 deletions(-) create mode 100644 src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedContractShapeSourceGeneratorTests.cs create mode 100644 src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedContractShapeSourceGenerator.cs diff --git a/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedContractShapeSourceGeneratorTests.cs b/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedContractShapeSourceGeneratorTests.cs new file mode 100644 index 000000000..1db8b2cd4 --- /dev/null +++ b/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedContractShapeSourceGeneratorTests.cs @@ -0,0 +1,173 @@ +#nullable enable + +using IntelliTect.Coalesce.Analyzer.SourceGenerators; +using IntelliTect.Coalesce.DataAnnotations; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Immutable; + +namespace IntelliTect.Coalesce.Analyzer.Tests; + +public class GeneratedContractShapeSourceGeneratorTests +{ + [Test] + public async Task GeneratesSameProjectContractShapesIntoCompilation() + { + var compilation = CreateCompilation( + "GeneratedContractConsumer", + """ + #nullable enable + using System.Collections.Generic; + using IntelliTect.Coalesce.DataAnnotations; + + namespace Demo; + + [GeneratedContractShape( + "same-project", + GeneratedContractOutputKind.Class, + "GeneratedContractConsumer", + "Demo.Contracts", + "PersonContract", + Members = [nameof(Name), nameof(Tags)])] + public class PersonSource + { + public string Name { get; set; } = null!; + public List Tags { get; set; } = []; + } + + public class Consumer + { + public Demo.Contracts.PersonContract Contract { get; set; } = new() + { + Name = string.Empty, + Tags = [] + }; + } + """); + + var updatedCompilation = RunGenerator(compilation, out var diagnostics); + + await Assert.That(diagnostics).IsEmpty(); + await Assert.That(updatedCompilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + await Assert.That(updatedCompilation.SyntaxTrees.Any(tree => tree.FilePath.Contains("Demo.Contracts.PersonContract.g.cs", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task GeneratesContractsFromReferencedAssembliesIntoTargetCompilation() + { + var producerCompilation = CreateCompilation( + "GeneratedContractProducer", + """ + #nullable enable + using IntelliTect.Coalesce.DataAnnotations; + + namespace Demo; + + [GeneratedContractShape( + "referenced-assembly", + GeneratedContractOutputKind.Class, + "GeneratedContractConsumer", + "Demo.Contracts", + "ReferencedPersonContract", + Members = [nameof(Name)])] + public class PersonSource + { + public string Name { get; set; } = null!; + } + """); + + var producerReference = CreateMetadataReference(producerCompilation); + + var consumerCompilation = CreateCompilation( + "GeneratedContractConsumer", + """ + #nullable enable + + namespace Demo; + + public class Consumer + { + public Demo.Contracts.ReferencedPersonContract Contract { get; set; } = new() + { + Name = string.Empty + }; + } + """, + additionalReferences: [producerReference]); + + var updatedCompilation = RunGenerator(consumerCompilation, out var diagnostics); + + await Assert.That(diagnostics).IsEmpty(); + await Assert.That(updatedCompilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + await Assert.That(updatedCompilation.SyntaxTrees.Any(tree => tree.FilePath.Contains("Demo.Contracts.ReferencedPersonContract.g.cs", StringComparison.Ordinal))).IsTrue(); + } + + private static CSharpCompilation CreateCompilation( + string assemblyName, + string source, + IReadOnlyList? additionalReferences = null) + { + var syntaxTree = CSharpSyntaxTree.ParseText( + SourceText.From(source), + new CSharpParseOptions(LanguageVersion.Preview), + path: $"{assemblyName}.cs"); + + var references = GetMetadataReferences(); + if (additionalReferences is not null) + { + references.AddRange(additionalReferences); + } + + return CSharpCompilation.Create( + assemblyName, + [syntaxTree], + references.Distinct(MetadataReferencePathComparer.Instance), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + } + + private static CSharpCompilation RunGenerator(CSharpCompilation compilation, out ImmutableArray diagnostics) + { + var parseOptions = (CSharpParseOptions)compilation.SyntaxTrees.First().Options; + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new GeneratedContractShapeSourceGenerator().AsSourceGenerator()], + parseOptions: parseOptions); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var updatedCompilation, out diagnostics); + return (CSharpCompilation)updatedCompilation; + } + + private static PortableExecutableReference CreateMetadataReference(CSharpCompilation compilation) + { + using var stream = new MemoryStream(); + EmitResult emitResult = compilation.Emit(stream); + if (!emitResult.Success) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, emitResult.Diagnostics)); + } + + stream.Position = 0; + return MetadataReference.CreateFromImage(stream.ToArray()); + } + + private static List GetMetadataReferences() + => AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(assembly => (MetadataReference)MetadataReference.CreateFromFile(assembly.Location)) + .Append(MetadataReference.CreateFromFile(typeof(GeneratedContractShapeAttribute).Assembly.Location)) + .Append(MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location)) + .Append(MetadataReference.CreateFromFile(typeof(List<>).Assembly.Location)) + .Append(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)) + .ToList(); + + private sealed class MetadataReferencePathComparer : IEqualityComparer + { + public static MetadataReferencePathComparer Instance { get; } = new(); + + public bool Equals(MetadataReference? x, MetadataReference? y) + => StringComparer.OrdinalIgnoreCase.Equals((x as PortableExecutableReference)?.FilePath, (y as PortableExecutableReference)?.FilePath); + + public int GetHashCode(MetadataReference obj) + => StringComparer.OrdinalIgnoreCase.GetHashCode((obj as PortableExecutableReference)?.FilePath ?? string.Empty); + } +} diff --git a/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj b/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj index 490da52b6..8bcca9770 100644 --- a/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj +++ b/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj @@ -48,9 +48,7 @@ - - - + @@ -58,4 +56,4 @@ Visible="false" /> - \ No newline at end of file + diff --git a/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedContractShapeSourceGenerator.cs b/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedContractShapeSourceGenerator.cs new file mode 100644 index 000000000..46bd7c925 --- /dev/null +++ b/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedContractShapeSourceGenerator.cs @@ -0,0 +1,834 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Immutable; +using System.Text; + +namespace IntelliTect.Coalesce.Analyzer.SourceGenerators; + +[Generator(LanguageNames.CSharp)] +public sealed class GeneratedContractShapeSourceGenerator : IIncrementalGenerator +{ + private const string ShapeAttributeMetadataName = "IntelliTect.Coalesce.DataAnnotations.GeneratedContractShapeAttribute"; + private const string AliasAttributeMetadataName = "IntelliTect.Coalesce.DataAnnotations.GeneratedContractAliasAttribute"; + private const string DtoSourceAttributeMetadataName = "IntelliTect.Coalesce.DataAnnotations.DtoSourceAttribute"; + private const string NullableAttributeMetadataName = "IntelliTect.Coalesce.DataAnnotations.GeneratedContractNullableAttribute"; + private const string NonNullableAttributeMetadataName = "IntelliTect.Coalesce.DataAnnotations.GeneratedContractNonNullableAttribute"; + private const string CoalesceAssemblyName = "IntelliTect.Coalesce"; + private const int ExplicitPolicy = 0; + private const int PublicScalarPropertiesPolicy = 1; + private const int ClassOutputKind = 0; + private const int InterfaceOutputKind = 1; + private const int NullableReferenceTypesTransform = 1; + private const int NullableValueTypesTransform = 2; + private const string MissingPropertyDiagnosticId = "COALESCEGC001"; + private const string DuplicateShapeDiagnosticId = "COALESCEGC002"; + + private static readonly DiagnosticDescriptor MissingPropertyDiagnostic = new( + MissingPropertyDiagnosticId, + "Generated contract shape references a missing property", + "{0}", + "Coalesce.GeneratedContracts", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor DuplicateShapeDiagnostic = new( + DuplicateShapeDiagnosticId, + "Generated contract shape is declared more than once", + "{0}", + "Coalesce.GeneratedContracts", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly SymbolDisplayFormat TypeDisplayFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: + SymbolDisplayMiscellaneousOptions.UseSpecialTypes | + SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | + SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var currentCompilationShapeTypes = context.SyntaxProvider + .ForAttributeWithMetadataName( + ShapeAttributeMetadataName, + static (_, _) => true, + static (syntaxContext, _) => (INamedTypeSymbol)syntaxContext.TargetSymbol) + .Collect(); + + var inputs = context.CompilationProvider + .Combine(currentCompilationShapeTypes); + + context.RegisterSourceOutput(inputs, static (productionContext, input) => + Execute(productionContext, input.Left, input.Right)); + } + + private static void Execute( + SourceProductionContext context, + Compilation compilation, + ImmutableArray currentCompilationShapeTypes) + { + if (string.IsNullOrWhiteSpace(compilation.AssemblyName)) + { + return; + } + + var targetAssemblyName = NormalizeAssemblyName(compilation.AssemblyName!); + var emittedTypes = new HashSet(StringComparer.Ordinal); + var processedSourceTypes = new HashSet(SymbolEqualityComparer.Default); + + foreach (var sourceType in currentCompilationShapeTypes) + { + EmitShapes(sourceType); + } + + foreach (var sourceType in EnumerateReferencedCandidateTypes(compilation)) + { + EmitShapes(sourceType); + } + + void EmitShapes(INamedTypeSymbol sourceType) + { + if (!processedSourceTypes.Add(sourceType)) + { + return; + } + + foreach (var shape in GetShapes(sourceType) + .Where(shape => string.Equals( + NormalizeAssemblyName(shape.TargetAssemblyName), + targetAssemblyName, + StringComparison.Ordinal)) + .Where(shape => ShouldGenerateShape(sourceType, shape))) + { + var typeKey = $"{shape.TargetNamespace}.{shape.TypeName}"; + if (!emittedTypes.Add(typeKey)) + { + context.ReportDiagnostic(Diagnostic.Create( + DuplicateShapeDiagnostic, + sourceType.Locations.FirstOrDefault(), + $"Generated contract '{typeKey}' is declared more than once. The duplicate declaration was found on '{sourceType.ToDisplayString()}'.")); + continue; + } + + IReadOnlyList properties; + try + { + properties = ResolveProperties(sourceType, shape); + } + catch (InvalidOperationException ex) + { + context.ReportDiagnostic(Diagnostic.Create( + MissingPropertyDiagnostic, + sourceType.Locations.FirstOrDefault(), + ex.Message)); + continue; + } + + var model = new GeneratedContractFileModel(shape, properties); + context.AddSource( + GetHintName(shape), + SourceText.From(Render(model), Encoding.UTF8)); + } + } + } + + private static IEnumerable EnumerateReferencedCandidateTypes(Compilation compilation) + { + var visitedAssemblies = new HashSet(SymbolEqualityComparer.Default); + + foreach (var assembly in compilation.SourceModule.ReferencedAssemblySymbols) + { + if (!ShouldInspectAssembly(assembly) || !visitedAssemblies.Add(assembly)) + { + continue; + } + + foreach (var type in EnumerateNamedTypes(assembly.GlobalNamespace)) + { + yield return type; + } + } + } + + private static bool ShouldInspectAssembly(IAssemblySymbol assembly) + { + if (assembly.Name == CoalesceAssemblyName) + { + return false; + } + + return assembly.Modules.Any(module => + module.ReferencedAssemblySymbols.Any(reference => reference.Name == CoalesceAssemblyName)); + } + + private static IEnumerable EnumerateNamedTypes(INamespaceSymbol @namespace) + { + foreach (var member in @namespace.GetMembers()) + { + foreach (var type in EnumerateNamedTypes(member)) + { + yield return type; + } + } + } + + private static IEnumerable EnumerateNamedTypes(INamespaceOrTypeSymbol symbol) + { + switch (symbol) + { + case INamespaceSymbol @namespace: + foreach (var member in @namespace.GetMembers()) + { + foreach (var type in EnumerateNamedTypes(member)) + { + yield return type; + } + } + break; + case INamedTypeSymbol type: + yield return type; + foreach (var nestedType in type.GetTypeMembers()) + { + foreach (var discovered in EnumerateNamedTypes(nestedType)) + { + yield return discovered; + } + } + break; + } + } + + private static bool ShouldGenerateShape(INamedTypeSymbol sourceType, ContractShape shape) + { + if (shape.OutputKind != ClassOutputKind) + { + return true; + } + + return shape.TypeName != sourceType.Name + || shape.TargetNamespace != sourceType.ContainingNamespace.ToDisplayString(); + } + + internal static IEnumerable GetShapes(INamedTypeSymbol sourceType) + => sourceType.GetAttributes() + .Where(IsShapeAttribute) + .Select(ParseShape) + .Where(static shape => shape is not null) + .Cast(); + + private static bool IsShapeAttribute(AttributeData attribute) + => attribute.AttributeClass?.ToDisplayString() == ShapeAttributeMetadataName; + + private static ContractShape? ParseShape(AttributeData attribute) + { + if (attribute.ConstructorArguments.Length < 5) + { + return null; + } + + return new ContractShape( + (string?)attribute.ConstructorArguments[0].Value ?? string.Empty, + Convert.ToInt32(attribute.ConstructorArguments[1].Value), + (string?)attribute.ConstructorArguments[2].Value ?? string.Empty, + (string?)attribute.ConstructorArguments[3].Value ?? string.Empty, + (string?)attribute.ConstructorArguments[4].Value ?? string.Empty, + GetInt(attribute, nameof(GeneratedContractShapeAttributePlaceholder.Policy)), + GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.Members)), + GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.ExcludedMembers)), + GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.Implements)), + GetBool(attribute, nameof(GeneratedContractShapeAttributePlaceholder.SettableProperties)), + GetInt(attribute, nameof(GeneratedContractShapeAttributePlaceholder.NullabilityTransform))); + } + + internal static IReadOnlyList ResolveProperties(INamedTypeSymbol sourceType, ContractShape shape) + { + var memberNames = ResolveMembers(sourceType, shape); + if (memberNames.Count == 0) + { + if (shape.OutputKind == InterfaceOutputKind) + { + return []; + } + + throw new InvalidOperationException( + $"Generated contract '{shape.TypeName}' on '{sourceType.ToDisplayString()}' must declare at least one member."); + } + + var properties = new List(memberNames.Count); + foreach (var memberName in memberNames) + { + var property = FindProperty(sourceType, memberName) + ?? throw new InvalidOperationException( + $"Generated contract '{shape.TypeName}' on '{sourceType.ToDisplayString()}' references missing property '{memberName}'."); + + properties.Add(new ContractPropertyModel( + GetAlias(property, shape.ShapeName) ?? property.Name, + property, + ShouldForceNullable(property, shape), + HasAttribute(property, NonNullableAttributeMetadataName, shape.ShapeName), + GetDtoSource(property))); + } + + return properties; + } + + private static IReadOnlyList ResolveMembers(INamedTypeSymbol sourceType, ContractShape shape) + { + if (shape.Members.Count > 0) + { + return shape.Members; + } + + if (shape.Policy == PublicScalarPropertiesPolicy) + { + var excludedMembers = new HashSet(shape.ExcludedMembers, StringComparer.Ordinal); + return EnumeratePolicyProperties(sourceType) + .Where(property => !excludedMembers.Contains(property.Name)) + .Select(property => property.Name) + .ToArray(); + } + + if (shape.Policy == ExplicitPolicy) + { + return []; + } + + throw new InvalidOperationException( + $"Generated contract '{shape.TypeName}' on '{sourceType.ToDisplayString()}' uses unknown policy value '{shape.Policy}'."); + } + + private static IEnumerable EnumeratePolicyProperties(INamedTypeSymbol sourceType) + { + var seen = new HashSet(StringComparer.Ordinal); + + for (var current = sourceType; current is not null; current = current.BaseType) + { + foreach (var property in current.GetMembers().OfType()) + { + if (!seen.Add(property.Name) || !IsPolicyProperty(property)) + { + continue; + } + + yield return property; + } + } + } + + private static bool IsPolicyProperty(IPropertySymbol property) + => !property.IsStatic + && property.Parameters.Length == 0 + && property.DeclaredAccessibility == Accessibility.Public + && property.GetMethod?.DeclaredAccessibility == Accessibility.Public + && property.SetMethod?.DeclaredAccessibility == Accessibility.Public + && IsScalarLikeType(property.Type); + + private static IPropertySymbol? FindProperty(INamedTypeSymbol type, string memberName) + { + for (var current = type; current is not null; current = current.BaseType) + { + var property = current.GetMembers(memberName).OfType().FirstOrDefault(prop => + !prop.IsStatic && + prop.DeclaredAccessibility == Accessibility.Public && + prop.Parameters.Length == 0); + + if (property is not null) + { + return property; + } + } + + return null; + } + + private static string? GetAlias(IPropertySymbol property, string shapeName) + => property.GetAttributes() + .Where(attribute => attribute.AttributeClass?.ToDisplayString() == AliasAttributeMetadataName) + .Where(attribute => string.Equals((string?)attribute.ConstructorArguments[0].Value, shapeName, StringComparison.Ordinal)) + .Select(attribute => (string?)attribute.ConstructorArguments[1].Value) + .FirstOrDefault(alias => !string.IsNullOrWhiteSpace(alias)); + + private static DtoSourceMetadata? GetDtoSource(IPropertySymbol property) + { + var attribute = property.GetAttributes() + .FirstOrDefault(attribute => attribute.AttributeClass?.ToDisplayString() == DtoSourceAttributeMetadataName); + + if (attribute is null || attribute.ConstructorArguments.Length == 0) + { + return null; + } + + var path = (string?)attribute.ConstructorArguments[0].Value; + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + return new DtoSourceMetadata( + path!, + GetString(attribute, nameof(DtoSourceAttributePlaceholder.OrderBy)), + GetInt(attribute, nameof(DtoSourceAttributePlaceholder.OrderByDirection))); + } + + private static bool HasAttribute(IPropertySymbol property, string attributeMetadataName, string shapeName) + => property.GetAttributes().Any(attribute => + attribute.AttributeClass?.ToDisplayString() == attributeMetadataName && + string.Equals((string?)attribute.ConstructorArguments[0].Value, shapeName, StringComparison.Ordinal)); + + private static bool ShouldForceNullable(IPropertySymbol property, ContractShape shape) + { + if (HasAttribute(property, NonNullableAttributeMetadataName, shape.ShapeName)) + { + return false; + } + + if (HasAttribute(property, NullableAttributeMetadataName, shape.ShapeName)) + { + return true; + } + + var transform = shape.NullabilityTransform; + if (transform == 0) + { + return false; + } + + return ((transform & NullableReferenceTypesTransform) != 0 && property.Type.IsReferenceType) + || ((transform & NullableValueTypesTransform) != 0 && IsNonNullableValueType(property.Type)); + } + + private static bool IsScalarLikeType(ITypeSymbol type) + { + if (type.TypeKind == TypeKind.Enum) + { + return true; + } + + if (type.SpecialType is + SpecialType.System_Boolean or + SpecialType.System_Byte or + SpecialType.System_Char or + SpecialType.System_Decimal or + SpecialType.System_Double or + SpecialType.System_Int16 or + SpecialType.System_Int32 or + SpecialType.System_Int64 or + SpecialType.System_SByte or + SpecialType.System_Single or + SpecialType.System_String or + SpecialType.System_UInt16 or + SpecialType.System_UInt32 or + SpecialType.System_UInt64) + { + return true; + } + + if (type is INamedTypeSymbol named && + named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && + named.TypeArguments.Length == 1) + { + return IsScalarLikeType(named.TypeArguments[0]); + } + + if (type is IArrayTypeSymbol arrayType) + { + return IsScalarLikeType(arrayType.ElementType); + } + + if (TryGetCollectionElementType(type, out var elementType)) + { + return IsScalarLikeType(elementType); + } + + var fullyQualifiedName = type.ToDisplayString(); + return fullyQualifiedName is + "System.Guid" or + "System.DateTime" or + "System.DateTimeOffset" or + "System.TimeSpan" or + "System.DateOnly" or + "System.TimeOnly" or + "System.Text.Json.JsonElement"; + } + + private static bool TryGetCollectionElementType(ITypeSymbol type, out ITypeSymbol elementType) + { + elementType = null!; + + if (type.SpecialType == SpecialType.System_String) + { + return false; + } + + if (type is IArrayTypeSymbol arrayType) + { + elementType = arrayType.ElementType; + return true; + } + + var enumerableInterface = type.AllInterfaces.FirstOrDefault(interfaceType => + interfaceType.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.IEnumerable"); + + if (enumerableInterface?.TypeArguments.Length == 1) + { + elementType = enumerableInterface.TypeArguments[0]; + return true; + } + + return false; + } + + private static bool IsNonNullableValueType(ITypeSymbol type) + { + if (!type.IsValueType) + { + return false; + } + + return type is not INamedTypeSymbol named + || named.OriginalDefinition.SpecialType != SpecialType.System_Nullable_T; + } + + internal static string Render(GeneratedContractFileModel model) + { + var builder = new StringBuilder(); + builder.AppendLine("// "); + builder.AppendLine("#nullable enable"); + builder.AppendLine(); + builder.Append("namespace ").Append(model.Shape.TargetNamespace).AppendLine(";"); + builder.AppendLine(); + + var declarationKind = model.Shape.OutputKind == InterfaceOutputKind ? "interface" : "partial class"; + var implements = model.Shape.Implements.Count > 0 + ? " : " + string.Join(", ", model.Shape.Implements.Select(NormalizeTypeName)) + : string.Empty; + + builder.Append("public ").Append(declarationKind).Append(' ').Append(model.Shape.TypeName).Append(implements).AppendLine(); + builder.AppendLine("{"); + + foreach (var property in model.Properties) + { + if (property.DtoSource is { } dtoSource) + { + builder.Append(" ").AppendLine(BuildDtoSourceAttribute(dtoSource)); + } + + builder.Append(" ").AppendLine(BuildPropertyLine(model, property)); + } + + builder.AppendLine("}"); + return builder.ToString(); + } + + private static string BuildPropertyLine(GeneratedContractFileModel model, ContractPropertyModel property) + { + var typeName = GetTypeName(property); + if (model.Shape.OutputKind == InterfaceOutputKind) + { + var accessor = model.Shape.SettableProperties ? "{ get; set; }" : "{ get; }"; + return $"{typeName} {property.Name} {accessor}"; + } + + var required = NeedsRequiredKeyword(property) ? "required " : string.Empty; + var initializer = HasCollectionInitializer(property) ? " = [];" : string.Empty; + return $"public {required}{typeName} {property.Name} {{ get; set; }}{initializer}"; + } + + private static string BuildDtoSourceAttribute(DtoSourceMetadata dtoSource) + { + var args = new List + { + SymbolDisplay.FormatLiteral(dtoSource.Path, quote: true) + }; + + if (!string.IsNullOrWhiteSpace(dtoSource.OrderBy)) + { + args.Add($"OrderBy = {SymbolDisplay.FormatLiteral(dtoSource.OrderBy!, quote: true)}"); + } + + if (dtoSource.OrderByDirection != 0) + { + var direction = dtoSource.OrderByDirection == 1 ? "Descending" : "Ascending"; + args.Add( + "OrderByDirection = " + + $"global::IntelliTect.Coalesce.DataAnnotations.DefaultOrderByAttribute.OrderByDirections.{direction}"); + } + + return $"[global::IntelliTect.Coalesce.DataAnnotations.DtoSource({string.Join(", ", args)})]"; + } + + private static string GetTypeName(ContractPropertyModel property) + { + var display = property.Property.Type.ToDisplayString(TypeDisplayFormat); + if (property.ForceNullable) + { + return MakeNullable(display, property.Property.Type); + } + + if (property.ForceNonNullable) + { + return MakeNonNullable(display, property.Property.Type); + } + + return display; + } + + private static string MakeNullable(string display, ITypeSymbol type) + { + if (display.EndsWith("?", StringComparison.Ordinal)) + { + return display; + } + + if (type is INamedTypeSymbol named && + named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T) + { + return display; + } + + return display + "?"; + } + + private static string MakeNonNullable(string display, ITypeSymbol type) + { + if (display.EndsWith("?", StringComparison.Ordinal)) + { + return display.Substring(0, display.Length - 1); + } + + if (type is INamedTypeSymbol named && + named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T && + named.TypeArguments.Length == 1) + { + return named.TypeArguments[0].ToDisplayString(TypeDisplayFormat); + } + + return display; + } + + private static bool NeedsRequiredKeyword(ContractPropertyModel property) + { + if (property.ForceNullable || HasCollectionInitializer(property)) + { + return false; + } + + return property.Property.Type.IsReferenceType && + property.Property.NullableAnnotation != NullableAnnotation.Annotated; + } + + private static bool HasCollectionInitializer(ContractPropertyModel property) + { + if (property.ForceNullable) + { + return false; + } + + return IsCollectionType(property.Property.Type); + } + + private static bool IsCollectionType(ITypeSymbol type) + { + if (type.SpecialType == SpecialType.System_String) + { + return false; + } + + if (type is IArrayTypeSymbol) + { + return true; + } + + return type.AllInterfaces.Any(interfaceType => + interfaceType.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.IEnumerable"); + } + + private static string NormalizeTypeName(string typeName) + => typeName.StartsWith("global::", StringComparison.Ordinal) + ? typeName + : $"global::{typeName}"; + + private static string NormalizeAssemblyName(string assemblyName) + { + if (assemblyName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || + assemblyName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + { + return Path.GetFileNameWithoutExtension(assemblyName); + } + + return assemblyName; + } + + private static string GetHintName(ContractShape shape) + => $"{shape.TargetNamespace}.{shape.TypeName}.g.cs" + .Replace("global::", string.Empty) + .Replace('<', '_') + .Replace('>', '_') + .Replace(':', '_'); + + private static int GetInt(AttributeData attribute, string name) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is not null) + { + return Convert.ToInt32(argument.Value.Value); + } + } + + return 0; + } + + private static IReadOnlyList GetStringArray(AttributeData attribute, string name) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key != name || argument.Value.Kind != TypedConstantKind.Array) + { + continue; + } + + return argument.Value.Values + .Where(value => value.Value is string) + .Select(value => (string)value.Value!) + .ToImmutableArray(); + } + + return []; + } + + private static bool GetBool(AttributeData attribute, string name) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is bool value) + { + return value; + } + } + + return false; + } + + private static string? GetString(AttributeData attribute, string name) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == name && argument.Value.Value is string value) + { + return value; + } + } + + return null; + } + + internal sealed class ContractShape + { + public ContractShape( + string shapeName, + int outputKind, + string targetAssemblyName, + string targetNamespace, + string typeName, + int policy, + IReadOnlyList members, + IReadOnlyList excludedMembers, + IReadOnlyList implements, + bool settableProperties, + int nullabilityTransform) + { + ShapeName = shapeName; + OutputKind = outputKind; + TargetAssemblyName = targetAssemblyName; + TargetNamespace = targetNamespace; + TypeName = typeName; + Policy = policy; + Members = members; + ExcludedMembers = excludedMembers; + Implements = implements; + SettableProperties = settableProperties; + NullabilityTransform = nullabilityTransform; + } + + public string ShapeName { get; } + public int OutputKind { get; } + public string TargetAssemblyName { get; } + public string TargetNamespace { get; } + public string TypeName { get; } + public int Policy { get; } + public IReadOnlyList Members { get; } + public IReadOnlyList ExcludedMembers { get; } + public IReadOnlyList Implements { get; } + public bool SettableProperties { get; } + public int NullabilityTransform { get; } + } + + internal sealed class GeneratedContractFileModel + { + public GeneratedContractFileModel(ContractShape shape, IReadOnlyList properties) + { + Shape = shape; + Properties = properties; + } + + public ContractShape Shape { get; } + public IReadOnlyList Properties { get; } + } + + internal sealed class ContractPropertyModel + { + public ContractPropertyModel( + string name, + IPropertySymbol property, + bool forceNullable, + bool forceNonNullable, + DtoSourceMetadata? dtoSource) + { + Name = name; + Property = property; + ForceNullable = forceNullable; + ForceNonNullable = forceNonNullable; + DtoSource = dtoSource; + } + + public string Name { get; } + public IPropertySymbol Property { get; } + public bool ForceNullable { get; } + public bool ForceNonNullable { get; } + public DtoSourceMetadata? DtoSource { get; } + } + + internal sealed class DtoSourceMetadata + { + public DtoSourceMetadata(string path, string? orderBy, int orderByDirection) + { + Path = path; + OrderBy = orderBy; + OrderByDirection = orderByDirection; + } + + public string Path { get; } + public string? OrderBy { get; } + public int OrderByDirection { get; } + } + + private static class GeneratedContractShapeAttributePlaceholder + { + public static int Policy { get; set; } + public static string[] Members { get; set; } = []; + public static string[] ExcludedMembers { get; set; } = []; + public static string[] Implements { get; set; } = []; + public static bool SettableProperties { get; set; } + public static int NullabilityTransform { get; set; } + } + + private static class DtoSourceAttributePlaceholder + { + public static string? OrderBy { get; set; } + public static int OrderByDirection { get; set; } + } +} diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractCompilationAugmentorTests.cs b/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractCompilationAugmentorTests.cs index d0752f214..37b5bbd2f 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractCompilationAugmentorTests.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractCompilationAugmentorTests.cs @@ -1,7 +1,9 @@ using IntelliTect.Coalesce.CodeGeneration.Analysis.Roslyn; using IntelliTect.Coalesce.Testing; using IntelliTect.Coalesce.Testing.Util; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; using Microsoft.CodeAnalysis.Text; namespace IntelliTect.Coalesce.CodeGeneration.Tests; @@ -40,4 +42,72 @@ public partial class SameProjectGeneratedClass ReflectionRepositoryFactory.AssertCompilationSuccess(augmentedCompilation); await Task.CompletedTask; } + + [Test] + public async Task ReferencedAssemblyGeneratedClassShapes_AreAugmentedIntoTargetCompilation() + { + var producerSource = """ + #nullable enable + using IntelliTect.Coalesce.DataAnnotations; + + namespace IntelliTect.Coalesce.Testing.GeneratedContracts; + + [GeneratedContractShape( + "referenced-generated-class", + GeneratedContractOutputKind.Class, + "GeneratedContractConsumer", + "IntelliTect.Coalesce.Testing.GeneratedContracts", + "ReferencedGeneratedClass", + Members = [nameof(Name)])] + public class ReferencedGeneratedClassSource + { + public string Name { get; set; } = null!; + } + """; + + var producerCompilation = ReflectionRepositoryFactory.GetCompilation( + [CSharpSyntaxTree.ParseText(SourceText.From(producerSource), path: "ReferencedGeneratedClassSource.cs")], + assertSuccess: true); + + var producerReference = CreateMetadataReference(producerCompilation); + + var consumerSource = """ + #nullable enable + + namespace IntelliTect.Coalesce.Testing.GeneratedContracts; + + public class Consumer + { + public ReferencedGeneratedClass Contract { get; set; } = new() + { + Name = string.Empty + }; + } + """; + + var consumerCompilation = (CSharpCompilation)ReflectionRepositoryFactory.GetCompilation( + [CSharpSyntaxTree.ParseText(SourceText.From(consumerSource), path: "Consumer.cs")], + assertSuccess: false) + .AddReferences(producerReference) + .WithAssemblyName("GeneratedContractConsumer"); + + var augmentedCompilation = (CSharpCompilation)GeneratedContractCompilationAugmentor + .AugmentWithSameProjectGeneratedContracts(consumerCompilation, "/tmp/coalesce-generated-contracts-test"); + + ReflectionRepositoryFactory.AssertCompilationSuccess(augmentedCompilation); + await Task.CompletedTask; + } + + private static PortableExecutableReference CreateMetadataReference(CSharpCompilation compilation) + { + using var stream = new MemoryStream(); + EmitResult emitResult = compilation.Emit(stream); + if (!emitResult.Success) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, emitResult.Diagnostics)); + } + + stream.Position = 0; + return MetadataReference.CreateFromImage(stream.ToArray()); + } } diff --git a/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/GeneratedContractCompilationAugmentor.cs b/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/GeneratedContractCompilationAugmentor.cs index b99e09755..9d088b2a6 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/GeneratedContractCompilationAugmentor.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/GeneratedContractCompilationAugmentor.cs @@ -35,7 +35,7 @@ public static Compilation AugmentWithSameProjectGeneratedContracts( var generatedTrees = new List(); var emittedTypes = new HashSet(StringComparer.Ordinal); - foreach (var sourceType in GetDeclaredTypes(compilation)) + foreach (var sourceType in GetCandidateTypes(compilation)) { var shapes = GeneratedContracts.GetShapes(sourceType) .Where(shape => ShouldAugmentShape(sourceType, shape)) @@ -110,10 +110,33 @@ private static string NormalizeAssemblyName(string assemblyName) return assemblyName; } - private static IEnumerable GetDeclaredTypes(Compilation compilation) + private static IEnumerable GetCandidateTypes(Compilation compilation) { var discovered = new HashSet(SymbolEqualityComparer.Default); + foreach (var sourceType in GetDeclaredTypes(compilation)) + { + discovered.Add(sourceType); + } + + foreach (var assembly in compilation.SourceModule.ReferencedAssemblySymbols) + { + if (!ShouldInspectAssembly(assembly)) + { + continue; + } + + foreach (var symbol in GetNamedTypes(assembly.GlobalNamespace)) + { + discovered.Add(symbol); + } + } + + return discovered; + } + + private static IEnumerable GetDeclaredTypes(Compilation compilation) + { foreach (var tree in compilation.SyntaxTrees) { var semanticModel = compilation.GetSemanticModel(tree); @@ -123,11 +146,51 @@ private static IEnumerable GetDeclaredTypes(Compilation compil symbol.ContainingAssembly is not null && SymbolEqualityComparer.Default.Equals(symbol.ContainingAssembly, compilation.Assembly)) { - discovered.Add(symbol); + yield return symbol; } } } + } - return discovered; + private static bool ShouldInspectAssembly(IAssemblySymbol assembly) + => assembly.Name != "IntelliTect.Coalesce" && + assembly.Modules.Any(module => + module.ReferencedAssemblySymbols.Any(reference => reference.Name == "IntelliTect.Coalesce")); + + private static IEnumerable GetNamedTypes(INamespaceSymbol @namespace) + { + foreach (var member in @namespace.GetMembers()) + { + foreach (var type in GetNamedTypes(member)) + { + yield return type; + } + } + } + + private static IEnumerable GetNamedTypes(INamespaceOrTypeSymbol symbol) + { + switch (symbol) + { + case INamespaceSymbol @namespace: + foreach (var member in @namespace.GetMembers()) + { + foreach (var type in GetNamedTypes(member)) + { + yield return type; + } + } + break; + case INamedTypeSymbol type: + yield return type; + foreach (var nestedType in type.GetTypeMembers()) + { + foreach (var discovered in GetNamedTypes(nestedType)) + { + yield return discovered; + } + } + break; + } } } From 07da34135ee36d56a563e40a4c2be343dd281128 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Tue, 12 May 2026 00:13:01 -0500 Subject: [PATCH 05/14] Fix codegen test harness portability --- .../TargetClassesFullGenerationTest.cs | 4 + .../CodeGenTestBase.cs | 109 +++++++++++++++--- 2 files changed, 96 insertions(+), 17 deletions(-) diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Tests/TargetClassesFullGenerationTest.cs b/src/IntelliTect.Coalesce.CodeGeneration.Tests/TargetClassesFullGenerationTest.cs index 26b43fc71..965f44f66 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Tests/TargetClassesFullGenerationTest.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Tests/TargetClassesFullGenerationTest.cs @@ -141,9 +141,13 @@ public async Task SecurityOverviewDataGenerates() .AddCoalesce(c => { c.AddContext(); + c.AddContext(); + c.AddContext(); }) .AddSingleton(Mock.Of()) .AddScoped() // good enough (doesn't need to be configured, just needs to exist) + .AddScoped() // good enough (doesn't need to be configured, just needs to exist) + .AddScoped() // good enough (doesn't need to be configured, just needs to exist) .BuildServiceProvider(); var reflectionData = CoalesceApplicationBuilderExtensions.GetSecurityOverviewData( diff --git a/src/IntelliTect.Coalesce.Testing/CodeGenTestBase.cs b/src/IntelliTect.Coalesce.Testing/CodeGenTestBase.cs index 0e145dc2f..5ae3e0908 100644 --- a/src/IntelliTect.Coalesce.Testing/CodeGenTestBase.cs +++ b/src/IntelliTect.Coalesce.Testing/CodeGenTestBase.cs @@ -13,6 +13,7 @@ using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; @@ -102,7 +103,7 @@ public static async Task GetCSharpCompilation(IRootGenerator private static ProcessStartInfo GetShellExecStartInfo(string program, IEnumerable args, string workingDirectory = null) { var arguments = args.ToList(); - var exeToRun = program; + var exeToRun = ResolveExecutablePath(program); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { // On Windows, the node executable is a .cmd file, so it can't be executed @@ -118,31 +119,85 @@ private static ProcessStartInfo GetShellExecStartInfo(string program, IEnumerabl WorkingDirectory = workingDirectory, WindowStyle = ProcessWindowStyle.Hidden }; + start.Environment["PATH"] = string.Join( + Path.PathSeparator, + new[] + { + Path.GetDirectoryName(ResolveExecutablePath("node")), + Path.GetDirectoryName(ResolveExecutablePath("npm")), + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + start.Environment.TryGetValue("PATH", out var existingPath) ? existingPath : Environment.GetEnvironmentVariable("PATH"), + } + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct() + ); foreach (var arg in arguments) start.ArgumentList.Add(arg); return start; } + private static string ResolveExecutablePath(string program) + { + if (Path.IsPathRooted(program) || program.Contains(Path.DirectorySeparatorChar) || program.Contains(Path.AltDirectorySeparatorChar)) + { + return program; + } + + var pathEntries = (Environment.GetEnvironmentVariable("PATH") ?? "") + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); + + foreach (var candidate in pathEntries + .Concat(new[] + { + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + }) + .Distinct()) + { + var fullPath = Path.Combine(candidate, program); + if (System.IO.File.Exists(fullPath)) + { + return fullPath; + } + } + + return program; + } + protected static async Task AssertTypescriptProjectCompiles( string tsConfigPath, string workingDirectory, string tsVersion ) { - var tsPath = Path.GetFullPath("./ts" + tsVersion); - await Process - .Start(GetShellExecStartInfo("npm", new[] { "i", "typescript@" + tsVersion, "--prefix", tsPath })) - .WaitForExitAsync(); + var targetFramework = Path.GetFileName(AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var tsPath = Path.Combine(Path.GetTempPath(), "coalesce-ts-tests", targetFramework, "ts" + tsVersion); + if (Directory.Exists(tsPath)) Directory.Delete(tsPath, recursive: true); + Directory.CreateDirectory(tsPath); + + using var npmInstall = Process.Start(GetShellExecStartInfo("npm", new[] { "i", "typescript@" + tsVersion, "--prefix", tsPath }, tsPath)) + ?? throw new InvalidOperationException("Failed to start npm."); + await npmInstall.WaitForExitAsync(); + await Assert.That(npmInstall.ExitCode).IsEqualTo(0); + + var effectiveWorkingDirectory = Directory.Exists(workingDirectory) + ? workingDirectory + : Path.GetDirectoryName(tsConfigPath) ?? tsPath; + Directory.CreateDirectory(effectiveWorkingDirectory); var start = GetShellExecStartInfo( - $"{tsPath}/node_modules/.bin/tsc", + ResolveExecutablePath("node"), new List { + Path.Combine(tsPath, "node_modules", "typescript", "bin", "tsc"), "--project", tsConfigPath, "--noEmit" }, - workingDirectory + effectiveWorkingDirectory ); start.RedirectStandardOutput = true; start.RedirectStandardError = true; @@ -172,16 +227,36 @@ await Assert.That(typescriptProcess.ExitCode).IsEqualTo(0) .Because(string.Join("\n\n", streams)); } - public static DirectoryInfo GetRepoRoot() + public static DirectoryInfo GetRepoRoot([CallerFilePath] string sourceFilePath = null) { - return - // Normal usage (e.g. executing out of a /bin folder - new DirectoryInfo(Directory.GetCurrentDirectory()) - .FindFileInAncestorDirectory("Coalesce.slnx") - ?.Directory - ?? - // For Live Unit Testing, which makes a copy of the whole repo elsewhere. - new DirectoryInfo(Directory.GetCurrentDirectory()) - .FindDirectoryInAncestorDirectory("b"); + DirectoryInfo FindRepoRoot(DirectoryInfo start) + { + if (start is null || !start.Exists) return null; + + return start.FindFileInAncestorDirectory("Coalesce.slnx")?.Directory + ?? start.FindDirectoryInAncestorDirectory("b"); + } + + var candidateRoots = new[] + { + AppContext.BaseDirectory, + Path.GetDirectoryName(typeof(CodeGenTestBase).Assembly.Location), + Directory.GetCurrentDirectory(), + sourceFilePath is null ? null : Path.GetDirectoryName(sourceFilePath), + } + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => new DirectoryInfo(path!)) + .DistinctBy(path => path.FullName); + + foreach (var candidateRoot in candidateRoots) + { + var repoRoot = FindRepoRoot(candidateRoot); + if (repoRoot is not null) + { + return repoRoot; + } + } + + throw new DirectoryNotFoundException("Could not locate the Coalesce repo root."); } } From 465fb412651049e9052e9be852f905268c9ef8f0 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Tue, 12 May 2026 00:16:46 -0500 Subject: [PATCH 06/14] Track generated contract diagnostics --- src/IntelliTect.Coalesce.Analyzer/AnalyzerReleases.Shipped.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/IntelliTect.Coalesce.Analyzer/AnalyzerReleases.Shipped.md b/src/IntelliTect.Coalesce.Analyzer/AnalyzerReleases.Shipped.md index 8d294e6ac..ea4ea791c 100644 --- a/src/IntelliTect.Coalesce.Analyzer/AnalyzerReleases.Shipped.md +++ b/src/IntelliTect.Coalesce.Analyzer/AnalyzerReleases.Shipped.md @@ -24,4 +24,6 @@ COA0014 | Usage | Warning | NoAutoInclude only affects navigation properties (ob COA0201 | Usage | Info | IFile parameters on Coalesce-exposed methods should specify suggested file types using the [FileType] attribute to improve default user experience. COA1001 | Style | Info | ItemResult and ItemResult constructors can often be replaced with implicit conversions from boolean, string, and object values. This provides cleaner, more readable code while maintaining the same functionality. COA1002 | Style | Hidden | Marks the unnecessary parts of ItemResult constructor calls that can be removed when using implicit conversions. This diagnostic helps IDE syntax highlighting identify which portions of the code will be simplified by the COA1001 code fix. -COA2001 | Security | Warning | Data sources that perform authorization checks should ensure their served type has a default data source to prevent security bypasses. Without a default data source, clients can directly access the served type without the authorization logic. \ No newline at end of file +COA2001 | Security | Warning | Data sources that perform authorization checks should ensure their served type has a default data source to prevent security bypasses. Without a default data source, clients can directly access the served type without the authorization logic. +COALESCEGC001 | Coalesce.GeneratedContracts | Error | Generated contract shape references a missing property. +COALESCEGC002 | Coalesce.GeneratedContracts | Error | Generated contract shape is declared more than once. From 9087a1995251f0dcc84d34692af8acd30a419b65 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Tue, 12 May 2026 00:34:45 -0500 Subject: [PATCH 07/14] Package generated contract analyzer with Coalesce --- src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj index dda700d62..413da4bf1 100644 --- a/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj +++ b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj @@ -14,7 +14,10 @@ + Include="..\IntelliTect.Coalesce.Analyzer\IntelliTect.Coalesce.Analyzer.csproj" + PrivateAssets="all" + ReferenceOutputAssembly="false" + OutputItemType="Analyzer" /> @@ -31,6 +34,11 @@ + From 5ed9d53c8c63a29689ed3b587aec717a0e6902c1 Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Tue, 12 May 2026 07:41:36 -0500 Subject: [PATCH 08/14] feat(sourcegen): bridge C# api generation --- .../AnalyzerReleases.Shipped.md | 2 + .../IntelliTect.Coalesce.Analyzer.csproj | 5 +- .../GeneratedApiSurfaceSourceGenerator.cs | 415 ++++++++++++++++++ .../Program.cs | 198 +++++++-- 4 files changed, 587 insertions(+), 33 deletions(-) create mode 100644 src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedApiSurfaceSourceGenerator.cs diff --git a/src/IntelliTect.Coalesce.Analyzer/AnalyzerReleases.Shipped.md b/src/IntelliTect.Coalesce.Analyzer/AnalyzerReleases.Shipped.md index ea4ea791c..1de0f8f5f 100644 --- a/src/IntelliTect.Coalesce.Analyzer/AnalyzerReleases.Shipped.md +++ b/src/IntelliTect.Coalesce.Analyzer/AnalyzerReleases.Shipped.md @@ -27,3 +27,5 @@ COA1002 | Style | Hidden | Marks the unnecessary parts of ItemResult constructor COA2001 | Security | Warning | Data sources that perform authorization checks should ensure their served type has a default data source to prevent security bypasses. Without a default data source, clients can directly access the served type without the authorization logic. COALESCEGC001 | Coalesce.GeneratedContracts | Error | Generated contract shape references a missing property. COALESCEGC002 | Coalesce.GeneratedContracts | Error | Generated contract shape is declared more than once. +COALESCESG001 | Coalesce.SourceGeneration | Error | Coalesce in-memory C# generation failed. +COALESCESG002 | Coalesce.SourceGeneration | Error | Coalesce generated C# files are still included in the compilation. diff --git a/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj b/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj index 8bcca9770..f914e68c8 100644 --- a/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj +++ b/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj @@ -9,8 +9,10 @@ + - $(NoWarn);RS1038 + $(NoWarn);RS1038;RS1035 false @@ -49,6 +51,7 @@ + diff --git a/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedApiSurfaceSourceGenerator.cs b/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedApiSurfaceSourceGenerator.cs new file mode 100644 index 000000000..c3fd8678c --- /dev/null +++ b/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedApiSurfaceSourceGenerator.cs @@ -0,0 +1,415 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Xml.Linq; + +namespace IntelliTect.Coalesce.Analyzer.SourceGenerators; + +[Generator(LanguageNames.CSharp)] +public sealed class GeneratedApiSurfaceSourceGenerator : IIncrementalGenerator +{ + private const string CaptureEnvironmentVariable = "COALESCE_SOURCEGEN_CAPTURE"; + private const string ModelsGeneratorName = "Models"; + private const string ModelsGeneratorFullName = "IntelliTect.Coalesce.CodeGeneration.Api.Generators.Models"; + private const string ControllersGeneratorName = "Controllers"; + private const string ControllersGeneratorFullName = "IntelliTect.Coalesce.CodeGeneration.Api.Generators.Controllers"; + private const string ConfigFileName = "coalesce.json"; + private const string CaptureOptionName = "--emit-csharp-sourcegen"; + + private static readonly DiagnosticDescriptor CaptureFailedDiagnostic = new( + id: "COALESCESG001", + title: "Coalesce in-memory C# generation failed", + messageFormat: "{0}", + category: "Coalesce.SourceGeneration", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor GeneratedFilesStillPresentDiagnostic = new( + id: "COALESCESG002", + title: "Coalesce generated C# files are still included in the compilation", + messageFormat: "{0}", + category: "Coalesce.SourceGeneration", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterSourceOutput(context.CompilationProvider, static (productionContext, compilation) => + Execute(productionContext, compilation)); + } + + private static void Execute(SourceProductionContext context, Compilation compilation) + { + if (string.Equals(Environment.GetEnvironmentVariable(CaptureEnvironmentVariable), "1", StringComparison.Ordinal)) + { + return; + } + + if (string.IsNullOrWhiteSpace(compilation.AssemblyName)) + { + return; + } + + var configPath = FindCoalesceConfigurationPath(compilation); + if (configPath is null) + { + return; + } + + SourceGenerationConfig config; + try + { + config = SourceGenerationConfig.Load(configPath); + } + catch (Exception ex) + { + context.ReportDiagnostic(Diagnostic.Create( + CaptureFailedDiagnostic, + location: null, + $"Unable to read '{ConfigFileName}' for Coalesce in-memory C# generation: {ex.Message}")); + return; + } + + if (!config.ShouldGenerateAnyCategory) + { + return; + } + + if (!string.Equals(config.WebProjectAssemblyName, NormalizeAssemblyName(compilation.AssemblyName!), StringComparison.Ordinal)) + { + return; + } + + var stillPresentPaths = GetStillPresentGeneratedFilePaths(compilation, config.GenerateModels, config.GenerateControllers); + if (stillPresentPaths.Count > 0) + { + context.ReportDiagnostic(Diagnostic.Create( + GeneratedFilesStillPresentDiagnostic, + location: null, + $"Coalesce in-memory C# generation is enabled via '{ConfigFileName}', but generated files are still compiled from disk: {string.Join(", ", stillPresentPaths.Take(5))}{(stillPresentPaths.Count > 5 ? ", ..." : string.Empty)}. Delete or remove the on-disk generated files before enabling the source generator.")); + return; + } + + string captureFilePath = Path.Combine(Path.GetTempPath(), $"coalesce-csharp-sourcegen-{Guid.NewGuid():N}.json"); + try + { + var captureResult = InvokeCoalesceTool(configPath, captureFilePath); + if (!captureResult.Success) + { + context.ReportDiagnostic(Diagnostic.Create( + CaptureFailedDiagnostic, + location: null, + captureResult.ErrorMessage)); + return; + } + + var outputs = LoadCapturedSources(captureFilePath); + foreach (var output in outputs) + { + var normalizedPath = NormalizePath(output.Key); + if (!ShouldEmit(normalizedPath, config.GenerateModels, config.GenerateControllers)) + { + continue; + } + + context.AddSource(normalizedPath, SourceText.From(output.Value, Encoding.UTF8)); + } + } + catch (Exception ex) + { + context.ReportDiagnostic(Diagnostic.Create( + CaptureFailedDiagnostic, + location: null, + $"Coalesce in-memory C# generation failed: {ex.Message}")); + } + finally + { + try + { + if (File.Exists(captureFilePath)) + { + File.Delete(captureFilePath); + } + } + catch + { + // Best effort only. + } + } + } + + private static string? FindCoalesceConfigurationPath(Compilation compilation) + { + foreach (var filePath in compilation.SyntaxTrees + .Select(tree => tree.FilePath) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + var directory = Path.GetDirectoryName(filePath!); + while (!string.IsNullOrWhiteSpace(directory)) + { + var candidate = Path.Combine(directory!, ConfigFileName); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = Directory.GetParent(directory!)?.FullName; + } + } + + return null; + } + + private static List GetStillPresentGeneratedFilePaths(Compilation compilation, bool includeModels, bool includeControllers) + => compilation.SyntaxTrees + .Select(tree => tree.FilePath) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => NormalizePath(path!)) + .Where(path => + (includeModels && path.Contains("/Models/Generated/", StringComparison.OrdinalIgnoreCase)) || + (includeControllers && path.Contains("/Api/Generated/", StringComparison.OrdinalIgnoreCase))) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + private static bool ShouldEmit(string normalizedPath, bool includeModels, bool includeControllers) + => (includeModels && normalizedPath.StartsWith("Models/Generated/", StringComparison.OrdinalIgnoreCase)) + || (includeControllers && normalizedPath.StartsWith("Api/Generated/", StringComparison.OrdinalIgnoreCase)); + + private static IReadOnlyDictionary LoadCapturedSources(string captureFilePath) + { + if (!File.Exists(captureFilePath)) + { + throw new FileNotFoundException($"Coalesce source-generation capture file was not created: {captureFilePath}"); + } + + var json = File.ReadAllText(captureFilePath); + return JsonSerializer.Deserialize>(json) + ?? new Dictionary(StringComparer.Ordinal); + } + + private static CaptureInvocationResult InvokeCoalesceTool(string configPath, string captureFilePath) + { + var workingDirectory = Path.GetDirectoryName(configPath)!; + var dotnetHost = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") + ?? Environment.GetEnvironmentVariable("DOTNET_EXE") + ?? "dotnet"; + var linkedToolDllPath = TryGetLinkedSourceToolDllPath(); + + var startInfo = new ProcessStartInfo + { + FileName = dotnetHost, + Arguments = linkedToolDllPath is { Length: > 0 } + ? $"{Quote(linkedToolDllPath)} {Quote(configPath)} {CaptureOptionName} {Quote(captureFilePath)} --verbosity Error" + : $"coalesce {Quote(configPath)} {CaptureOptionName} {Quote(captureFilePath)} --verbosity Error", + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + startInfo.EnvironmentVariables[CaptureEnvironmentVariable] = "1"; + + using var process = Process.Start(startInfo); + if (process is null) + { + return CaptureInvocationResult.Failure("Failed to start 'dotnet coalesce' for Coalesce in-memory C# generation."); + } + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit(300_000)) + { + try + { + process.Kill(); + } + catch + { + // Ignore kill failures when timing out. + } + + return CaptureInvocationResult.Failure("'dotnet coalesce' timed out while capturing Coalesce in-memory C# sources."); + } + + var stdout = stdoutTask.GetAwaiter().GetResult(); + var stderr = stderrTask.GetAwaiter().GetResult(); + + if (process.ExitCode != 0) + { + var details = string.Join(Environment.NewLine, + new[] + { + TrimOutput("stdout", stdout), + TrimOutput("stderr", stderr), + }.Where(s => !string.IsNullOrWhiteSpace(s))); + + return CaptureInvocationResult.Failure( + $"'dotnet coalesce' exited with code {process.ExitCode} while capturing Coalesce in-memory C# sources.{(string.IsNullOrWhiteSpace(details) ? string.Empty : Environment.NewLine + details)}"); + } + + return CaptureInvocationResult.Successful(); + } + + private static string TrimOutput(string streamName, string? output) + { + if (string.IsNullOrWhiteSpace(output)) + { + return string.Empty; + } + + const int maxLength = 4_000; + var trimmed = output!.Length <= maxLength ? output : output.Substring(output.Length - maxLength, maxLength); + return $"{streamName}:{Environment.NewLine}{trimmed.Trim()}"; + } + + private static string? TryGetLinkedSourceToolDllPath() + { + var assemblyDirectory = Path.GetDirectoryName(typeof(GeneratedApiSurfaceSourceGenerator).Assembly.Location); + if (string.IsNullOrWhiteSpace(assemblyDirectory)) + { + return null; + } + + var candidateRoots = EnumerateAncestorDirectories(assemblyDirectory!); + var candidateRelativePaths = new[] + { + Path.Combine("src", "IntelliTect.Coalesce.DotnetTool", "bin", "Debug", "net10.0", "dotnet-coalesce.dll"), + Path.Combine("src", "IntelliTect.Coalesce.DotnetTool", "bin", "Release", "net10.0", "dotnet-coalesce.dll"), + Path.Combine("src", "IntelliTect.Coalesce.DotnetTool", "bin", "Debug", "net9.0", "dotnet-coalesce.dll"), + Path.Combine("src", "IntelliTect.Coalesce.DotnetTool", "bin", "Release", "net9.0", "dotnet-coalesce.dll"), + Path.Combine("src", "IntelliTect.Coalesce.DotnetTool", "bin", "Debug", "net8.0", "dotnet-coalesce.dll"), + Path.Combine("src", "IntelliTect.Coalesce.DotnetTool", "bin", "Release", "net8.0", "dotnet-coalesce.dll"), + }; + + foreach (var root in candidateRoots) + { + foreach (var relativePath in candidateRelativePaths) + { + var candidate = Path.Combine(root, relativePath); + if (File.Exists(candidate)) + { + return candidate; + } + } + } + + return null; + } + + private static IEnumerable EnumerateAncestorDirectories(string directory) + { + var current = new DirectoryInfo(directory); + while (current is not null) + { + yield return current.FullName; + current = current.Parent; + } + } + + private static string Quote(string value) + => '"' + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + '"'; + + private static string NormalizePath(string path) + => path.Replace('\\', '/'); + + private static string NormalizeAssemblyName(string assemblyName) + => assemblyName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || assemblyName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? Path.GetFileNameWithoutExtension(assemblyName) + : assemblyName; + + private sealed class CaptureInvocationResult + { + public CaptureInvocationResult(bool success, string errorMessage) + { + Success = success; + ErrorMessage = errorMessage; + } + + public bool Success { get; } + public string ErrorMessage { get; } + + public static CaptureInvocationResult Successful() => new(true, string.Empty); + public static CaptureInvocationResult Failure(string message) => new(false, message); + } + + private sealed class SourceGenerationConfig + { + public string WebProjectAssemblyName { get; set; } = string.Empty; + public bool GenerateModels { get; set; } + public bool GenerateControllers { get; set; } + + public bool ShouldGenerateAnyCategory => GenerateModels || GenerateControllers; + + public static SourceGenerationConfig Load(string configPath) + { + using var stream = File.OpenRead(configPath); + using var document = JsonDocument.Parse(stream); + var root = document.RootElement; + + if (!root.TryGetProperty("webProject", out var webProject) || !webProject.TryGetProperty("projectFile", out var projectFileProperty)) + { + throw new InvalidOperationException($"'{ConfigFileName}' does not contain a webProject.projectFile entry."); + } + + var configDirectory = Path.GetDirectoryName(configPath)!; + var resolvedWebProjectPath = Path.GetFullPath(Path.Combine(configDirectory, projectFileProperty.GetString()!)); + var webProjectAssemblyName = GetProjectAssemblyName(resolvedWebProjectPath); + + return new SourceGenerationConfig + { + WebProjectAssemblyName = webProjectAssemblyName, + GenerateModels = IsGeneratorDisabled(root, ModelsGeneratorName, ModelsGeneratorFullName), + GenerateControllers = IsGeneratorDisabled(root, ControllersGeneratorName, ControllersGeneratorFullName), + }; + } + + private static bool IsGeneratorDisabled(JsonElement root, string shortName, string fullName) + { + if (!root.TryGetProperty("generatorConfig", out var generatorConfig)) + { + return false; + } + + foreach (var key in new[] { fullName, shortName }) + { + if (!generatorConfig.TryGetProperty(key, out var generatorSettings)) + { + continue; + } + + if (generatorSettings.ValueKind == JsonValueKind.Object && + generatorSettings.TryGetProperty("disabled", out var disabledProperty) && + disabledProperty.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + return disabledProperty.GetBoolean(); + } + } + + return false; + } + + private static string GetProjectAssemblyName(string projectFilePath) + { + if (!File.Exists(projectFilePath)) + { + throw new FileNotFoundException($"Configured Coalesce web project was not found: {projectFilePath}"); + } + + var projectDocument = XDocument.Load(projectFilePath); + var assemblyName = projectDocument + .Descendants() + .FirstOrDefault(element => element.Name.LocalName == "AssemblyName") + ?.Value + ?.Trim(); + + return NormalizeAssemblyName(string.IsNullOrWhiteSpace(assemblyName) + ? Path.GetFileNameWithoutExtension(projectFilePath) + : assemblyName!); + } + } +} diff --git a/src/IntelliTect.Coalesce.DotnetTool/Program.cs b/src/IntelliTect.Coalesce.DotnetTool/Program.cs index 535060549..aa34751e6 100644 --- a/src/IntelliTect.Coalesce.DotnetTool/Program.cs +++ b/src/IntelliTect.Coalesce.DotnetTool/Program.cs @@ -1,4 +1,7 @@ +#nullable enable + using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; @@ -13,6 +16,7 @@ using McMaster.Extensions.CommandLineUtils; using Microsoft.Extensions.Logging; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; // ReSharper disable UnassignedGetOnlyAutoProperty @@ -21,6 +25,15 @@ namespace IntelliTect.Coalesce.Cli; [HelpOption] public class Program { + private const string ModelsGeneratorName = "Models"; + private const string ModelsGeneratorFullName = "IntelliTect.Coalesce.CodeGeneration.Api.Generators.Models"; + private const string ControllersGeneratorName = "Controllers"; + private const string ControllersGeneratorFullName = "IntelliTect.Coalesce.CodeGeneration.Api.Generators.Controllers"; + private const string ScriptsGeneratorName = "Scripts"; + private const string ScriptsGeneratorFullName = "IntelliTect.Coalesce.CodeGeneration.Vue.Generators.Scripts"; + private const string KernelPluginsGeneratorName = "KernelPlugins"; + private const string KernelPluginsGeneratorFullName = "IntelliTect.Coalesce.CodeGeneration.Api.Generators.KernelPlugins"; + [Option(CommandOptionType.NoValue, Description = "Wait for a debugger to be attached before starting generation", LongName = "debug", ShortName = "d")] @@ -34,14 +47,19 @@ public class Program Description = "Verify that no output changes have been made. Use in CI builds to ensure that codegen has not been forgotten.", LongName = "verify", ShortName = "")] public bool Verify { get; } + [Option(CommandOptionType.SingleValue, + Description = "Write Coalesce-generated C# Models/Generated and Api/Generated outputs as a JSON map to the specified file path. Intended for the Coalesce source generator.", + LongName = "emit-csharp-sourcegen")] + public string? EmitCSharpSourceGenOutput { get; } + [Argument(0, "config", Description = "Path to a coalesce.json configuration file that will drive generation. If not specified, it will search in current folder.")] - public string ConfigFile { get; } + public string? ConfigFile { get; } [Option(CommandOptionType.SingleValue, ShortName = "v", LongName = "verbosity", Description = "Output verbosity. Options are Trace, Debug, Information, Warning, Error, Critical, None.")] // TODO: Change this type to be the Enum once Nate McMaster ships v2.2.0 of his library. - public string LogLevelOption { get; } + public string? LogLevelOption { get; } private static Task Main(string[] args) { @@ -60,7 +78,7 @@ private async Task OnExecuteAsync(CommandLineApplication app) var frameworkVersion = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription; // This reflects the version of the nuget package. - string version = FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).ProductVersion; + string version = FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).ProductVersion ?? "unknown"; if (!Enum.TryParse(LogLevelOption, true, out LogLevel logLevel)) logLevel = LogLevel.Information; @@ -72,47 +90,30 @@ private async Task OnExecuteAsync(CommandLineApplication app) } FileInfo configFile = LocateConfigFile(ConfigFile); - - CoalesceConfiguration config; - - using (var reader = new StreamReader(configFile.FullName)) - using (var jsonReader = new JsonTextReader(reader)) - { - var serializer = new JsonSerializer(); - config = serializer.Deserialize(jsonReader); - } - + CoalesceConfiguration config = LoadConfiguration(configFile.FullName); config.DryRun = DryRun; // Must go AFTER we load in the config file, since if the config file was a relative path, changing this ruins that. - Directory.SetCurrentDirectory(configFile.DirectoryName); + Directory.SetCurrentDirectory(configFile.DirectoryName!); if (logLevel <= LogLevel.Information) { Console.WriteLine($"Working in '{Directory.GetCurrentDirectory()}', using '{Path.GetFileName(configFile.FullName)}'"); } - // TODO: dynamic resolution of the specific generator. - // For now, we hard-reference all of them and then try and match one of them. - // This may ultimately be the best approach in the long run, since it lets us easily do partial matching as below: - var rootGeneratorName = config.RootGenerator ?? "Vue"; - var rootGenerators = new[] - { - typeof(CodeGeneration.Vue.Generators.VueSuite), - }; - - Type rootGenerator = - rootGenerators.FirstOrDefault(t => t.FullName == rootGeneratorName) - ?? rootGenerators.FirstOrDefault(t => t.Name == rootGeneratorName) - ?? rootGenerators.SingleOrDefault(t => t.FullName.Contains(rootGeneratorName)); - + var rootGenerator = ResolveRootGenerator(config.RootGenerator); if (rootGenerator == null) { - Console.Error.WriteLine($"Couldn't find a root generator that matches {rootGeneratorName}"); - Console.Error.WriteLine($"Valid root generators are: {string.Join(",", rootGenerators.Select(g => g.FullName))}"); + Console.Error.WriteLine($"Couldn't find a root generator that matches {config.RootGenerator ?? "Vue"}"); + Console.Error.WriteLine($"Valid root generators are: {string.Join(",", GetRootGenerators().Select(g => g.FullName))}"); return -1; } + if (!string.IsNullOrWhiteSpace(EmitCSharpSourceGenOutput)) + { + return await EmitCSharpSourceGenOutputsAsync(config, logLevel, rootGenerator); + } + var executor = new GenerationExecutor(config, logLevel); try { @@ -145,6 +146,139 @@ private async Task OnExecuteAsync(CommandLineApplication app) return 0; } + private static CoalesceConfiguration LoadConfiguration(string configFilePath) + { + using var reader = new StreamReader(configFilePath); + using var jsonReader = new JsonTextReader(reader); + var serializer = new JsonSerializer(); + return serializer.Deserialize(jsonReader)!; + } + + private static Type? ResolveRootGenerator(string? rootGeneratorName) + { + var effectiveRootGeneratorName = rootGeneratorName ?? "Vue"; + var rootGenerators = GetRootGenerators(); + + return rootGenerators.FirstOrDefault(t => t.FullName == effectiveRootGeneratorName) + ?? rootGenerators.FirstOrDefault(t => t.Name == effectiveRootGeneratorName) + ?? rootGenerators.SingleOrDefault(t => t.FullName!.Contains(effectiveRootGeneratorName, StringComparison.Ordinal)); + } + + private static Type[] GetRootGenerators() + => + [ + typeof(CodeGeneration.Vue.Generators.VueSuite), + ]; + + private async Task EmitCSharpSourceGenOutputsAsync(CoalesceConfiguration config, LogLevel logLevel, Type rootGenerator) + { + var originalTargetDirectory = config.Output.TargetDirectory; + var originalDryRun = config.DryRun; + string tempOutputDirectory = Path.Combine(Path.GetTempPath(), $"coalesce-sourcegen-{Guid.NewGuid():N}"); + + try + { + Directory.CreateDirectory(tempOutputDirectory); + config.Output.TargetDirectory = tempOutputDirectory; + config.DryRun = false; + + EnsureGeneratorDisabled(config, disabled: true, ScriptsGeneratorName, ScriptsGeneratorFullName); + EnsureGeneratorDisabled(config, disabled: true, KernelPluginsGeneratorName, KernelPluginsGeneratorFullName); + EnsureGeneratorDisabled(config, disabled: false, ModelsGeneratorName, ModelsGeneratorFullName); + EnsureGeneratorDisabled(config, disabled: false, ControllersGeneratorName, ControllersGeneratorFullName); + + var executor = new GenerationExecutor(config, logLevel); + try + { + await executor.GenerateAsync(rootGenerator); + } + catch (CoalesceModelException e) + { + executor.Logger.LogError(e.Message); + return -1; + } + catch (ProjectAnalysisException e) + { + executor.Logger.LogError(e.Message); + return -1; + } + catch (Exception e) + { + executor.Logger.LogError(e.ToString()); + return -1; + } + + var outputs = CaptureGeneratedCSharpOutputs(tempOutputDirectory); + var outputDirectory = Path.GetDirectoryName(Path.GetFullPath(EmitCSharpSourceGenOutput!)); + if (!string.IsNullOrWhiteSpace(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + await File.WriteAllTextAsync( + Path.GetFullPath(EmitCSharpSourceGenOutput!), + JsonConvert.SerializeObject(outputs, Formatting.None)); + + return 0; + } + finally + { + config.Output.TargetDirectory = originalTargetDirectory; + config.DryRun = originalDryRun; + + try + { + if (Directory.Exists(tempOutputDirectory)) + { + Directory.Delete(tempOutputDirectory, recursive: true); + } + } + catch + { + // Best effort cleanup only. + } + } + } + + private static Dictionary CaptureGeneratedCSharpOutputs(string tempOutputDirectory) + { + var outputs = new Dictionary(StringComparer.Ordinal); + + foreach (var relativeDirectory in new[] + { + Path.Combine("Models", "Generated"), + Path.Combine("Api", "Generated"), + }) + { + var fullDirectory = Path.Combine(tempOutputDirectory, relativeDirectory); + if (!Directory.Exists(fullDirectory)) + { + continue; + } + + foreach (var filePath in Directory.GetFiles(fullDirectory, "*.g.cs", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(tempOutputDirectory, filePath).Replace('\\', '/'); + outputs[relativePath] = File.ReadAllText(filePath); + } + } + + return outputs; + } + + private static void EnsureGeneratorDisabled(CoalesceConfiguration config, bool disabled, params string[] generatorNames) + { + foreach (var generatorName in generatorNames.Where(name => !string.IsNullOrWhiteSpace(name))) + { + if (!config.GeneratorConfig.TryGetValue(generatorName, out var generatorConfig)) + { + generatorConfig = new JObject(); + config.GeneratorConfig[generatorName] = generatorConfig; + } + + generatorConfig[Generator.DisabledJsonPropertyName] = disabled; + } + } private static void WaitForDebugger() { @@ -163,9 +297,9 @@ private static void WaitForDebugger() } } - private static FileInfo LocateConfigFile(string explicitLocation) + private static FileInfo LocateConfigFile(string? explicitLocation) { - FileInfo file = null; + FileInfo? file = null; if (!string.IsNullOrWhiteSpace(explicitLocation)) { file = new FileInfo(explicitLocation); From e795a76c0229b20523c5149dc07886b6f7a582ef Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Tue, 12 May 2026 10:31:11 -0500 Subject: [PATCH 09/14] feat(build): export Coalesce analyzer snapshot --- Directory.Packages.props | 4 + .../AnalyzerTypeAliases.cs | 15 + ...temResultConversionCodeFixProviderTests.cs | 1 - ...GeneratedApiSurfaceSourceGeneratorTests.cs | 142 ++++++ ...eratedContractShapeSourceGeneratorTests.cs | 1 - ...IntelliTect.Coalesce.Analyzer.Tests.csproj | 4 +- .../IntelliTect.Coalesce.Analyzer.csproj | 13 +- .../GeneratedApiSurfaceSourceGenerator.cs | 416 ++++------------ .../ExportCoalesceGeneratedCSharpTask.cs | 457 ++++++++++++++++++ ...lliTect.Coalesce.CodeGeneration.Vue.csproj | 11 +- .../Roslyn/RoslynProjectContextFactory.cs | 24 +- .../Generation/GenerationExecutor.cs | 57 ++- .../IntelliTect.Coalesce.csproj | 17 +- .../IntelliTect.Coalesce.dotnet-link.csproj | 62 +++ .../build/IntelliTect.Coalesce.targets | 49 ++ .../IntelliTect.Coalesce.targets | 4 + 16 files changed, 923 insertions(+), 354 deletions(-) create mode 100644 src/IntelliTect.Coalesce.Analyzer.Tests/AnalyzerTypeAliases.cs create mode 100644 src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedApiSurfaceSourceGeneratorTests.cs create mode 100644 src/IntelliTect.Coalesce.CodeGeneration.Vue/ExportCoalesceGeneratedCSharpTask.cs create mode 100644 src/IntelliTect.Coalesce/IntelliTect.Coalesce.dotnet-link.csproj create mode 100644 src/IntelliTect.Coalesce/build/IntelliTect.Coalesce.targets create mode 100644 src/IntelliTect.Coalesce/buildTransitive/IntelliTect.Coalesce.targets diff --git a/Directory.Packages.props b/Directory.Packages.props index 06a001267..1c279a721 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,8 +45,11 @@ + + + @@ -62,6 +65,7 @@ + diff --git a/src/IntelliTect.Coalesce.Analyzer.Tests/AnalyzerTypeAliases.cs b/src/IntelliTect.Coalesce.Analyzer.Tests/AnalyzerTypeAliases.cs new file mode 100644 index 000000000..3c87b7d01 --- /dev/null +++ b/src/IntelliTect.Coalesce.Analyzer.Tests/AnalyzerTypeAliases.cs @@ -0,0 +1,15 @@ +extern alias CoalesceAnalyzer; + +global using AttributeUsageAnalyzer = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.AttributeUsageAnalyzer; +global using Coalesce0001_InvalidPermissionLevel = CoalesceAnalyzer::Coalesce0001_InvalidPermissionLevel; +global using Coalesce0201_MissingFileTypeAttributeCodeFixProvider = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.Coalesce0201_MissingFileTypeAttributeCodeFixProvider; +global using Coalesce1001_SimplifyItemResult = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.Coalesce1001_SimplifyItemResult; +global using Coalesce1001_SimplifyItemResultCodeFixProvider = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.Coalesce1001_SimplifyItemResultCodeFixProvider; +global using Coalesce0005_UnexposedSecondaryAttributeCodeFixProvider = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.Coalesce0005_UnexposedSecondaryAttributeCodeFixProvider; +global using CS0457_AmbiguousItemResultConversionCodeFixProvider = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Fixers.CS0457_AmbiguousItemResultConversionCodeFixProvider; +global using GeneratedApiSurfaceSourceGenerator = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.SourceGenerators.GeneratedApiSurfaceSourceGenerator; +global using GeneratedContractShapeSourceGenerator = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.SourceGenerators.GeneratedContractShapeSourceGenerator; +global using GetQueryOrderingAnalyzer = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.GetQueryOrderingAnalyzer; +global using InvalidBehaviorsOverrideWithDenyAllAnalyzer = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.InvalidBehaviorsOverrideWithDenyAllAnalyzer; +global using RemoveAttributeCodeFixProvider = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.RemoveAttributeCodeFixProvider; +global using SecurityBypassAnalyzer = CoalesceAnalyzer::IntelliTect.Coalesce.Analyzer.Analyzers.SecurityBypassAnalyzer; diff --git a/src/IntelliTect.Coalesce.Analyzer.Tests/CS0457_AmbiguousItemResultConversionCodeFixProviderTests.cs b/src/IntelliTect.Coalesce.Analyzer.Tests/CS0457_AmbiguousItemResultConversionCodeFixProviderTests.cs index fa7040c22..f821b872b 100644 --- a/src/IntelliTect.Coalesce.Analyzer.Tests/CS0457_AmbiguousItemResultConversionCodeFixProviderTests.cs +++ b/src/IntelliTect.Coalesce.Analyzer.Tests/CS0457_AmbiguousItemResultConversionCodeFixProviderTests.cs @@ -1,5 +1,4 @@ using Microsoft.CodeAnalysis.Testing; -using IntelliTect.Coalesce.Analyzer.Fixers; namespace IntelliTect.Coalesce.Analyzer.Tests; diff --git a/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedApiSurfaceSourceGeneratorTests.cs b/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedApiSurfaceSourceGeneratorTests.cs new file mode 100644 index 000000000..d6994e80b --- /dev/null +++ b/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedApiSurfaceSourceGeneratorTests.cs @@ -0,0 +1,142 @@ +#nullable enable + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Immutable; +using System.Text; + +namespace IntelliTect.Coalesce.Analyzer.Tests; + +public class GeneratedApiSurfaceSourceGeneratorTests +{ + [Test] + public async Task GeneratesSourcesFromSnapshotAdditionalFile() + { + var compilation = CreateCompilation( + "GeneratedApiSurfaceConsumer", + ("Consumer.cs", + """ + namespace Demo; + + public class Consumer + { + public Demo.Generated.PersonDto Contract { get; set; } = new(); + } + """) + ); + + var snapshot = new InMemoryAdditionalText( + "/tmp/obj/coalesce-generated-csharp.json", + """ + { + "Models/Generated/PersonDto.g.cs": "namespace Demo.Generated { public class PersonDto { public string Name { get; set; } = string.Empty; } }" + } + """); + + var updatedCompilation = RunGenerator(compilation, [snapshot], out var diagnostics); + + await Assert.That(diagnostics).IsEmpty(); + await Assert.That(updatedCompilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + await Assert.That(updatedCompilation.SyntaxTrees.Any(tree => tree.FilePath.Contains("Models/Generated/PersonDto.g.cs", StringComparison.Ordinal))).IsTrue(); + } + + [Test] + public async Task ReportsDiagnosticForInvalidSnapshot() + { + var compilation = CreateCompilation( + "GeneratedApiSurfaceConsumer", + ("Consumer.cs", "namespace Demo; public class Consumer { }") + ); + + var snapshot = new InMemoryAdditionalText( + "/tmp/obj/coalesce-generated-csharp.json", + "{ invalid json }"); + + RunGenerator(compilation, [snapshot], out var diagnostics); + + await Assert.That(diagnostics.Select(d => d.Id)).Contains("COALESCESG001"); + } + + [Test] + public async Task ReportsDiagnosticWhenGeneratedFilesAreStillCompiledFromDisk() + { + var compilation = CreateCompilation( + "GeneratedApiSurfaceConsumer", + ("Consumer.cs", "namespace Demo; public class Consumer { }"), + ("/repo/Models/Generated/PersonDto.g.cs", "namespace Demo.Generated { public class PersonDto { } }") + ); + + var snapshot = new InMemoryAdditionalText( + "/tmp/obj/coalesce-generated-csharp.json", + """ + { + "Models/Generated/PersonDto.g.cs": "namespace Demo.Generated { public class PersonDto { } }" + } + """); + + RunGenerator(compilation, [snapshot], out var diagnostics); + + await Assert.That(diagnostics.Select(d => d.Id)).Contains("COALESCESG002"); + } + + private static CSharpCompilation CreateCompilation( + string assemblyName, + params (string Path, string Source)[] sources) + { + var syntaxTrees = sources + .Select(source => CSharpSyntaxTree.ParseText( + SourceText.From(source.Source, Encoding.UTF8), + new CSharpParseOptions(LanguageVersion.Preview), + path: source.Path)) + .ToArray(); + + return CSharpCompilation.Create( + assemblyName, + syntaxTrees, + GetMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + } + + private static CSharpCompilation RunGenerator( + CSharpCompilation compilation, + ImmutableArray additionalTexts, + out ImmutableArray diagnostics) + { + var parseOptions = (CSharpParseOptions)compilation.SyntaxTrees.First().Options; + GeneratorDriver driver = CSharpGeneratorDriver.Create( + generators: [new GeneratedApiSurfaceSourceGenerator().AsSourceGenerator()], + additionalTexts: additionalTexts, + parseOptions: parseOptions); + + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var updatedCompilation, out diagnostics); + return (CSharpCompilation)updatedCompilation; + } + + private static List GetMetadataReferences() + => AppDomain.CurrentDomain.GetAssemblies() + .Where(assembly => !assembly.IsDynamic && !string.IsNullOrWhiteSpace(assembly.Location)) + .Select(assembly => (MetadataReference)MetadataReference.CreateFromFile(assembly.Location)) + .Append(MetadataReference.CreateFromFile(typeof(object).Assembly.Location)) + .Distinct(MetadataReferencePathComparer.Instance) + .ToList(); + + private sealed class InMemoryAdditionalText(string path, string content) : AdditionalText + { + public override string Path { get; } = path; + + public override SourceText GetText(CancellationToken cancellationToken = default) + => SourceText.From(content, Encoding.UTF8); + } + + private sealed class MetadataReferencePathComparer : IEqualityComparer + { + public static MetadataReferencePathComparer Instance { get; } = new(); + + public bool Equals(MetadataReference? x, MetadataReference? y) + => StringComparer.OrdinalIgnoreCase.Equals((x as PortableExecutableReference)?.FilePath, (y as PortableExecutableReference)?.FilePath); + + public int GetHashCode(MetadataReference obj) + => StringComparer.OrdinalIgnoreCase.GetHashCode((obj as PortableExecutableReference)?.FilePath ?? string.Empty); + } +} diff --git a/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedContractShapeSourceGeneratorTests.cs b/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedContractShapeSourceGeneratorTests.cs index 1db8b2cd4..684cc5a5b 100644 --- a/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedContractShapeSourceGeneratorTests.cs +++ b/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedContractShapeSourceGeneratorTests.cs @@ -1,6 +1,5 @@ #nullable enable -using IntelliTect.Coalesce.Analyzer.SourceGenerators; using IntelliTect.Coalesce.DataAnnotations; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; diff --git a/src/IntelliTect.Coalesce.Analyzer.Tests/IntelliTect.Coalesce.Analyzer.Tests.csproj b/src/IntelliTect.Coalesce.Analyzer.Tests/IntelliTect.Coalesce.Analyzer.Tests.csproj index d46bdf20a..b2bbcf9fc 100644 --- a/src/IntelliTect.Coalesce.Analyzer.Tests/IntelliTect.Coalesce.Analyzer.Tests.csproj +++ b/src/IntelliTect.Coalesce.Analyzer.Tests/IntelliTect.Coalesce.Analyzer.Tests.csproj @@ -8,17 +8,17 @@ - + - + diff --git a/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj b/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj index f914e68c8..95e4c1dd6 100644 --- a/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj +++ b/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj @@ -9,9 +9,8 @@ - - + $(NoWarn);RS1038;RS1035 @@ -50,10 +49,18 @@ + + + + + <_Parameter1>IntelliTect.Coalesce.Analyzer.Tests + + + diff --git a/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedApiSurfaceSourceGenerator.cs b/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedApiSurfaceSourceGenerator.cs index c3fd8678c..011b60257 100644 --- a/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedApiSurfaceSourceGenerator.cs +++ b/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedApiSurfaceSourceGenerator.cs @@ -1,24 +1,17 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; -using System.Diagnostics; +using System.Collections.Immutable; using System.Text; using System.Text.Json; -using System.Xml.Linq; namespace IntelliTect.Coalesce.Analyzer.SourceGenerators; [Generator(LanguageNames.CSharp)] public sealed class GeneratedApiSurfaceSourceGenerator : IIncrementalGenerator { - private const string CaptureEnvironmentVariable = "COALESCE_SOURCEGEN_CAPTURE"; - private const string ModelsGeneratorName = "Models"; - private const string ModelsGeneratorFullName = "IntelliTect.Coalesce.CodeGeneration.Api.Generators.Models"; - private const string ControllersGeneratorName = "Controllers"; - private const string ControllersGeneratorFullName = "IntelliTect.Coalesce.CodeGeneration.Api.Generators.Controllers"; - private const string ConfigFileName = "coalesce.json"; - private const string CaptureOptionName = "--emit-csharp-sourcegen"; + private const string SnapshotFileName = "coalesce-generated-csharp.json"; - private static readonly DiagnosticDescriptor CaptureFailedDiagnostic = new( + private static readonly DiagnosticDescriptor SnapshotFailedDiagnostic = new( id: "COALESCESG001", title: "Coalesce in-memory C# generation failed", messageFormat: "{0}", @@ -36,380 +29,165 @@ public sealed class GeneratedApiSurfaceSourceGenerator : IIncrementalGenerator public void Initialize(IncrementalGeneratorInitializationContext context) { - context.RegisterSourceOutput(context.CompilationProvider, static (productionContext, compilation) => - Execute(productionContext, compilation)); + var snapshots = context.AdditionalTextsProvider + .Where(static file => string.Equals(Path.GetFileName(file.Path), SnapshotFileName, StringComparison.OrdinalIgnoreCase)) + .Select(static (file, cancellationToken) => LoadSnapshot(file, cancellationToken)) + .Collect(); + + var inputs = context.CompilationProvider.Combine(snapshots); + context.RegisterSourceOutput(inputs, static (productionContext, input) => + Emit(productionContext, input.Left, input.Right)); } - private static void Execute(SourceProductionContext context, Compilation compilation) + private static SnapshotParseResult LoadSnapshot(AdditionalText file, CancellationToken cancellationToken) { - if (string.Equals(Environment.GetEnvironmentVariable(CaptureEnvironmentVariable), "1", StringComparison.Ordinal)) - { - return; - } - - if (string.IsNullOrWhiteSpace(compilation.AssemblyName)) - { - return; - } - - var configPath = FindCoalesceConfigurationPath(compilation); - if (configPath is null) - { - return; - } - - SourceGenerationConfig config; try { - config = SourceGenerationConfig.Load(configPath); + var sourceText = file.GetText(cancellationToken); + if (sourceText is null) + { + return SnapshotParseResult.Failure(file.Path, $"Unable to read Coalesce generated source snapshot '{file.Path}'."); + } + + var files = ParseSnapshot(sourceText.ToString()); + return SnapshotParseResult.Success(file.Path, files); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { - context.ReportDiagnostic(Diagnostic.Create( - CaptureFailedDiagnostic, - location: null, - $"Unable to read '{ConfigFileName}' for Coalesce in-memory C# generation: {ex.Message}")); - return; + return SnapshotParseResult.Failure(file.Path, $"Unable to parse Coalesce generated source snapshot '{file.Path}': {ex.Message}"); } + } - if (!config.ShouldGenerateAnyCategory) + private static IReadOnlyDictionary ParseSnapshot(string json) + { + if (string.IsNullOrWhiteSpace(json)) { - return; + return new Dictionary(StringComparer.OrdinalIgnoreCase); } - if (!string.Equals(config.WebProjectAssemblyName, NormalizeAssemblyName(compilation.AssemblyName!), StringComparison.Ordinal)) - { - return; - } + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + JsonElement filesElement; - var stillPresentPaths = GetStillPresentGeneratedFilePaths(compilation, config.GenerateModels, config.GenerateControllers); - if (stillPresentPaths.Count > 0) + if (root.ValueKind == JsonValueKind.Object && root.EnumerateObject().All(static property => property.Value.ValueKind == JsonValueKind.String)) { - context.ReportDiagnostic(Diagnostic.Create( - GeneratedFilesStillPresentDiagnostic, - location: null, - $"Coalesce in-memory C# generation is enabled via '{ConfigFileName}', but generated files are still compiled from disk: {string.Join(", ", stillPresentPaths.Take(5))}{(stillPresentPaths.Count > 5 ? ", ..." : string.Empty)}. Delete or remove the on-disk generated files before enabling the source generator.")); - return; + filesElement = root; } - - string captureFilePath = Path.Combine(Path.GetTempPath(), $"coalesce-csharp-sourcegen-{Guid.NewGuid():N}.json"); - try + else if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("files", out var nestedFiles)) { - var captureResult = InvokeCoalesceTool(configPath, captureFilePath); - if (!captureResult.Success) - { - context.ReportDiagnostic(Diagnostic.Create( - CaptureFailedDiagnostic, - location: null, - captureResult.ErrorMessage)); - return; - } - - var outputs = LoadCapturedSources(captureFilePath); - foreach (var output in outputs) - { - var normalizedPath = NormalizePath(output.Key); - if (!ShouldEmit(normalizedPath, config.GenerateModels, config.GenerateControllers)) - { - continue; - } - - context.AddSource(normalizedPath, SourceText.From(output.Value, Encoding.UTF8)); - } + filesElement = nestedFiles; } - catch (Exception ex) + else { - context.ReportDiagnostic(Diagnostic.Create( - CaptureFailedDiagnostic, - location: null, - $"Coalesce in-memory C# generation failed: {ex.Message}")); + throw new InvalidOperationException($"Expected '{SnapshotFileName}' to contain either a JSON object map or an object with a 'files' property."); } - finally + + if (filesElement.ValueKind != JsonValueKind.Object) { - try - { - if (File.Exists(captureFilePath)) - { - File.Delete(captureFilePath); - } - } - catch - { - // Best effort only. - } + throw new InvalidOperationException($"Expected '{SnapshotFileName}' to contain a JSON object of generated files."); } - } - private static string? FindCoalesceConfigurationPath(Compilation compilation) - { - foreach (var filePath in compilation.SyntaxTrees - .Select(tree => tree.FilePath) - .Where(path => !string.IsNullOrWhiteSpace(path)) - .Distinct(StringComparer.OrdinalIgnoreCase)) + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in filesElement.EnumerateObject()) { - var directory = Path.GetDirectoryName(filePath!); - while (!string.IsNullOrWhiteSpace(directory)) + if (property.Value.ValueKind != JsonValueKind.String) { - var candidate = Path.Combine(directory!, ConfigFileName); - if (File.Exists(candidate)) - { - return candidate; - } - - directory = Directory.GetParent(directory!)?.FullName; + throw new InvalidOperationException($"Generated source '{property.Name}' in '{SnapshotFileName}' must be a JSON string."); } - } - - return null; - } - private static List GetStillPresentGeneratedFilePaths(Compilation compilation, bool includeModels, bool includeControllers) - => compilation.SyntaxTrees - .Select(tree => tree.FilePath) - .Where(path => !string.IsNullOrWhiteSpace(path)) - .Select(path => NormalizePath(path!)) - .Where(path => - (includeModels && path.Contains("/Models/Generated/", StringComparison.OrdinalIgnoreCase)) || - (includeControllers && path.Contains("/Api/Generated/", StringComparison.OrdinalIgnoreCase))) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - - private static bool ShouldEmit(string normalizedPath, bool includeModels, bool includeControllers) - => (includeModels && normalizedPath.StartsWith("Models/Generated/", StringComparison.OrdinalIgnoreCase)) - || (includeControllers && normalizedPath.StartsWith("Api/Generated/", StringComparison.OrdinalIgnoreCase)); - - private static IReadOnlyDictionary LoadCapturedSources(string captureFilePath) - { - if (!File.Exists(captureFilePath)) - { - throw new FileNotFoundException($"Coalesce source-generation capture file was not created: {captureFilePath}"); + files[NormalizePath(property.Name)] = property.Value.GetString() ?? string.Empty; } - var json = File.ReadAllText(captureFilePath); - return JsonSerializer.Deserialize>(json) - ?? new Dictionary(StringComparer.Ordinal); + return files; } - private static CaptureInvocationResult InvokeCoalesceTool(string configPath, string captureFilePath) + private static void Emit( + SourceProductionContext context, + Compilation compilation, + ImmutableArray snapshots) { - var workingDirectory = Path.GetDirectoryName(configPath)!; - var dotnetHost = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") - ?? Environment.GetEnvironmentVariable("DOTNET_EXE") - ?? "dotnet"; - var linkedToolDllPath = TryGetLinkedSourceToolDllPath(); - - var startInfo = new ProcessStartInfo - { - FileName = dotnetHost, - Arguments = linkedToolDllPath is { Length: > 0 } - ? $"{Quote(linkedToolDllPath)} {Quote(configPath)} {CaptureOptionName} {Quote(captureFilePath)} --verbosity Error" - : $"coalesce {Quote(configPath)} {CaptureOptionName} {Quote(captureFilePath)} --verbosity Error", - WorkingDirectory = workingDirectory, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - CreateNoWindow = true, - }; - - startInfo.EnvironmentVariables[CaptureEnvironmentVariable] = "1"; - - using var process = Process.Start(startInfo); - if (process is null) + if (snapshots.IsDefaultOrEmpty) { - return CaptureInvocationResult.Failure("Failed to start 'dotnet coalesce' for Coalesce in-memory C# generation."); + return; } - var stdoutTask = process.StandardOutput.ReadToEndAsync(); - var stderrTask = process.StandardError.ReadToEndAsync(); - - if (!process.WaitForExit(300_000)) + var generatedFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var snapshot in snapshots) { - try + if (snapshot.ErrorMessage is { Length: > 0 }) { - process.Kill(); + context.ReportDiagnostic(Diagnostic.Create(SnapshotFailedDiagnostic, location: null, snapshot.ErrorMessage)); + continue; } - catch + + foreach (var generatedFile in snapshot.Files) { - // Ignore kill failures when timing out. + generatedFiles[NormalizePath(generatedFile.Key)] = generatedFile.Value; } - - return CaptureInvocationResult.Failure("'dotnet coalesce' timed out while capturing Coalesce in-memory C# sources."); } - var stdout = stdoutTask.GetAwaiter().GetResult(); - var stderr = stderrTask.GetAwaiter().GetResult(); - - if (process.ExitCode != 0) + if (generatedFiles.Count == 0) { - var details = string.Join(Environment.NewLine, - new[] - { - TrimOutput("stdout", stdout), - TrimOutput("stderr", stderr), - }.Where(s => !string.IsNullOrWhiteSpace(s))); - - return CaptureInvocationResult.Failure( - $"'dotnet coalesce' exited with code {process.ExitCode} while capturing Coalesce in-memory C# sources.{(string.IsNullOrWhiteSpace(details) ? string.Empty : Environment.NewLine + details)}"); - } - - return CaptureInvocationResult.Successful(); - } - - private static string TrimOutput(string streamName, string? output) - { - if (string.IsNullOrWhiteSpace(output)) - { - return string.Empty; + return; } - const int maxLength = 4_000; - var trimmed = output!.Length <= maxLength ? output : output.Substring(output.Length - maxLength, maxLength); - return $"{streamName}:{Environment.NewLine}{trimmed.Trim()}"; - } - - private static string? TryGetLinkedSourceToolDllPath() - { - var assemblyDirectory = Path.GetDirectoryName(typeof(GeneratedApiSurfaceSourceGenerator).Assembly.Location); - if (string.IsNullOrWhiteSpace(assemblyDirectory)) + var stillPresentPaths = GetStillPresentGeneratedFilePaths(compilation, generatedFiles.Keys); + if (stillPresentPaths.Count > 0) { - return null; + context.ReportDiagnostic(Diagnostic.Create( + GeneratedFilesStillPresentDiagnostic, + location: null, + $"Coalesce generated C# files are compiled from disk while the generated snapshot is also being applied: {string.Join(", ", stillPresentPaths.Take(5))}{(stillPresentPaths.Count > 5 ? ", ..." : string.Empty)}. Delete or remove the on-disk generated files before enabling Coalesce in-memory C# generation.")); + return; } - var candidateRoots = EnumerateAncestorDirectories(assemblyDirectory!); - var candidateRelativePaths = new[] + foreach (var generatedFile in generatedFiles.OrderBy(static entry => entry.Key, StringComparer.OrdinalIgnoreCase)) { - Path.Combine("src", "IntelliTect.Coalesce.DotnetTool", "bin", "Debug", "net10.0", "dotnet-coalesce.dll"), - Path.Combine("src", "IntelliTect.Coalesce.DotnetTool", "bin", "Release", "net10.0", "dotnet-coalesce.dll"), - Path.Combine("src", "IntelliTect.Coalesce.DotnetTool", "bin", "Debug", "net9.0", "dotnet-coalesce.dll"), - Path.Combine("src", "IntelliTect.Coalesce.DotnetTool", "bin", "Release", "net9.0", "dotnet-coalesce.dll"), - Path.Combine("src", "IntelliTect.Coalesce.DotnetTool", "bin", "Debug", "net8.0", "dotnet-coalesce.dll"), - Path.Combine("src", "IntelliTect.Coalesce.DotnetTool", "bin", "Release", "net8.0", "dotnet-coalesce.dll"), - }; - - foreach (var root in candidateRoots) - { - foreach (var relativePath in candidateRelativePaths) - { - var candidate = Path.Combine(root, relativePath); - if (File.Exists(candidate)) - { - return candidate; - } - } + context.AddSource(generatedFile.Key, SourceText.From(generatedFile.Value, Encoding.UTF8)); } - - return null; } - private static IEnumerable EnumerateAncestorDirectories(string directory) + private static List GetStillPresentGeneratedFilePaths(Compilation compilation, IEnumerable generatedFilePaths) { - var current = new DirectoryInfo(directory); - while (current is not null) + var includeModels = generatedFilePaths.Any(path => path.StartsWith("Models/Generated/", StringComparison.OrdinalIgnoreCase)); + var includeControllers = generatedFilePaths.Any(path => path.StartsWith("Api/Generated/", StringComparison.OrdinalIgnoreCase)); + + if (!includeModels && !includeControllers) { - yield return current.FullName; - current = current.Parent; + return []; } - } - private static string Quote(string value) - => '"' + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + '"'; + return compilation.SyntaxTrees + .Select(tree => tree.FilePath) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => NormalizePath(path!)) + .Where(path => + (includeModels && path.Contains("/Models/Generated/", StringComparison.OrdinalIgnoreCase)) || + (includeControllers && path.Contains("/Api/Generated/", StringComparison.OrdinalIgnoreCase))) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } private static string NormalizePath(string path) => path.Replace('\\', '/'); - private static string NormalizeAssemblyName(string assemblyName) - => assemblyName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) || assemblyName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) - ? Path.GetFileNameWithoutExtension(assemblyName) - : assemblyName; - - private sealed class CaptureInvocationResult + private sealed class SnapshotParseResult { - public CaptureInvocationResult(bool success, string errorMessage) + public SnapshotParseResult(string path, IReadOnlyDictionary files, string? errorMessage) { - Success = success; + Path = path; + Files = files; ErrorMessage = errorMessage; } - public bool Success { get; } - public string ErrorMessage { get; } - - public static CaptureInvocationResult Successful() => new(true, string.Empty); - public static CaptureInvocationResult Failure(string message) => new(false, message); - } - - private sealed class SourceGenerationConfig - { - public string WebProjectAssemblyName { get; set; } = string.Empty; - public bool GenerateModels { get; set; } - public bool GenerateControllers { get; set; } - - public bool ShouldGenerateAnyCategory => GenerateModels || GenerateControllers; + public string Path { get; } + public IReadOnlyDictionary Files { get; } + public string? ErrorMessage { get; } - public static SourceGenerationConfig Load(string configPath) - { - using var stream = File.OpenRead(configPath); - using var document = JsonDocument.Parse(stream); - var root = document.RootElement; - - if (!root.TryGetProperty("webProject", out var webProject) || !webProject.TryGetProperty("projectFile", out var projectFileProperty)) - { - throw new InvalidOperationException($"'{ConfigFileName}' does not contain a webProject.projectFile entry."); - } - - var configDirectory = Path.GetDirectoryName(configPath)!; - var resolvedWebProjectPath = Path.GetFullPath(Path.Combine(configDirectory, projectFileProperty.GetString()!)); - var webProjectAssemblyName = GetProjectAssemblyName(resolvedWebProjectPath); - - return new SourceGenerationConfig - { - WebProjectAssemblyName = webProjectAssemblyName, - GenerateModels = IsGeneratorDisabled(root, ModelsGeneratorName, ModelsGeneratorFullName), - GenerateControllers = IsGeneratorDisabled(root, ControllersGeneratorName, ControllersGeneratorFullName), - }; - } + public static SnapshotParseResult Success(string path, IReadOnlyDictionary files) + => new(path, files, null); - private static bool IsGeneratorDisabled(JsonElement root, string shortName, string fullName) - { - if (!root.TryGetProperty("generatorConfig", out var generatorConfig)) - { - return false; - } - - foreach (var key in new[] { fullName, shortName }) - { - if (!generatorConfig.TryGetProperty(key, out var generatorSettings)) - { - continue; - } - - if (generatorSettings.ValueKind == JsonValueKind.Object && - generatorSettings.TryGetProperty("disabled", out var disabledProperty) && - disabledProperty.ValueKind is JsonValueKind.True or JsonValueKind.False) - { - return disabledProperty.GetBoolean(); - } - } - - return false; - } - - private static string GetProjectAssemblyName(string projectFilePath) - { - if (!File.Exists(projectFilePath)) - { - throw new FileNotFoundException($"Configured Coalesce web project was not found: {projectFilePath}"); - } - - var projectDocument = XDocument.Load(projectFilePath); - var assemblyName = projectDocument - .Descendants() - .FirstOrDefault(element => element.Name.LocalName == "AssemblyName") - ?.Value - ?.Trim(); - - return NormalizeAssemblyName(string.IsNullOrWhiteSpace(assemblyName) - ? Path.GetFileNameWithoutExtension(projectFilePath) - : assemblyName!); - } + public static SnapshotParseResult Failure(string path, string errorMessage) + => new(path, new Dictionary(StringComparer.OrdinalIgnoreCase), errorMessage); } } diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Vue/ExportCoalesceGeneratedCSharpTask.cs b/src/IntelliTect.Coalesce.CodeGeneration.Vue/ExportCoalesceGeneratedCSharpTask.cs new file mode 100644 index 000000000..ec3fb8bae --- /dev/null +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/ExportCoalesceGeneratedCSharpTask.cs @@ -0,0 +1,457 @@ +#nullable enable + +using IntelliTect.Coalesce.CodeGeneration.Analysis.MsBuild; +using IntelliTect.Coalesce.CodeGeneration.Analysis.Roslyn; +using IntelliTect.Coalesce.CodeGeneration.Configuration; +using IntelliTect.Coalesce.CodeGeneration.Generation; +using IntelliTect.Coalesce.CodeGeneration.Vue.Generators; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Text.Json; + +namespace IntelliTect.Coalesce.CodeGeneration.Vue.Tasks; + +public sealed class ExportCoalesceGeneratedCSharpTask : Microsoft.Build.Utilities.Task +{ + private const string ConfigFileName = "coalesce.json"; + private const string ModelsGeneratorName = "Models"; + private const string ModelsGeneratorFullName = "IntelliTect.Coalesce.CodeGeneration.Api.Generators.Models"; + private const string ControllersGeneratorName = "Controllers"; + private const string ControllersGeneratorFullName = "IntelliTect.Coalesce.CodeGeneration.Api.Generators.Controllers"; + private const string ScriptsGeneratorName = "Scripts"; + private const string ScriptsGeneratorFullName = "IntelliTect.Coalesce.CodeGeneration.Vue.Generators.Scripts"; + private const string KernelPluginsGeneratorName = "KernelPlugins"; + private const string KernelPluginsGeneratorFullName = "IntelliTect.Coalesce.CodeGeneration.Api.Generators.KernelPlugins"; + + [Required] + public string ProjectFilePath { get; set; } = string.Empty; + + [Required] + public string ProjectDirectory { get; set; } = string.Empty; + + [Required] + public string SnapshotPath { get; set; } = string.Empty; + + public string? ConfigPath { get; set; } + public string? ProjectName { get; set; } + public string? RootNamespace { get; set; } + public string? AssemblyName { get; set; } + public string? Configuration { get; set; } + public string? TargetFramework { get; set; } + public string? LangVersion { get; set; } + public string? Nullable { get; set; } + public string? DefineConstants { get; set; } + public string? OutputType { get; set; } + public string? Platform { get; set; } + public string? TargetPath { get; set; } + public string? TargetDirectory { get; set; } + public ITaskItem[] CompileItems { get; set; } = []; + public ITaskItem[] ResolvedReferences { get; set; } = []; + public ITaskItem[] ProjectReferences { get; set; } = []; + public ITaskItem[] EmbeddedItems { get; set; } = []; + + public override bool Execute() + { + try + { + ExecuteAsync().GetAwaiter().GetResult(); + return !Log.HasLoggedErrors; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex, showStackTrace: true, showDetail: true, file: null); + return false; + } + } + + private async System.Threading.Tasks.Task ExecuteAsync() + { + var resolvedProjectFilePath = Path.GetFullPath(ProjectFilePath); + var resolvedProjectDirectory = Path.GetFullPath(ProjectDirectory); + var resolvedSnapshotPath = Path.GetFullPath(SnapshotPath); + var resolvedConfigPath = ResolveConfigPath(ConfigPath, resolvedProjectDirectory); + + if (resolvedConfigPath is null) + { + DeleteSnapshot(resolvedSnapshotPath); + return; + } + + var sourceGenerationConfig = SourceGenerationConfig.Load(resolvedConfigPath); + if (!sourceGenerationConfig.ShouldGenerateAnyCategory) + { + DeleteSnapshot(resolvedSnapshotPath); + return; + } + + if (!PathsEqual(sourceGenerationConfig.WebProjectPath, resolvedProjectFilePath)) + { + DeleteSnapshot(resolvedSnapshotPath); + return; + } + + if (IsSnapshotCurrent(resolvedSnapshotPath, resolvedConfigPath, sourceGenerationConfig)) + { + return; + } + + var configuration = LoadConfiguration(resolvedConfigPath); + ApplyBuildSettings(configuration, resolvedConfigPath, resolvedProjectFilePath); + + using var loggerFactory = LoggerFactory.Create(builder => builder.SetMinimumLevel(LogLevel.Warning)); + var roslynLogger = loggerFactory.CreateLogger(); + + var dataProjectContext = (RoslynProjectContext)new RoslynProjectContextFactory(roslynLogger) + .CreateContext(configuration.DataProject, restore: false); + var webProjectContext = CreateCurrentProjectContext(configuration.WebProject, resolvedProjectFilePath); + + var outputs = await CaptureGeneratedCSharpOutputsAsync( + configuration, + dataProjectContext, + webProjectContext, + sourceGenerationConfig); + + WriteSnapshotIfChanged(resolvedSnapshotPath, outputs); + } + + private async System.Threading.Tasks.Task> CaptureGeneratedCSharpOutputsAsync( + CoalesceConfiguration configuration, + RoslynProjectContext dataProjectContext, + RoslynProjectContext webProjectContext, + SourceGenerationConfig sourceGenerationConfig) + { + string tempOutputDirectory = Path.Combine(Path.GetTempPath(), $"coalesce-sourcegen-{Guid.NewGuid():N}"); + var originalTargetDirectory = configuration.Output.TargetDirectory; + var originalDryRun = configuration.DryRun; + + try + { + Directory.CreateDirectory(tempOutputDirectory); + configuration.Output.TargetDirectory = tempOutputDirectory; + configuration.DryRun = false; + + EnsureGeneratorDisabled(configuration, disabled: true, ScriptsGeneratorName, ScriptsGeneratorFullName); + EnsureGeneratorDisabled(configuration, disabled: true, KernelPluginsGeneratorName, KernelPluginsGeneratorFullName); + EnsureGeneratorDisabled(configuration, disabled: !sourceGenerationConfig.GenerateModels, ModelsGeneratorName, ModelsGeneratorFullName); + EnsureGeneratorDisabled(configuration, disabled: !sourceGenerationConfig.GenerateControllers, ControllersGeneratorName, ControllersGeneratorFullName); + + var executor = new GenerationExecutor( + configuration, + LogLevel.Warning, + dataProjectOverride: dataProjectContext, + webProjectOverride: webProjectContext, + throwOnFailure: true); + + await executor.GenerateAsync(typeof(VueSuite)); + + return CaptureGeneratedCSharpOutputs( + tempOutputDirectory, + sourceGenerationConfig.GenerateModels, + sourceGenerationConfig.GenerateControllers); + } + finally + { + configuration.Output.TargetDirectory = originalTargetDirectory; + configuration.DryRun = originalDryRun; + + try + { + if (Directory.Exists(tempOutputDirectory)) + { + Directory.Delete(tempOutputDirectory, recursive: true); + } + } + catch + { + // Best effort cleanup only. + } + } + } + + private void ApplyBuildSettings(CoalesceConfiguration configuration, string configPath, string resolvedProjectFilePath) + { + var configDirectory = Path.GetDirectoryName(configPath)!; + + configuration.WebProject.ProjectFile = resolvedProjectFilePath; + configuration.WebProject.RootNamespace ??= RootNamespace; + configuration.WebProject.Configuration = string.IsNullOrWhiteSpace(Configuration) + ? configuration.WebProject.Configuration + : Configuration!; + configuration.WebProject.Framework = string.IsNullOrWhiteSpace(TargetFramework) + ? configuration.WebProject.Framework + : TargetFramework!; + + configuration.DataProject.ProjectFile = ResolveProjectPath(configDirectory, configuration.DataProject.ProjectFile); + configuration.DataProject.Configuration = string.IsNullOrWhiteSpace(Configuration) + ? configuration.DataProject.Configuration + : Configuration!; + configuration.DataProject.Framework = string.IsNullOrWhiteSpace(TargetFramework) + ? configuration.DataProject.Framework + : TargetFramework!; + } + + private RoslynProjectContext CreateCurrentProjectContext(ProjectConfiguration projectConfiguration, string resolvedProjectFilePath) + { + var msBuildProjectContext = new MsBuildProjectContext + { + DependenciesDesignTime = [], + CompilationItems = GetExistingFullPaths(CompileItems), + ProjectReferences = GetExistingFullPaths(ProjectReferences), + ResolvedReferences = GetExistingFullPaths(ResolvedReferences), + EmbededItems = GetExistingFullPaths(EmbeddedItems), + ProjectName = ProjectName ?? Path.GetFileNameWithoutExtension(resolvedProjectFilePath), + ProjectFullPath = resolvedProjectFilePath, + AssemblyFullPath = string.IsNullOrWhiteSpace(TargetPath) + ? Path.ChangeExtension(resolvedProjectFilePath, ".dll") + : Path.GetFullPath(TargetPath), + OutputType = OutputType ?? "Library", + Platform = Platform ?? string.Empty, + RootNamespace = RootNamespace ?? projectConfiguration.RootNamespace ?? Path.GetFileNameWithoutExtension(resolvedProjectFilePath), + TargetDirectory = string.IsNullOrWhiteSpace(TargetDirectory) + ? Path.GetDirectoryName(string.IsNullOrWhiteSpace(TargetPath) + ? resolvedProjectFilePath + : Path.GetFullPath(TargetPath)) ?? Path.GetDirectoryName(resolvedProjectFilePath)! + : Path.GetFullPath(TargetDirectory), + DepsFile = string.IsNullOrWhiteSpace(TargetPath) ? string.Empty : Path.GetFileNameWithoutExtension(TargetPath) + ".deps.json", + RuntimeConfig = string.IsNullOrWhiteSpace(TargetPath) ? string.Empty : Path.GetFileNameWithoutExtension(TargetPath) + ".runtimeconfig.json", + Configuration = Configuration ?? projectConfiguration.Configuration, + TargetFramework = TargetFramework ?? projectConfiguration.Framework ?? string.Empty, + LangVersion = LangVersion ?? LanguageVersion.Preview.ToDisplayString(), + Nullable = Nullable ?? string.Empty, + DefineConstants = DefineConstants ?? string.Empty, + }; + + return RoslynProjectContextFactory.CreateContext(projectConfiguration, msBuildProjectContext); + } + + private bool IsSnapshotCurrent(string snapshotPath, string configPath, SourceGenerationConfig sourceGenerationConfig) + { + if (!File.Exists(snapshotPath)) + { + return false; + } + + var snapshotTimestamp = File.GetLastWriteTimeUtc(snapshotPath); + + foreach (var inputPath in EnumerateSnapshotInputs(configPath, sourceGenerationConfig)) + { + if (File.Exists(inputPath) && File.GetLastWriteTimeUtc(inputPath) > snapshotTimestamp) + { + return false; + } + } + + return true; + } + + private IEnumerable EnumerateSnapshotInputs(string configPath, SourceGenerationConfig sourceGenerationConfig) + { + yield return configPath; + yield return Path.GetFullPath(ProjectFilePath); + yield return sourceGenerationConfig.WebProjectPath; + yield return sourceGenerationConfig.DataProjectPath; + yield return typeof(ExportCoalesceGeneratedCSharpTask).Assembly.Location; + yield return typeof(GenerationExecutor).Assembly.Location; + yield return typeof(VueSuite).Assembly.Location; + + foreach (var path in GetExistingFullPaths(CompileItems)) + { + yield return path; + } + + foreach (var path in GetExistingFullPaths(ResolvedReferences)) + { + yield return path; + } + + foreach (var path in GetExistingFullPaths(ProjectReferences)) + { + yield return path; + } + } + + private void WriteSnapshotIfChanged(string snapshotPath, IReadOnlyDictionary outputs) + { + var json = System.Text.Json.JsonSerializer.Serialize(outputs); + Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!); + + if (File.Exists(snapshotPath) && string.Equals(File.ReadAllText(snapshotPath), json, StringComparison.Ordinal)) + { + return; + } + + File.WriteAllText(snapshotPath, json); + } + + private static Dictionary CaptureGeneratedCSharpOutputs( + string tempOutputDirectory, + bool includeModels, + bool includeControllers) + { + var outputs = new Dictionary(StringComparer.Ordinal); + + if (includeModels) + { + CaptureDirectory(Path.Combine("Models", "Generated")); + } + + if (includeControllers) + { + CaptureDirectory(Path.Combine("Api", "Generated")); + } + + return outputs; + + void CaptureDirectory(string relativeDirectory) + { + var fullDirectory = Path.Combine(tempOutputDirectory, relativeDirectory); + if (!Directory.Exists(fullDirectory)) + { + return; + } + + foreach (var filePath in Directory.GetFiles(fullDirectory, "*.g.cs", SearchOption.AllDirectories) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase)) + { + var relativePath = Path.GetRelativePath(tempOutputDirectory, filePath).Replace('\\', '/'); + outputs[relativePath] = File.ReadAllText(filePath); + } + } + } + + private static void EnsureGeneratorDisabled(CoalesceConfiguration config, bool disabled, params string[] generatorNames) + { + foreach (var generatorName in generatorNames.Where(name => !string.IsNullOrWhiteSpace(name))) + { + if (!config.GeneratorConfig.TryGetValue(generatorName, out var generatorConfig)) + { + generatorConfig = new JObject(); + config.GeneratorConfig[generatorName] = generatorConfig; + } + + generatorConfig[Generator.DisabledJsonPropertyName] = disabled; + } + } + + private static string? ResolveConfigPath(string? explicitConfigPath, string projectDirectory) + { + if (!string.IsNullOrWhiteSpace(explicitConfigPath)) + { + var resolvedExplicitPath = Path.GetFullPath(explicitConfigPath); + return File.Exists(resolvedExplicitPath) ? resolvedExplicitPath : null; + } + + var directory = new DirectoryInfo(projectDirectory); + while (directory is not null) + { + var candidate = Path.Combine(directory.FullName, ConfigFileName); + if (File.Exists(candidate)) + { + return candidate; + } + + directory = directory.Parent; + } + + return null; + } + + private static CoalesceConfiguration LoadConfiguration(string configPath) + => JsonConvert.DeserializeObject(File.ReadAllText(configPath)) + ?? throw new InvalidOperationException($"Unable to read '{configPath}'."); + + private static string ResolveProjectPath(string configDirectory, string projectFilePath) + => Path.GetFullPath(Path.Combine(configDirectory, projectFilePath)); + + private static string[] GetExistingFullPaths(IEnumerable items) + => items + .Select(item => item.ItemSpec) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(Path.GetFullPath) + .Where(File.Exists) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + private static bool PathsEqual(string left, string right) + => string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase); + + private static void DeleteSnapshot(string snapshotPath) + { + try + { + if (File.Exists(snapshotPath)) + { + File.Delete(snapshotPath); + } + } + catch + { + // Best effort only. + } + } + + private sealed class SourceGenerationConfig + { + public required string WebProjectPath { get; init; } + public required string DataProjectPath { get; init; } + public required bool GenerateModels { get; init; } + public required bool GenerateControllers { get; init; } + + public bool ShouldGenerateAnyCategory => GenerateModels || GenerateControllers; + + public static SourceGenerationConfig Load(string configPath) + { + using var stream = File.OpenRead(configPath); + using var document = JsonDocument.Parse(stream); + var root = document.RootElement; + + if (!root.TryGetProperty("webProject", out var webProject) || !webProject.TryGetProperty("projectFile", out var webProjectFileProperty)) + { + throw new InvalidOperationException($"'{ConfigFileName}' does not contain a webProject.projectFile entry."); + } + + if (!root.TryGetProperty("dataProject", out var dataProject) || !dataProject.TryGetProperty("projectFile", out var dataProjectFileProperty)) + { + throw new InvalidOperationException($"'{ConfigFileName}' does not contain a dataProject.projectFile entry."); + } + + var configDirectory = Path.GetDirectoryName(configPath)!; + return new SourceGenerationConfig + { + WebProjectPath = ResolveProjectPath(configDirectory, webProjectFileProperty.GetString() ?? string.Empty), + DataProjectPath = ResolveProjectPath(configDirectory, dataProjectFileProperty.GetString() ?? string.Empty), + GenerateModels = IsGeneratorDisabled(root, ModelsGeneratorName, ModelsGeneratorFullName), + GenerateControllers = IsGeneratorDisabled(root, ControllersGeneratorName, ControllersGeneratorFullName), + }; + } + + private static bool IsGeneratorDisabled(JsonElement root, string shortName, string fullName) + { + if (!root.TryGetProperty("generatorConfig", out var generatorConfig)) + { + return false; + } + + foreach (var key in new[] { fullName, shortName }) + { + if (!generatorConfig.TryGetProperty(key, out var generatorSettings)) + { + continue; + } + + if (generatorSettings.ValueKind == JsonValueKind.Object + && generatorSettings.TryGetProperty("disabled", out var disabledProperty) + && disabledProperty.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + return disabledProperty.GetBoolean(); + } + } + + return false; + } + } +} diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Vue/IntelliTect.Coalesce.CodeGeneration.Vue.csproj b/src/IntelliTect.Coalesce.CodeGeneration.Vue/IntelliTect.Coalesce.CodeGeneration.Vue.csproj index 92f2d543e..6e9bceedd 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Vue/IntelliTect.Coalesce.CodeGeneration.Vue.csproj +++ b/src/IntelliTect.Coalesce.CodeGeneration.Vue/IntelliTect.Coalesce.CodeGeneration.Vue.csproj @@ -3,11 +3,16 @@ AnyCPU Library + true Generates frontend Vue.js metadata, models, api clients, and view models for Coalesce projects. Learn more at https://coalesce.intellitect.com/stacks/vue/overview.html - + - \ No newline at end of file + + + + + + diff --git a/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/RoslynProjectContextFactory.cs b/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/RoslynProjectContextFactory.cs index e4984a80c..d48c9cc3c 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/RoslynProjectContextFactory.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/RoslynProjectContextFactory.cs @@ -1,3 +1,5 @@ +#nullable enable + using IntelliTect.Coalesce.CodeGeneration.Analysis.Base; using IntelliTect.Coalesce.CodeGeneration.Configuration; using IntelliTect.Coalesce.CodeGeneration.Analysis.MsBuild; @@ -17,19 +19,31 @@ public RoslynProjectContextFactory(ILogger logger) public ProjectContext CreateContext(ProjectConfiguration projectConfig, bool restore = false) { - var context = new RoslynProjectContext(projectConfig); + var tempContext = new RoslynProjectContext(projectConfig); - var builder = new MsBuildProjectContextBuilder(Logger, context); + var builder = new MsBuildProjectContextBuilder(Logger, tempContext); if (restore) { builder = builder.RestoreProjectPackages(); } - var msbContext = context.MsBuildProjectContext = builder.BuildProjectContext(); + var msbContext = builder.BuildProjectContext(); + return CreateContext(projectConfig, msbContext, Logger); + } + + public static RoslynProjectContext CreateContext( + ProjectConfiguration projectConfig, + MsBuildProjectContext msBuildProjectContext, + ILogger? logger = null) + { + var context = new RoslynProjectContext(projectConfig) + { + MsBuildProjectContext = msBuildProjectContext, + }; - if (!LanguageVersionFacts.TryParse(msbContext.LangVersion, out var langVersion)) + if (!LanguageVersionFacts.TryParse(msBuildProjectContext.LangVersion, out var langVersion)) { - Logger.LogWarning($"Unknown or unsupported C# Language version '{msbContext.LangVersion}' specified by {msbContext.ProjectName}. Code generation may malfunction."); + logger?.LogWarning($"Unknown or unsupported C# Language version '{msBuildProjectContext.LangVersion}' specified by {msBuildProjectContext.ProjectName}. Code generation may malfunction."); } else { diff --git a/src/IntelliTect.Coalesce.CodeGeneration/Generation/GenerationExecutor.cs b/src/IntelliTect.Coalesce.CodeGeneration/Generation/GenerationExecutor.cs index 470bb0d34..622ed7740 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration/Generation/GenerationExecutor.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration/Generation/GenerationExecutor.cs @@ -1,3 +1,5 @@ +#nullable enable + using IntelliTect.Coalesce.CodeGeneration.Analysis; using IntelliTect.Coalesce.CodeGeneration.Analysis.Base; using IntelliTect.Coalesce.CodeGeneration.Analysis.Roslyn; @@ -21,11 +23,22 @@ namespace IntelliTect.Coalesce.CodeGeneration.Generation; public class GenerationExecutor { private readonly LogLevel logLevel; - - public GenerationExecutor(CoalesceConfiguration config, LogLevel logLevel) + private readonly ProjectContext? dataProjectOverride; + private readonly ProjectContext? webProjectOverride; + private readonly bool throwOnFailure; + + public GenerationExecutor( + CoalesceConfiguration config, + LogLevel logLevel, + ProjectContext? dataProjectOverride = null, + ProjectContext? webProjectOverride = null, + bool throwOnFailure = false) { Config = config; this.logLevel = logLevel; + this.dataProjectOverride = dataProjectOverride; + this.webProjectOverride = webProjectOverride; + this.throwOnFailure = throwOnFailure; var services = new ServiceCollection(); services.AddLogging(builder => builder @@ -44,7 +57,7 @@ public GenerationExecutor(CoalesceConfiguration config, LogLevel logLevel) } public CoalesceConfiguration Config { get; } - public ILogger Logger { get; private set; } + public ILogger Logger { get; private set; } = null!; public ServiceProvider ServiceProvider { get; private set; } public GenerationContext GenerationContext => ServiceProvider.GetRequiredService(); @@ -68,7 +81,7 @@ public IRootGenerator CreateRootGenerator(Type rootGenerator) throw new ArgumentException("type is not an IRootGenerator"); } - return ActivatorUtilities.CreateInstance(ServiceProvider, rootGenerator) as IRootGenerator; + return (IRootGenerator)ActivatorUtilities.CreateInstance(ServiceProvider, rootGenerator); } public async Task GenerateAsync(Type rootGenerator) @@ -105,16 +118,14 @@ public async Task GenerateAsync(Type rootGenerator) var types = ProjectTypeDiscovery.GetAllTypes(genContext); Logger.LogInformation("Checking Diagnostics"); - bool die = false; - foreach (var diag in ProjectTypeDiscovery.GetDiagnostics(genContext)) + var projectDiagnostics = ProjectTypeDiscovery.GetDiagnostics(genContext).ToList(); + foreach (var diag in projectDiagnostics) { Logger.LogError(diag); - die = true; } - if (die) + if (projectDiagnostics.Count != 0) { - Environment.Exit(-1); - return; + FailGeneration(string.Join(Environment.NewLine, projectDiagnostics)); } Logger.LogInformation($"Analyzing {types.Count()} Types"); @@ -147,8 +158,7 @@ public async Task GenerateAsync(Type rootGenerator) if (issues.Any(i => !i.IsWarning)) { Logger.LogError("Model validation failed. Exiting."); - Environment.Exit(-1); - return; + FailGeneration("Model validation failed."); } string outputPath = genContext.WebProject.ProjectPath; @@ -178,14 +188,24 @@ public async Task GenerateAsync(Type rootGenerator) private string GetCodeGenVersion() { - var generatorAssembly = Assembly.GetEntryAssembly(); - return FileVersionInfo + var generatorAssembly = typeof(GenerationExecutor).Assembly; + return (FileVersionInfo .GetVersionInfo(generatorAssembly.Location) - .ProductVersion + .ProductVersion ?? "unknown") // SourceLink will append the commit hash to the version, using '+' as a delimiter. .Split('+').First(); } + private void FailGeneration(string message) + { + if (throwOnFailure) + { + throw new InvalidOperationException(message); + } + + Environment.Exit(-1); + } + private async Task LoadProjects(ILogger logger, GenerationContext genContext) { const int maxProjectLoadRetries = 10; @@ -196,14 +216,14 @@ private async Task LoadProjects(ILogger logger, GenerationCo // it seems that more and more often, the projects step on // one another when they're building and end up failing due // to file contention of some form or another. - genContext.DataProject = await TryLoadProject(Config.DataProject); + genContext.DataProject = dataProjectOverride ?? await TryLoadProject(Config.DataProject); // Don't build references of the web project (which always includes the data project). // We don't need of those build outputs (we ingest the data project source code directly), // so doing so would be a waste of time (and is indeed a LARGE waste of time on large projects, // especially those with many EF migrations). Config.WebProject.BuildProjectReferences = false; - genContext.WebProject = await TryLoadProject(Config.WebProject); + genContext.WebProject = webProjectOverride ?? await TryLoadProject(Config.WebProject); async Task TryLoadProject(ProjectConfiguration config) { @@ -328,8 +348,7 @@ bool TestLinesForRetry(params string[] lineSubstrings) } } - // Not possible to reach, but need to satisfy the compiler. - return null; + throw new InvalidOperationException($"Unable to analyze project '{config.ProjectFile}'."); } } } diff --git a/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj index 413da4bf1..d7604a65b 100644 --- a/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj +++ b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj @@ -6,6 +6,7 @@ AnyCPU enable + $(NoWarn);NU5100 @@ -39,9 +40,23 @@ Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> + + + - \ No newline at end of file + + + + + diff --git a/src/IntelliTect.Coalesce/IntelliTect.Coalesce.dotnet-link.csproj b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.dotnet-link.csproj new file mode 100644 index 000000000..d7604a65b --- /dev/null +++ b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.dotnet-link.csproj @@ -0,0 +1,62 @@ + + + + README.md + Core library for IntelliTect Coalesce. Learn more at https://coalesce.intellitect.com/ + AnyCPU + + enable + $(NoWarn);NU5100 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/IntelliTect.Coalesce/build/IntelliTect.Coalesce.targets b/src/IntelliTect.Coalesce/build/IntelliTect.Coalesce.targets new file mode 100644 index 000000000..e2b99741e --- /dev/null +++ b/src/IntelliTect.Coalesce/build/IntelliTect.Coalesce.targets @@ -0,0 +1,49 @@ + + + $(MSBuildThisFileDirectory)..\tasks\net8.0\IntelliTect.Coalesce.CodeGeneration.Vue.dll + $(IntermediateOutputPath)coalesce-generated-csharp.json + + + + + + + + + + + + + + + + + + diff --git a/src/IntelliTect.Coalesce/buildTransitive/IntelliTect.Coalesce.targets b/src/IntelliTect.Coalesce/buildTransitive/IntelliTect.Coalesce.targets new file mode 100644 index 000000000..732dc8796 --- /dev/null +++ b/src/IntelliTect.Coalesce/buildTransitive/IntelliTect.Coalesce.targets @@ -0,0 +1,4 @@ + + + From fd783f2071dbaebf7b58d66d757f5ac99237f2ee Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Tue, 12 May 2026 11:27:25 -0500 Subject: [PATCH 10/14] feat(contracts): copy selected property attrs --- ...eratedContractShapeSourceGeneratorTests.cs | 60 +++++++ .../GeneratedContractShapeSourceGenerator.cs | 147 +++++++++++++++++- .../GeneratedContractShapeGenerationTests.cs | 39 +++++ .../Generation/GeneratedContracts.cs | 143 ++++++++++++++++- .../GeneratedContractShapeEntity.cs | 26 ++++ .../GeneratedContractShapeAttribute.cs | 1 + 6 files changed, 408 insertions(+), 8 deletions(-) diff --git a/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedContractShapeSourceGeneratorTests.cs b/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedContractShapeSourceGeneratorTests.cs index 684cc5a5b..203512754 100644 --- a/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedContractShapeSourceGeneratorTests.cs +++ b/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedContractShapeSourceGeneratorTests.cs @@ -103,6 +103,66 @@ public class Consumer await Assert.That(updatedCompilation.SyntaxTrees.Any(tree => tree.FilePath.Contains("Demo.Contracts.ReferencedPersonContract.g.cs", StringComparison.Ordinal))).IsTrue(); } + [Test] + public async Task PropagatesSelectedPropertyAttributesToGeneratedContracts() + { + var compilation = CreateCompilation( + "GeneratedContractConsumer", + """ + #nullable enable + using System; + using IntelliTect.Coalesce.DataAnnotations; + + namespace Demo; + + [AttributeUsage(AttributeTargets.Property)] + public sealed class CopyMeAttribute : Attribute + { + public CopyMeAttribute(string label) + { + Label = label; + } + + public string Label { get; } + public bool Flag { get; init; } + } + + [GeneratedContractShape( + "same-project", + GeneratedContractOutputKind.Class, + "GeneratedContractConsumer", + "Demo.Contracts", + "AttributedContract", + Members = [nameof(Name)], + IncludedPropertyAttributes = [typeof(CopyMeAttribute)])] + public class PersonSource + { + [CopyMe("tenant-name", Flag = true)] + public string Name { get; set; } = null!; + } + + public class Consumer + { + public Demo.Contracts.AttributedContract Contract { get; set; } = new() + { + Name = string.Empty + }; + } + """); + + var updatedCompilation = RunGenerator(compilation, out var diagnostics); + + await Assert.That(diagnostics).IsEmpty(); + await Assert.That(updatedCompilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error)).IsEmpty(); + + var generatedTree = updatedCompilation.SyntaxTrees + .Single(tree => tree.FilePath.Contains("Demo.Contracts.AttributedContract.g.cs", StringComparison.Ordinal)); + var generatedText = generatedTree.GetText().ToString(); + + await Assert.That(generatedText.Contains("[global::Demo.CopyMeAttribute(\"tenant-name\", Flag = true)]")).IsTrue(); + await Assert.That(generatedText.Contains("public required string Name { get; set; }")).IsTrue(); + } + private static CSharpCompilation CreateCompilation( string assemblyName, string source, diff --git a/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedContractShapeSourceGenerator.cs b/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedContractShapeSourceGenerator.cs index 46bd7c925..0c5d7557a 100644 --- a/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedContractShapeSourceGenerator.cs +++ b/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedContractShapeSourceGenerator.cs @@ -239,6 +239,7 @@ private static bool IsShapeAttribute(AttributeData attribute) GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.Members)), GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.ExcludedMembers)), GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.Implements)), + GetTypeArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.IncludedPropertyAttributes)), GetBool(attribute, nameof(GeneratedContractShapeAttributePlaceholder.SettableProperties)), GetInt(attribute, nameof(GeneratedContractShapeAttributePlaceholder.NullabilityTransform))); } @@ -269,7 +270,8 @@ internal static IReadOnlyList ResolveProperties(INamedTyp property, ShouldForceNullable(property, shape), HasAttribute(property, NonNullableAttributeMetadataName, shape.ShapeName), - GetDtoSource(property))); + GetDtoSource(property), + GetIncludedPropertyAttributes(property, shape))); } return properties; @@ -373,6 +375,20 @@ private static bool IsPolicyProperty(IPropertySymbol property) GetInt(attribute, nameof(DtoSourceAttributePlaceholder.OrderByDirection))); } + private static IReadOnlyList GetIncludedPropertyAttributes(IPropertySymbol property, ContractShape shape) + { + if (shape.IncludedPropertyAttributes.Count == 0) + { + return []; + } + + var includedAttributeTypes = new HashSet(shape.IncludedPropertyAttributes, StringComparer.Ordinal); + return property.GetAttributes() + .Where(attribute => attribute.AttributeClass is not null) + .Where(attribute => includedAttributeTypes.Contains(attribute.AttributeClass!.ToDisplayString())) + .ToImmutableArray(); + } + private static bool HasAttribute(IPropertySymbol property, string attributeMetadataName, string shapeName) => property.GetAttributes().Any(attribute => attribute.AttributeClass?.ToDisplayString() == attributeMetadataName && @@ -511,9 +527,9 @@ internal static string Render(GeneratedContractFileModel model) foreach (var property in model.Properties) { - if (property.DtoSource is { } dtoSource) + foreach (var attributeLine in BuildPropertyAttributeLines(property)) { - builder.Append(" ").AppendLine(BuildDtoSourceAttribute(dtoSource)); + builder.Append(" ").AppendLine(attributeLine); } builder.Append(" ").AppendLine(BuildPropertyLine(model, property)); @@ -523,6 +539,19 @@ internal static string Render(GeneratedContractFileModel model) return builder.ToString(); } + private static IEnumerable BuildPropertyAttributeLines(ContractPropertyModel property) + { + if (property.DtoSource is { } dtoSource) + { + yield return BuildDtoSourceAttribute(dtoSource); + } + + foreach (var attribute in property.IncludedPropertyAttributes) + { + yield return BuildAttribute(attribute); + } + } + private static string BuildPropertyLine(GeneratedContractFileModel model, ContractPropertyModel property) { var typeName = GetTypeName(property); @@ -560,6 +589,91 @@ private static string BuildDtoSourceAttribute(DtoSourceMetadata dtoSource) return $"[global::IntelliTect.Coalesce.DataAnnotations.DtoSource({string.Join(", ", args)})]"; } + private static string BuildAttribute(AttributeData attribute) + { + var typeName = attribute.AttributeClass?.ToDisplayString(TypeDisplayFormat) + ?? throw new InvalidOperationException("Unable to resolve generated contract attribute type."); + + var arguments = attribute.ConstructorArguments + .Select(FormatAttributeArgument) + .Concat(attribute.NamedArguments.Select(argument => $"{argument.Key} = {FormatAttributeArgument(argument.Value)}")) + .ToArray(); + + return arguments.Length == 0 + ? $"[{typeName}]" + : $"[{typeName}({string.Join(", ", arguments)})]"; + } + + private static string FormatAttributeArgument(TypedConstant value) + { + if (value.IsNull) + { + return "null"; + } + + if (value.Kind == TypedConstantKind.Array) + { + var elementTypeName = value.Type is IArrayTypeSymbol arrayType + ? arrayType.ElementType.ToDisplayString(TypeDisplayFormat) + : "object"; + + return $"new {elementTypeName}[] {{ {string.Join(", ", value.Values.Select(FormatAttributeArgument))} }}"; + } + + if (value.Kind == TypedConstantKind.Type && value.Value is ITypeSymbol typeSymbol) + { + return $"typeof({typeSymbol.ToDisplayString(TypeDisplayFormat)})"; + } + + if (value.Type?.TypeKind == TypeKind.Enum) + { + return FormatEnumAttributeArgument(value); + } + + return FormatPrimitiveAttributeArgument(value.Value!); + } + + private static string FormatEnumAttributeArgument(TypedConstant value) + { + if (value.Type is not INamedTypeSymbol enumType) + { + return FormatPrimitiveAttributeArgument(value.Value!); + } + + var namedMember = enumType.GetMembers() + .OfType() + .FirstOrDefault(field => field.HasConstantValue && Equals(field.ConstantValue, value.Value)); + + if (namedMember is not null) + { + return $"{enumType.ToDisplayString(TypeDisplayFormat)}.{namedMember.Name}"; + } + + return $"({enumType.ToDisplayString(TypeDisplayFormat)}){FormatPrimitiveAttributeArgument(value.Value!)}"; + } + + private static string FormatPrimitiveAttributeArgument(object value) + => value switch + { + string stringValue => SymbolDisplay.FormatLiteral(stringValue, quote: true), + char charValue => SymbolDisplay.FormatLiteral(charValue, quote: true), + bool boolValue => boolValue ? "true" : "false", + float floatValue when float.IsNaN(floatValue) => "global::System.Single.NaN", + float floatValue when float.IsPositiveInfinity(floatValue) => "global::System.Single.PositiveInfinity", + float floatValue when float.IsNegativeInfinity(floatValue) => "global::System.Single.NegativeInfinity", + float floatValue => floatValue.ToString("R", global::System.Globalization.CultureInfo.InvariantCulture) + "F", + double doubleValue when double.IsNaN(doubleValue) => "global::System.Double.NaN", + double doubleValue when double.IsPositiveInfinity(doubleValue) => "global::System.Double.PositiveInfinity", + double doubleValue when double.IsNegativeInfinity(doubleValue) => "global::System.Double.NegativeInfinity", + double doubleValue => doubleValue.ToString("R", global::System.Globalization.CultureInfo.InvariantCulture) + "D", + decimal decimalValue => decimalValue.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "M", + long longValue => longValue.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "L", + ulong ulongValue => ulongValue.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "UL", + uint uintValue => uintValue.ToString(global::System.Globalization.CultureInfo.InvariantCulture) + "U", + _ => Convert.ToString(value, global::System.Globalization.CultureInfo.InvariantCulture) + ?? throw new InvalidOperationException($"Unable to format generated contract attribute argument '{value}'.") + }; + private static string GetTypeName(ContractPropertyModel property) { var display = property.Property.Type.ToDisplayString(TypeDisplayFormat); @@ -700,6 +814,24 @@ private static IReadOnlyList GetStringArray(AttributeData attribute, str return []; } + private static IReadOnlyList GetTypeArray(AttributeData attribute, string name) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key != name || argument.Value.Kind != TypedConstantKind.Array) + { + continue; + } + + return argument.Value.Values + .Where(value => value.Kind == TypedConstantKind.Type && value.Value is ITypeSymbol) + .Select(value => ((ITypeSymbol)value.Value!).ToDisplayString()) + .ToImmutableArray(); + } + + return []; + } + private static bool GetBool(AttributeData attribute, string name) { foreach (var argument in attribute.NamedArguments) @@ -738,6 +870,7 @@ public ContractShape( IReadOnlyList members, IReadOnlyList excludedMembers, IReadOnlyList implements, + IReadOnlyList includedPropertyAttributes, bool settableProperties, int nullabilityTransform) { @@ -750,6 +883,7 @@ public ContractShape( Members = members; ExcludedMembers = excludedMembers; Implements = implements; + IncludedPropertyAttributes = includedPropertyAttributes; SettableProperties = settableProperties; NullabilityTransform = nullabilityTransform; } @@ -763,6 +897,7 @@ public ContractShape( public IReadOnlyList Members { get; } public IReadOnlyList ExcludedMembers { get; } public IReadOnlyList Implements { get; } + public IReadOnlyList IncludedPropertyAttributes { get; } public bool SettableProperties { get; } public int NullabilityTransform { get; } } @@ -786,13 +921,15 @@ public ContractPropertyModel( IPropertySymbol property, bool forceNullable, bool forceNonNullable, - DtoSourceMetadata? dtoSource) + DtoSourceMetadata? dtoSource, + IReadOnlyList includedPropertyAttributes) { Name = name; Property = property; ForceNullable = forceNullable; ForceNonNullable = forceNonNullable; DtoSource = dtoSource; + IncludedPropertyAttributes = includedPropertyAttributes; } public string Name { get; } @@ -800,6 +937,7 @@ public ContractPropertyModel( public bool ForceNullable { get; } public bool ForceNonNullable { get; } public DtoSourceMetadata? DtoSource { get; } + public IReadOnlyList IncludedPropertyAttributes { get; } } internal sealed class DtoSourceMetadata @@ -822,6 +960,7 @@ private static class GeneratedContractShapeAttributePlaceholder public static string[] Members { get; set; } = []; public static string[] ExcludedMembers { get; set; } = []; public static string[] Implements { get; set; } = []; + public static Type[] IncludedPropertyAttributes { get; set; } = []; public static bool SettableProperties { get; set; } public static int NullabilityTransform { get; set; } } diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractShapeGenerationTests.cs b/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractShapeGenerationTests.cs index 1259d53f4..e4f2f8b56 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractShapeGenerationTests.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractShapeGenerationTests.cs @@ -121,4 +121,43 @@ public async Task GeneratedContracts_PropagateDtoSourceAttributesToClassOutputs( [CSharpSyntaxTree.ParseText(SourceText.From(contents), path: "GeneratedContractShapeProjectionSpec.g.cs")], assertSuccess: true); } + + [Test] + public async Task GeneratedContracts_PropagateSelectedPropertyAttributesToClassOutputs() + { + var executor = BuildExecutor(); + var generatorServices = executor.ServiceProvider.GetRequiredService(); + + var compilation = ReflectionRepositoryFactory.GetCompilation(ReflectionRepositoryFactory.ModelSyntaxTrees); + var sourceType = compilation.GetTypeByMetadataName( + "IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext.GeneratedContractShapeAttributeSource"); + await Assert.That(sourceType).IsNotNull(); + var shapeSourceType = sourceType!; + + var getShapes = typeof(GeneratedContracts) + .GetMethod("GetShapes", BindingFlags.NonPublic | BindingFlags.Static)!; + var resolveProperties = typeof(GeneratedContracts) + .GetMethod("ResolveProperties", BindingFlags.NonPublic | BindingFlags.Static)!; + + var shape = ((IEnumerable)getShapes.Invoke(null, [shapeSourceType])!) + .Single(candidate => candidate.TypeName == "GeneratedContractShapeAttributeSpec"); + + var properties = (IReadOnlyList)resolveProperties.Invoke(null, [shapeSourceType, shape])!; + var file = new GeneratedContractFile(generatorServices) + { + Model = new GeneratedContracts.GeneratedContractFileModel( + shape, + properties, + shapeSourceType.ToDisplayString(SymbolTypeViewModel.DefaultDisplayFormat)), + }; + + var contents = await file.BuildOutputAsync(); + + await Assert.That(contents.Contains("[global::IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext.GeneratedContractShapeMirrorAttribute(\"tenant-name\", Enabled = true)]")).IsTrue(); + await Assert.That(contents.Contains("public required string Name { get; set; }")).IsTrue(); + + ReflectionRepositoryFactory.GetCompilation( + [CSharpSyntaxTree.ParseText(SourceText.From(contents), path: "GeneratedContractShapeAttributeSpec.g.cs")], + assertSuccess: true); + } } diff --git a/src/IntelliTect.Coalesce.CodeGeneration/Generation/GeneratedContracts.cs b/src/IntelliTect.Coalesce.CodeGeneration/Generation/GeneratedContracts.cs index 8c520f594..0601033a0 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration/Generation/GeneratedContracts.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration/Generation/GeneratedContracts.cs @@ -218,6 +218,7 @@ private static bool IsShapeAttribute(AttributeData attribute) GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.Members)), GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.ExcludedMembers)), GetStringArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.Implements)), + GetTypeArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.IncludedPropertyAttributes)), GetBool(attribute, nameof(GeneratedContractShapeAttributePlaceholder.SettableProperties)), GetInt(attribute, nameof(GeneratedContractShapeAttributePlaceholder.NullabilityTransform))); } @@ -253,6 +254,24 @@ private static IReadOnlyList GetStringArray(AttributeData attribute, str return []; } + private static IReadOnlyList GetTypeArray(AttributeData attribute, string name) + { + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key != name || argument.Value.Kind != TypedConstantKind.Array) + { + continue; + } + + return argument.Value.Values + .Where(value => value.Kind == TypedConstantKind.Type && value.Value is ITypeSymbol) + .Select(value => ((ITypeSymbol)value.Value!).ToDisplayString()) + .ToArray(); + } + + return []; + } + private static bool GetBool(AttributeData attribute, string name) { foreach (var argument in attribute.NamedArguments) @@ -305,7 +324,8 @@ internal static IReadOnlyList ResolveProperties(INamedTyp property, ShouldForceNullable(property, shape), HasAttribute(property, NonNullableAttributeMetadataName, shape.ShapeName), - GetDtoSource(property))); + GetDtoSource(property), + GetIncludedPropertyAttributes(property, shape))); } return properties; @@ -409,6 +429,20 @@ private static bool IsPolicyProperty(IPropertySymbol property) GetInt(attribute, nameof(DtoSourceAttribute.OrderByDirection))); } + private static IReadOnlyList GetIncludedPropertyAttributes(IPropertySymbol property, ContractShape shape) + { + if (shape.IncludedPropertyAttributes.Count == 0) + { + return []; + } + + var includedAttributeTypes = new HashSet(shape.IncludedPropertyAttributes, StringComparer.Ordinal); + return property.GetAttributes() + .Where(attribute => attribute.AttributeClass is not null) + .Where(attribute => includedAttributeTypes.Contains(attribute.AttributeClass!.ToDisplayString())) + .ToArray(); + } + private static bool HasAttribute(IPropertySymbol property, string attributeMetadataName, string shapeName) => property.GetAttributes().Any(attribute => attribute.AttributeClass?.ToDisplayString() == attributeMetadataName && @@ -523,6 +557,7 @@ private static class GeneratedContractShapeAttributePlaceholder public static string[] Members { get; set; } = []; public static string[] ExcludedMembers { get; set; } = []; public static string[] Implements { get; set; } = []; + public static Type[] IncludedPropertyAttributes { get; set; } = []; public static bool SettableProperties { get; set; } public static int NullabilityTransform { get; set; } } @@ -537,6 +572,7 @@ internal sealed record ContractShape( IReadOnlyList Members, IReadOnlyList ExcludedMembers, IReadOnlyList Implements, + IReadOnlyList IncludedPropertyAttributes, bool SettableProperties, int NullabilityTransform); @@ -550,7 +586,8 @@ internal sealed record ContractPropertyModel( IPropertySymbol Property, bool ForceNullable, bool ForceNonNullable, - DtoSourceMetadata? DtoSource); + DtoSourceMetadata? DtoSource, + IReadOnlyList IncludedPropertyAttributes); internal sealed record DtoSourceMetadata( string Path, @@ -648,9 +685,9 @@ private static IEnumerable BuildPropertyLines( GeneratedContracts.GeneratedContractFileModel model, GeneratedContracts.ContractPropertyModel property) { - if (property.DtoSource is { } dtoSource) + foreach (var attributeLine in BuildPropertyAttributeLines(property)) { - yield return BuildDtoSourceAttribute(dtoSource); + yield return attributeLine; } var typeName = GetTypeName(property); @@ -666,6 +703,19 @@ private static IEnumerable BuildPropertyLines( yield return $"public {required}{typeName} {property.Name} {{ get; set; }}{initializer}"; } + private static IEnumerable BuildPropertyAttributeLines(GeneratedContracts.ContractPropertyModel property) + { + if (property.DtoSource is { } dtoSource) + { + yield return BuildDtoSourceAttribute(dtoSource); + } + + foreach (var attribute in property.IncludedPropertyAttributes) + { + yield return BuildAttribute(attribute); + } + } + private static string BuildDtoSourceAttribute(GeneratedContracts.DtoSourceMetadata dtoSource) { var args = new List @@ -689,6 +739,91 @@ private static string BuildDtoSourceAttribute(GeneratedContracts.DtoSourceMetada return $"[global::IntelliTect.Coalesce.DataAnnotations.DtoSource({string.Join(", ", args)})]"; } + private static string BuildAttribute(AttributeData attribute) + { + var typeName = attribute.AttributeClass?.ToDisplayString(TypeDisplayFormat) + ?? throw new InvalidOperationException("Unable to resolve generated contract attribute type."); + + var arguments = attribute.ConstructorArguments + .Select(FormatAttributeArgument) + .Concat(attribute.NamedArguments.Select(argument => $"{argument.Key} = {FormatAttributeArgument(argument.Value)}")) + .ToArray(); + + return arguments.Length == 0 + ? $"[{typeName}]" + : $"[{typeName}({string.Join(", ", arguments)})]"; + } + + private static string FormatAttributeArgument(TypedConstant value) + { + if (value.IsNull) + { + return "null"; + } + + if (value.Kind == TypedConstantKind.Array) + { + var elementTypeName = value.Type is IArrayTypeSymbol arrayType + ? arrayType.ElementType.ToDisplayString(TypeDisplayFormat) + : "object"; + + return $"new {elementTypeName}[] {{ {string.Join(", ", value.Values.Select(FormatAttributeArgument))} }}"; + } + + if (value.Kind == TypedConstantKind.Type && value.Value is ITypeSymbol typeSymbol) + { + return $"typeof({typeSymbol.ToDisplayString(TypeDisplayFormat)})"; + } + + if (value.Type?.TypeKind == TypeKind.Enum) + { + return FormatEnumAttributeArgument(value); + } + + return FormatPrimitiveAttributeArgument(value.Value!); + } + + private static string FormatEnumAttributeArgument(TypedConstant value) + { + if (value.Type is not INamedTypeSymbol enumType) + { + return FormatPrimitiveAttributeArgument(value.Value!); + } + + var namedMember = enumType.GetMembers() + .OfType() + .FirstOrDefault(field => field.HasConstantValue && Equals(field.ConstantValue, value.Value)); + + if (namedMember is not null) + { + return $"{enumType.ToDisplayString(TypeDisplayFormat)}.{namedMember.Name}"; + } + + return $"({enumType.ToDisplayString(TypeDisplayFormat)}){FormatPrimitiveAttributeArgument(value.Value!)}"; + } + + private static string FormatPrimitiveAttributeArgument(object value) + => value switch + { + string stringValue => SymbolDisplay.FormatLiteral(stringValue, quote: true), + char charValue => SymbolDisplay.FormatLiteral(charValue, quote: true), + bool boolValue => boolValue ? "true" : "false", + float floatValue when float.IsNaN(floatValue) => "global::System.Single.NaN", + float floatValue when float.IsPositiveInfinity(floatValue) => "global::System.Single.PositiveInfinity", + float floatValue when float.IsNegativeInfinity(floatValue) => "global::System.Single.NegativeInfinity", + float floatValue => floatValue.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + "F", + double doubleValue when double.IsNaN(doubleValue) => "global::System.Double.NaN", + double doubleValue when double.IsPositiveInfinity(doubleValue) => "global::System.Double.PositiveInfinity", + double doubleValue when double.IsNegativeInfinity(doubleValue) => "global::System.Double.NegativeInfinity", + double doubleValue => doubleValue.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + "D", + decimal decimalValue => decimalValue.ToString(System.Globalization.CultureInfo.InvariantCulture) + "M", + long longValue => longValue.ToString(System.Globalization.CultureInfo.InvariantCulture) + "L", + ulong ulongValue => ulongValue.ToString(System.Globalization.CultureInfo.InvariantCulture) + "UL", + uint uintValue => uintValue.ToString(System.Globalization.CultureInfo.InvariantCulture) + "U", + _ => Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) + ?? throw new InvalidOperationException($"Unable to format generated contract attribute argument '{value}'.") + }; + private static string GetTypeName(GeneratedContracts.ContractPropertyModel property) { var display = property.Property.Type.ToDisplayString(TypeDisplayFormat); diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/GeneratedContractShapeEntity.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/GeneratedContractShapeEntity.cs index deba0f8f3..b61c773c4 100644 --- a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/GeneratedContractShapeEntity.cs +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/GeneratedContractShapeEntity.cs @@ -76,3 +76,29 @@ public class GeneratedContractShapeProjectionSource OrderByDirection = DefaultOrderByAttribute.OrderByDirections.Descending)] public List OrderedChildren { get; set; } = []; } + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class GeneratedContractShapeMirrorAttribute : Attribute +{ + public GeneratedContractShapeMirrorAttribute(string label) + { + Label = label; + } + + public string Label { get; } + public bool Enabled { get; init; } +} + +[GeneratedContractShape( + "attribute-spec", + GeneratedContractOutputKind.Class, + "IntelliTect.Coalesce.Testing", + "IntelliTect.Coalesce.Testing.GeneratedContracts", + "GeneratedContractShapeAttributeSpec", + Members = [nameof(Name)], + IncludedPropertyAttributes = [typeof(GeneratedContractShapeMirrorAttribute)])] +public class GeneratedContractShapeAttributeSource +{ + [GeneratedContractShapeMirror("tenant-name", Enabled = true)] + public string Name { get; set; } = null!; +} diff --git a/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractShapeAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractShapeAttribute.cs index ca83b9fbf..bd532b4fd 100644 --- a/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractShapeAttribute.cs +++ b/src/IntelliTect.Coalesce/DataAnnotations/GeneratedContractShapeAttribute.cs @@ -28,6 +28,7 @@ public GeneratedContractShapeAttribute( public string[] Members { get; init; } = []; public string[] ExcludedMembers { get; init; } = []; public string[] Implements { get; init; } = []; + public Type[] IncludedPropertyAttributes { get; init; } = []; public bool SettableProperties { get; init; } public GeneratedContractNullabilityTransform NullabilityTransform { get; init; } } From 5c37b11be8980acaf68d461758820d2912fe8cab Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Tue, 12 May 2026 12:27:01 -0500 Subject: [PATCH 11/14] fix(ci): unblock preview package publish - scope Microsoft.Build package pins to the task project - skip rebuilding the task assembly during pack --no-build --- Directory.Packages.props | 4 ++-- src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 1c279a721..9737ef8d3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -48,8 +48,8 @@ - - + + diff --git a/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj index d7604a65b..313f8e1fb 100644 --- a/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj +++ b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj @@ -53,7 +53,7 @@ - + Date: Tue, 12 May 2026 12:52:10 -0500 Subject: [PATCH 12/14] fix(pack): include task deps for msbuild - pack the CodeGeneration.Vue deps json with task assets - skip rebuilding task assembly during pack --no-build in dotnet-link shim --- src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj | 5 +++++ .../IntelliTect.Coalesce.dotnet-link.csproj | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj index 313f8e1fb..982600f92 100644 --- a/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj +++ b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj @@ -47,6 +47,11 @@ Pack="true" PackagePath="tasks\net8.0" Visible="false" /> + diff --git a/src/IntelliTect.Coalesce/IntelliTect.Coalesce.dotnet-link.csproj b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.dotnet-link.csproj index d7604a65b..982600f92 100644 --- a/src/IntelliTect.Coalesce/IntelliTect.Coalesce.dotnet-link.csproj +++ b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.dotnet-link.csproj @@ -47,13 +47,18 @@ Pack="true" PackagePath="tasks\net8.0" Visible="false" /> + - + Date: Wed, 13 May 2026 06:43:41 -0500 Subject: [PATCH 13/14] feat(dto): add typed content-view responses - add policy-based UTC response DateTime naming/conversion - generate content-view-specific response DTOs and action return types - enforce fixed includes for typed action response endpoints - cover flattened collection read-shapes in codegen tests --- .../Generators/ClassDto.cs | 203 +++++++++++++----- .../Generators/ModelApiController.cs | 37 ++-- .../DtoContentViewGenerationTests.cs | 27 ++- .../TestDbContext/ContentViewEntity.cs | 30 ++- .../TestDbContext/TestDbContext.cs | 2 + .../Api/Controllers/BaseApiController.cs | 49 ++++- .../DtoActionDefaultsAttribute.cs | 6 + .../DtoDateTimeOptionsAttribute.cs | 24 +++ .../TypeDefinition/ClassViewModel.cs | 68 ++++++ src/test-targets/api-clients.g.ts | 10 + src/test-targets/metadata.g.ts | 135 ++++++++++++ src/test-targets/models.g.ts | 59 +++++ src/test-targets/viewmodels.g.ts | 57 +++++ 13 files changed, 626 insertions(+), 81 deletions(-) create mode 100644 src/IntelliTect.Coalesce/DataAnnotations/DtoDateTimeOptionsAttribute.cs diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs index 290f6afa6..5add3c570 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs @@ -57,6 +57,11 @@ public override void BuildOutput(CSharpCodeBuilder b) WriteParameterDto(b); b.Line(); WriteResponseDto(b); + foreach (var contentView in Model.GeneratedResponseContentViews) + { + b.Line(); + WriteResponseDto(b, contentView); + } if (Model.ShouldGenerateSummaryDto) { b.Line(); @@ -299,29 +304,39 @@ string InlinePropertyRhs(PropertyViewModel p) } private void WriteResponseDto(CSharpCodeBuilder b) + => WriteResponseDto(b, contentView: null); + + private void WriteResponseDto(CSharpCodeBuilder b, string contentView) { - var allVariants = Model.ClientDerivedTypes; + var fixedContentView = string.IsNullOrWhiteSpace(contentView) ? null : contentView; + var responseTypeName = GetGeneratedResponseDtoTypeName(Model, fixedContentView); + + var allVariants = fixedContentView is null + ? Model.ClientDerivedTypes + : Model.ClientDerivedTypes.Where(d => d.HasResponseDtoTypeForContentView(fixedContentView)); + if (allVariants.Any() && !Model.Type.IsAbstract) allVariants = allVariants.Prepend(Model); foreach (var derived in allVariants) { - b.Line($"[JsonDerivedType(typeof({derived.ResponseDtoTypeName}), typeDiscriminator: {derived.ClientTypeName.QuotedStringLiteralForCSharp()})]"); + b.Line($"[JsonDerivedType(typeof({GetGeneratedResponseDtoTypeName(derived, fixedContentView)}), typeDiscriminator: {derived.ClientTypeName.QuotedStringLiteralForCSharp()})]"); } ClassViewModel baseType = Model.ClientBaseTypes.FirstOrDefault(); string inheritClause = baseType is not null - ? $"{baseType.ResponseDtoTypeName}, IGeneratedResponseDto<{Model.FullyQualifiedName}>" + ? $"{GetGeneratedResponseDtoTypeName(baseType, fixedContentView)}, IGeneratedResponseDto<{Model.FullyQualifiedName}>" : $"IGeneratedResponseDto<{Model.FullyQualifiedName}>"; - using (b.Block($"public partial class {Model.ResponseDtoTypeName} : {inheritClause}")) + using (b.Block($"public partial class {responseTypeName} : {inheritClause}")) { - b.Line($"public {Model.ResponseDtoTypeName}() {{ }}"); + b.Line($"public {responseTypeName}() {{ }}"); b.Line(); var orderedProps = Model .ClientProperties .Where(p => p.SecurityInfo.Read.IsAllowed()) + .Where(p => fixedContentView is null || p.IsMappedForContentView(fixedContentView)) // PK always first so it is available to guide decisions in IPropertyRestrictions .OrderBy(p => !p.IsPrimaryKey) // Scalars before objects @@ -332,17 +347,18 @@ private void WriteResponseDto(CSharpCodeBuilder b) var ownProps = orderedProps.Where(p => baseType?.PropertyByName(p.Name) is null); var flattenedProps = Model.FlattenedResponseProperties - .Where(p => baseType?.PropertyByName(p.Name) is null + .Where(p => (fixedContentView is null || p.IsMappedForContentView(fixedContentView)) + && baseType?.PropertyByName(p.Name) is null && !(baseType?.FlattenedResponseProperties.Any(fp => fp.Name == p.Name) ?? false)) .ToList(); foreach (PropertyViewModel prop in ownProps) { - b.Line($"public {ResponsePropertyType(prop)} {prop.Name} {{ get; set; }}"); + b.Line($"public {ResponsePropertyType(prop, fixedContentView)} {ResponsePropertyName(prop)} {{ get; set; }}"); } foreach (var prop in flattenedProps) { - b.Line($"public {prop.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)} {prop.Name} {{ get; set; }}"); + b.Line($"public {prop.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)} {ResponsePropertyName(prop)} {{ get; set; }}"); } b.DocComment("Map from the domain object to the properties of the current DTO instance."); @@ -350,7 +366,9 @@ private void WriteResponseDto(CSharpCodeBuilder b) { b.Line("if (obj is null) return;"); - var derivedTypes = Model.ClientDerivedTypes.ToList(); + var derivedTypes = fixedContentView is null + ? Model.ClientDerivedTypes.ToList() + : Model.ClientDerivedTypes.Where(d => d.HasResponseDtoTypeForContentView(fixedContentView)).ToList(); if (derivedTypes.Any()) { // Dispatch to derived types, since usages of this DTO in other generated code will @@ -359,19 +377,22 @@ private void WriteResponseDto(CSharpCodeBuilder b) { foreach (var derived in derivedTypes) { - b.Line($"case {derived.ResponseDtoTypeName} _{derived.Name}:"); + b.Line($"case {GetGeneratedResponseDtoTypeName(derived, fixedContentView)} _{derived.Name}:"); b.Indented($"_{derived.Name}.MapFrom(({derived.FullyQualifiedName})obj, context, tree);"); b.Indented($"return;"); } } } - b.Line("var includes = context.Includes;"); - b.Line(); + if (fixedContentView is null) + { + b.Line("var includes = context.Includes;"); + b.Line(); + } WriteSetters(b, orderedProps - .Select(ModelToDtoPropertySetter) - .Concat(flattenedProps.Select(ModelToDtoFlattenedPropertySetter))); + .Select(p => ModelToDtoPropertySetter(p, fixedContentView)) + .Concat(flattenedProps.Select(p => ModelToDtoFlattenedPropertySetter(p, fixedContentView)))); } } } @@ -385,11 +406,11 @@ private void WriteSummaryDto(CSharpCodeBuilder b) { b.Line($"public {Model.SummaryDtoTypeName}() {{ }}"); b.Line(); - b.Line($"public {primaryKey.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)} {primaryKey.Name} {{ get; set; }}"); + b.Line($"public {primaryKey.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)} {ResponsePropertyName(primaryKey)} {{ get; set; }}"); foreach (var prop in Model.SummaryProperties) { - b.Line($"public {prop.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)} {prop.Name} {{ get; set; }}"); + b.Line($"public {prop.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)} {ResponsePropertyName(prop)} {{ get; set; }}"); } b.DocComment("Map from the domain object to the properties of the current summary DTO instance."); @@ -398,7 +419,7 @@ private void WriteSummaryDto(CSharpCodeBuilder b) b.Line("if (obj is null) return;"); b.Line("var includes = context.Includes;"); b.Line(); - b.Line($"this.{primaryKey.Name} = obj.{primaryKey.Name};"); + b.Line($"this.{ResponsePropertyName(primaryKey)} = {TransformResponseValue(primaryKey.Type, $"obj.{primaryKey.Name}")};"); WriteSetters(b, Model.SummaryProperties.Select(ModelToSummaryDtoPropertySetter)); } } @@ -453,11 +474,13 @@ void WriteSetters(CSharpCodeBuilder b, IEnumerable<(IEnumerable conditio /// The property whose permissions will be evaluated. /// The permission info to pull the required roles from. /// The variable that holds the entity/model instance. + /// The fixed content view for action-specific response DTOs, if any. /// private IEnumerable GetPropertySetterConditional( PropertyViewModel property, PropertySecurityPermission permission, - string modelVar) + string modelVar, + string fixedContentView = null) { string RoleCheck(string role) => $"context.IsInRoleCached(\"{role.EscapeStringLiteralForCSharp()}\")"; string IncludesCheck(string include) => $"includes == \"{include.EscapeStringLiteralForCSharp()}\""; @@ -470,17 +493,21 @@ private IEnumerable GetPropertySetterConditional( ) .Distinct()); - var includes = string.Join(" || ", property.DtoIncludes.Select(IncludesCheck)); - var excludes = string.Join(" || ", property.DtoExcludes.Select(IncludesCheck)); - var explicitViewExcludes = string.Join(" || ", property.EffectiveParent.DtoContentViews - .Where(v => !v.Value && !property.DtoIncludes.Contains(v.Key, StringComparer.Ordinal)) - .Select(v => IncludesCheck(v.Key))); - var statement = new List(); if (!string.IsNullOrEmpty(roles)) statement.Add($"({roles})"); - if (!string.IsNullOrEmpty(includes)) statement.Add($"({includes})"); - if (!string.IsNullOrEmpty(excludes)) statement.Add($"!({excludes})"); - if (!string.IsNullOrEmpty(explicitViewExcludes)) statement.Add($"!({explicitViewExcludes})"); + + if (fixedContentView is null) + { + var includes = string.Join(" || ", property.DtoIncludes.Select(IncludesCheck)); + var excludes = string.Join(" || ", property.DtoExcludes.Select(IncludesCheck)); + var explicitViewExcludes = string.Join(" || ", property.EffectiveParent.DtoContentViews + .Where(v => !v.Value && !property.DtoIncludes.Contains(v.Key, StringComparer.Ordinal)) + .Select(v => IncludesCheck(v.Key))); + + if (!string.IsNullOrEmpty(includes)) statement.Add($"({includes})"); + if (!string.IsNullOrEmpty(excludes)) statement.Add($"!({excludes})"); + if (!string.IsNullOrEmpty(explicitViewExcludes)) statement.Add($"!({explicitViewExcludes})"); + } foreach (var restriction in property.SecurityInfo.Restrictions) { @@ -553,19 +580,22 @@ private IEnumerable GetPropertySetterConditional( /// Get the conditional and a C# expression that will map the property from a local object to a DTO. /// /// The property to map - private (IEnumerable conditionals, string setter) ModelToDtoPropertySetter(PropertyViewModel property) + /// The fixed content view for action-specific response DTOs, if any. + private (IEnumerable conditionals, string setter) ModelToDtoPropertySetter(PropertyViewModel property, string fixedContentView = null) { string name = property.Name; + string dtoName = ResponsePropertyName(property); string dtoVar = "this"; + string targetDtoTypeName = GetResponseDtoTypeName(property, fixedContentView); string setter; string mapCall() => property.Object.IsCustomDto ? "" // If we hang an IClassDto off an external type, or another IClassDto, no mapping needed - it is already the desired type. - : $".MapToDto<{property.Object.FullyQualifiedName}, {GetResponseDtoTypeName(property)}>(context, tree?[nameof({dtoVar}.{name})])"; + : $".MapToDto<{property.Object.FullyQualifiedName}, {targetDtoTypeName}>(context, tree?[nameof({dtoVar}.{dtoName})])"; if (property.Type.IsDictionary) { - setter = $"{dtoVar}.{name} = {DictionaryModelToDtoExpression(property.Type, $"obj.{name}")};"; + setter = $"{dtoVar}.{dtoName} = {DictionaryModelToDtoExpression(property.Type, $"obj.{name}", fixedContentView)};"; } else if (property.Type.IsCollection) { @@ -580,12 +610,12 @@ string mapCall() => property.Object.IsCustomDto sb.Append($"if (propVal{name} != null"); if (property.Object.HasDbSet) { - sb.Append($" && (tree == null || tree[nameof({dtoVar}.{name})] != null)"); + sb.Append($" && (tree == null || tree[nameof({dtoVar}.{dtoName})] != null)"); } sb.Line(") {"); using (sb.Indented()) { - sb.Line($"{dtoVar}.{name} = propVal{name}"); + sb.Line($"{dtoVar}.{dtoName} = propVal{name}"); var defaultOrderBy = property.Object.DefaultOrderBy; if (defaultOrderBy.Count > 0) @@ -615,8 +645,8 @@ string mapCall() => property.Object.IsCustomDto { // If we know for sure that we're loading these things (becuse the IncludeTree said so), // but EF didn't load any, then add a blank collection so the client will delete any that already exist. - sb.Line($"}} else if (propVal{name} == null && tree?[nameof({dtoVar}.{name})] != null) {{"); - sb.Indented($"{dtoVar}.{name} = new {property.Object.ResponseDtoTypeName}[0];"); + sb.Line($"}} else if (propVal{name} == null && tree?[nameof({dtoVar}.{dtoName})] != null) {{"); + sb.Indented($"{dtoVar}.{dtoName} = new {targetDtoTypeName}[0];"); sb.Line("}"); } else @@ -635,14 +665,14 @@ string mapCall() => property.Object.IsCustomDto { // Collection types which emit properly compatible property types on the DTO. // No coersion to a real collection type required. - setter = $"{dtoVar}.{name} = obj.{name};"; + setter = $"{dtoVar}.{dtoName} = obj.{name};"; } else { // Collection is not really a collection. Probably an IEnumerable. // We will have emitted the property type as ICollection, // so we need to do a ToList() so that it can be assigned. - setter = $"{dtoVar}.{name} = obj.{name}?.ToList();"; + setter = $"{dtoVar}.{dtoName} = obj.{name}?.ToList();"; } } @@ -652,31 +682,33 @@ string mapCall() => property.Object.IsCustomDto // Only check the includes tree for things that are in the database. // Otherwise, this would break IncludesExternal. string treeCheck = property.Type.ClassViewModel.HasDbSet - ? $"if (tree == null || tree[nameof({dtoVar}.{name})] != null)" + ? $"if (tree == null || tree[nameof({dtoVar}.{dtoName})] != null)" : ""; setter = $@"{treeCheck} - {dtoVar}.{name} = obj.{name}{mapCall()}; + {dtoVar}.{dtoName} = obj.{name}{mapCall()}; "; } else { - setter = $"{dtoVar}.{name} = obj.{name};"; + setter = $"{dtoVar}.{dtoName} = {TransformResponseValue(property.Type, $"obj.{name}")};"; } - var statement = GetPropertySetterConditional(property, property.SecurityInfo.Read, "obj"); + var statement = GetPropertySetterConditional(property, property.SecurityInfo.Read, "obj", fixedContentView); return (statement, setter); } - private (IEnumerable conditionals, string setter) ModelToDtoFlattenedPropertySetter(FlattenedResponsePropertyViewModel property) + private (IEnumerable conditionals, string setter) ModelToDtoFlattenedPropertySetter(FlattenedResponsePropertyViewModel property, string fixedContentView = null) => ( - GetContentViewConditionals(property.DeclaringClass, property.ContentViews, property.ExcludedContentViews), - $"this.{property.Name} = {property.AccessExpression("obj")};"); + fixedContentView is null + ? GetContentViewConditionals(property.DeclaringClass, property.ContentViews, property.ExcludedContentViews) + : [], + $"this.{ResponsePropertyName(property)} = {TransformResponseValue(property.Type, property.AccessExpression("obj"))};"); private (IEnumerable conditionals, string setter) ModelToSummaryDtoPropertySetter(SummaryPropertyViewModel property) => ( GetContentViewConditionals(property.Parent, property.ContentViews, property.ExcludedContentViews), - $"this.{property.Name} = {property.AccessExpression("obj")};"); + $"this.{ResponsePropertyName(property)} = {TransformResponseValue(property.Type, property.AccessExpression("obj"))};"); private static IEnumerable GetContentViewConditionals( ClassViewModel declaringClass, @@ -708,26 +740,88 @@ private static IEnumerable GetContentViewConditionals( } } - private string ResponsePropertyType(PropertyViewModel property) + private string ResponsePropertyType(PropertyViewModel property, string fixedContentView = null) { if (property.UsesDtoReferenceSummary && property.Object is not null) { return $"{DtoNamespace}.{property.Object.SummaryDtoTypeName}"; } - return property.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace); + var typeName = property.Type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace); + if (fixedContentView is not null && property.Object is not null) + { + typeName = typeName.Replace(property.Object.ResponseDtoTypeName, GetResponseDtoTypeName(property, fixedContentView)); + } + + return typeName; } - private string GetResponseDtoTypeName(PropertyViewModel property) + private string GetResponseDtoTypeName(PropertyViewModel property, string fixedContentView = null) { if (property.UsesDtoReferenceSummary && property.Object is not null) { return property.Object.SummaryDtoTypeName; } + if (property.Object is null) + { + return string.Empty; + } + + if (fixedContentView is not null && property.Object.HasResponseDtoTypeForContentView(fixedContentView)) + { + return property.Object.ResponseDtoTypeNameForContentView(fixedContentView); + } + return property.Object.ResponseDtoTypeName; } + private string ResponsePropertyName(PropertyViewModel property) + => GetResponsePropertyName(property.Name, property.Type); + + private string ResponsePropertyName(FlattenedResponsePropertyViewModel property) + => GetResponsePropertyName(property.Name, property.Type); + + private string ResponsePropertyName(SummaryPropertyViewModel property) + => GetResponsePropertyName(property.Name, property.Type); + + private string GetResponsePropertyName(string propertyName, TypeViewModel propertyType) + { + if (Model.ResponseDtoDateTimeMode != DtoDateTimeMode.Utc || !propertyType.IsDateTime) + { + return propertyName; + } + + if (propertyName.EndsWith("UTC", StringComparison.Ordinal)) + { + return propertyName; + } + + if (propertyName.EndsWith("Utc", StringComparison.OrdinalIgnoreCase)) + { + return propertyName[..^3] + "UTC"; + } + + return propertyName + "UTC"; + } + + private string TransformResponseValue(TypeViewModel type, string sourceExpression) + { + if (Model.ResponseDtoDateTimeMode != DtoDateTimeMode.Utc || !type.IsDateTime) + { + return sourceExpression; + } + + return sourceExpression.Contains("?.", StringComparison.Ordinal) || type.IsReferenceOrNullableValue + ? $"{sourceExpression}?.ToUniversalTime()" + : $"{sourceExpression}.ToUniversalTime()"; + } + + private string GetGeneratedResponseDtoTypeName(ClassViewModel model, string contentView) + => contentView is not null && model.HasResponseDtoTypeForContentView(contentView) + ? model.ResponseDtoTypeNameForContentView(contentView) + : model.ResponseDtoTypeName; + private static bool IsImmutableDictionary(TypeViewModel type) => type.IsA(typeof(IImmutableDictionary<,>)) || type.IsA(typeof(ImmutableDictionary<,>)); @@ -761,27 +855,30 @@ private string DictionaryValueDtoToModelExpression(TypeViewModel type, string so return sourceExpression; } - private string DictionaryModelToDtoExpression(TypeViewModel type, string sourceExpression) + private string DictionaryModelToDtoExpression(TypeViewModel type, string sourceExpression, string fixedContentView = null) { var args = type.GenericArgumentsFor(typeof(IDictionary<,>)) ?? throw new InvalidOperationException($"Dictionary type '{type}' is missing generic arguments."); - return $"{sourceExpression}?.ToDictionary(k => k.Key, v => {DictionaryValueModelToDtoExpression(args[1], "v.Value")})"; + return $"{sourceExpression}?.ToDictionary(k => k.Key, v => {DictionaryValueModelToDtoExpression(args[1], "v.Value", fixedContentView)})"; } - private string DictionaryValueModelToDtoExpression(TypeViewModel type, string sourceExpression) + private string DictionaryValueModelToDtoExpression(TypeViewModel type, string sourceExpression, string fixedContentView = null) { if (type.IsDictionary) { - return $"({type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)}){DictionaryModelToDtoExpression(type, sourceExpression)}"; + return $"({type.NullableTypeForDto(isInput: false, dtoNamespace: DtoNamespace)}){DictionaryModelToDtoExpression(type, sourceExpression, fixedContentView)}"; } var pureType = type.PureType; if (pureType.ClassViewModel is { } model) { - return $"{sourceExpression}.MapToDto<{model.FullyQualifiedName}, {model.ResponseDtoTypeName}>(context)"; + var dtoTypeName = fixedContentView is not null && model.HasResponseDtoTypeForContentView(fixedContentView) + ? model.ResponseDtoTypeNameForContentView(fixedContentView) + : model.ResponseDtoTypeName; + return $"{sourceExpression}.MapToDto<{model.FullyQualifiedName}, {dtoTypeName}>(context)"; } - return sourceExpression; + return TransformResponseValue(type, sourceExpression); } } diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ModelApiController.cs b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ModelApiController.cs index 09043170f..f19f87e82 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ModelApiController.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ModelApiController.cs @@ -55,6 +55,17 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI string defaultSaveIncludes = DefaultIncludesLiteral(Model.DefaultSaveDtoIncludes); string defaultBulkSaveIncludes = DefaultIncludesLiteral(Model.DefaultBulkSaveDtoIncludes); string defaultDeleteIncludes = DefaultIncludesLiteral(Model.DefaultDeleteDtoIncludes); + string getResponseType = Model.GetStandardActionResponseDtoTypeName(Model.DefaultGetDtoIncludes); + string listResponseType = Model.GetStandardActionResponseDtoTypeName(Model.DefaultListDtoIncludes); + string saveResponseType = Model.GetStandardActionResponseDtoTypeName(Model.DefaultSaveDtoIncludes); + string bulkSaveResponseType = Model.GetStandardActionResponseDtoTypeName(Model.DefaultBulkSaveDtoIncludes); + string deleteResponseType = Model.GetStandardActionResponseDtoTypeName(Model.DefaultDeleteDtoIncludes); + bool fixGetIncludes = Model.ShouldUseContentViewResponseType(Model.DefaultGetDtoIncludes); + bool fixListIncludes = Model.ShouldUseContentViewResponseType(Model.DefaultListDtoIncludes); + bool fixCountIncludes = Model.ShouldUseContentViewResponseType(Model.DefaultCountDtoIncludes); + bool fixSaveIncludes = Model.ShouldUseContentViewResponseType(Model.DefaultSaveDtoIncludes); + bool fixBulkSaveIncludes = Model.ShouldUseContentViewResponseType(Model.DefaultBulkSaveDtoIncludes); + bool fixDeleteIncludes = Model.ShouldUseContentViewResponseType(Model.DefaultDeleteDtoIncludes); #pragma warning disable CS0618 // Type or member is obsolete var accessModifier = Model.ApiActionAccessModifier; #pragma warning restore CS0618 // Type or member is obsolete @@ -80,20 +91,20 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI b.Line(); b.Line("""[HttpGet("get/{id}")]"""); b.Line($"{securityInfo.Read.MvcAnnotation()}"); - b.Line($"{accessModifier} virtual Task> Get("); + b.Line($"{accessModifier} virtual Task> Get("); b.Indented($"{primaryKeyParameter},"); b.Indented($"[FromQuery] DataSourceParameters parameters,"); b.Indented($"{dataSourceParameter})"); - b.Indented($"=> GetImplementation(id, ApplyDefaultIncludes(parameters, {defaultGetIncludes}), dataSource);"); + b.Indented($"=> GetImplementation<{getResponseType}>(id, {(fixGetIncludes ? "ApplyFixedIncludes" : "ApplyDefaultIncludes")}(parameters, {defaultGetIncludes}), dataSource);"); // ENDPOINT: /list b.Line(); b.Line("""[HttpGet("list")]"""); b.Line($"{securityInfo.Read.MvcAnnotation()}"); - b.Line($"{accessModifier} virtual Task> List("); + b.Line($"{accessModifier} virtual Task> List("); b.Indented($"[FromQuery] ListParameters parameters,"); b.Indented($"{dataSourceParameter})"); - b.Indented($"=> ListImplementation(ApplyDefaultIncludes(parameters, {defaultListIncludes}), dataSource);"); + b.Indented($"=> ListImplementation<{listResponseType}>({(fixListIncludes ? "ApplyFixedIncludes" : "ApplyDefaultIncludes")}(parameters, {defaultListIncludes}), dataSource);"); // ENDPOINT: /count b.Line(); @@ -102,7 +113,7 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI b.Line($"{accessModifier} virtual Task> Count("); b.Indented($"[FromQuery] FilterParameters parameters,"); b.Indented($"{dataSourceParameter})"); - b.Indented($"=> CountImplementation(ApplyDefaultIncludes(parameters, {defaultCountIncludes}), dataSource);"); + b.Indented($"=> CountImplementation({(fixCountIncludes ? "ApplyFixedIncludes" : "ApplyDefaultIncludes")}(parameters, {defaultCountIncludes}), dataSource);"); } if (securityInfo.Save.IsAllowed()) @@ -112,23 +123,23 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI b.Line("""[HttpPost("save")]"""); b.Line("""[Consumes("application/x-www-form-urlencoded", "multipart/form-data")]"""); b.Line($"{securityInfo.Save.MvcAnnotation()}"); - b.Line($"{accessModifier} virtual Task> Save("); + b.Line($"{accessModifier} virtual Task> Save("); b.Indented($"[FromForm] {Model.ParameterDtoTypeName} dto,"); b.Indented($"[FromQuery] DataSourceParameters parameters,"); b.Indented($"{dataSourceParameter},"); b.Indented($"{behaviorsParameter})"); - b.Indented($"=> SaveImplementation(dto, ApplyDefaultIncludes(parameters, {defaultSaveIncludes}), dataSource, behaviors);"); + b.Indented($"=> SaveImplementation<{saveResponseType}>(dto, {(fixSaveIncludes ? "ApplyFixedIncludes" : "ApplyDefaultIncludes")}(parameters, {defaultSaveIncludes}), dataSource, behaviors);"); b.Line(); b.Line("""[HttpPost("save")]"""); b.Line("""[Consumes("application/json")]"""); b.Line($"{securityInfo.Save.MvcAnnotation()}"); - b.Line($"{accessModifier} virtual Task> SaveFromJson("); + b.Line($"{accessModifier} virtual Task> SaveFromJson("); b.Indented($"[FromBody] {Model.ParameterDtoTypeName} dto,"); b.Indented($"[FromQuery] DataSourceParameters parameters,"); b.Indented($"{dataSourceParameter},"); b.Indented($"{behaviorsParameter})"); - b.Indented($"=> SaveImplementation(dto, ApplyDefaultIncludes(parameters, {defaultSaveIncludes}), dataSource, behaviors);"); + b.Indented($"=> SaveImplementation<{saveResponseType}>(dto, {(fixSaveIncludes ? "ApplyFixedIncludes" : "ApplyDefaultIncludes")}(parameters, {defaultSaveIncludes}), dataSource, behaviors);"); } if (Model.DbContext != null && securityInfo.IsReadAllowed() && (securityInfo.Save.IsAllowed() || securityInfo.IsDeleteAllowed())) @@ -141,13 +152,13 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI b.Line(); b.Line("[HttpPost(\"bulkSave\")]"); b.Line($"{securityInfo.Read.MvcAnnotation()}"); - b.Line($"{accessModifier} virtual Task> BulkSave("); + b.Line($"{accessModifier} virtual Task> BulkSave("); b.Indented($"[FromBody] BulkSaveRequest dto,"); b.Indented($"[FromQuery] DataSourceParameters parameters,"); b.Indented($"{dataSourceParameter},"); b.Indented($"[FromServices] IDataSourceFactory dataSourceFactory,"); b.Indented($"[FromServices] IBehaviorsFactory behaviorsFactory)"); - b.Indented($"=> BulkSaveImplementation(dto, ApplyDefaultIncludes(parameters, {defaultBulkSaveIncludes}), dataSource, dataSourceFactory, behaviorsFactory);"); + b.Indented($"=> BulkSaveImplementation<{bulkSaveResponseType}>(dto, {(fixBulkSaveIncludes ? "ApplyFixedIncludes" : "ApplyDefaultIncludes")}(parameters, {defaultBulkSaveIncludes}), dataSource, dataSourceFactory, behaviorsFactory);"); } if (securityInfo.IsDeleteAllowed()) @@ -156,11 +167,11 @@ private void WriteClassContents(CSharpCodeBuilder b, ClassSecurityInfo securityI b.Line(); b.Line("[HttpPost(\"delete/{id}\")]"); b.Line($"{securityInfo.Delete.MvcAnnotation()}"); - b.Line($"{accessModifier} virtual Task> Delete("); + b.Line($"{accessModifier} virtual Task> Delete("); b.Indented($"{primaryKeyParameter},"); b.Indented($"{behaviorsParameter},"); b.Indented($"{dataSourceParameter})"); - b.Indented($"=> DeleteImplementation(id, ApplyDefaultIncludes(new DataSourceParameters(), {defaultDeleteIncludes}), dataSource, behaviors);"); + b.Indented($"=> DeleteImplementation<{deleteResponseType}>(id, {(fixDeleteIncludes ? "ApplyFixedIncludes" : "ApplyDefaultIncludes")}(new DataSourceParameters(), {defaultDeleteIncludes}), dataSource, behaviors);"); } if (Model.ClientMethods.Any()) diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoContentViewGenerationTests.cs b/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoContentViewGenerationTests.cs index 31ff9ff4f..1b2da77b6 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoContentViewGenerationTests.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoContentViewGenerationTests.cs @@ -21,16 +21,31 @@ public async Task ApiDtos_GenerateActionDefaultsAndViewAwareMappings() var dtoFile = Directory.GetFiles(outDir, "ContentViewEntityDto.g.cs", SearchOption.AllDirectories).Single(); var controllerFile = Directory.GetFiles(outDir, "ContentViewEntityController.g.cs", SearchOption.AllDirectories).Single(); var personDtoFile = Directory.GetFiles(outDir, "PersonDto.g.cs", SearchOption.AllDirectories).Single(); + var tagLinkDtoFile = Directory.GetFiles(outDir, "ContentViewEntityTagLinkDto.g.cs", SearchOption.AllDirectories).Single(); var dtoContents = await File.ReadAllTextAsync(dtoFile); var controllerContents = await File.ReadAllTextAsync(controllerFile); var personDtoContents = await File.ReadAllTextAsync(personDtoFile); - - await Assert.That(controllerContents.Contains("GetImplementation(id, ApplyDefaultIncludes(parameters, \"detail\"), dataSource)")).IsTrue(); - await Assert.That(controllerContents.Contains("ListImplementation(ApplyDefaultIncludes(parameters, \"list\"), dataSource)")).IsTrue(); - await Assert.That(controllerContents.Contains("SaveImplementation(dto, ApplyDefaultIncludes(parameters, \"save\"), dataSource, behaviors)")).IsTrue(); - await Assert.That(controllerContents.Contains("CountImplementation(ApplyDefaultIncludes(parameters, \"list\"), dataSource)")).IsTrue(); - + var tagLinkDtoContents = await File.ReadAllTextAsync(tagLinkDtoFile); + + await Assert.That(controllerContents.Contains("Task> Get(")).IsTrue(); + await Assert.That(controllerContents.Contains("GetImplementation(id, ApplyFixedIncludes(parameters, \"detail\"), dataSource)")).IsTrue(); + await Assert.That(controllerContents.Contains("Task> List(")).IsTrue(); + await Assert.That(controllerContents.Contains("ListImplementation(ApplyFixedIncludes(parameters, \"list\"), dataSource)")).IsTrue(); + await Assert.That(controllerContents.Contains("Task> Save(")).IsTrue(); + await Assert.That(controllerContents.Contains("SaveImplementation(dto, ApplyFixedIncludes(parameters, \"save\"), dataSource, behaviors)")).IsTrue(); + await Assert.That(controllerContents.Contains("CountImplementation(ApplyFixedIncludes(parameters, \"list\"), dataSource)")).IsTrue(); + + await Assert.That(dtoContents.Contains("public partial class ContentViewEntityListResponse")).IsTrue(); + await Assert.That(dtoContents.Contains("public partial class ContentViewEntityDetailResponse")).IsTrue(); + await Assert.That(dtoContents.Contains("public partial class ContentViewEntitySaveResponse")).IsTrue(); + await Assert.That(dtoContents.Contains("public System.DateTime? CreatedAtUTC { get; set; }")).IsTrue(); + await Assert.That(dtoContents.Contains("this.CreatedAtUTC = obj.CreatedAt.ToUniversalTime();")).IsTrue(); + await Assert.That(dtoContents.Contains("ContentViewEntityTagLinkDetailResponse> Tags { get; set; }")).IsTrue(); + await Assert.That(tagLinkDtoContents.Contains("public partial class ContentViewEntityTagLinkDetailResponse")).IsTrue(); + await Assert.That(tagLinkDtoContents.Contains("public partial class ContentViewEntityTagLinkListResponse")).IsTrue(); + await Assert.That(tagLinkDtoContents.Contains("this.Id = obj.Tag?.Id;")).IsTrue(); + await Assert.That(tagLinkDtoContents.Contains("this.Name = obj.Tag?.Name;")).IsTrue(); await Assert.That(dtoContents.Contains("this.ReportedByCompanyName = obj.ReportedBy?.Company?.Name;")).IsTrue(); await Assert.That(dtoContents.Contains("includes == \"detail\"")).IsTrue(); await Assert.That(dtoContents.Contains("includes == \"list\"")).IsTrue(); diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs index 3570519f0..00cb6906b 100644 --- a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs @@ -1,4 +1,6 @@ using IntelliTect.Coalesce.DataAnnotations; +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; namespace IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext; @@ -8,7 +10,8 @@ namespace IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext; [DtoContentView("list", IncludeByDefault = false)] [DtoContentView("detail", IncludeByDefault = false)] [DtoContentView("save", IncludeByDefault = false)] -[DtoActionDefaults(List = "list", Get = "detail", Save = "save", Count = "list")] +[DtoActionDefaults(List = "list", Get = "detail", Save = "save", Count = "list", UseContentViewResponseTypes = true)] +[DtoDateTimeOptions(DtoDateTimeMode.Utc)] [DtoFlatten("ReportedBy.Company.Name", Name = "ReportedByCompanyName", ContentViews = "detail")] public class ContentViewEntity { @@ -21,6 +24,9 @@ public class ContentViewEntity [DtoIncludes("detail,save")] public string Description { get; set; } + [DtoIncludes("list,detail")] + public DateTime CreatedAt { get; set; } + public int? AssignedToId { get; set; } [DtoIncludes("list,detail")] @@ -34,5 +40,27 @@ public class ContentViewEntity [ForeignKey(nameof(ReportedById))] public Person ReportedBy { get; set; } + [DtoIncludes("list,detail")] + public ICollection Tags { get; set; } = new List(); + public string NeverMapped { get; set; } } + +[DtoContentView("list", IncludeByDefault = false)] +[DtoContentView("detail", IncludeByDefault = false)] +[DtoFlatten("Tag.Id", Name = "Id", ContentViews = "list,detail")] +[DtoFlatten("Tag.Name", Name = "Name", ContentViews = "list,detail")] +public class ContentViewEntityTagLink +{ + public int ContentViewEntityTagLinkId { get; set; } + public int ContentViewEntityId { get; set; } + public ContentViewEntity ContentViewEntity { get; set; } + public int TagId { get; set; } + public ContentViewTag Tag { get; set; } +} + +public class ContentViewTag +{ + public int Id { get; set; } + public string Name { get; set; } +} diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/TestDbContext.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/TestDbContext.cs index 4ebfd9aa2..c2c44224c 100644 --- a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/TestDbContext.cs +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/TestDbContext.cs @@ -56,6 +56,8 @@ public class AppDbContext : DbContext public DbSet SuppressedDefaultOrderings { get; set; } public DbSet ContentViewEntities { get; set; } + public DbSet ContentViewEntityTagLinks { get; set; } + public DbSet ContentViewTags { get; set; } public DbSet Students { get; set; } public DbSet Advisors { get; set; } diff --git a/src/IntelliTect.Coalesce/Api/Controllers/BaseApiController.cs b/src/IntelliTect.Coalesce/Api/Controllers/BaseApiController.cs index 6a8105bcd..97c19558b 100644 --- a/src/IntelliTect.Coalesce/Api/Controllers/BaseApiController.cs +++ b/src/IntelliTect.Coalesce/Api/Controllers/BaseApiController.cs @@ -65,6 +65,13 @@ protected static TParameters ApplyDefaultIncludes(TParameters param return parameters; } + protected static TParameters ApplyFixedIncludes(TParameters parameters, string? includes) + where TParameters : DataSourceParameters + { + parameters.Includes = includes; + return parameters; + } + protected ActionResult File(IFile _methodResult) { string? _contentType = _methodResult.ContentType; @@ -106,13 +113,21 @@ protected BaseApiController(CrudContext context) : base(context) protected ClassViewModel EntityClassViewModel { get; } protected Task> GetImplementation(object id, DataSourceParameters parameters, IDataSource dataSource) + => GetImplementation(id, parameters, dataSource); + + protected Task> GetImplementation(object id, DataSourceParameters parameters, IDataSource dataSource) + where TDto : class, IResponseDto, new() { - return dataSource.GetMappedItemAsync(id, parameters); + return dataSource.GetMappedItemAsync(id, parameters); } protected Task> ListImplementation(ListParameters listParameters, IDataSource dataSource) + => ListImplementation(listParameters, dataSource); + + protected Task> ListImplementation(ListParameters listParameters, IDataSource dataSource) + where TDto : class, IResponseDto, new() { - return dataSource.GetMappedListAsync(listParameters); + return dataSource.GetMappedListAsync(listParameters); } protected Task> CountImplementation(FilterParameters parameters, IDataSource dataSource) @@ -120,7 +135,11 @@ protected Task> CountImplementation(FilterParameters parameters, return dataSource.GetCountAsync(parameters); } - protected async Task> SaveImplementation(TDtoIn dto, DataSourceParameters parameters, IDataSource dataSource, IBehaviors behaviors) + protected Task> SaveImplementation(TDtoIn dto, DataSourceParameters parameters, IDataSource dataSource, IBehaviors behaviors) + => SaveImplementation(dto, parameters, dataSource, behaviors); + + protected async Task> SaveImplementation(TDtoIn dto, DataSourceParameters parameters, IDataSource dataSource, IBehaviors behaviors) + where TDto : class, IResponseDto, new() { var kind = (await behaviors.DetermineSaveKindAsync(dto, dataSource, parameters)).Kind; @@ -145,12 +164,16 @@ protected Task> CountImplementation(FilterParameters parameters, return $"Editing of {GeneratedForClassViewModel.DisplayName} items not allowed."; } - return await behaviors.SaveAsync(dto, dataSource, parameters); + return await behaviors.SaveAsync(dto, dataSource, parameters); } protected Task> DeleteImplementation(object id, DataSourceParameters parameters, IDataSource dataSource, IBehaviors behaviors) + => DeleteImplementation(id, parameters, dataSource, behaviors); + + protected Task> DeleteImplementation(object id, DataSourceParameters parameters, IDataSource dataSource, IBehaviors behaviors) + where TDto : class, IResponseDto, new() { - return behaviors.DeleteAsync(id, dataSource, parameters); + return behaviors.DeleteAsync(id, dataSource, parameters); } } @@ -171,13 +194,23 @@ protected BaseApiController(CrudContext context) : base(context) public TContext Db => Context.DbContext; - protected async Task> BulkSaveImplementation( + protected Task> BulkSaveImplementation( + BulkSaveRequest dto, + DataSourceParameters parameters, + IDataSource rootDataSource, + IDataSourceFactory dataSourceFactory, + IBehaviorsFactory behaviorsFactory + ) + => BulkSaveImplementation(dto, parameters, rootDataSource, dataSourceFactory, behaviorsFactory); + + protected async Task> BulkSaveImplementation( BulkSaveRequest dto, DataSourceParameters parameters, IDataSource rootDataSource, IDataSourceFactory dataSourceFactory, IBehaviorsFactory behaviorsFactory ) + where TDto : class, IResponseDto, new() { if (dto is null && HttpContext.RequestServices.GetService(typeof(IJsonHelper))?.GetType().Name.Contains("Newtonsoft") == true) { @@ -190,7 +223,7 @@ IBehaviorsFactory behaviorsFactory } var strategy = Db.Database.CreateExecutionStrategy(); - return await strategy.ExecuteAsync>(async () => + return await strategy.ExecuteAsync>(async () => { using var tran = await Db.Database.BeginTransactionAsync(); @@ -343,7 +376,7 @@ IBehaviorsFactory behaviorsFactory else { // Read security is implemented by the generated controller action. - var rootResult = await GetImplementation(root.PrimaryKey, parameters, rootDataSource); + var rootResult = await GetImplementation(root.PrimaryKey, parameters, rootDataSource); await tran.CommitAsync(); diff --git a/src/IntelliTect.Coalesce/DataAnnotations/DtoActionDefaultsAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/DtoActionDefaultsAttribute.cs index be40cfe40..638fd5b72 100644 --- a/src/IntelliTect.Coalesce/DataAnnotations/DtoActionDefaultsAttribute.cs +++ b/src/IntelliTect.Coalesce/DataAnnotations/DtoActionDefaultsAttribute.cs @@ -14,4 +14,10 @@ public sealed class DtoActionDefaultsAttribute : Attribute public string? Save { get; set; } public string? BulkSave { get; set; } public string? Delete { get; set; } + + /// + /// If true, generated standard CRUD controller actions will use distinct response DTO types + /// based on their configured content views, and will enforce those content views at runtime. + /// + public bool UseContentViewResponseTypes { get; set; } } diff --git a/src/IntelliTect.Coalesce/DataAnnotations/DtoDateTimeOptionsAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/DtoDateTimeOptionsAttribute.cs new file mode 100644 index 000000000..e5df1fec0 --- /dev/null +++ b/src/IntelliTect.Coalesce/DataAnnotations/DtoDateTimeOptionsAttribute.cs @@ -0,0 +1,24 @@ +using System; + +namespace IntelliTect.Coalesce.DataAnnotations; + +public enum DtoDateTimeMode +{ + Preserve = 0, + Utc = 1, +} + +/// +/// Configures generated response DTO handling for properties. +/// Can be applied at the model or assembly level. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Assembly, AllowMultiple = false, Inherited = false)] +public sealed class DtoDateTimeOptionsAttribute : Attribute +{ + public DtoDateTimeOptionsAttribute(DtoDateTimeMode mode) + { + Mode = mode; + } + + public DtoDateTimeMode Mode { get; } +} diff --git a/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs index ee695ac70..cdf66128e 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs @@ -11,6 +11,7 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; +using System.Text.RegularExpressions; namespace IntelliTect.Coalesce.TypeDefinition; @@ -264,12 +265,79 @@ public bool ShouldIncludeUnspecifiedPropertiesForContentView(string? contentView || !DtoContentViews.TryGetValue(contentView, out var includeByDefault) || includeByDefault; + private IReadOnlyList? _ownResponseContentViews; + public IReadOnlyList OwnResponseContentViews + => _ownResponseContentViews ??= CollectResponseContentViews(this) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + private IReadOnlyList? _generatedResponseContentViews; + public IReadOnlyList GeneratedResponseContentViews + => _generatedResponseContentViews ??= (ClientBaseTypes.FirstOrDefault()?.GeneratedResponseContentViews ?? []) + .Concat(OwnResponseContentViews) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + private DtoDateTimeMode? _responseDtoDateTimeMode; + public DtoDateTimeMode ResponseDtoDateTimeMode + => _responseDtoDateTimeMode ??= + this.GetAttributeValue(a => a.Mode) + ?? Type.Assembly.GetAttributeValue(a => a.Mode) + ?? DtoDateTimeMode.Preserve; + public string? DefaultGetDtoIncludes => this.GetAttributeValue(a => a.Get); public string? DefaultListDtoIncludes => this.GetAttributeValue(a => a.List); public string? DefaultCountDtoIncludes => this.GetAttributeValue(a => a.Count); public string? DefaultSaveDtoIncludes => this.GetAttributeValue(a => a.Save); public string? DefaultBulkSaveDtoIncludes => this.GetAttributeValue(a => a.BulkSave); public string? DefaultDeleteDtoIncludes => this.GetAttributeValue(a => a.Delete); + public bool UseContentViewResponseTypes => this.GetAttributeValue(a => a.UseContentViewResponseTypes) ?? false; + + public bool ShouldUseContentViewResponseType(string? contentView) + => UseContentViewResponseTypes && !string.IsNullOrWhiteSpace(contentView); + + public bool HasResponseDtoTypeForContentView(string? contentView) + => !string.IsNullOrWhiteSpace(contentView) + && GeneratedResponseContentViews.Contains(contentView, StringComparer.Ordinal); + + public string ResponseDtoTypeNameForContentView(string contentView) + => $"{ClientTypeName}{GetContentViewResponseTypeSuffix(contentView)}Response"; + + public string GetStandardActionResponseDtoTypeName(string? contentView) + => ShouldUseContentViewResponseType(contentView) + ? ResponseDtoTypeNameForContentView(contentView!) + : ResponseDtoTypeName; + + private static IEnumerable CollectResponseContentViews(ClassViewModel model) + => model.DtoContentViews.Keys + .Concat(new[] + { + model.DefaultGetDtoIncludes, + model.DefaultListDtoIncludes, + model.DefaultCountDtoIncludes, + model.DefaultSaveDtoIncludes, + model.DefaultBulkSaveDtoIncludes, + model.DefaultDeleteDtoIncludes, + }.OfType().Where(v => !string.IsNullOrWhiteSpace(v))) + .Concat(model.ClientProperties.SelectMany(p => p.DtoIncludes.Concat(p.DtoExcludes))) + .Concat(model.FlattenedResponseProperties.SelectMany(p => p.ContentViews.Concat(p.ExcludedContentViews))) + .Concat(model.SummaryProperties.SelectMany(p => p.ContentViews.Concat(p.ExcludedContentViews))) + .Where(v => !string.IsNullOrWhiteSpace(v)); + + private static string GetContentViewResponseTypeSuffix(string contentView) + { + var parts = Regex.Split(contentView, @"[^A-Za-z0-9]+") + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Select(p => p.ToPascalCase()); + + var suffix = string.Concat(parts); + if (string.IsNullOrWhiteSpace(suffix)) + { + suffix = "View"; + } + + return suffix.GetValidCSharpIdentifier(); + } /// /// List of method names that should not be exposed to the client. diff --git a/src/test-targets/api-clients.g.ts b/src/test-targets/api-clients.g.ts index dcd4031be..832c14263 100644 --- a/src/test-targets/api-clients.g.ts +++ b/src/test-targets/api-clients.g.ts @@ -582,6 +582,16 @@ export class ContentViewEntityApiClient extends ModelApiClient<$models.ContentVi } +export class ContentViewEntityTagLinkApiClient extends ModelApiClient<$models.ContentViewEntityTagLink> { + constructor() { super($metadata.ContentViewEntityTagLink) } +} + + +export class ContentViewTagApiClient extends ModelApiClient<$models.ContentViewTag> { + constructor() { super($metadata.ContentViewTag) } +} + + export class CourseApiClient extends ModelApiClient<$models.Course> { constructor() { super($metadata.Course) } } diff --git a/src/test-targets/metadata.g.ts b/src/test-targets/metadata.g.ts index 6531e8186..ab48f90fd 100644 --- a/src/test-targets/metadata.g.ts +++ b/src/test-targets/metadata.g.ts @@ -3313,6 +3313,14 @@ export const ContentViewEntity = domain.types.ContentViewEntity = { type: "string", role: "value", }, + createdAt: { + name: "createdAt", + displayName: "Created At", + type: "date", + dateKind: "datetime", + noOffset: true, + role: "value", + }, assignedToId: { name: "assignedToId", displayName: "Assigned To Id", @@ -3351,6 +3359,22 @@ export const ContentViewEntity = domain.types.ContentViewEntity = { get principalKey() { return (domain.types.Person as ModelType & { name: "Person" }).props.personId as PrimaryKeyProperty }, dontSerialize: true, }, + tags: { + name: "tags", + displayName: "Tags", + type: "collection", + itemType: { + name: "$collectionItem", + displayName: "", + role: "value", + type: "model", + get typeDef() { return (domain.types.ContentViewEntityTagLink as ModelType & { name: "ContentViewEntityTagLink" }) }, + }, + role: "collectionNavigation", + get foreignKey() { return (domain.types.ContentViewEntityTagLink as ModelType & { name: "ContentViewEntityTagLink" }).props.contentViewEntityId as ForeignKeyProperty }, + get inverseNavigation() { return (domain.types.ContentViewEntityTagLink as ModelType & { name: "ContentViewEntityTagLink" }).props.contentViewEntity as ModelReferenceNavigationProperty }, + dontSerialize: true, + }, neverMapped: { name: "neverMapped", displayName: "Never Mapped", @@ -3369,6 +3393,115 @@ export const ContentViewEntity = domain.types.ContentViewEntity = { dataSources: { }, } +export const ContentViewEntityTagLink = domain.types.ContentViewEntityTagLink = { + name: "ContentViewEntityTagLink" as const, + displayName: "Content View Entity Tag Link", + get displayProp() { return this.props.contentViewEntityTagLinkId }, + type: "model", + controllerRoute: "ContentViewEntityTagLink", + get keyProp() { return this.props.contentViewEntityTagLinkId }, + behaviorFlags: 7 as BehaviorFlags, + props: { + contentViewEntityTagLinkId: { + name: "contentViewEntityTagLinkId", + displayName: "Content View Entity Tag Link Id", + type: "number", + role: "primaryKey", + hidden: 3 as HiddenAreas, + }, + contentViewEntityId: { + name: "contentViewEntityId", + displayName: "Content View Entity Id", + type: "number", + role: "foreignKey", + get principalKey() { return (domain.types.ContentViewEntity as ModelType & { name: "ContentViewEntity" }).props.contentViewEntityId as PrimaryKeyProperty }, + get principalType() { return (domain.types.ContentViewEntity as ModelType & { name: "ContentViewEntity" }) }, + get navigationProp() { return (domain.types.ContentViewEntityTagLink as ModelType & { name: "ContentViewEntityTagLink" }).props.contentViewEntity as ModelReferenceNavigationProperty }, + hidden: 3 as HiddenAreas, + rules: { + required: val => val != null || "Content View Entity is required.", + } + }, + contentViewEntity: { + name: "contentViewEntity", + displayName: "Content View Entity", + type: "model", + get typeDef() { return (domain.types.ContentViewEntity as ModelType & { name: "ContentViewEntity" }) }, + role: "referenceNavigation", + get foreignKey() { return (domain.types.ContentViewEntityTagLink as ModelType & { name: "ContentViewEntityTagLink" }).props.contentViewEntityId as ForeignKeyProperty }, + get principalKey() { return (domain.types.ContentViewEntity as ModelType & { name: "ContentViewEntity" }).props.contentViewEntityId as PrimaryKeyProperty }, + get inverseNavigation() { return (domain.types.ContentViewEntity as ModelType & { name: "ContentViewEntity" }).props.tags as ModelCollectionNavigationProperty }, + dontSerialize: true, + }, + tagId: { + name: "tagId", + displayName: "Tag Id", + type: "number", + role: "foreignKey", + get principalKey() { return (domain.types.ContentViewTag as ModelType & { name: "ContentViewTag" }).props.id as PrimaryKeyProperty }, + get principalType() { return (domain.types.ContentViewTag as ModelType & { name: "ContentViewTag" }) }, + get navigationProp() { return (domain.types.ContentViewEntityTagLink as ModelType & { name: "ContentViewEntityTagLink" }).props.tag as ModelReferenceNavigationProperty }, + hidden: 3 as HiddenAreas, + rules: { + required: val => val != null || "Tag is required.", + } + }, + tag: { + name: "tag", + displayName: "Tag", + type: "model", + get typeDef() { return (domain.types.ContentViewTag as ModelType & { name: "ContentViewTag" }) }, + role: "referenceNavigation", + get foreignKey() { return (domain.types.ContentViewEntityTagLink as ModelType & { name: "ContentViewEntityTagLink" }).props.tagId as ForeignKeyProperty }, + get principalKey() { return (domain.types.ContentViewTag as ModelType & { name: "ContentViewTag" }).props.id as PrimaryKeyProperty }, + dontSerialize: true, + }, + id: { + name: "id", + displayName: "Id", + type: "number", + role: "value", + }, + name: { + name: "name", + displayName: "Name", + type: "string", + role: "value", + }, + }, + methods: { + }, + dataSources: { + }, +} +export const ContentViewTag = domain.types.ContentViewTag = { + name: "ContentViewTag" as const, + displayName: "Content View Tag", + get displayProp() { return this.props.name }, + type: "model", + controllerRoute: "ContentViewTag", + get keyProp() { return this.props.id }, + behaviorFlags: 7 as BehaviorFlags, + props: { + id: { + name: "id", + displayName: "Id", + type: "number", + role: "primaryKey", + hidden: 3 as HiddenAreas, + }, + name: { + name: "name", + displayName: "Name", + type: "string", + role: "value", + }, + }, + methods: { + }, + dataSources: { + }, +} export const Course = domain.types.Course = { name: "Course" as const, displayName: "Course", @@ -6398,6 +6531,8 @@ interface AppDomain extends Domain { ComplexModel: typeof ComplexModel ComplexModelDependent: typeof ComplexModelDependent ContentViewEntity: typeof ContentViewEntity + ContentViewEntityTagLink: typeof ContentViewEntityTagLink + ContentViewTag: typeof ContentViewTag Course: typeof Course DateOnlyPk: typeof DateOnlyPk DateTimeOffsetPk: typeof DateTimeOffsetPk diff --git a/src/test-targets/models.g.ts b/src/test-targets/models.g.ts index 644299775..b7df87895 100644 --- a/src/test-targets/models.g.ts +++ b/src/test-targets/models.g.ts @@ -535,10 +535,12 @@ export interface ContentViewEntity extends Model { + contentViewEntityTagLinkId: number | null + contentViewEntityId: number | null + contentViewEntity: ContentViewEntity | null + tagId: number | null + tag: ContentViewTag | null + id: number | null + name: string | null +} +export class ContentViewEntityTagLink { + + /** Mutates the input object and its descendants into a valid ContentViewEntityTagLink implementation. */ + static convert(data?: Partial): ContentViewEntityTagLink { + return convertToModel(data || {}, metadata.ContentViewEntityTagLink) + } + + /** Maps the input object and its descendants to a new, valid ContentViewEntityTagLink implementation. */ + static map(data?: Partial): ContentViewEntityTagLink { + return mapToModel(data || {}, metadata.ContentViewEntityTagLink) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.ContentViewEntityTagLink; } + + /** Instantiate a new ContentViewEntityTagLink, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, ContentViewEntityTagLink.map(data || {})); + } +} + + +export interface ContentViewTag extends Model { + id: number | null + name: string | null +} +export class ContentViewTag { + + /** Mutates the input object and its descendants into a valid ContentViewTag implementation. */ + static convert(data?: Partial): ContentViewTag { + return convertToModel(data || {}, metadata.ContentViewTag) + } + + /** Maps the input object and its descendants to a new, valid ContentViewTag implementation. */ + static map(data?: Partial): ContentViewTag { + return mapToModel(data || {}, metadata.ContentViewTag) + } + + static [Symbol.hasInstance](x: any) { return x?.$metadata === metadata.ContentViewTag; } + + /** Instantiate a new ContentViewTag, optionally basing it on the given data. */ + constructor(data?: Partial | {[k: string]: any}) { + Object.assign(this, ContentViewTag.map(data || {})); + } +} + + export interface Course extends Model { courseId: number | null name: string | null @@ -2227,6 +2284,8 @@ declare module "coalesce-vue/lib/model" { ComplexModel: ComplexModel ComplexModelDependent: ComplexModelDependent ContentViewEntity: ContentViewEntity + ContentViewEntityTagLink: ContentViewEntityTagLink + ContentViewTag: ContentViewTag Course: Course DateOnlyPk: DateOnlyPk DateTimeOffsetPk: DateTimeOffsetPk diff --git a/src/test-targets/viewmodels.g.ts b/src/test-targets/viewmodels.g.ts index 05ce917ed..ec9c1d3ac 100644 --- a/src/test-targets/viewmodels.g.ts +++ b/src/test-targets/viewmodels.g.ts @@ -997,15 +997,23 @@ export interface ContentViewEntityViewModel extends $models.ContentViewEntity { contentViewEntityId: number | null; name: string | null; description: string | null; + createdAt: Date | null; assignedToId: number | null; assignedTo: $models.PersonSummary | null; reportedById: number | null; get reportedBy(): PersonViewModel | null; set reportedBy(value: PersonViewModel | $models.Person | null); + get tags(): ViewModelCollection; + set tags(value: (ContentViewEntityTagLinkViewModel | $models.ContentViewEntityTagLink)[] | null); neverMapped: string | null; } export class ContentViewEntityViewModel extends ViewModel<$models.ContentViewEntity, $apiClients.ContentViewEntityApiClient, number> implements $models.ContentViewEntity { + + public addToTags(initialData?: DeepPartial<$models.ContentViewEntityTagLink> | null) { + return this.$addChild('tags', initialData) as ContentViewEntityTagLinkViewModel + } + constructor(initialData?: DeepPartial<$models.ContentViewEntity> | null) { super($metadata.ContentViewEntity, new $apiClients.ContentViewEntityApiClient(), initialData) } @@ -1020,6 +1028,51 @@ export class ContentViewEntityListViewModel extends ListViewModel<$models.Conten } +export interface ContentViewEntityTagLinkViewModel extends $models.ContentViewEntityTagLink { + contentViewEntityTagLinkId: number | null; + contentViewEntityId: number | null; + get contentViewEntity(): ContentViewEntityViewModel | null; + set contentViewEntity(value: ContentViewEntityViewModel | $models.ContentViewEntity | null); + tagId: number | null; + get tag(): ContentViewTagViewModel | null; + set tag(value: ContentViewTagViewModel | $models.ContentViewTag | null); +} +export class ContentViewEntityTagLinkViewModel extends ViewModel<$models.ContentViewEntityTagLink, $apiClients.ContentViewEntityTagLinkApiClient, number> implements $models.ContentViewEntityTagLink { + + constructor(initialData?: DeepPartial<$models.ContentViewEntityTagLink> | null) { + super($metadata.ContentViewEntityTagLink, new $apiClients.ContentViewEntityTagLinkApiClient(), initialData) + } +} +defineProps(ContentViewEntityTagLinkViewModel, $metadata.ContentViewEntityTagLink) + +export class ContentViewEntityTagLinkListViewModel extends ListViewModel<$models.ContentViewEntityTagLink, $apiClients.ContentViewEntityTagLinkApiClient, ContentViewEntityTagLinkViewModel> { + + constructor() { + super($metadata.ContentViewEntityTagLink, new $apiClients.ContentViewEntityTagLinkApiClient()) + } +} + + +export interface ContentViewTagViewModel extends $models.ContentViewTag { + id: number | null; + name: string | null; +} +export class ContentViewTagViewModel extends ViewModel<$models.ContentViewTag, $apiClients.ContentViewTagApiClient, number> implements $models.ContentViewTag { + + constructor(initialData?: DeepPartial<$models.ContentViewTag> | null) { + super($metadata.ContentViewTag, new $apiClients.ContentViewTagApiClient(), initialData) + } +} +defineProps(ContentViewTagViewModel, $metadata.ContentViewTag) + +export class ContentViewTagListViewModel extends ListViewModel<$models.ContentViewTag, $apiClients.ContentViewTagApiClient, ContentViewTagViewModel> { + + constructor() { + super($metadata.ContentViewTag, new $apiClients.ContentViewTagApiClient()) + } +} + + export interface CourseViewModel extends $models.Course { courseId: number | null; name: string | null; @@ -1986,6 +2039,8 @@ const viewModelTypeLookup = ViewModel.typeLookup = { ComplexModel: ComplexModelViewModel, ComplexModelDependent: ComplexModelDependentViewModel, ContentViewEntity: ContentViewEntityViewModel, + ContentViewEntityTagLink: ContentViewEntityTagLinkViewModel, + ContentViewTag: ContentViewTagViewModel, Course: CourseViewModel, DateOnlyPk: DateOnlyPkViewModel, DateTimeOffsetPk: DateTimeOffsetPkViewModel, @@ -2033,6 +2088,8 @@ const listViewModelTypeLookup = ListViewModel.typeLookup = { ComplexModel: ComplexModelListViewModel, ComplexModelDependent: ComplexModelDependentListViewModel, ContentViewEntity: ContentViewEntityListViewModel, + ContentViewEntityTagLink: ContentViewEntityTagLinkListViewModel, + ContentViewTag: ContentViewTagListViewModel, Course: CourseListViewModel, DateOnlyPk: DateOnlyPkListViewModel, DateTimeOffsetPk: DateTimeOffsetPkListViewModel, From 9bff62708b32cf66879a1fbd797d166d6812658a Mon Sep 17 00:00:00 2001 From: Darren Kattan Date: Wed, 13 May 2026 08:33:16 -0500 Subject: [PATCH 14/14] feat(dto): support custom response names - add ResponseDtoClassName model knob for generated responses - apply custom response names to content-view response variants - ignore missing generated API surface diagnostics during snapshot bootstrap - cover named content-view responses in codegen tests --- .../Generators/ClassDto.cs | 18 ++++++++-- .../DtoContentViewGenerationTests.cs | 19 +++++----- .../Analysis/Roslyn/RoslynTypeLocator.cs | 35 ++++++++++++++++++- .../TestDbContext/ContentViewEntity.cs | 2 ++ .../DataAnnotations/CoalesceAttribute.cs | 7 ++++ .../TypeDefinition/ClassViewModel.cs | 18 ++++++++-- 6 files changed, 85 insertions(+), 14 deletions(-) diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs index 5add3c570..1068699c2 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs @@ -336,7 +336,9 @@ private void WriteResponseDto(CSharpCodeBuilder b, string contentView) var orderedProps = Model .ClientProperties .Where(p => p.SecurityInfo.Read.IsAllowed()) - .Where(p => fixedContentView is null || p.IsMappedForContentView(fixedContentView)) + .Where(p => fixedContentView is null + ? ShouldEmitInBaseResponse(p) + : p.IsMappedForContentView(fixedContentView)) // PK always first so it is available to guide decisions in IPropertyRestrictions .OrderBy(p => !p.IsPrimaryKey) // Scalars before objects @@ -347,7 +349,9 @@ private void WriteResponseDto(CSharpCodeBuilder b, string contentView) var ownProps = orderedProps.Where(p => baseType?.PropertyByName(p.Name) is null); var flattenedProps = Model.FlattenedResponseProperties - .Where(p => (fixedContentView is null || p.IsMappedForContentView(fixedContentView)) + .Where(p => (fixedContentView is null + ? ShouldEmitInBaseResponse(p) + : p.IsMappedForContentView(fixedContentView)) && baseType?.PropertyByName(p.Name) is null && !(baseType?.FlattenedResponseProperties.Any(fp => fp.Name == p.Name) ?? false)) .ToList(); @@ -710,6 +714,16 @@ fixedContentView is null GetContentViewConditionals(property.Parent, property.ContentViews, property.ExcludedContentViews), $"this.{ResponsePropertyName(property)} = {TransformResponseValue(property.Type, property.AccessExpression("obj"))};"); + private bool ShouldEmitInBaseResponse(PropertyViewModel property) + => !Model.UseContentViewResponseTypes + || Model.GeneratedResponseContentViews.Count == 0 + || Model.GeneratedResponseContentViews.Any(property.IsMappedForContentView); + + private bool ShouldEmitInBaseResponse(FlattenedResponsePropertyViewModel property) + => !Model.UseContentViewResponseTypes + || Model.GeneratedResponseContentViews.Count == 0 + || Model.GeneratedResponseContentViews.Any(property.IsMappedForContentView); + private static IEnumerable GetContentViewConditionals( ClassViewModel declaringClass, IEnumerable includes, diff --git a/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoContentViewGenerationTests.cs b/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoContentViewGenerationTests.cs index 1b2da77b6..29ba0d225 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoContentViewGenerationTests.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoContentViewGenerationTests.cs @@ -28,17 +28,18 @@ public async Task ApiDtos_GenerateActionDefaultsAndViewAwareMappings() var personDtoContents = await File.ReadAllTextAsync(personDtoFile); var tagLinkDtoContents = await File.ReadAllTextAsync(tagLinkDtoFile); - await Assert.That(controllerContents.Contains("Task> Get(")).IsTrue(); - await Assert.That(controllerContents.Contains("GetImplementation(id, ApplyFixedIncludes(parameters, \"detail\"), dataSource)")).IsTrue(); - await Assert.That(controllerContents.Contains("Task> List(")).IsTrue(); - await Assert.That(controllerContents.Contains("ListImplementation(ApplyFixedIncludes(parameters, \"list\"), dataSource)")).IsTrue(); - await Assert.That(controllerContents.Contains("Task> Save(")).IsTrue(); - await Assert.That(controllerContents.Contains("SaveImplementation(dto, ApplyFixedIncludes(parameters, \"save\"), dataSource, behaviors)")).IsTrue(); + await Assert.That(controllerContents.Contains("Task> Get(")).IsTrue(); + await Assert.That(controllerContents.Contains("GetImplementation(id, ApplyFixedIncludes(parameters, \"detail\"), dataSource)")).IsTrue(); + await Assert.That(controllerContents.Contains("Task> List(")).IsTrue(); + await Assert.That(controllerContents.Contains("ListImplementation(ApplyFixedIncludes(parameters, \"list\"), dataSource)")).IsTrue(); + await Assert.That(controllerContents.Contains("Task> Save(")).IsTrue(); + await Assert.That(controllerContents.Contains("SaveImplementation(dto, ApplyFixedIncludes(parameters, \"save\"), dataSource, behaviors)")).IsTrue(); await Assert.That(controllerContents.Contains("CountImplementation(ApplyFixedIncludes(parameters, \"list\"), dataSource)")).IsTrue(); - await Assert.That(dtoContents.Contains("public partial class ContentViewEntityListResponse")).IsTrue(); - await Assert.That(dtoContents.Contains("public partial class ContentViewEntityDetailResponse")).IsTrue(); - await Assert.That(dtoContents.Contains("public partial class ContentViewEntitySaveResponse")).IsTrue(); + await Assert.That(dtoContents.Contains("public partial class GetContentViewEntityResponse")).IsTrue(); + await Assert.That(dtoContents.Contains("public partial class GetContentViewEntityListResponse")).IsTrue(); + await Assert.That(dtoContents.Contains("public partial class GetContentViewEntityDetailResponse")).IsTrue(); + await Assert.That(dtoContents.Contains("public partial class GetContentViewEntitySaveResponse")).IsTrue(); await Assert.That(dtoContents.Contains("public System.DateTime? CreatedAtUTC { get; set; }")).IsTrue(); await Assert.That(dtoContents.Contains("this.CreatedAtUTC = obj.CreatedAt.ToUniversalTime();")).IsTrue(); await Assert.That(dtoContents.Contains("ContentViewEntityTagLinkDetailResponse> Tags { get; set; }")).IsTrue(); diff --git a/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/RoslynTypeLocator.cs b/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/RoslynTypeLocator.cs index 7477c47cd..4a965cfcf 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/RoslynTypeLocator.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/RoslynTypeLocator.cs @@ -9,6 +9,7 @@ using IntelliTect.Coalesce.CodeGeneration.Analysis.Base; using Microsoft.VisualStudio.Web.CodeGeneration.Utils; using Microsoft.CodeAnalysis.CSharp; +using System.Text.RegularExpressions; namespace IntelliTect.Coalesce.CodeGeneration.Analysis.Roslyn; @@ -79,10 +80,42 @@ public IEnumerable GetDiagnostics() ]; return diagnostics - .Where(d => d.Severity == DiagnosticSeverity.Error && !ignored.Contains(d.Descriptor.Id)) + .Where(d => d.Severity == DiagnosticSeverity.Error + && !ignored.Contains(d.Descriptor.Id) + && !IsLikelyMissingGeneratedApiSurfaceDiagnostic(d)) .Select(d => d.ToString()); } + private static bool IsLikelyMissingGeneratedApiSurfaceDiagnostic(Diagnostic diagnostic) + { + if (diagnostic.Id is not ("CS0234" or "CS0246")) + { + return false; + } + + var message = diagnostic.GetMessage(); + var matches = Regex.Matches(message, "'([^']+)'"); + if (matches.Count == 0) + { + return false; + } + + static bool IsLikelyGeneratedSurfaceName(string candidate) + => candidate.EndsWith("Response", StringComparison.Ordinal) + || candidate.EndsWith("Parameter", StringComparison.Ordinal) + || candidate.EndsWith("Controller", StringComparison.Ordinal); + + var missingName = matches + .Select(match => match.Groups[1].Value) + .FirstOrDefault(IsLikelyGeneratedSurfaceName); + if (string.IsNullOrWhiteSpace(missingName)) + { + return false; + } + + return true; + } + private List _allTypes; private List _allTypesWithReferences; diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs index 00cb6906b..6ea1f5fd5 100644 --- a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs @@ -1,3 +1,4 @@ +using IntelliTect.Coalesce; using IntelliTect.Coalesce.DataAnnotations; using System; using System.Collections.Generic; @@ -5,6 +6,7 @@ namespace IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext; +[Coalesce(ResponseDtoClassName = "GetContentViewEntityResponse")] [Create(PermissionLevel = SecurityPermissionLevels.AllowAll)] [Edit(PermissionLevel = SecurityPermissionLevels.AllowAll)] [DtoContentView("list", IncludeByDefault = false)] diff --git a/src/IntelliTect.Coalesce/DataAnnotations/CoalesceAttribute.cs b/src/IntelliTect.Coalesce/DataAnnotations/CoalesceAttribute.cs index 39aee044a..4f2775ea9 100644 --- a/src/IntelliTect.Coalesce/DataAnnotations/CoalesceAttribute.cs +++ b/src/IntelliTect.Coalesce/DataAnnotations/CoalesceAttribute.cs @@ -14,6 +14,13 @@ public sealed class CoalesceAttribute : Attribute /// public string? ClientTypeName { get; set; } + /// + /// When placed on a type, overrides the generated server-side response DTO class name. + /// For example, setting this to GetTenantResponse will cause Coalesce to generate + /// that response DTO class name instead of the default TenantResponse. + /// + public string? ResponseDtoClassName { get; set; } + /// /// When placed on a , controls whether /// inherited non-Microsoft properties diff --git a/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs index cdf66128e..5c20ffaef 100644 --- a/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs +++ b/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs @@ -87,7 +87,21 @@ public string ApiControllerClassName : "public"; public string ParameterDtoTypeName => IsCustomDto ? FullyQualifiedName : $"{ClientTypeName}Parameter"; - public string ResponseDtoTypeName => IsCustomDto ? FullyQualifiedName : $"{ClientTypeName}Response"; + + public string ResponseDtoTypeName => IsCustomDto + ? FullyQualifiedName + : this.GetAttributeValue(a => a.ResponseDtoClassName) ?? $"{ClientTypeName}Response"; + + private string ResponseDtoTypeNameStem + { + get + { + var responseTypeName = ResponseDtoTypeName; + return responseTypeName.EndsWith("Response", StringComparison.Ordinal) + ? responseTypeName[..^"Response".Length] + : responseTypeName; + } + } public ClassViewModel BaseViewModel => DtoBaseViewModel ?? this; @@ -301,7 +315,7 @@ public bool HasResponseDtoTypeForContentView(string? contentView) && GeneratedResponseContentViews.Contains(contentView, StringComparer.Ordinal); public string ResponseDtoTypeNameForContentView(string contentView) - => $"{ClientTypeName}{GetContentViewResponseTypeSuffix(contentView)}Response"; + => $"{ResponseDtoTypeNameStem}{GetContentViewResponseTypeSuffix(contentView)}Response"; public string GetStandardActionResponseDtoTypeName(string? contentView) => ShouldUseContentViewResponseType(contentView)