diff --git a/Directory.Packages.props b/Directory.Packages.props index 06a001267..9737ef8d3 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -45,8 +45,11 @@ + + + @@ -62,6 +65,7 @@ + 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. 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 new file mode 100644 index 000000000..203512754 --- /dev/null +++ b/src/IntelliTect.Coalesce.Analyzer.Tests/GeneratedContractShapeSourceGeneratorTests.cs @@ -0,0 +1,232 @@ +#nullable enable + +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(); + } + + [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, + 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.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/AnalyzerReleases.Shipped.md b/src/IntelliTect.Coalesce.Analyzer/AnalyzerReleases.Shipped.md index 8d294e6ac..1de0f8f5f 100644 --- a/src/IntelliTect.Coalesce.Analyzer/AnalyzerReleases.Shipped.md +++ b/src/IntelliTect.Coalesce.Analyzer/AnalyzerReleases.Shipped.md @@ -24,4 +24,8 @@ 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. +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 490da52b6..95e4c1dd6 100644 --- a/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj +++ b/src/IntelliTect.Coalesce.Analyzer/IntelliTect.Coalesce.Analyzer.csproj @@ -9,8 +9,9 @@ - - $(NoWarn);RS1038 + + $(NoWarn);RS1038;RS1035 false @@ -48,9 +49,16 @@ - - - + + + + + + + + + <_Parameter1>IntelliTect.Coalesce.Analyzer.Tests + @@ -58,4 +66,4 @@ Visible="false" /> - \ No newline at end of file + diff --git a/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedApiSurfaceSourceGenerator.cs b/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedApiSurfaceSourceGenerator.cs new file mode 100644 index 000000000..011b60257 --- /dev/null +++ b/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedApiSurfaceSourceGenerator.cs @@ -0,0 +1,193 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Immutable; +using System.Text; +using System.Text.Json; + +namespace IntelliTect.Coalesce.Analyzer.SourceGenerators; + +[Generator(LanguageNames.CSharp)] +public sealed class GeneratedApiSurfaceSourceGenerator : IIncrementalGenerator +{ + private const string SnapshotFileName = "coalesce-generated-csharp.json"; + + private static readonly DiagnosticDescriptor SnapshotFailedDiagnostic = 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) + { + 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 SnapshotParseResult LoadSnapshot(AdditionalText file, CancellationToken cancellationToken) + { + try + { + 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) when (ex is not OperationCanceledException) + { + return SnapshotParseResult.Failure(file.Path, $"Unable to parse Coalesce generated source snapshot '{file.Path}': {ex.Message}"); + } + } + + private static IReadOnlyDictionary ParseSnapshot(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + JsonElement filesElement; + + if (root.ValueKind == JsonValueKind.Object && root.EnumerateObject().All(static property => property.Value.ValueKind == JsonValueKind.String)) + { + filesElement = root; + } + else if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("files", out var nestedFiles)) + { + filesElement = nestedFiles; + } + else + { + throw new InvalidOperationException($"Expected '{SnapshotFileName}' to contain either a JSON object map or an object with a 'files' property."); + } + + if (filesElement.ValueKind != JsonValueKind.Object) + { + throw new InvalidOperationException($"Expected '{SnapshotFileName}' to contain a JSON object of generated files."); + } + + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var property in filesElement.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.String) + { + throw new InvalidOperationException($"Generated source '{property.Name}' in '{SnapshotFileName}' must be a JSON string."); + } + + files[NormalizePath(property.Name)] = property.Value.GetString() ?? string.Empty; + } + + return files; + } + + private static void Emit( + SourceProductionContext context, + Compilation compilation, + ImmutableArray snapshots) + { + if (snapshots.IsDefaultOrEmpty) + { + return; + } + + var generatedFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var snapshot in snapshots) + { + if (snapshot.ErrorMessage is { Length: > 0 }) + { + context.ReportDiagnostic(Diagnostic.Create(SnapshotFailedDiagnostic, location: null, snapshot.ErrorMessage)); + continue; + } + + foreach (var generatedFile in snapshot.Files) + { + generatedFiles[NormalizePath(generatedFile.Key)] = generatedFile.Value; + } + } + + if (generatedFiles.Count == 0) + { + return; + } + + var stillPresentPaths = GetStillPresentGeneratedFilePaths(compilation, generatedFiles.Keys); + if (stillPresentPaths.Count > 0) + { + 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; + } + + foreach (var generatedFile in generatedFiles.OrderBy(static entry => entry.Key, StringComparer.OrdinalIgnoreCase)) + { + context.AddSource(generatedFile.Key, SourceText.From(generatedFile.Value, Encoding.UTF8)); + } + } + + private static List GetStillPresentGeneratedFilePaths(Compilation compilation, IEnumerable generatedFilePaths) + { + 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) + { + return []; + } + + 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 sealed class SnapshotParseResult + { + public SnapshotParseResult(string path, IReadOnlyDictionary files, string? errorMessage) + { + Path = path; + Files = files; + ErrorMessage = errorMessage; + } + + public string Path { get; } + public IReadOnlyDictionary Files { get; } + public string? ErrorMessage { get; } + + public static SnapshotParseResult Success(string path, IReadOnlyDictionary files) + => new(path, files, null); + + public static SnapshotParseResult Failure(string path, string errorMessage) + => new(path, new Dictionary(StringComparer.OrdinalIgnoreCase), errorMessage); + } +} diff --git a/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedContractShapeSourceGenerator.cs b/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedContractShapeSourceGenerator.cs new file mode 100644 index 000000000..0c5d7557a --- /dev/null +++ b/src/IntelliTect.Coalesce.Analyzer/SourceGenerators/GeneratedContractShapeSourceGenerator.cs @@ -0,0 +1,973 @@ +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)), + GetTypeArray(attribute, nameof(GeneratedContractShapeAttributePlaceholder.IncludedPropertyAttributes)), + 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), + GetIncludedPropertyAttributes(property, shape))); + } + + 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 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 && + 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) + { + foreach (var attributeLine in BuildPropertyAttributeLines(property)) + { + builder.Append(" ").AppendLine(attributeLine); + } + + builder.Append(" ").AppendLine(BuildPropertyLine(model, property)); + } + + builder.AppendLine("}"); + 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); + 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 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); + 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 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) + { + 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, + IReadOnlyList includedPropertyAttributes, + bool settableProperties, + int nullabilityTransform) + { + ShapeName = shapeName; + OutputKind = outputKind; + TargetAssemblyName = targetAssemblyName; + TargetNamespace = targetNamespace; + TypeName = typeName; + Policy = policy; + Members = members; + ExcludedMembers = excludedMembers; + Implements = implements; + IncludedPropertyAttributes = includedPropertyAttributes; + 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 IReadOnlyList IncludedPropertyAttributes { 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, + IReadOnlyList includedPropertyAttributes) + { + Name = name; + Property = property; + ForceNullable = forceNullable; + ForceNonNullable = forceNonNullable; + DtoSource = dtoSource; + IncludedPropertyAttributes = includedPropertyAttributes; + } + + public string Name { get; } + public IPropertySymbol Property { get; } + public bool ForceNullable { get; } + public bool ForceNonNullable { get; } + public DtoSourceMetadata? DtoSource { get; } + public IReadOnlyList IncludedPropertyAttributes { 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 Type[] IncludedPropertyAttributes { 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.Api/Generators/ClassDto.cs b/src/IntelliTect.Coalesce.CodeGeneration.Api/Generators/ClassDto.cs index 290f6afa6..1068699c2 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,41 @@ 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 + ? ShouldEmitInBaseResponse(p) + : p.IsMappedForContentView(fixedContentView)) // PK always first so it is available to guide decisions in IPropertyRestrictions .OrderBy(p => !p.IsPrimaryKey) // Scalars before objects @@ -332,17 +349,20 @@ 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 + ? ShouldEmitInBaseResponse(p) + : 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 +370,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 +381,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 +410,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 +423,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 +478,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 +497,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 +584,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 +614,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 +649,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 +669,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 +686,43 @@ 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 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, @@ -708,26 +754,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 +869,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..29ba0d225 100644 --- a/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoContentViewGenerationTests.cs +++ b/src/IntelliTect.Coalesce.CodeGeneration.Tests/DtoContentViewGenerationTests.cs @@ -21,16 +21,32 @@ 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 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(); + 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.CodeGeneration.Tests/GeneratedContractCompilationAugmentorTests.cs b/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractCompilationAugmentorTests.cs new file mode 100644 index 000000000..37b5bbd2f --- /dev/null +++ b/src/IntelliTect.Coalesce.CodeGeneration.Tests/GeneratedContractCompilationAugmentorTests.cs @@ -0,0 +1,113 @@ +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; + +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; + } + + [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.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.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.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/GeneratedContractCompilationAugmentor.cs b/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/GeneratedContractCompilationAugmentor.cs new file mode 100644 index 000000000..9d088b2a6 --- /dev/null +++ b/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/GeneratedContractCompilationAugmentor.cs @@ -0,0 +1,196 @@ +#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 GetCandidateTypes(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 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); + 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)) + { + yield return symbol; + } + } + } + } + + 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; + } + } +} 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/Analysis/Roslyn/RoslynTypeLocator.cs b/src/IntelliTect.Coalesce.CodeGeneration/Analysis/Roslyn/RoslynTypeLocator.cs index 0083d0cde..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; @@ -58,6 +59,11 @@ private Compilation GetProjectCompilation() .WithMetadataReferences(_projectContext.GetMetadataReferences()) .GetCompilationAsync().Result; + _compilation = GeneratedContractCompilationAugmentor.AugmentWithSameProjectGeneratedContracts( + _compilation, + _projectContext.ProjectPath, + parseOptions); + return _compilation; } @@ -74,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.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.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.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); 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."); } } diff --git a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs index 3570519f0..6ea1f5fd5 100644 --- a/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs +++ b/src/IntelliTect.Coalesce.Testing/TargetClasses/TestDbContext/ContentViewEntity.cs @@ -1,14 +1,19 @@ +using IntelliTect.Coalesce; using IntelliTect.Coalesce.DataAnnotations; +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; namespace IntelliTect.Coalesce.Testing.TargetClasses.TestDbContext; +[Coalesce(ResponseDtoClassName = "GetContentViewEntityResponse")] [Create(PermissionLevel = SecurityPermissionLevels.AllowAll)] [Edit(PermissionLevel = SecurityPermissionLevels.AllowAll)] [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 +26,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 +42,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/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.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/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/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/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; } } diff --git a/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj index dda700d62..982600f92 100644 --- a/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj +++ b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.csproj @@ -6,6 +6,7 @@ AnyCPU enable + $(NoWarn);NU5100 @@ -14,7 +15,10 @@ + Include="..\IntelliTect.Coalesce.Analyzer\IntelliTect.Coalesce.Analyzer.csproj" + PrivateAssets="all" + ReferenceOutputAssembly="false" + OutputItemType="Analyzer" /> @@ -31,9 +35,33 @@ + + + + + - \ 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..982600f92 --- /dev/null +++ b/src/IntelliTect.Coalesce/IntelliTect.Coalesce.dotnet-link.csproj @@ -0,0 +1,67 @@ + + + + README.md + Core library for IntelliTect Coalesce. Learn more at https://coalesce.intellitect.com/ + AnyCPU + + enable + $(NoWarn);NU5100 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs b/src/IntelliTect.Coalesce/TypeDefinition/ClassViewModel.cs index ee695ac70..5c20ffaef 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; @@ -86,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; @@ -264,12 +279,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) + => $"{ResponseDtoTypeNameStem}{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/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 @@ + + + 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,