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
5 changes: 5 additions & 0 deletions .github/workflows/fsharp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ on:
- 'src/Testing/Wolverine.Http.FSharp*/**'
- 'src/Testing/Wolverine.EfCore.FSharp*/**'
- 'src/Testing/Wolverine.Marten.FSharp*/**'
- 'src/Testing/Wolverine.MartenAggregate.FSharp*/**'
- 'src/Samples/WolverineFSharpSample/**'
- 'src/Samples/WolverineMartenFSharpSample/**'
- 'src/Samples/WolverineMartenAggregateFSharpSample/**'
- '.github/workflows/fsharp.yml'
workflow_dispatch:

Expand Down Expand Up @@ -71,3 +73,6 @@ jobs:

- name: Compile-gate (Marten document surface)
run: dotnet test src/Testing/Wolverine.Marten.FSharpTests/Wolverine.Marten.FSharpTests.csproj -c "$config" --nologo

- name: Compile-gate (Marten aggregate surface)
run: dotnet test src/Testing/Wolverine.MartenAggregate.FSharpTests/Wolverine.MartenAggregate.FSharpTests.csproj -c "$config" --nologo
49 changes: 49 additions & 0 deletions src/Persistence/Wolverine.Marten/Codegen/LoadAggregateFrame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,53 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)

Next?.GenerateCode(method, writer);
}

private string NaturalKeyFetchForWritingFSharp(string targetUsage, string cancellationTokenUsage)
{
var aggType = _att.AggregateType.FSharpName();
var nkType = _identity.VariableType.FSharpName();
return $"{targetUsage}.Events.FetchForWriting<{aggType}, {nkType}>({_identity.FSharpUsage}, {cancellationTokenUsage})";
}

public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer)
{
if (BatchResolutionGenerated)
{
Next?.GenerateFSharpCode(method, writer);
return;
}

writer.WriteComment("Loading Marten aggregate as part of the aggregate handler workflow");
if (_batchQueryItem == null)
{
// `var x = await ...` -> F# `let! x = ...` inside the task { } CE (drop ConfigureAwait).
if (_att.IsNaturalKey)
{
writer.WriteLine($"let! {Stream.Usage} = {NaturalKeyFetchForWritingFSharp(_session!.FSharpUsage, _token!.FSharpUsage)}");
}
else if (_att.LoadStyle == ConcurrencyStyle.Exclusive)
{
writer.WriteLine($"let! {Stream.Usage} = {_session!.FSharpUsage}.Events.FetchForExclusiveWriting<{_att.AggregateType.FSharpName()}>({_rawIdentity.FSharpUsage}, {_token!.FSharpUsage})");
}
else if (_version == null)
{
writer.WriteLine($"let! {Stream.Usage} = {_session!.FSharpUsage}.Events.FetchForWriting<{_att.AggregateType.FSharpName()}>({_rawIdentity.FSharpUsage}, {_token!.FSharpUsage})");
}
else
{
writer.WriteLine($"let! {Stream.Usage} = {_session!.FSharpUsage}.Events.FetchForWriting<{_att.AggregateType.FSharpName()}>({_rawIdentity.FSharpUsage}, {_version.FSharpUsage}, {_token!.FSharpUsage})");
}
}
else
{
writer.WriteLine($"let! {Stream.Usage} = {_batchQueryItem.FSharpUsage}");
}

if (_att.AlwaysEnforceConsistency)
{
writer.WriteLine($"{Stream.Usage}.{nameof(IEventStream<string>.AlwaysEnforceConsistency)} <- true");
}

Next?.GenerateFSharpCode(method, writer);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,12 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)

Next?.GenerateCode(method, writer);
}

public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer)
{
writer.WriteLine(
$"if isNull {_eventStream.FSharpUsage}.{nameof(IEventStream<string>.Aggregate)} then raise({typeof(UnknownAggregateException).FSharpName()}(typeof<{_aggregateType.FSharpName()}>, {_identity.FSharpUsage}))");

Next?.GenerateFSharpCode(method, writer);
}
}
11 changes: 11 additions & 0 deletions src/Persistence/Wolverine.Marten/Codegen/TagAggregateOtelFrame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,15 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
writer.WriteLine($"{typeof(Activity).FullNameInCode()}.{nameof(Activity.Current)}?.{nameof(Activity.SetTag)}(\"{WolverineTracing.StreamType}\", \"{_aggregateType.FullName}\");");
Next?.GenerateCode(method, writer);
}

