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
68 changes: 52 additions & 16 deletions src/PatternKit.Generators/UnitOfWork/UnitOfWorkGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,31 +110,67 @@ private static string GenerateSource(INamedTypeSymbol type, IReadOnlyList<StepCo
sb.AppendLine();
}

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.UnitOfWork.UnitOfWork ").Append(factoryName).AppendLine("()");
sb.AppendLine(" {");
sb.AppendLine(" var builder = global::PatternKit.Application.UnitOfWork.UnitOfWork.Create();");
var containingTypes = GetContainingTypes(type);
var indentLevel = 0;
foreach (var containingType in containingTypes)
{
AppendTypeDeclaration(sb, containingType, indentLevel);
Comment on lines +113 to +117
sb.AppendLine();
sb.AppendLine(new string(' ', indentLevel * 4) + "{");
indentLevel++;
}

AppendTypeDeclaration(sb, type, indentLevel);
var indent = new string(' ', indentLevel * 4);
sb.AppendLine();
sb.AppendLine(indent + "{");
var memberIndent = indent + " ";
var bodyIndent = memberIndent + " ";
sb.Append(memberIndent).Append("public static global::PatternKit.Application.UnitOfWork.UnitOfWork ").Append(factoryName).AppendLine("()");
sb.AppendLine(memberIndent + "{");
sb.AppendLine(bodyIndent + "var builder = global::PatternKit.Application.UnitOfWork.UnitOfWork.Create();");
foreach (var step in steps)
{
sb.Append(" builder.Enlist(\"").Append(Escape(step.Name)).Append("\", ").Append(step.Commit.Name);
sb.Append(bodyIndent).Append("builder.Enlist(\"").Append(Escape(step.Name)).Append("\", ").Append(step.Commit.Name);
if (step.Rollback is not null)
sb.Append(", ").Append(step.Rollback.Name);
sb.AppendLine(");");
}
sb.AppendLine(" return builder.Build();");
sb.AppendLine(" }");
sb.AppendLine("}");
sb.AppendLine(bodyIndent + "return builder.Build();");
sb.AppendLine(memberIndent + "}");
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);
Comment on lines +165 to +171
}

private static bool IsStep(IMethodSymbol method)
=> method.IsStatic
&& !method.IsGenericMethod
Expand Down
270 changes: 221 additions & 49 deletions test/PatternKit.Generators.Tests/UnitOfWorkGeneratorTests.cs
Original file line number Diff line number Diff line change
@@ -1,94 +1,266 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using PatternKit.Application.UnitOfWork;
using PatternKit.Generators.UnitOfWork;
using TinyBDD;
using TinyBDD.Xunit;
using Xunit.Abstractions;

namespace PatternKit.Generators.Tests;

public sealed class UnitOfWorkGeneratorTests
[Feature("Unit of Work generator")]
public sealed partial class UnitOfWorkGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output)
{
[Scenario("Generates unit of work factory")]
[Fact]
public void GeneratesUnitOfWorkFactory()
{
var source = """
public Task Generates_Unit_Of_Work_Factory()
=> Given("a unit of work declaration with ordered commit and rollback steps", () => Compile("""
using System.Threading;
using System.Threading.Tasks;
using PatternKit.Generators.UnitOfWork;

[GenerateUnitOfWork(FactoryName = "Build")]
public static partial class CheckoutWork
{
[UnitOfWorkStep("charge", 20)]
private static ValueTask Charge(CancellationToken ct) => default;

[UnitOfWorkStep("reserve", 10, RollbackMethodName = nameof(UndoReserve))]
private static ValueTask Reserve(CancellationToken ct) => default;

private static ValueTask UndoReserve(CancellationToken ct) => default;
}
""";

var comp = CreateCompilation(source, nameof(GeneratesUnitOfWorkFactory));
var gen = new UnitOfWorkGenerator();
_ = 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("CheckoutWork.UnitOfWork.g.cs", generated.HintName);
var text = generated.SourceText.ToString();
ScenarioExpect.Contains("Build", text);
ScenarioExpect.Contains("builder.Enlist(\"reserve\", Reserve, UndoReserve);", text);
ScenarioExpect.True(updated.Emit(Stream.Null).Success);
}
"""))
.Then("the generated factory enlists steps in order", result =>
{
ScenarioExpect.Empty(result.Diagnostics);
var source = ScenarioExpect.Single(result.GeneratedSources);
ScenarioExpect.Contains("public static partial class CheckoutWork", source);
ScenarioExpect.Contains("UnitOfWork Build()", source);
ScenarioExpect.Contains("builder.Enlist(\"reserve\", Reserve, UndoReserve);", source);
ScenarioExpect.Contains("builder.Enlist(\"charge\", Charge);", source);
ScenarioExpect.True(source.IndexOf("\"reserve\"", StringComparison.Ordinal) < source.IndexOf("\"charge\"", StringComparison.Ordinal));
ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics));
})
.AssertPassed();

