Skip to content
Merged
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
53 changes: 45 additions & 8 deletions src/PatternKit.Generators/Repository/RepositoryGenerator.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
Expand Down Expand Up @@ -105,21 +106,57 @@ private static string GenerateSource(
sb.AppendLine();
}

var containingTypes = GetContainingTypes(type);
var indentLevel = 0;
foreach (var containingType in containingTypes)
{
AppendTypeDeclaration(sb, containingType, indentLevel);
sb.AppendLine();
sb.AppendLine(new string(' ', indentLevel * 4) + "{");
indentLevel++;
}

AppendTypeDeclaration(sb, type, indentLevel);
sb.AppendLine();
var indent = new string(' ', indentLevel * 4);
sb.AppendLine(indent + "{");
var memberIndent = indent + " ";
var bodyIndent = memberIndent + " ";
sb.Append(memberIndent).Append("public static global::PatternKit.Application.Repository.InMemoryRepository<")
.Append(entityName).Append(", ").Append(keyName).Append("> ").Append(factoryName).AppendLine("()");
sb.Append(bodyIndent).Append("=> global::PatternKit.Application.Repository.InMemoryRepository<")
.Append(entityName).Append(", ").Append(keyName).Append(">.Create(").Append(selectorName).AppendLine(").Build();");
sb.AppendLine(indent + "}");
for (var i = containingTypes.Length - 1; i >= 0; i--)
{
sb.AppendLine(new string(' ', i * 4) + "}");
}

return sb.ToString();
}

private static INamedTypeSymbol[] GetContainingTypes(INamedTypeSymbol type)
{
var containingTypes = new Stack<INamedTypeSymbol>();
for (var current = type.ContainingType; current is not null; current = current.ContainingType)
{
containingTypes.Push(current);
}

return containingTypes.ToArray();
}

private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, int indentLevel)
{
sb.Append(new string(' ', indentLevel * 4));
sb.Append(GetAccessibility(type.DeclaredAccessibility)).Append(' ');
if (type.IsStatic)
sb.Append("static ");
else if (type.IsAbstract && type.TypeKind == TypeKind.Class)
sb.Append("abstract ");
else if (type.IsSealed && type.TypeKind == TypeKind.Class)
sb.Append("sealed ");
sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine();
sb.AppendLine("{");
sb.Append(" public static global::PatternKit.Application.Repository.InMemoryRepository<")
.Append(entityName).Append(", ").Append(keyName).Append("> ").Append(factoryName).AppendLine("()");
sb.Append(" => global::PatternKit.Application.Repository.InMemoryRepository<")
.Append(entityName).Append(", ").Append(keyName).Append(">.Create(").Append(selectorName).AppendLine(").Build();");
sb.AppendLine("}");
return sb.ToString();
sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name);
}

private static bool IsKeySelector(IMethodSymbol method, INamedTypeSymbol entityType, INamedTypeSymbol keyType)
Expand Down
232 changes: 160 additions & 72 deletions test/PatternKit.Generators.Tests/RepositoryGeneratorTests.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using PatternKit.Application.Repository;
using PatternKit.Generators.Repository;
using TinyBDD;
using TinyBDD.Xunit;
using Xunit.Abstractions;

namespace PatternKit.Generators.Tests;

