From 8362590ff723eaf06d16325e81ab3876d276baca Mon Sep 17 00:00:00 2001 From: "Jeremy D. Miller" Date: Fri, 29 May 2026 16:19:17 -0500 Subject: [PATCH] F# codegen: scoped-DI frame emit + bump to 2.2.6 (jasperfx#397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generated method that resolves a scoped service via service location uses two JasperFx.CodeGeneration.Services frames with no F# emit, so any handler injecting a scoped dependency (EF Core DbContext, Marten IDocumentSession, …) threw on the F# render. - ScopedContainerCreation: `use serviceScope = ServiceProviderServiceExtensions .CreateAsyncScope(serviceScopeFactory)` inside a task { } body (the CE's `use` awaits DisposeAsync), or `use serviceScope = serviceScopeFactory.CreateScope()` for a synchronous body; then postprocessors, then Next. - GetServiceFromScopedContainerFrame: `let x = ServiceProviderServiceExtensions .GetRequiredService(scoped)` (or GetRequiredKeyedService). The optional C# /* */ Header comment is not emitted on the F# path (invalid F# comment syntax). - Fixture: GeneratedScopedConsumer resolves an AddScoped(_ => …) lambda-factory dependency (forcing service location) and awaits it; the compile gate proves the scoped-DI F# compiles. Mirrors Bug_244's scoped-session scenario. - Bump JasperFxVersion 2.2.5 -> 2.2.6. Closes #397. Co-Authored-By: Claude Opus 4.8 (1M context) --- Directory.Build.props | 2 +- .../FSharpCodegenSample.cs | 33 ++++++++++++++++++- .../CodegenTests.FSharpFixture.fsproj | 6 ++++ src/CodegenTests.FSharpFixture/Generated.fs | 13 ++++++++ src/FSharpCodegenTarget/Contracts.cs | 26 +++++++++++++++ .../GetServiceFromScopedContainerFrame.cs | 18 ++++++++++ .../Services/ScopedContainerCreation.cs | 30 +++++++++++++++++ 7 files changed, 126 insertions(+), 2 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index bfa4e2a..5fa02c8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,7 +7,7 @@ JasperFx.Events, JasperFx.Events.SourceGenerator) set $(JasperFxVersion) so they always release together. Bump this one value to release the whole set. --> - 2.2.5 + 2.2.6 13 1570;1571;1572;1573;1574;1587;1591;1701;1702;1711;1735;0618 Jeremy D. Miller;Jaedyn Tonee diff --git a/src/CodegenTests.FSharp/FSharpCodegenSample.cs b/src/CodegenTests.FSharp/FSharpCodegenSample.cs index 8f56ac8..6c107dd 100644 --- a/src/CodegenTests.FSharp/FSharpCodegenSample.cs +++ b/src/CodegenTests.FSharp/FSharpCodegenSample.cs @@ -1,8 +1,11 @@ using System.Runtime.CompilerServices; using FSharpCodegenTarget; +using JasperFx; using JasperFx.CodeGeneration; using JasperFx.CodeGeneration.Frames; using JasperFx.CodeGeneration.Model; +using JasperFx.CodeGeneration.Services; +using Microsoft.Extensions.DependencyInjection; namespace CodegenTests.FSharp; @@ -58,10 +61,30 @@ public static GeneratedAssembly BuildSampleAssembly() AddSyncTaskHandlerType(assembly); AddCalculatorType(assembly); AddCastType(assembly); + AddScopedConsumerType(assembly); return assembly; } + /// + /// A handler that resolves a service-located and awaits it. + /// Exercises the scoped-DI frames: use serviceScope = …CreateAsyncScope(…) + + /// let scopedThing = …GetRequiredService<IScopedThing>(serviceScope.ServiceProvider) + /// (jasperfx#397). Rendered with a ServiceCollectionServerVariableSource whose IScopedThing is + /// an opaque scoped lambda factory (forcing service location). + /// + private static void AddScopedConsumerType(GeneratedAssembly assembly) + { + var type = assembly.AddType("GeneratedScopedConsumer", typeof(IScopedConsumerHandler)); + var method = type.MethodFor(nameof(IScopedConsumerHandler.Handle)); + + // No Target — IScopedThing is resolved by the service-variable source (scoped lambda factory), + // which inserts ScopedContainerCreation + GetServiceFromScopedContainerFrame. Two awaits make + // the body a task { } (AsyncTask), exercising the `use … CreateAsyncScope()` path. + method.Frames.Add(new MethodCall(typeof(IScopedThing), nameof(IScopedThing.DoAsync))); + method.Frames.Add(new MethodCall(typeof(IScopedThing), nameof(IScopedThing.DoAsync))); + } + /// /// Constructs a concrete and passes it to a service expecting the /// interface via a . Exercises the F# cast @@ -304,7 +327,15 @@ private static void AddAccumulatorType(GeneratedAssembly assembly) /// public static string GenerateCode() { - return BuildSampleAssembly().GenerateFSharpCode(); + // A service-variable source backed by a scoped lambda-factory registration so the + // GeneratedScopedConsumer resolves IScopedThing via service location (the scoped-DI frames). + // The other sample types use constructor injection and are unaffected by the source. + var services = new ServiceCollection(); + services.AddScoped(_ => new ScopedThing()); + var container = new ServiceContainer(services, services.BuildServiceProvider()); + var source = new ServiceCollectionServerVariableSource(container); + + return BuildSampleAssembly().GenerateFSharpCode(source); } /// diff --git a/src/CodegenTests.FSharpFixture/CodegenTests.FSharpFixture.fsproj b/src/CodegenTests.FSharpFixture/CodegenTests.FSharpFixture.fsproj index 9eab387..3f28994 100644 --- a/src/CodegenTests.FSharpFixture/CodegenTests.FSharpFixture.fsproj +++ b/src/CodegenTests.FSharpFixture/CodegenTests.FSharpFixture.fsproj @@ -25,4 +25,10 @@ + + + + + diff --git a/src/CodegenTests.FSharpFixture/Generated.fs b/src/CodegenTests.FSharpFixture/Generated.fs index 618c1dc..ae8bb1e 100644 --- a/src/CodegenTests.FSharpFixture/Generated.fs +++ b/src/CodegenTests.FSharpFixture/Generated.fs @@ -3,6 +3,7 @@ namespace FSharpCodegenTarget.Generated open FSharpCodegenTarget +open Microsoft.Extensions.DependencyInjection open System open System.Threading.Tasks @@ -112,3 +113,15 @@ type GeneratedThingHandler(thingDescriber: FSharpCodegenTarget.ThingDescriber) = let thing = FSharpCodegenTarget.Thing() _thingDescriber.Describe((thing :> FSharpCodegenTarget.IThing)) +type GeneratedScopedConsumer(serviceScopeFactory: Microsoft.Extensions.DependencyInjection.IServiceScopeFactory) = + let _serviceScopeFactory = serviceScopeFactory + + interface FSharpCodegenTarget.IScopedConsumerHandler with + member this.Handle() : System.Threading.Tasks.Task = + task { + use serviceScope = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.CreateAsyncScope(_serviceScopeFactory) + let scopedThing = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService(serviceScope.ServiceProvider) + do! scopedThing.DoAsync() + do! scopedThing.DoAsync() + } + diff --git a/src/FSharpCodegenTarget/Contracts.cs b/src/FSharpCodegenTarget/Contracts.cs index 1cd6460..45fd7f3 100644 --- a/src/FSharpCodegenTarget/Contracts.cs +++ b/src/FSharpCodegenTarget/Contracts.cs @@ -76,6 +76,32 @@ public interface ISyncTaskHandler Task HandleAsync(string name); } +/// +/// A scoped dependency + its consumer. Registering as an opaque +/// AddScoped<T>(_ => …) lambda factory forces JasperFx to resolve it via service +/// location, so the generated implementation emits the +/// scoped-DI frames (ScopedContainerCreation + GetServiceFromScopedContainerFrame). The async +/// DoAsync makes the handler a task { } body, exercising the +/// use … CreateAsyncScope() path. See jasperfx#397. +/// +public interface IScopedThing +{ + Task DoAsync(); +} + +public class ScopedThing : IScopedThing +{ + public Task DoAsync() + { + return Task.CompletedTask; + } +} + +public interface IScopedConsumerHandler +{ + Task Handle(); +} + /// /// A base class with a public instance method () and an abstract method to /// override (). The generated subclass overrides Compute and calls the diff --git a/src/JasperFx/CodeGeneration/Services/GetServiceFromScopedContainerFrame.cs b/src/JasperFx/CodeGeneration/Services/GetServiceFromScopedContainerFrame.cs index 001d734..a6a6237 100644 --- a/src/JasperFx/CodeGeneration/Services/GetServiceFromScopedContainerFrame.cs +++ b/src/JasperFx/CodeGeneration/Services/GetServiceFromScopedContainerFrame.cs @@ -77,4 +77,22 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } + + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + // NOTE: the optional Header is a C#-style code fragment (/* */ comments) and is intentionally + // not emitted on the F# path — those comment delimiters are invalid F#. + if (_serviceKey != null) + { + writer.Write( + $"{Variable.FSharpAssignmentUsage} = {typeof(ServiceProviderKeyedServiceExtensions).FSharpName()}.{nameof(ServiceProviderKeyedServiceExtensions.GetRequiredKeyedService)}<{Variable.VariableType.FSharpName()}>({_scoped.FSharpUsage}, {CodeFormatter.Write(_serviceKey)})"); + } + else + { + writer.Write( + $"{Variable.FSharpAssignmentUsage} = {typeof(ServiceProviderServiceExtensions).FSharpName()}.{nameof(ServiceProviderServiceExtensions.GetRequiredService)}<{Variable.VariableType.FSharpName()}>({_scoped.FSharpUsage})"); + } + + Next?.GenerateFSharpCode(method, writer); + } } \ No newline at end of file diff --git a/src/JasperFx/CodeGeneration/Services/ScopedContainerCreation.cs b/src/JasperFx/CodeGeneration/Services/ScopedContainerCreation.cs index 887d998..7adcffd 100644 --- a/src/JasperFx/CodeGeneration/Services/ScopedContainerCreation.cs +++ b/src/JasperFx/CodeGeneration/Services/ScopedContainerCreation.cs @@ -1,5 +1,6 @@ using JasperFx.CodeGeneration.Frames; using JasperFx.CodeGeneration.Model; +using JasperFx.Core.Reflection; using Microsoft.Extensions.DependencyInjection; namespace JasperFx.CodeGeneration.Services; @@ -104,4 +105,33 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) Next?.GenerateCode(method, writer); } + + public override void GenerateFSharpCode(GeneratedMethod method, ISourceWriter writer) + { + // F# `use` disposes the scope at the end of the enclosing scope, like `using var`. In a + // `task { }` body it binds an AsyncServiceScope (IAsyncDisposable) and the task CE's `use` + // awaits DisposeAsync. CreateAsyncScope is an extension method, so emit it as a static call to + // avoid relying on an `open`; CreateScope is a direct interface method. See jasperfx#397. + if (method.AsyncMode == AsyncMode.AsyncTask) + { + writer.Write( + $"use {Scope.Usage} = {typeof(ServiceProviderServiceExtensions).FSharpName()}.{nameof(ServiceProviderServiceExtensions.CreateAsyncScope)}({Factory.FSharpUsage})"); + } + else + { + writer.Write($"use {Scope.Usage} = {Factory.FSharpUsage}.{nameof(IServiceScopeFactory.CreateScope)}()"); + } + + if (_postprocessors.Count > 0) + { + for (var i = 1; i < _postprocessors.Count; i++) + { + _postprocessors[i - 1].Next = _postprocessors[i]; + } + + _postprocessors[0].GenerateFSharpCode(method, writer); + } + + Next?.GenerateFSharpCode(method, writer); + } }