Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/topics/dto-shapes-and-read-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,9 @@ Those summary shapes are the foundation for later flattened-property and auto-pr
Coalesce can now generate flattened DTO properties from selected navigation paths so callers can bind to the data they actually need without manually creating one-off view types for every combination.

This keeps generated read models compact while still making important related values available as first-class DTO members.

## Type discovery can merge results across referenced projects

Generators no longer have to assume that every relevant type lives in the same project as the active startup target.

When your solution is split across multiple projects, Coalesce can merge discovered types across those project boundaries so generated DTOs and read shapes still resolve the right sources.
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using IntelliTect.Coalesce.CodeGeneration.Generation;
using IntelliTect.Coalesce;
using IntelliTect.Coalesce.TypeDefinition;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using System;
using System.Collections.Generic;
using System.Linq;

namespace IntelliTect.Coalesce.CodeGeneration.Tests;

public class ProjectTypeDiscoveryTests
{
[Test]
public async Task MergeTypes_IncludesTypesFromEachProject()
{
var dataType = GetNamedType("namespace DataProject; [IntelliTect.Coalesce.Coalesce] public class DataRoot { }", "DataProject.DataRoot");
var webType = GetNamedType("namespace WebProject; [IntelliTect.Coalesce.Coalesce] public class WebRoot { }", "WebProject.WebRoot");

var merged = ProjectTypeDiscovery.MergeTypes([
("data.csproj", [dataType]),
("web.csproj", [webType]),
]);

await Assert.That(merged.Select(type => type.ToDisplayString(SymbolTypeViewModel.DefaultDisplayFormat)))
.IsEquivalentTo(["DataProject.DataRoot", "WebProject.WebRoot"]);
}

[Test]
public async Task MergeTypes_PrefersEarlierProjectWhenTypeNameCollides()
{
var dataType = GetNamedType("namespace Shared; [IntelliTect.Coalesce.Coalesce] public class TenantRead { }", "Shared.TenantRead");
var webType = GetNamedType("namespace Shared; [IntelliTect.Coalesce.Coalesce] public class TenantRead { }", "Shared.TenantRead");

var merged = ProjectTypeDiscovery.MergeTypes([
("data.csproj", [dataType]),
("web.csproj", [webType]),
]);

await Assert.That(merged).HasSingleItem();
await Assert.That(SymbolEqualityComparer.Default.Equals(merged.Single(), dataType)).IsTrue();
}

[Test]
public async Task ResolveTypes_UsesMasterProjectSymbolUniverse()
{
var dataCompilation = CreateCompilation("DataProject", "namespace Shared; [IntelliTect.Coalesce.Coalesce] public class TenantRead { public SharedEntity Entity { get; set; } } public class SharedEntity { }");
var dataReference = dataCompilation.ToMetadataReference();
var webCompilation = CreateCompilation(
"WebProject",
"namespace WebProject; [IntelliTect.Coalesce.Coalesce] public class WebRoot { }",
dataReference);

var dataType = dataCompilation.GetTypeByMetadataName("Shared.TenantRead")
?? throw new InvalidOperationException("Could not resolve Shared.TenantRead from data compilation.");
var webType = webCompilation.GetTypeByMetadataName("WebProject.WebRoot")
?? throw new InvalidOperationException("Could not resolve WebProject.WebRoot from web compilation.");

var resolved = ProjectTypeDiscovery.ResolveTypes([
new ProjectTypeDiscovery.DiscoveryProject(
"data.csproj",
[dataType],
_ => null,
[]),
new ProjectTypeDiscovery.DiscoveryProject(
"web.csproj",
[webType],
typeName => webCompilation.GetTypeByMetadataName(typeName),
[]),
]);

await Assert.That(resolved.Select(type => type.ToDisplayString(SymbolTypeViewModel.DefaultDisplayFormat)))
.IsEquivalentTo(["Shared.TenantRead", "WebProject.WebRoot"]);
await Assert.That(SymbolEqualityComparer.Default.Equals(
resolved.Single(type => type.ToDisplayString(SymbolTypeViewModel.DefaultDisplayFormat) == "Shared.TenantRead"),
webCompilation.GetTypeByMetadataName("Shared.TenantRead")!)).IsTrue();
}

private static INamedTypeSymbol GetNamedType(string source, string typeName)
=> CreateCompilation(Guid.NewGuid().ToString("N"), source).GetTypeByMetadataName(typeName)
?? throw new InvalidOperationException($"Could not resolve {typeName}.");

private static CSharpCompilation CreateCompilation(
string assemblyName,
string source,
params MetadataReference[] additionalReferences)
{
return CSharpCompilation.Create(
assemblyName: assemblyName,
syntaxTrees: [CSharpSyntaxTree.ParseText(source)],
references:
[
MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
MetadataReference.CreateFromFile(typeof(CoalesceAttribute).Assembly.Location),
..additionalReferences,
]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public IEnumerable<string> GetDiagnostics()
}

private List<INamedTypeSymbol> _allTypes;
private List<INamedTypeSymbol> _allTypesWithReferences;

public List<INamedTypeSymbol> GetAllTypes()
{
Expand All @@ -91,6 +92,25 @@ public List<INamedTypeSymbol> GetAllTypes()
return _allTypes = visitor.Discovered;
}

public List<INamedTypeSymbol> GetAllTypes(bool includeReferencedAssemblies)
{
if (!includeReferencedAssemblies)
{
return GetAllTypes();
}

if (_allTypesWithReferences != null) return _allTypesWithReferences;

var compilation = GetProjectCompilation();

var visitor = new SymbolDiscoveryVisitor();
compilation.GlobalNamespace.Accept(visitor);
return _allTypesWithReferences = visitor.Discovered;
}

public INamedTypeSymbol FindTypeByMetadataName(string typeName)
=> GetProjectCompilation().GetTypeByMetadataName(typeName);

private class SymbolDiscoveryVisitor : SymbolVisitor
{
public List<INamedTypeSymbol> Discovered { get; } = new List<INamedTypeSymbol>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,14 @@ public async Task GenerateAsync(Type rootGenerator)
new ProjectAssemblyTypeResolver(dataProject.MsBuildProjectContext).Resolve);
}

var locator = genContext.DataProject.TypeLocator as RoslynTypeLocator;
var npmPackageVersionTask = ServiceProvider.GetRequiredService<NpmDependencyAnalayzer>().GetNpmPackageVersion("coalesce-vue");

Logger.LogInformation("Gathering Types");
var types = locator.GetAllTypes();
var types = ProjectTypeDiscovery.GetAllTypes(genContext);

Logger.LogInformation("Checking Diagnostics");
bool die = false;
foreach (var diag in locator.GetDiagnostics())
foreach (var diag in ProjectTypeDiscovery.GetDiagnostics(genContext))
{
Logger.LogError(diag);
die = true;
Expand All @@ -127,7 +126,12 @@ public async Task GenerateAsync(Type rootGenerator)
}

rr.DiscoverCoalescedTypes(
types.Select(t => new SymbolTypeViewModel(rr, t))
types
.Where(t => t.GetAttributes().Any(a =>
a.AttributeClass?.ToDisplayString() is
"IntelliTect.Coalesce.CoalesceAttribute" or
"IntelliTect.Coalesce.SimpleModelAttribute"))
.Select(t => new SymbolTypeViewModel(rr, t))
);


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using IntelliTect.Coalesce.CodeGeneration.Analysis.Roslyn;
using IntelliTect.Coalesce.TypeDefinition;
using Microsoft.CodeAnalysis;
using System;
using System.Collections.Generic;
using System.Linq;

namespace IntelliTect.Coalesce.CodeGeneration.Generation;

public static class ProjectTypeDiscovery
{
public sealed record DiscoveryProject(
string ProjectFilePath,
IReadOnlyList<INamedTypeSymbol> DeclaredTypes,
Func<string, INamedTypeSymbol> ResolveTypeByMetadataName,
IReadOnlyList<string> Diagnostics);

public static IReadOnlyList<string> GetDiagnostics(GenerationContext generationContext)
=> GetDiscoveryProjects(generationContext)
.SelectMany(project => project.Diagnostics)
.Distinct()
.ToList();

public static IReadOnlyList<INamedTypeSymbol> GetAllTypes(GenerationContext generationContext)
=> ResolveTypes(GetDiscoveryProjects(generationContext));

internal static IReadOnlyList<INamedTypeSymbol> MergeTypes(
IEnumerable<(string ProjectFilePath, IEnumerable<INamedTypeSymbol> Types)> projectTypes)
{
var mergedTypes = new Dictionary<string, INamedTypeSymbol>(StringComparer.Ordinal);

foreach (var (projectFilePath, types) in projectTypes)
{
if (string.IsNullOrWhiteSpace(projectFilePath))
{
continue;
}

foreach (var type in types)
{
var key = type.ToDisplayString(SymbolTypeViewModel.DefaultDisplayFormat);
mergedTypes.TryAdd(key, type);
}
}

return mergedTypes.Values.ToList();
}

internal static IReadOnlyList<INamedTypeSymbol> ResolveTypes(
IReadOnlyList<DiscoveryProject> projectTypes)
{
if (projectTypes.Count == 0)
{
return [];
}

var mergedTypes = MergeTypes(projectTypes
.Select(project => (project.ProjectFilePath, (IEnumerable<INamedTypeSymbol>)project.DeclaredTypes)));

if (projectTypes.Count == 1)
{
return mergedTypes;
}

var masterProject = projectTypes[^1];

return mergedTypes
.Select(type =>
{
var key = type.ToDisplayString(SymbolTypeViewModel.DefaultDisplayFormat);
return masterProject.ResolveTypeByMetadataName(key) ?? type;
})
.ToList();
}

private static IReadOnlyList<DiscoveryProject> GetDiscoveryProjects(
GenerationContext generationContext)
{
return new[] { generationContext.DataProject, generationContext.WebProject }
.OfType<RoslynProjectContext>()
.GroupBy(project => project.ProjectFilePath, StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var locator = (RoslynTypeLocator)group.First().TypeLocator;
return new DiscoveryProject(
group.Key,
locator.GetAllTypes(),
locator.FindTypeByMetadataName,
locator.GetDiagnostics().ToList());
})
.ToList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("IntelliTect.Coalesce.CodeGeneration.Tests")]