public sealed class RepositoryGeneratorTests
[Feature("Repository generator")]
public sealed partial class RepositoryGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output)
{
[Scenario("Generates repository factory")]
[Scenario("Generator emits repository factory")]
[Fact]
public void GeneratesRepositoryFactory()
{
var source = """
public Task Generator_Emits_Repository_Factory()
=> Given("a valid repository declaration", () => Compile("""
using PatternKit.Generators.Repository;

namespace MyApp;
namespace Demo;

public sealed record Order(string Id);

Expand All @@ -25,90 +26,177 @@ public static partial class OrderRepositoryFactory
[RepositoryKeySelector]
private static string SelectKey(Order order) => order.Id;
}
"""))
.Then("generated source creates the repository", result =>
{
ScenarioExpect.Empty(result.Diagnostics);
var source = ScenarioExpect.Single(result.GeneratedSources);
ScenarioExpect.Contains("public static partial class OrderRepositoryFactory", source);
ScenarioExpect.Contains("Build()", source);
ScenarioExpect.Contains("InMemoryRepository<global::Demo.Order, string>.Create(SelectKey).Build()", source);
ScenarioExpect.True(result.EmitSuccess, result.EmitDiagnostics);
})
.AssertPassed();

[Scenario("Generator reports invalid repository declarations")]
[Theory]
[InlineData("public static class OrderRepositoryFactory { [RepositoryKeySelector] private static string SelectKey(Order order) => order.Id; }", "PKREP001")]
[InlineData("public static partial class OrderRepositoryFactory;", "PKREP002")]
[InlineData("public static partial class OrderRepositoryFactory { [RepositoryKeySelector] private static string One(Order order) => order.Id; [RepositoryKeySelector] private static string Two(Order order) => order.Id; }", "PKREP002")]
[InlineData("public partial class OrderRepositoryFactory { [RepositoryKeySelector] private string SelectKey(Order order) => order.Id; }", "PKREP003")]
[InlineData("public static partial class OrderRepositoryFactory { [RepositoryKeySelector] private static T SelectKey<T>(Order order) => default!; }", "PKREP003")]
[InlineData("public static partial class OrderRepositoryFactory { [RepositoryKeySelector] private static string SelectKey() => string.Empty; }", "PKREP003")]
[InlineData("public static partial class OrderRepositoryFactory { [RepositoryKeySelector] private static string SelectKey(Order order, string tenant) => order.Id; }", "PKREP003")]
[InlineData("public static partial class OrderRepositoryFactory { [RepositoryKeySelector] private static string SelectKey(string order) => order; }", "PKREP003")]
[InlineData("public static partial class OrderRepositoryFactory { [RepositoryKeySelector] private static int SelectKey(Order order) => 1; }", "PKREP003")]
public Task Generator_Reports_Invalid_Repository_Declarations(string declaration, string diagnosticId)
=> Given("an invalid repository declaration", () => Compile($$"""
using PatternKit.Generators.Repository;
public sealed record Order(string Id);
[GenerateRepository(typeof(Order), typeof(string))]
{{declaration}}
"""))
.Then("the expected diagnostic is reported", result =>
ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == diagnosticId))
.AssertPassed();

public static class Demo
{
public static object Run() => OrderRepositoryFactory.Build();
}
""";

var comp = CreateCompilation(source, nameof(GeneratesRepositoryFactory));
var gen = new RepositoryGenerator();
_ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated);

ScenarioExpect.All(run.Results, result => ScenarioExpect.Empty(result.Diagnostics));
var generated = ScenarioExpect.Single(run.Results.SelectMany(result => result.GeneratedSources));
ScenarioExpect.Equal("OrderRepositoryFactory.Repository.g.cs", generated.HintName);
var text = generated.SourceText.ToString();
ScenarioExpect.Contains("Build", text);
ScenarioExpect.Contains("InMemoryRepository<global::MyApp.Order, string>.Create(SelectKey).Build()", text);
ScenarioExpect.True(updated.Emit(Stream.Null).Success);
}

[Scenario("Reports diagnostic for non partial repository")]
[Scenario("Generator emits repository defaults and type shapes")]
[Fact]
public void ReportsDiagnosticForNonPartialRepository()
{
var source = """
public Task Generator_Emits_Repository_Defaults_And_Type_Shapes()
=> Given("repository declarations using default names and different host shapes", () => Compile("""
using PatternKit.Generators.Repository;

namespace Demo;

public sealed record Order(string Id);
[GenerateRepository(typeof(Order), typeof(string))]
public static class OrderRepositoryFactory;
""";

var comp = CreateCompilation(source, nameof(ReportsDiagnosticForNonPartialRepository));
var gen = new RepositoryGenerator();
_ = RoslynTestHelpers.Run(comp, gen, out var run, out _);
[GenerateRepository(typeof(Order), typeof(string))]
internal abstract partial class AbstractRepositoryFactory
{
[RepositoryKeySelector]
private static string SelectKey(Order order) => order.Id;
}

var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics));
ScenarioExpect.Equal("PKREP001", diagnostic.Id);
}
[GenerateRepository(typeof(Order), typeof(string))]
public sealed partial class SealedRepositoryFactory
{
[RepositoryKeySelector]
private static string SelectKey(Order order) => order.Id;
}

[Scenario("Reports diagnostic for missing repository key selector")]
[GenerateRepository(typeof(Order), typeof(string))]
internal partial struct StructRepositoryFactory
{
[RepositoryKeySelector]
private static string SelectKey(Order order) => order.Id;
}
"""))
.Then("generated sources preserve host shape and default factory names", result =>
{
ScenarioExpect.Empty(result.Diagnostics);
ScenarioExpect.Equal(3, result.GeneratedSources.Count);

var combined = string.Join("\n", result.GeneratedSources);
ScenarioExpect.Contains("internal abstract partial class AbstractRepositoryFactory", combined);
ScenarioExpect.Contains("InMemoryRepository<global::Demo.Order, string> Create()", combined);
ScenarioExpect.Contains("public sealed partial class SealedRepositoryFactory", combined);
ScenarioExpect.Contains("internal partial struct StructRepositoryFactory", combined);
ScenarioExpect.True(result.EmitSuccess, result.EmitDiagnostics);
})
.AssertPassed();