[Scenario("Reports diagnostic for non partial unit of work")]
[Scenario("Reports diagnostic for non-partial unit of work declarations")]
[Fact]
public void ReportsDiagnosticForNonPartialUnitOfWork()
{
var comp = CreateCompilation("""
public Task Reports_Diagnostic_For_Non_Partial_Unit_Of_Work_Declarations()
=> Given("a non-partial unit of work declaration", () => Compile("""
using PatternKit.Generators.UnitOfWork;

[GenerateUnitOfWork]
public static class CheckoutWork;
""", nameof(ReportsDiagnosticForNonPartialUnitOfWork));
_ = RoslynTestHelpers.Run(comp, new UnitOfWorkGenerator(), out var run, out _);

ScenarioExpect.Equal("PKUOW001", ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)).Id);
}
"""))
.Then("the diagnostic identifies the host", result =>
ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKUOW001"))
.AssertPassed();

[Scenario("Reports diagnostic for missing unit of work steps")]
[Fact]
public void ReportsDiagnosticForMissingUnitOfWorkSteps()
{
var comp = CreateCompilation("""
public Task Reports_Diagnostic_For_Missing_Unit_Of_Work_Steps()
=> Given("a unit of work declaration without steps", () => Compile("""
using PatternKit.Generators.UnitOfWork;

[GenerateUnitOfWork]
public static partial class CheckoutWork;
""", nameof(ReportsDiagnosticForMissingUnitOfWorkSteps));
_ = RoslynTestHelpers.Run(comp, new UnitOfWorkGenerator(), out var run, out _);
"""))
.Then("the diagnostic identifies the missing steps", result =>
ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKUOW002"))
.AssertPassed();

ScenarioExpect.Equal("PKUOW002", ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)).Id);
}
[Scenario("Reports diagnostic for duplicate unit of work step identity")]
[Theory]
[InlineData("""
[UnitOfWorkStep("reserve", 10)]
private static ValueTask Reserve(CancellationToken ct) => default;
[UnitOfWorkStep("reserve", 20)]
private static ValueTask Charge(CancellationToken ct) => default;
""")]
[InlineData("""
[UnitOfWorkStep("reserve", 10)]
private static ValueTask Reserve(CancellationToken ct) => default;
[UnitOfWorkStep("charge", 10)]
private static ValueTask Charge(CancellationToken ct) => default;
""")]
public Task Reports_Diagnostic_For_Duplicate_Unit_Of_Work_Step_Identity(string steps)
=> Given("a unit of work declaration with duplicate step names or orders", () => Compile($$"""
using System.Threading;
using System.Threading.Tasks;
using PatternKit.Generators.UnitOfWork;

[GenerateUnitOfWork]
public static partial class CheckoutWork
{
{{steps}}
}
"""))
.Then("the duplicate diagnostic is reported", result =>
ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKUOW004"))
.AssertPassed();

[Scenario("Reports diagnostic for invalid unit of work step signatures")]
[Theory]
[InlineData("[UnitOfWorkStep(\"reserve\", 10)] private ValueTask Reserve(CancellationToken ct) => default;")]
[InlineData("[UnitOfWorkStep(\"reserve\", 10)] private static ValueTask Reserve<T>(CancellationToken ct) => default;")]
[InlineData("[UnitOfWorkStep(\"reserve\", 10)] private static Task Reserve(CancellationToken ct) => Task.CompletedTask;")]
[InlineData("[UnitOfWorkStep(\"reserve\", 10)] private static ValueTask Reserve() => default;")]
[InlineData("[UnitOfWorkStep(\"reserve\", 10)] private static ValueTask Reserve(CancellationToken ct, string tenant) => default;")]
[InlineData("[UnitOfWorkStep(\"reserve\", 10)] private static ValueTask Reserve(string ct) => default;")]
public Task Reports_Diagnostic_For_Invalid_Unit_Of_Work_Step_Signatures(string step)
=> Given("a unit of work declaration with an invalid commit step", () => Compile($$"""
using System.Threading;
using System.Threading.Tasks;
using PatternKit.Generators.UnitOfWork;

[Scenario("Reports diagnostic for invalid unit of work step")]
[GenerateUnitOfWork]
public static partial class CheckoutWork
{
{{step}}
}
"""))
.Then("the invalid step diagnostic is reported", result =>
ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKUOW003"))
.AssertPassed();

[Scenario("Reports diagnostic for invalid unit of work rollback signatures")]
[Fact]
public void ReportsDiagnosticForInvalidUnitOfWorkStep()
{
var comp = CreateCompilation("""
public Task Reports_Diagnostic_For_Invalid_Unit_Of_Work_Rollback_Signatures()
=> Given("a unit of work declaration with an invalid rollback step", () => Compile("""
using System.Threading;
using System.Threading.Tasks;
using PatternKit.Generators.UnitOfWork;

[GenerateUnitOfWork]
public static partial class CheckoutWork
{
[UnitOfWorkStep("reserve", 10)]
private static void Reserve() { }
[UnitOfWorkStep("reserve", 10, RollbackMethodName = nameof(UndoReserve))]
private static ValueTask Reserve(CancellationToken ct) => default;

private static void UndoReserve() { }
}
"""))
.Then("the invalid step diagnostic is reported", result =>
ScenarioExpect.Contains(result.Diagnostics, diagnostic => diagnostic.Id == "PKUOW003"))
.AssertPassed();

[Scenario("Generates unit of work defaults and type shapes")]
[Fact]
public Task Generates_Unit_Of_Work_Defaults_And_Type_Shapes()
=> Given("unit of work declarations using default names and different host shapes", () => Compile("""
using System.Threading;
using System.Threading.Tasks;
using PatternKit.Generators.UnitOfWork;

namespace Demo;

[GenerateUnitOfWork]
internal abstract partial class AbstractWork
{
[UnitOfWorkStep("abstract\\\"step", 10)]
private static ValueTask Execute(CancellationToken ct) => default;
}
""", nameof(ReportsDiagnosticForInvalidUnitOfWorkStep));
_ = RoslynTestHelpers.Run(comp, new UnitOfWorkGenerator(), out var run, out _);

ScenarioExpect.Equal("PKUOW003", ScenarioExpect.Single(run.Results.SelectMany(result => result.Diagnostics)).Id);
}
[GenerateUnitOfWork]
public sealed partial class SealedWork
{
[UnitOfWorkStep("sealed", 10)]
private static ValueTask Execute(CancellationToken ct) => default;
}

[GenerateUnitOfWork]
internal partial struct StructWork
{
[UnitOfWorkStep("struct", 10)]
private static ValueTask Execute(CancellationToken ct) => default;
}
"""))
.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 AbstractWork", combined);
ScenarioExpect.Contains("UnitOfWork Create()", combined);
ScenarioExpect.Contains("builder.Enlist(\"abstract\\\\\\\"step\", Execute);", combined);
ScenarioExpect.Contains("public sealed partial class SealedWork", combined);
ScenarioExpect.Contains("internal partial struct StructWork", combined);
ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics));
})
.AssertPassed();