public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer)
{
// F# has no null-conditional operator; guard Activity.Current explicitly. SetTag returns the
// Activity for chaining, so pipe the result to ignore.
writer.WriteLine(
$"if not (isNull {typeof(Activity).FSharpName()}.{nameof(Activity.Current)}) then {typeof(Activity).FSharpName()}.{nameof(Activity.Current)}.{nameof(Activity.SetTag)}(\"{WolverineTracing.StreamId}\", {_aggregateId.FSharpUsage}.ToString()) |> ignore");
writer.WriteLine(
$"if not (isNull {typeof(Activity).FSharpName()}.{nameof(Activity.Current)}) then {typeof(Activity).FSharpName()}.{nameof(Activity.Current)}.{nameof(Activity.SetTag)}(\"{WolverineTracing.StreamType}\", \"{_aggregateType.FullName}\") |> ignore");
Next?.GenerateFSharpCode(method, writer);
}
}
29 changes: 29 additions & 0 deletions src/Samples/WolverineMartenAggregateFSharpSample/Domain.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
namespace WolverineMartenAggregateFSharpSample

open System
open JasperFx.Events
open Marten.Events.Aggregation

/// Events for the Counter stream.
type CounterStarted = { Id: Guid }
type Incremented = { By: int }

/// The Marten event-sourced aggregate, built by single-stream aggregation from the Counter stream.
/// [<CLIMutable>] gives Marten the parameterless constructor + settable properties it needs.
[<CLIMutable>]
type Counter = { Id: Guid; Count: int }

/// A single-stream projection that folds the Counter events into the aggregate. It overrides Evolve
/// directly (an explicit per-event fold) rather than using convention Create/Apply methods: those are
/// dispatched by the C#-only JasperFx.Events source generator, which does not run for F# assemblies.
type CounterProjection() =
inherit SingleStreamProjection<Counter, Guid>()

override _.Evolve(snapshot: Counter, _id: Guid, e: IEvent) : Counter =
match e.Data with
| :? CounterStarted as started -> { Id = started.Id; Count = 0 }
| :? Incremented as inc -> { snapshot with Count = snapshot.Count + inc.By }
| _ -> snapshot

/// The command handled by IncrementHandler. CounterId names the aggregate stream to load.
type IncrementCounter = { CounterId: Guid; By: int }
12 changes: 12 additions & 0 deletions src/Samples/WolverineMartenAggregateFSharpSample/Handlers.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace WolverineMartenAggregateFSharpSample

open Wolverine.Marten

/// A Marten event-sourced aggregate handler written in F#. [<AggregateHandler>] tells Wolverine to
/// load the Counter aggregate for the stream named by the command's CounterId (FetchForWriting), pass
/// it to this method, and append the returned event(s) back to that stream. The returned Incremented
/// is registered against the loaded stream by the generated adapter.
type IncrementHandler =
[<AggregateHandler>]
static member Handle(command: IncrementCounter, counter: Counter) : Incremented =
{ By = command.By }
57 changes: 57 additions & 0 deletions src/Samples/WolverineMartenAggregateFSharpSample/Program.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
module WolverineMartenAggregateFSharpSample.Program

open System
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.DependencyInjection
open Marten
open Marten.Events.Projections
open JasperFx.Events.Projections
open JasperFx.Resources
open Wolverine
open Wolverine.Marten
open WolverineMartenAggregateFSharpSample

[<Literal>]
let connectionString =
"Host=localhost;Port=5433;Database=postgres;Username=postgres;password=postgres"

[<EntryPoint>]
let main args =
let host =
Host
.CreateDefaultBuilder(args)
.UseWolverine(fun opts ->
opts.Services
.AddMarten(fun (m: StoreOptions) ->
m.Connection(connectionString)
m.DatabaseSchemaName <- "wolverine_fsharp_aggregate"
// Inline single-stream projection so FetchForWriting can rebuild the Counter.
m.Projections.Add(CounterProjection(), ProjectionLifecycle.Inline))
.IntegrateWithWolverine()
|> ignore

opts.Policies.AutoApplyTransactions() |> ignore
opts.Discovery.IncludeType<IncrementHandler>() |> ignore
opts.UseRuntimeCompilation() |> ignore)
.UseResourceSetupOnStartup()
.Build()

host.Start()