[Scenario("Generator emits nested repository host wrappers")]
[Fact]
public void ReportsDiagnosticForMissingRepositoryKeySelector()
{
var source = """
public Task Generator_Emits_Nested_Repository_Host_Wrappers()
=> Given("nested repository declarations with non-public accessibility", () => Compile("""
using PatternKit.Generators.Repository;
public sealed record Order(string Id);
[GenerateRepository(typeof(Order), typeof(string))]
public static partial class OrderRepositoryFactory;
""";

var comp = CreateCompilation(source, nameof(ReportsDiagnosticForMissingRepositoryKeySelector));
var gen = new RepositoryGenerator();
_ = RoslynTestHelpers.Run(comp, gen, out var run, out _);
namespace Demo;

var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics));
ScenarioExpect.Equal("PKREP002", diagnostic.Id);
}
public sealed record Order(string Id);

[Scenario("Reports diagnostic for invalid repository key selector")]
[Fact]
public void ReportsDiagnosticForInvalidRepositoryKeySelector()
{
var source = """
public partial class RepositoryContainer
{
private partial class PrivateHost
{
[GenerateRepository(typeof(Order), typeof(string))]
protected partial class ProtectedRepository
{
[RepositoryKeySelector]
private static string SelectKey(Order order) => order.Id;
}

[GenerateRepository(typeof(Order), typeof(string))]
private protected partial class PrivateProtectedRepository
{
[RepositoryKeySelector]
private static string SelectKey(Order order) => order.Id;
}

[GenerateRepository(typeof(Order), typeof(string))]
protected internal partial class ProtectedInternalRepository
{
[RepositoryKeySelector]
private static string SelectKey(Order order) => order.Id;
}
}
}
"""))
.Then("generated sources preserve containing partial type wrappers", result =>
{
ScenarioExpect.Empty(result.Diagnostics);
ScenarioExpect.Equal(3, result.GeneratedSources.Count);

var combined = string.Join("\n", result.GeneratedSources);
ScenarioExpect.Contains("public partial class RepositoryContainer", combined);
ScenarioExpect.Contains("private partial class PrivateHost", combined);
ScenarioExpect.Contains("protected partial class ProtectedRepository", combined);
ScenarioExpect.Contains("private protected partial class PrivateProtectedRepository", combined);
ScenarioExpect.Contains("protected internal partial class ProtectedInternalRepository", combined);
ScenarioExpect.True(result.EmitSuccess, result.EmitDiagnostics);
})
.AssertPassed();

[Scenario("Generator skips malformed repository type arguments")]
[Theory]
[InlineData("null!", "typeof(string)")]
[InlineData("typeof(Order)", "null!")]
public Task Generator_Skips_Malformed_Repository_Type_Arguments(string entityType, string keyType)
=> Given("a repository declaration with a null type argument", () => Compile($$"""
using PatternKit.Generators.Repository;

public sealed record Order(string Id);
[GenerateRepository(typeof(Order), typeof(string))]

[GenerateRepository({{entityType}}, {{keyType}})]
public static partial class OrderRepositoryFactory
{
[RepositoryKeySelector]
private static int SelectKey(Order order) => 1;
private static string SelectKey(Order order) => order.Id;
}
""";

var comp = CreateCompilation(source, nameof(ReportsDiagnosticForInvalidRepositoryKeySelector));
var gen = new RepositoryGenerator();
_ = RoslynTestHelpers.Run(comp, gen, out var run, out _);
"""))
.Then("no source is generated", result =>
ScenarioExpect.Empty(result.GeneratedSources))
.AssertPassed();

var diagnostic = ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics));
ScenarioExpect.Equal("PKREP003", diagnostic.Id);
}

private static CSharpCompilation CreateCompilation(string source, string assemblyName)
=> RoslynTestHelpers.CreateCompilation(
private static GeneratorResult Compile(string source)
{
var compilation = RoslynTestHelpers.CreateCompilation(
source,
assemblyName,
"RepositoryGeneratorTests",
extra: MetadataReference.CreateFromFile(typeof(InMemoryRepository<,>).Assembly.Location));
_ = RoslynTestHelpers.Run(compilation, new RepositoryGenerator(), out var run, out var updated);
var result = run.Results.Single();
var emit = updated.Emit(Stream.Null);
return new GeneratorResult(
result.Diagnostics.ToArray(),
result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray(),
emit.Success,
string.Join("\n", emit.Diagnostics));
}

private sealed record GeneratorResult(
IReadOnlyList<Diagnostic> Diagnostics,
IReadOnlyList<string> GeneratedSources,
bool EmitSuccess,
string EmitDiagnostics);
}
Loading