private static CSharpCompilation CreateCompilation(string source, string assemblyName)
=> RoslynTestHelpers.CreateCompilation(
[Scenario("Generates nested unit of work host wrappers")]
[Fact]
public Task Generates_Nested_Unit_Of_Work_Host_Wrappers()
=> Given("nested unit of work declarations with non-public accessibility", () => Compile("""
using System.Threading;
using System.Threading.Tasks;
using PatternKit.Generators.UnitOfWork;

namespace Demo;

public partial class WorkContainer
{
private partial class PrivateHost
{
[GenerateUnitOfWork]
protected partial class ProtectedWork
{
[UnitOfWorkStep("protected", 10)]
private static ValueTask Execute(CancellationToken ct) => default;
}

[GenerateUnitOfWork]
private protected partial class PrivateProtectedWork
{
[UnitOfWorkStep("private-protected", 10)]
private static ValueTask Execute(CancellationToken ct) => default;
}

[GenerateUnitOfWork]
protected internal partial class ProtectedInternalWork
{
[UnitOfWorkStep("protected-internal", 10)]
private static ValueTask Execute(CancellationToken ct) => default;
}
}
}
"""))
.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 WorkContainer", combined);
ScenarioExpect.Contains("private partial class PrivateHost", combined);
ScenarioExpect.Contains("protected partial class ProtectedWork", combined);
ScenarioExpect.Contains("private protected partial class PrivateProtectedWork", combined);
ScenarioExpect.Contains("protected internal partial class ProtectedInternalWork", combined);
ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics));
})
.AssertPassed();

private static GeneratorResult Compile(string source)
{
var compilation = RoslynTestHelpers.CreateCompilation(
source,
assemblyName,
extra: MetadataReference.CreateFromFile(typeof(PatternKit.Application.UnitOfWork.UnitOfWork).Assembly.Location));
"UnitOfWorkGeneratorTests",
extra: MetadataReference.CreateFromFile(typeof(global::PatternKit.Application.UnitOfWork.UnitOfWork).Assembly.Location));
_ = RoslynTestHelpers.Run(compilation, new UnitOfWorkGenerator(), 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,
emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray());
}

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