// Seed a Counter stream so the aggregate handler has something to load + append to.
let counterId = Guid.NewGuid()

(use scope = host.Services.CreateScope()
let store = scope.ServiceProvider.GetRequiredService<IDocumentStore>()
use session = store.LightweightSession()
session.Events.StartStream<Counter>(counterId, [| box ({ Id = counterId }: CounterStarted) |]) |> ignore
session.SaveChangesAsync().GetAwaiter().GetResult())

// Demonstrate the F# Marten aggregate handler end-to-end (dynamic codegen): load the stream,
// run Handle, append the returned Incremented event.
let bus = host.Services.GetRequiredService<IMessageBus>()
bus.InvokeAsync({ CounterId = counterId; By = 5 }).GetAwaiter().GetResult()
printfn "Incremented the Counter through the F# Wolverine + Marten aggregate handler."

host.StopAsync().GetAwaiter().GetResult()
0
46 changes: 46 additions & 0 deletions src/Samples/WolverineMartenAggregateFSharpSample/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# WolverineMartenAggregateFSharpSample

A small **F#** Wolverine application demonstrating the F# code-generation approach (issue
[GH-2969](https://github.com/JasperFx/wolverine/issues/2969)). This slice exercises **Marten event
sourcing** — the heaviest frame set (the document slice lives in `WolverineMartenFSharpSample`):

- `Domain.fs` — `CounterStarted`/`Incremented` events, a `Counter` aggregate, a `CounterProjection`,
and an `IncrementCounter` command.
- `Handlers.fs` — `IncrementHandler.Handle`, an `[<AggregateHandler>]` that receives the loaded
`Counter` aggregate + the command and returns an `Incremented` event (appended to the stream).
- `Program.fs` — `UseWolverine` + `AddMarten(...).IntegrateWithWolverine()`, seeds a Counter stream,
then invokes the increment command.

## Two things this proves

1. **It runs (dynamic codegen).** `dotnet run` boots the app, seeds a stream, invokes
`IncrementCounter`, and the F# `[<AggregateHandler>]` loads the aggregate via `FetchForWriting`,
runs the decision method, and appends the returned event. Core Wolverine no longer ships the Roslyn
compiler ([GH-2876](https://github.com/JasperFx/wolverine/issues/2876)), so the sample references
`Wolverine.RuntimeCompilation` and calls `opts.UseRuntimeCompilation()`.

2. **It static-codegens to F# (compile-gate).** `src/Testing/Wolverine.MartenAggregate.FSharpTests`
renders this handler's real chain to F# via Wolverine's static codegen path and `dotnet build`s the
result, proving the Marten aggregate frames (`LoadAggregateFrame` → `FetchForWriting`,
`TagAggregateOtelFrame`, `MissingAggregateCheckFrame`, `RegisterEvents`, `SaveChanges`) emit valid F#.

## F# + Marten event sourcing: a key constraint

Marten's convention-based aggregation (`Create`/`Apply` methods) is dispatched by the **C#-only**
`JasperFx.Events` source generator, which does **not** run for F# assemblies. So `CounterProjection`
**overrides `Evolve` directly** (an explicit per-event fold) instead of using convention methods. This
is the supported escape hatch for self-aggregating types defined in F#.

## Running it

Marten needs Postgres, so the runnable sample is **not** infra-free (the *static* F# story in the
compile-gate is). Start the repo's docker-compose infrastructure first:

```bash
docker compose up -d # Postgres on :5433
dotnet run --project src/Samples/WolverineMartenAggregateFSharpSample --framework net9.0
```

Expected output: `Incremented the Counter through the F# Wolverine + Marten aggregate handler.`

The sample uses a dedicated `wolverine_fsharp_aggregate` schema in the shared `postgres` database.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
A small F# Wolverine sample demonstrating the F# code-generation approach (issue GH-2969).
This slice exercises Marten event sourcing: an [<AggregateHandler>] that loads an aggregate via
FetchForWriting, runs a decision method, and appends the returned event back to the stream. It
runs today via dynamic codegen; the F# compile-gate (src/Testing/Wolverine.MartenAggregate.FSharpTests)
renders this handler's real chain to F# and compiles it, proving the Marten aggregate frames emit
valid F#.
-->

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net9.0</TargetFrameworks>
<IsPackable>false</IsPackable>

<!-- F# project: clear the C#-oriented props inherited from Directory.Build.props. -->
<LangVersion></LangVersion>
<ImplicitUsings>false</ImplicitUsings>
<Nullable>disable</Nullable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>

<DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference>
</PropertyGroup>

<ItemGroup>
<Compile Include="Domain.fs" />
<Compile Include="Handlers.fs" />
<Compile Include="Program.fs" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Persistence\Wolverine.Marten\Wolverine.Marten.csproj" />
<ProjectReference Include="..\..\Wolverine.RuntimeCompilation\Wolverine.RuntimeCompilation.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="FSharp.Core" />
</ItemGroup>

</Project>
48 changes: 48 additions & 0 deletions src/Testing/Wolverine.MartenAggregate.FSharpFixture/Generated.fs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// <auto-generated/>

namespace Internal.Generated.WolverineHandlers

open System
open System.Threading
open System.Threading.Tasks
open Wolverine.Marten.Publishing
open Wolverine.Runtime
open Wolverine.Runtime.Handlers

type IncrementCounterHandler805386214(outboxedSessionFactory: Wolverine.Marten.Publishing.OutboxedSessionFactory) =
inherit Wolverine.Runtime.Handlers.MessageHandler()
let _outboxedSessionFactory = outboxedSessionFactory

override this.HandleAsync(context: Wolverine.Runtime.MessageContext, cancellation: System.Threading.CancellationToken) : System.Threading.Tasks.Task =
task {
// Building the Marten session
use documentSession = _outboxedSessionFactory.OpenSession(context)
// The actual message body
let incrementCounter = context.Envelope.Message :?> WolverineMartenAggregateFSharpSample.IncrementCounter

let counter_Id = incrementCounter.CounterId
// Application-specific Open Telemetry auditing
if not (isNull System.Diagnostics.Activity.Current) then
System.Diagnostics.Activity.Current.SetTag("counter.id", incrementCounter.CounterId) |> ignore
if not (isNull System.Diagnostics.Activity.Current) then
System.Diagnostics.Activity.Current.SetTag("message.handler", "WolverineMartenAggregateFSharpSample.IncrementHandler") |> ignore
System.Diagnostics.Activity.Current.SetTag("handler.type", "WolverineMartenAggregateFSharpSample.IncrementHandler") |> ignore
// Loading Marten aggregate as part of the aggregate handler workflow
let! eventStreamOfCounter = documentSession.Events.FetchForWriting<WolverineMartenAggregateFSharpSample.Counter>(counter_Id, cancellation)
if not (isNull System.Diagnostics.Activity.Current) then System.Diagnostics.Activity.Current.SetTag("wolverine.stream.id", counter_Id.ToString()) |> ignore
if not (isNull System.Diagnostics.Activity.Current) then System.Diagnostics.Activity.Current.SetTag("wolverine.stream.type", "WolverineMartenAggregateFSharpSample.Counter") |> ignore

// The actual message execution
let outgoing1 = WolverineMartenAggregateFSharpSample.IncrementHandler.Handle(incrementCounter, eventStreamOfCounter.Aggregate)

eventStreamOfCounter.AppendOne(outgoing1)

// Save all pending changes to this Marten session
do! documentSession.SaveChangesAsync(cancellation)


// Have to flush outgoing messages just in case Marten did nothing because of https://github.com/JasperFx/wolverine/issues/536
do! context.FlushOutgoingMessagesAsync()

}

Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
Checked-in F# fixture for the Marten event-sourced aggregate F# code-generation surface (issue
GH-2969). Generated.fs is the emitted MessageHandler adapter for the sample's aggregate handler
chain (open session -> FetchForWriting -> missing-aggregate guard -> Handle -> append events ->
SaveChanges). The compile-gate regenerates it and `dotnet build`s this project to prove the Marten
aggregate frames emit compiling F#. Mirrors the Core/Http/EfCore/Marten fixtures.
-->

<PropertyGroup>
<TargetFrameworks>net9.0</TargetFrameworks>
<IsPackable>false</IsPackable>

<LangVersion></LangVersion>
<ImplicitUsings>false</ImplicitUsings>
<Nullable>disable</Nullable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>

<DisableImplicitFSharpCoreReference>true</DisableImplicitFSharpCoreReference>
</PropertyGroup>

<ItemGroup>
<Compile Include="Generated.fs" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Samples\WolverineMartenAggregateFSharpSample\WolverineMartenAggregateFSharpSample.fsproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="FSharp.Core" />
</ItemGroup>

</Project>
Loading