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
31 changes: 31 additions & 0 deletions docs/codegen/projection-apply-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,34 @@ attached to that project. It is opt-in because nothing in MSBuild can tell a pro
suppressed" is not by itself evidence of a mistake: Marten's own build suppresses the
analyzer package's assets deliberately, bundling the dll instead. That is the finding that
argued #894 out of being a default warning.

## Inferring a handler's emitted events (#990)

Since [#990](https://github.com/JasperFx/jasperfx/issues/990) the generator also records which
events each handler method appends, read from its body, as an assembly-level manifest:

```csharp
[assembly: JasperFx.Events.EmittedEvents(typeof(AcceptHomeCheckAssignmentHandler), "Handle",
typeof(HomeCheckAssignmentAccepted))]
```

It keys off JasperFx.Events types only, so the generator needs nothing from Wolverine:

- A method is a candidate when a parameter is an `IEventStream<T>`, a parameter carries an
`IRefersToAggregate` attribute, or the return type (or a tuple element, through
`Task<T>` / `ValueTask<T>`) implements the `ICarriesEvents` marker.
- Events are read from `AppendOne` / `AppendMany` on an `IEventStream<T>`, from the arguments
of a constructor or factory returning an `ICarriesEvents` type, and from `Add` (or a
collection initializer) on one. Only `object`-typed parameter slots count as events, so a
`Guid` or `string` stream id beside them is never mistaken for one.
- An event whose static type is `object` can't be inferred and is skipped silently. The
manifest is a lower bound, and an explicit `[Emits]` remains the override.

Reading the manifest belongs to the consumer (Wolverine). Like the marker above, it is emitted
only when the compilation can see `EmittedEventsAttribute`, and the attribute allows
multiple applications for the double-load case.

The same issue fixed pass 2's syntax filter, which let a method through only when a parameter
attribute had "Aggregate" in its name. Wolverine's `[WriteModel]` / `[ReadModel]` /
`[DcbModel]` implement `IRefersToAggregate` but never reached the semantic check. The filter
now admits any attributed parameter, and the marker check decides.
Original file line number Diff line number Diff line change
@@ -0,0 +1,357 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Shouldly;

namespace JasperFx.Events.SourceGenerator.Tests;

/// <summary>
/// jasperfx#990 — the generator infers the events a handler appends from its body, keyed off
/// JasperFx.Events marker types (<c>IEventStream&lt;T&gt;</c>, <c>IRefersToAggregate</c>,
/// <c>ICarriesEvents</c>), and records them as <c>[assembly: EmittedEvents(...)]</c> so Wolverine can
/// stop scaffolding <c>[Emits]</c>.
/// </summary>
public class EmittedEventsManifestTests
{
private const string ManifestFileName = "JasperFxEmittedEvents.g.cs";

/// <summary>
/// Stand-ins for Wolverine's store-agnostic attribute and event-carrying return types. They are
/// defined here, not referenced, because the point is that the generator needs nothing from
/// Wolverine: only the JasperFx.Events markers they implement.
/// </summary>
private const string Preamble = @"
using System;
using System.Collections.Generic;
using JasperFx.Events;
using JasperFx.Events.Aggregation;

namespace App;

public class WriteModelAttribute : Attribute, IRefersToAggregate;

public class Events : List<object>, ICarriesEvents;

public class StartStream : ICarriesEvents
{
public StartStream(Guid streamId, params object[] events) { }
}

public static class Storage
{
public static StartStream StartStream<T>(Guid id, params object[] events) => new(id, events);
}

public class Appointment { public Guid Id { get; set; } }
public class Patient { public Guid Id { get; set; } }

public record Booked;
public record Confirmed;
public record Cancelled;
public record PatientNotified;
";

private static string Manifest(string body) =>
GeneratorHarness.GeneratedSource(Preamble + body, ManifestFileName);

private static string[] FileNames(string body) => GeneratorHarness.GeneratedFileNames(Preamble + body);

private static void ShouldCompile(string body) =>
GeneratorHarness.GeneratedCodeErrors(Preamble + body).ShouldBeEmpty();

[Fact]
public void append_one_on_an_event_stream()
{
const string body = @"
public static class ConfirmHandler
{
public static void Handle(string command, IEventStream<Appointment> stream)
{
stream.AppendOne(new Confirmed());
}
}
";
Manifest(body).ShouldContain(
"[assembly: global::JasperFx.Events.EmittedEvents(typeof(global::App.ConfirmHandler), \"Handle\", typeof(global::App.Confirmed))]");
ShouldCompile(body);
}

[Fact]
public void append_many_with_several_events()
{
const string body = @"
public static class BookHandler
{
public static void Handle(string command, IEventStream<Appointment> stream)
{
var confirmed = new Confirmed();
stream.AppendMany(new Booked(), confirmed);
stream.AppendMany(new object[] { new Cancelled() });
}
}
";
Manifest(body).ShouldContain(
"typeof(global::App.BookHandler), \"Handle\", typeof(global::App.Booked), typeof(global::App.Confirmed), typeof(global::App.Cancelled))]");
ShouldCompile(body);
}

[Fact]
public void multi_stream_with_two_event_stream_parameters()
{
const string body = @"
public static class TransferHandler
{
public static void Handle(string command, IEventStream<Appointment> appointment, IEventStream<Patient> patient)
{
appointment.AppendOne(new Cancelled());
patient.AppendOne(new PatientNotified());
}
}
";
Manifest(body).ShouldContain(
"typeof(global::App.TransferHandler), \"Handle\", typeof(global::App.Cancelled), typeof(global::App.PatientNotified))]");
ShouldCompile(body);
}

[Fact]
public void a_factory_returning_an_event_carrier()
{
const string body = @"
public static class StartHandler
{
public static StartStream Handle(string command)
{
return Storage.StartStream<Appointment>(Guid.NewGuid(), new Booked(), new Confirmed());
}
}

public static class StartByConstructorHandler
{
public static StartStream Handle(string command) => new StartStream(Guid.NewGuid(), new Booked());
}
";
var manifest = Manifest(body);

// The Guid stream id is not an event: only object-typed slots are read.
manifest.ShouldContain(
"typeof(global::App.StartHandler), \"Handle\", typeof(global::App.Booked), typeof(global::App.Confirmed))]");
manifest.ShouldContain(
"typeof(global::App.StartByConstructorHandler), \"Handle\", typeof(global::App.Booked))]");
manifest.ShouldNotContain("typeof(global::System.Guid)");
ShouldCompile(body);
}

[Fact]
public void a_collection_initializer()
{
const string body = @"
public static class InitializerHandler
{
public static Events Handle(string command, [WriteModel] Appointment appointment)
{
return new Events { new Confirmed(), new PatientNotified() };
}
}
";
Manifest(body).ShouldContain(
"typeof(global::App.InitializerHandler), \"Handle\", typeof(global::App.Confirmed), typeof(global::App.PatientNotified))]");
ShouldCompile(body);
}

[Fact]
public void add_on_a_local()
{
const string body = @"
public static class AddHandler
{
public static Events Handle(string command, bool cancel)
{
var events = new Events();
events.Add(new Booked());
if (cancel) events.Add(new Cancelled());
return events;
}
}
";
Manifest(body).ShouldContain(
"typeof(global::App.AddHandler), \"Handle\", typeof(global::App.Booked), typeof(global::App.Cancelled))]");
ShouldCompile(body);
}

[Fact]
public void a_tuple_return()
{
const string body = @"
public static class TupleHandler
{
public static System.Threading.Tasks.Task<(Events, string)> Handle(string command)
{
var events = new Events { new Booked() };
return System.Threading.Tasks.Task.FromResult((events, ""done""));
}
}
";
Manifest(body).ShouldContain(
"typeof(global::App.TupleHandler), \"Handle\", typeof(global::App.Booked))]");
ShouldCompile(body);
}

[Fact]
public void an_object_typed_event_is_skipped()
{
const string body = @"
public static class ObjectHandler
{
public static void Handle(string command, IEventStream<Appointment> stream)
{
object unknown = new Booked();
stream.AppendOne(unknown);
stream.AppendOne(new Confirmed());
}
}
";
var manifest = Manifest(body);

manifest.ShouldContain("typeof(global::App.ObjectHandler), \"Handle\", typeof(global::App.Confirmed))]");
manifest.ShouldNotContain("typeof(object)");
manifest.ShouldNotContain("global::App.Booked");
ShouldCompile(body);
}

[Fact]
public void a_method_with_no_candidates_produces_no_manifest()
{
const string body = @"
public static class NotAnEventHandler
{
// Appends nothing a marker identifies: a plain List<object> is not an ICarriesEvents.
public static List<object> Handle(string command)
{
return new List<object> { new Booked() };
}
}

public static class CandidateWithNoEvents
{
// A candidate by parameter, but it appends nothing — still no entry.
public static void Handle(string command, IEventStream<Appointment> stream) { }
}
";
FileNames(body).ShouldNotContain(ManifestFileName);
}

[Fact]
public void the_manifest_is_skipped_when_jasperfx_events_is_not_referenced()
{
GeneratorHarness.GeneratedFileNamesWithoutJasperFxReference(@"
public static class Handler
{
public static void Handle(string command) { }
}
").ShouldBeEmpty();
}

[Fact]
public void an_unrelated_edit_does_not_reanalyze_other_methods()
{
const string handlerFile = @"
using JasperFx.Events;

namespace App;

public static class ConfirmHandler
{
public static void Handle(string command, IEventStream<Appointment> stream)
{
stream.AppendOne(new Confirmed());
}
}
";
const string otherFile = @"
namespace App;

public static class Unrelated
{
public static int Compute(int x) => x + 1;
}
";

var parseOptions = CSharpParseOptions.Default;
var compilation = CSharpCompilation.Create(
"IncrementalAssembly",
[
CSharpSyntaxTree.ParseText(Preamble, parseOptions),
CSharpSyntaxTree.ParseText(handlerFile, parseOptions),
CSharpSyntaxTree.ParseText(otherFile, parseOptions)
],
[
MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
MetadataReference.CreateFromFile(typeof(IEvent).Assembly.Location),
MetadataReference.CreateFromFile(System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(typeof(object).Assembly.Location)!, "System.Runtime.dll")),
MetadataReference.CreateFromFile(System.IO.Path.Combine(
System.IO.Path.GetDirectoryName(typeof(object).Assembly.Location)!, "System.Collections.dll"))
],
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

GeneratorDriver driver = CSharpGeneratorDriver.Create(
[new AggregateEvolverGenerator().AsSourceGenerator()],
driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true));

driver = driver.RunGenerators(compilation);

var otherTree = compilation.SyntaxTrees.Last();
var editedTree = otherTree.WithChangedText(Microsoft.CodeAnalysis.Text.SourceText.From(
otherFile.Replace("x + 1", "x + 2")));
driver = driver.RunGenerators(compilation.ReplaceSyntaxTree(otherTree, editedTree));

var steps = driver.GetRunResult().Results.Single().TrackedSteps["JasperFxEmittedEvents"];
var outputs = steps.SelectMany(s => s.Outputs).ToArray();

// The handler's analysis came out of the cache; nothing was recomputed for it.
outputs.ShouldNotBeEmpty();
outputs.ShouldAllBe(o => o.Reason == IncrementalStepRunReason.Cached || o.Reason == IncrementalStepRunReason.Unchanged);
outputs.ShouldContain(o => o.Reason == IncrementalStepRunReason.Cached);
}
}

/// <summary>
/// jasperfx#990, part 1 — pass 2's syntax filter let a method through only when a parameter
/// attribute had "Aggregate" in its name, so Wolverine's [WriteModel] / [ReadModel] / [DcbModel]
/// were never semantically checked for <c>IRefersToAggregate</c> at all.
/// </summary>
public class RefersToAggregateByMarkerNotNameTests
{
// An aggregate built only through an event constructor declares no Apply/Create, so pass 1
// never sees it — the parameter attribute is the only way it reaches the generator.
private const string Source = @"
using System;
using JasperFx.Events.Aggregation;

namespace App;

public class WriteModelAttribute : Attribute, IRefersToAggregate;

public record AppointmentBooked(Guid AppointmentId);

public class Appointment
{
public Guid Id { get; set; }

public Appointment(AppointmentBooked e) { Id = e.AppointmentId; }
}

public static class BookHandler
{
public static void Handle(string command, [WriteModel] Appointment appointment) { }
}
";

[Fact]
public void an_irefers_to_aggregate_attribute_without_aggregate_in_its_name_is_analyzed()
{
var (_, generatedSources) = GeneratorHarness.Run(Source);

generatedSources.ShouldContain(s => s.Contains("typeof(global::App.Appointment)"));
GeneratorHarness.GeneratedCodeErrors(Source).ShouldBeEmpty();
}
}
Loading
Loading