From 24df244ceb4ac60b553c8fd1ef107b4da2d1ab2d Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Fri, 29 May 2026 17:01:29 -0500 Subject: [PATCH] =?UTF-8?q?F#=20code=20generation:=20EF=20Core=20slice=20?= =?UTF-8?q?=E2=80=94=20runnable=20sample=20+=20compile-gate=20(GH-2969)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First store-specific slice of the F# code-generation audit: a runnable F# Wolverine + EF Core app and a compile-gate proving its handler chain emits valid F# through Wolverine's static codegen path. EF Core frame F# emit: - EnrollDbContextInTransaction -> task { } body: enroll-in-outbox, conditional BeginTransactionAsync (let! _ = ...; isNull guard), try/with around Next. reraise() is illegal inside a CE try/with (FS0413), so the handler rolls back and rethrows via ExceptionDispatchInfo.Capture(ex).Throw() (preserves the stack trace, the semantics of C# `throw;`). - CommitEfCoreEnvelopeTransaction -> do! envelopeTransaction.CommitAsync(...). Runnable sample (src/Samples/WolverineFSharpSample): - F# Item/ItemsDbContext/CreateItemCommand/ItemCreated + a [] CreateItemHandler. Runs via dynamic codegen (references Wolverine.RuntimeCompilation + UseRuntimeCompilation, since core dropped the in-box compiler, GH-2876). - Postgres-backed: the EF Core outbox needs a durable message store, so the sample is not infra-free (the *static* F# story is the compile-gate's job). Compile-gate (src/Testing/Wolverine.EfCore.FSharp{Tests,Fixture}): - Renders the sample's real CreateItemCommand chain to F# via the no-host HandlerGraph/AssembleTypes/GenerateFSharpCode path and dotnet-builds the fixture. Wire-up: sample + gate added to wolverine_fsharp.slnx; fsharp.yml runs the EF gate as its own sequential step. Bumps JasperFx 2.2.5 -> 2.2.7 (the service-location frame surface the EF render needs: LazyServiceLocationFrame et al., jasperfx#400). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/fsharp.yml | 6 ++ Directory.Packages.props | 8 +- .../Codegen/EnrollDbContextInTransaction.cs | 49 ++++++++++++ src/Samples/WolverineFSharpSample/Domain.fs | 27 +++++++ src/Samples/WolverineFSharpSample/Handlers.fs | 15 ++++ src/Samples/WolverineFSharpSample/Program.fs | 59 ++++++++++++++ src/Samples/WolverineFSharpSample/README.md | 43 +++++++++++ .../WolverineFSharpSample.fsproj | 48 ++++++++++++ .../Generated.fs | 56 ++++++++++++++ .../Wolverine.EfCore.FSharpFixture.fsproj | 37 +++++++++ .../EfCoreFSharpCodegenSample.cs | 76 +++++++++++++++++++ .../EfCoreFSharpCompileGate.cs | 65 ++++++++++++++++ .../Wolverine.EfCore.FSharpTests.csproj | 32 ++++++++ wolverine_fsharp.slnx | 4 + 14 files changed, 521 insertions(+), 4 deletions(-) create mode 100644 src/Samples/WolverineFSharpSample/Domain.fs create mode 100644 src/Samples/WolverineFSharpSample/Handlers.fs create mode 100644 src/Samples/WolverineFSharpSample/Program.fs create mode 100644 src/Samples/WolverineFSharpSample/README.md create mode 100644 src/Samples/WolverineFSharpSample/WolverineFSharpSample.fsproj create mode 100644 src/Testing/Wolverine.EfCore.FSharpFixture/Generated.fs create mode 100644 src/Testing/Wolverine.EfCore.FSharpFixture/Wolverine.EfCore.FSharpFixture.fsproj create mode 100644 src/Testing/Wolverine.EfCore.FSharpTests/EfCoreFSharpCodegenSample.cs create mode 100644 src/Testing/Wolverine.EfCore.FSharpTests/EfCoreFSharpCompileGate.cs create mode 100644 src/Testing/Wolverine.EfCore.FSharpTests/Wolverine.EfCore.FSharpTests.csproj diff --git a/.github/workflows/fsharp.yml b/.github/workflows/fsharp.yml index 64951c9d5..914d80fb5 100644 --- a/.github/workflows/fsharp.yml +++ b/.github/workflows/fsharp.yml @@ -21,6 +21,9 @@ on: - '**/*Frame.cs' - 'wolverine_fsharp.slnx' - 'src/Testing/Wolverine.Core.FSharp*/**' + - 'src/Testing/Wolverine.Http.FSharp*/**' + - 'src/Testing/Wolverine.EfCore.FSharp*/**' + - 'src/Samples/WolverineFSharpSample/**' - '.github/workflows/fsharp.yml' workflow_dispatch: @@ -60,3 +63,6 @@ jobs: - name: Compile-gate + fsharp-coverage (Http surface) run: dotnet test src/Testing/Wolverine.Http.FSharpTests/Wolverine.Http.FSharpTests.csproj -c "$config" --nologo + + - name: Compile-gate (EF Core surface) + run: dotnet test src/Testing/Wolverine.EfCore.FSharpTests/Wolverine.EfCore.FSharpTests.csproj -c "$config" --nologo diff --git a/Directory.Packages.props b/Directory.Packages.props index a64e345e1..7ba1cb4c2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,13 +32,13 @@ - - - + + + - + diff --git a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EnrollDbContextInTransaction.cs b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EnrollDbContextInTransaction.cs index 81cc5d1bb..a8c31b8d3 100644 --- a/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EnrollDbContextInTransaction.cs +++ b/src/Persistence/Wolverine.EntityFrameworkCore/Codegen/EnrollDbContextInTransaction.cs @@ -63,6 +63,47 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) writer.FinishBlock(); } + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + // This middleware only ever runs inside an async handler/endpoint, so the body is a `task { }` + // computation expression: awaits are `do!` (or `let! _ =` when a result must be discarded), + // and `.ConfigureAwait(false)` is dropped (the CE controls scheduling). + writer.Write(""); + writer.WriteComment( + "Enroll the DbContext & IMessagingContext in the outgoing Wolverine outbox transaction"); + writer.Write($"{_envelopeTransaction.FSharpAssignmentUsage} = {typeof(EfCoreEnvelopeTransaction).FSharpName()}({_dbContext.FSharpUsage}, {_context!.FSharpUsage}, {_scrapers.FSharpUsage})"); + writer.Write($"do! {_context.FSharpUsage}.{nameof(MessageContext.EnlistInOutboxAsync)}({_envelopeTransaction.FSharpUsage})"); + + writer.WriteComment("Start the actual database transaction if one does not already exist"); + // F# has no `== null`; use `isNull`. BeginTransactionAsync returns Task, + // so bind-and-discard with `let! _ =` (then `()` makes the then-branch unit, as `if/then` requires). + writer.Write($"BLOCK:if isNull {_dbContext.FSharpUsage}.Database.CurrentTransaction then"); + writer.Write($"let! _ = {_dbContext.FSharpUsage}.Database.BeginTransactionAsync({_cancellation.FSharpUsage})"); + writer.Write("()"); + writer.FinishBlock(); + + writer.Write("BLOCK:try"); + + // EF Core can only do eager idempotent checks + if (_idempotencyStyle == IdempotencyStyle.Eager || _idempotencyStyle == IdempotencyStyle.Optimistic) + { + writer.Write($"do! {_context.FSharpUsage}.{nameof(MessageContext.AssertEagerIdempotencyAsync)}({_cancellation.FSharpUsage})"); + } + + // See the C# overload for why the commit/flush lives in Next (the CommitEfCoreEnvelopeTransaction + // postprocessor) rather than here. GH-2917. + Next?.GenerateFSharpCode(method, writer); + + writer.FinishBlock(); + // F# exception handler: roll back, then rethrow. `reraise()` is illegal inside a computation + // expression's try/with (FS0413), so use ExceptionDispatchInfo to preserve the original stack + // trace — the exact semantics of C# `throw;`. + writer.Write("BLOCK:with ex ->"); + writer.Write($"do! {_envelopeTransaction.FSharpUsage}.RollbackAsync()"); + writer.Write($"{typeof(System.Runtime.ExceptionServices.ExceptionDispatchInfo).FSharpName()}.Capture(ex).Throw()"); + writer.FinishBlock(); + } + public override IEnumerable FindVariables(IMethodVariables chain) { _scrapers = chain.FindVariable(typeof(IEnumerable)); @@ -99,6 +140,14 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + writer.WriteComment( + "Commit the EF Core transaction and flush outgoing messages before writing the response (GH-2917)"); + writer.Write($"do! {_envelopeTransaction.FSharpUsage}.CommitAsync({_cancellation.FSharpUsage})"); + Next?.GenerateFSharpCode(method, writer); + } + public override IEnumerable FindVariables(IMethodVariables chain) { _envelopeTransaction = chain.FindVariable(typeof(EfCoreEnvelopeTransaction)); diff --git a/src/Samples/WolverineFSharpSample/Domain.fs b/src/Samples/WolverineFSharpSample/Domain.fs new file mode 100644 index 000000000..f43f1f190 --- /dev/null +++ b/src/Samples/WolverineFSharpSample/Domain.fs @@ -0,0 +1,27 @@ +namespace WolverineFSharpSample + +open System +open Microsoft.EntityFrameworkCore + +/// The EF Core entity persisted by the sample. +[] +type Item = { Id: Guid; Name: string } + +/// The command handled by CreateItemHandler. +type CreateItemCommand = { Name: string } + +/// The event cascaded out after the item is created. +type ItemCreated = { Id: Guid } + +/// The sample's EF Core DbContext. Wolverine's EF Core integration enrolls this in its +/// transactional outbox; the generated handler adapter constructs/commits an EfCoreEnvelopeTransaction +/// around it. +type ItemsDbContext(options: DbContextOptions) = + inherit DbContext(options) + + [] + val mutable private items: DbSet + + member this.Items + with get () = this.items + and set v = this.items <- v diff --git a/src/Samples/WolverineFSharpSample/Handlers.fs b/src/Samples/WolverineFSharpSample/Handlers.fs new file mode 100644 index 000000000..c58c9adbb --- /dev/null +++ b/src/Samples/WolverineFSharpSample/Handlers.fs @@ -0,0 +1,15 @@ +namespace WolverineFSharpSample + +open System +open Wolverine.Attributes + +/// An EF Core transactional message handler written in F#. Mirrors the C# EFCoreSample handler: +/// [], the command + injected DbContext, add an entity, and return an event that +/// Wolverine sends as a cascading message. Wolverine's EF Core middleware wraps this in the outbox +/// transaction (enroll -> SaveChanges -> commit), all of which the F# codegen now emits. +type CreateItemHandler = + [] + static member Handle(command: CreateItemCommand, db: ItemsDbContext) : ItemCreated = + let item = { Id = Guid.NewGuid(); Name = command.Name } + db.Items.Add(item) |> ignore + { Id = item.Id } diff --git a/src/Samples/WolverineFSharpSample/Program.fs b/src/Samples/WolverineFSharpSample/Program.fs new file mode 100644 index 000000000..176b42988 --- /dev/null +++ b/src/Samples/WolverineFSharpSample/Program.fs @@ -0,0 +1,59 @@ +module WolverineFSharpSample.Program + +open Microsoft.EntityFrameworkCore +open Microsoft.Extensions.Hosting +open Microsoft.Extensions.DependencyInjection +open JasperFx.Resources +open Wolverine +open Wolverine.EntityFrameworkCore +open Wolverine.Postgresql +open WolverineFSharpSample + +// The same Postgres instance the rest of the Wolverine test suite uses (docker-compose, :5433). +// Wolverine's EF Core outbox requires a durable message store, so the sample is not infra-free; the +// *static* F# story (no DB, no host) is what the compile-gate test proves. +// A dedicated database so EF Core's EnsureCreated() provisions the Item table on a fresh DB (it +// no-ops against the shared `postgres` DB, which already has tables). +[] +let connectionString = + "Host=localhost;Port=5433;Database=wolverine_fsharp_sample;Username=postgres;password=postgres" + +[] +let main args = + let host = + Host + .CreateDefaultBuilder(args) + .UseWolverine(fun opts -> + // Register the DbContext with Wolverine's EF Core outbox integration. + opts.Services.AddDbContextWithWolverineIntegration(fun o -> + o.UseNpgsql(connectionString) |> ignore) + |> ignore + + // Durable message store backing the transactional outbox. + opts.PersistMessagesWithPostgresql(connectionString) |> ignore + + opts.UseEntityFrameworkCoreTransactions() |> ignore + opts.Policies.AutoApplyTransactions() |> ignore + opts.Discovery.IncludeType() |> ignore + + // Core Wolverine dropped the in-box Roslyn compiler (GH-2876); enable it so this demo + // runs via dynamic codegen. (The static F# story is proven by the compile-gate test.) + opts.UseRuntimeCompilation() |> ignore) + // Provision the Wolverine message-store tables on startup. + .UseResourceSetupOnStartup() + .Build() + + // Create the database + the DbContext's Item table BEFORE starting the host, so Wolverine's + // message-store resource setup (on Start) finds an existing database to provision into. + (use scope = host.Services.CreateScope() + scope.ServiceProvider.GetRequiredService().Database.EnsureCreated() |> ignore) + + host.Start() + + // Demonstrate the F# EF Core handler end-to-end (dynamic codegen). + let bus = host.Services.GetRequiredService() + bus.InvokeAsync({ Name = "Sample" }).GetAwaiter().GetResult() + printfn "Created an Item through the F# Wolverine + EF Core handler." + + host.StopAsync().GetAwaiter().GetResult() + 0 diff --git a/src/Samples/WolverineFSharpSample/README.md b/src/Samples/WolverineFSharpSample/README.md new file mode 100644 index 000000000..df321da58 --- /dev/null +++ b/src/Samples/WolverineFSharpSample/README.md @@ -0,0 +1,43 @@ +# WolverineFSharpSample + +A small **F#** Wolverine application demonstrating the F# code-generation approach (issue +[GH-2969](https://github.com/JasperFx/wolverine/issues/2969)). This is the first slice of the F# +sample and exercises **EF Core**: + +- `Domain.fs` — an `Item` entity, an `ItemsDbContext`, a `CreateItemCommand`, and an `ItemCreated` event. +- `Handlers.fs` — `CreateItemHandler.Handle`, a `[]` handler that takes the command + + the `ItemsDbContext`, adds an `Item`, and returns an `ItemCreated` (cascaded through the outbox). +- `Program.fs` — a generic host wiring `UseWolverine` + `AddDbContextWithWolverineIntegration` + + `UseEntityFrameworkCoreTransactions` + `AutoApplyTransactions`, backed by a Postgres message store. + +## Two things this proves + +1. **It runs (dynamic codegen).** `dotnet run` boots the app, invokes a `CreateItemCommand`, and the + F# handler writes through EF Core inside Wolverine's transactional outbox. 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.EfCore.FSharpTests` renders this + handler's real chain to F# via Wolverine's static codegen path and `dotnet build`s the result, + proving the EF Core transactional frames (scoped-DI resolution, `EnrollDbContextInTransaction`, + `SaveChangesAsync`, `CommitEfCoreEnvelopeTransaction`) emit valid, compiling F#. + +## Running it + +Wolverine's EF Core outbox needs a durable message store, 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/WolverineFSharpSample --framework net9.0 +``` + +Expected output: `Created an Item through the F# Wolverine + EF Core handler.` + +The sample uses a dedicated `wolverine_fsharp_sample` database (EF Core `EnsureCreated()` provisions +the `Item` table; `UseResourceSetupOnStartup()` provisions the Wolverine message-store tables). + +## Later slices + +Per #2969, the sample grows to mix Marten (document + event-sourced aggregate) and richer HTTP, each +driving the remaining store-specific frames' F# emit toward complete coverage. diff --git a/src/Samples/WolverineFSharpSample/WolverineFSharpSample.fsproj b/src/Samples/WolverineFSharpSample/WolverineFSharpSample.fsproj new file mode 100644 index 000000000..470412e0f --- /dev/null +++ b/src/Samples/WolverineFSharpSample/WolverineFSharpSample.fsproj @@ -0,0 +1,48 @@ + + + + + + Exe + net9.0 + false + + + + false + disable + false + false + + + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Testing/Wolverine.EfCore.FSharpFixture/Generated.fs b/src/Testing/Wolverine.EfCore.FSharpFixture/Generated.fs new file mode 100644 index 000000000..21b485c40 --- /dev/null +++ b/src/Testing/Wolverine.EfCore.FSharpFixture/Generated.fs @@ -0,0 +1,56 @@ +// + +namespace Internal.Generated.WolverineHandlers + +open Microsoft.Extensions.DependencyInjection +open System +open System.Collections.Generic +open System.Threading +open System.Threading.Tasks +open Wolverine.Runtime +open Wolverine.Runtime.Handlers + +type CreateItemCommandHandler670389475(serviceScopeFactory: Microsoft.Extensions.DependencyInjection.IServiceScopeFactory, domainEventScraperIEnumerable: System.Collections.Generic.IEnumerable) = + inherit Wolverine.Runtime.Handlers.MessageHandler() + let _serviceScopeFactory = serviceScopeFactory + let _domainEventScraperIEnumerable = domainEventScraperIEnumerable + + override this.HandleAsync(context: Wolverine.Runtime.MessageContext, cancellation: System.Threading.CancellationToken) : System.Threading.Tasks.Task = + task { + use serviceScope = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.CreateAsyncScope(_serviceScopeFactory) + // This service has been marked as requiring service location independent of Wolverine's ability to use constructor injection of everything else + let itemsDbContext = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(serviceScope.ServiceProvider) + // The actual message body + let createItemCommand = context.Envelope.Message :?> WolverineFSharpSample.CreateItemCommand + + + // Enroll the DbContext & IMessagingContext in the outgoing Wolverine outbox transaction + let efCoreEnvelopeTransaction = Wolverine.EntityFrameworkCore.Internals.EfCoreEnvelopeTransaction(itemsDbContext, context, _domainEventScraperIEnumerable) + do! context.EnlistInOutboxAsync(efCoreEnvelopeTransaction) + // Start the actual database transaction if one does not already exist + if isNull itemsDbContext.Database.CurrentTransaction then + let! _ = itemsDbContext.Database.BeginTransactionAsync(cancellation) + () + try + if not (isNull System.Diagnostics.Activity.Current) then + System.Diagnostics.Activity.Current.SetTag("message.handler", "WolverineFSharpSample.CreateItemHandler") |> ignore + System.Diagnostics.Activity.Current.SetTag("handler.type", "WolverineFSharpSample.CreateItemHandler") |> ignore + + // The actual message execution + let outgoing1 = WolverineFSharpSample.CreateItemHandler.Handle(createItemCommand, itemsDbContext) + + + // Outgoing, cascaded message + do! context.EnqueueCascadingAsync(outgoing1) + + + // Added by EF Core Transaction Middleware + let! result_of_SaveChangesAsync = itemsDbContext.SaveChangesAsync(cancellation) + + // Commit the EF Core transaction and flush outgoing messages before writing the response (GH-2917) + do! efCoreEnvelopeTransaction.CommitAsync(cancellation) + with ex -> + do! efCoreEnvelopeTransaction.RollbackAsync() + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex).Throw() + } + diff --git a/src/Testing/Wolverine.EfCore.FSharpFixture/Wolverine.EfCore.FSharpFixture.fsproj b/src/Testing/Wolverine.EfCore.FSharpFixture/Wolverine.EfCore.FSharpFixture.fsproj new file mode 100644 index 000000000..7bd433bc3 --- /dev/null +++ b/src/Testing/Wolverine.EfCore.FSharpFixture/Wolverine.EfCore.FSharpFixture.fsproj @@ -0,0 +1,37 @@ + + + + + + net9.0 + false + + + false + disable + false + false + + true + + + + + + + + + + + + + + + + diff --git a/src/Testing/Wolverine.EfCore.FSharpTests/EfCoreFSharpCodegenSample.cs b/src/Testing/Wolverine.EfCore.FSharpTests/EfCoreFSharpCodegenSample.cs new file mode 100644 index 000000000..75d1a675a --- /dev/null +++ b/src/Testing/Wolverine.EfCore.FSharpTests/EfCoreFSharpCodegenSample.cs @@ -0,0 +1,76 @@ +using System.Runtime.CompilerServices; +using JasperFx.CodeGeneration; +using JasperFx.CodeGeneration.Model; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Wolverine; +using Wolverine.EntityFrameworkCore; +using Wolverine.Runtime.Handlers; +using WolverineFSharpSample; + +namespace Wolverine.EfCore.FSharpTests; + +/// +/// Renders the sample's EF Core handler chain (issue GH-2969) as F#. Builds a minimal in-memory +/// host that discovers with EF Core transactional middleware +/// enabled, compiles the handler graph without starting it, and emits the adapter as F# via +/// — exercising the EF Core transactional +/// frames (enroll-in-outbox, SaveChanges, commit). +/// +public static class EfCoreFSharpCodegenSample +{ + public static string GenerateCode() + { + DynamicCodeBuilder.WithinCodegenCommand = true; + try + { + using var host = Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services.AddDbContextWithWolverineIntegration(o => + o.UseInMemoryDatabase("items").ConfigureWarnings(w => + w.Ignore(InMemoryEventId.TransactionIgnoredWarning))); + + opts.UseEntityFrameworkCoreTransactions(); + opts.Policies.AutoApplyTransactions(); + + opts.Discovery.DisableConventionalDiscovery(); + opts.Discovery.IncludeType(); + }) + .Build(); + + // Force HandlerGraph.Compile() without starting the host (no Roslyn, no real DB). + _ = host.Services.GetServices().ToArray(); + + var handlerGraph = host.Services.GetRequiredService(); + var chain = handlerGraph.ChainFor(typeof(CreateItemCommand)) + ?? throw new InvalidOperationException("No handler chain was built for CreateItemCommand."); + + var serviceVariableSource = host.Services.GetService(); + var generatedAssembly = handlerGraph.StartAssembly(handlerGraph.Rules); + ((ICodeFile)chain).AssembleTypes(generatedAssembly); + + return generatedAssembly.GenerateFSharpCode(serviceVariableSource); + } + finally + { + DynamicCodeBuilder.WithinCodegenCommand = false; + } + } + + public static string DefaultGeneratedFilePath([CallerFilePath] string thisFile = "") + { + var testProjectDir = Path.GetDirectoryName(thisFile)!; + var srcTestingDir = Path.GetDirectoryName(testProjectDir)!; + return Path.Combine(srcTestingDir, "Wolverine.EfCore.FSharpFixture", "Generated.fs"); + } + + public static string FixtureProjectPath([CallerFilePath] string thisFile = "") + { + var testProjectDir = Path.GetDirectoryName(thisFile)!; + var srcTestingDir = Path.GetDirectoryName(testProjectDir)!; + return Path.Combine(srcTestingDir, "Wolverine.EfCore.FSharpFixture", "Wolverine.EfCore.FSharpFixture.fsproj"); + } +} diff --git a/src/Testing/Wolverine.EfCore.FSharpTests/EfCoreFSharpCompileGate.cs b/src/Testing/Wolverine.EfCore.FSharpTests/EfCoreFSharpCompileGate.cs new file mode 100644 index 000000000..c42e9d717 --- /dev/null +++ b/src/Testing/Wolverine.EfCore.FSharpTests/EfCoreFSharpCompileGate.cs @@ -0,0 +1,65 @@ +using System.Diagnostics; +using Shouldly; +using Xunit; +using Xunit.Abstractions; + +namespace Wolverine.EfCore.FSharpTests; + +/// +/// The EF Core acceptance gate (issue GH-2969): regenerates the fixture's Generated.fs from +/// the sample's EF Core handler chain, then shells dotnet build on the checked-in F# fixture +/// and asserts a clean build. Mirrors the Core/Http surface gates. +/// +public class EfCoreFSharpCompileGate +{ + private readonly ITestOutputHelper _output; + + public EfCoreFSharpCompileGate(ITestOutputHelper output) + { + _output = output; + } + + [Fact] + public void generated_fsharp_compiles_via_dotnet_build() + { + var code = EfCoreFSharpCodegenSample.GenerateCode(); + var generatedFile = EfCoreFSharpCodegenSample.DefaultGeneratedFilePath(); + File.WriteAllText(generatedFile, code); + + File.Exists(generatedFile).ShouldBeTrue(); + _output.WriteLine(code); + + var fixtureProject = EfCoreFSharpCodegenSample.FixtureProjectPath(); + var (exitCode, output) = RunDotnet($"build \"{fixtureProject}\" -c Debug --nologo"); + + // Retry once on the transient FS0193 internal-compiler crash or a concurrent-build file lock. + if (exitCode != 0 && (output.Contains("FS0193") || output.Contains("internal error") + || output.Contains("being used by another process") + || output.Contains("MSB3883"))) + { + (exitCode, output) = RunDotnet($"build \"{fixtureProject}\" -c Debug --nologo"); + } + + _output.WriteLine(output); + exitCode.ShouldBe(0); + } + + private static (int ExitCode, string Output) RunDotnet(string arguments) + { + var info = new ProcessStartInfo("dotnet", arguments) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false + }; + info.Environment["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0"; + info.Environment["MSBUILDDISABLENODEREUSE"] = "1"; + + using var process = Process.Start(info)!; + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + process.WaitForExit(); + + return (process.ExitCode, stdout.GetAwaiter().GetResult() + stderr.GetAwaiter().GetResult()); + } +} diff --git a/src/Testing/Wolverine.EfCore.FSharpTests/Wolverine.EfCore.FSharpTests.csproj b/src/Testing/Wolverine.EfCore.FSharpTests/Wolverine.EfCore.FSharpTests.csproj new file mode 100644 index 000000000..a992ee36c --- /dev/null +++ b/src/Testing/Wolverine.EfCore.FSharpTests/Wolverine.EfCore.FSharpTests.csproj @@ -0,0 +1,32 @@ + + + + + + net9.0 + false + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + diff --git a/wolverine_fsharp.slnx b/wolverine_fsharp.slnx index b3ae2c921..afc4ca0c5 100644 --- a/wolverine_fsharp.slnx +++ b/wolverine_fsharp.slnx @@ -16,9 +16,13 @@ + + + +