diff --git a/src/Persistence/MartenTests/Dcb/dedup_load_boundary_frame_tests.cs b/src/Persistence/MartenTests/Dcb/dedup_load_boundary_frame_tests.cs new file mode 100644 index 000000000..1bfe6c8f1 --- /dev/null +++ b/src/Persistence/MartenTests/Dcb/dedup_load_boundary_frame_tests.cs @@ -0,0 +1,145 @@ +using IntegrationTests; +using JasperFx.Events; +using JasperFx.Events.Tags; +using JasperFx.Resources; +using Marten; +using Marten.Events; +using MartenTests.AncillaryStores; +using MartenTests.Dcb.University; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Npgsql; +using Shouldly; +using Wolverine; +using Wolverine.Marten; +using Wolverine.Tracking; + +namespace MartenTests.Dcb; + +// Regression: two [BoundaryModel] parameters on the same chain (Validate + +// Handle) used to emit duplicate var declarations -> CS0128. +public record TwoBoundaryModelParamsCommand(StudentId StudentId, CourseId CourseId); + +public static class TwoBoundaryModelParamsHandler +{ + public static EventTagQuery Load(TwoBoundaryModelParamsCommand command) + => EventTagQuery + .For(command.CourseId) + .AndEventsOfType() + .Or(command.StudentId) + .AndEventsOfType(); + + public static HandlerContinuation Validate( + TwoBoundaryModelParamsCommand command, + [BoundaryModel] SubscriptionState state, + ILogger logger) + { + if (state.StudentId == null) + { + logger.LogDebug("Student {StudentId} not enrolled", command.StudentId); + return HandlerContinuation.Stop; + } + + return HandlerContinuation.Continue; + } + + public static StudentSubscribedToCourse Handle( + TwoBoundaryModelParamsCommand command, + [BoundaryModel] SubscriptionState state) + { + return new StudentSubscribedToCourse(FacultyId.Default, command.StudentId, command.CourseId); + } +} + +public class dedup_load_boundary_frame_tests : PostgresqlContext, IAsyncLifetime +{ + private IHost theHost = null!; + private IDocumentStore theStore = null!; + + public async Task InitializeAsync() + { + await using (var conn = new NpgsqlConnection(Servers.PostgresConnectionString)) + { + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = "DROP SCHEMA IF EXISTS dcb_dedup_tests CASCADE;"; + await cmd.ExecuteNonQueryAsync(); + } + + theHost = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services.AddMarten(m => + { + m.Connection(Servers.PostgresConnectionString); + m.DatabaseSchemaName = "dcb_dedup_tests"; + + m.Events.RegisterTagType("student") + .ForAggregate(); + m.Events.RegisterTagType("course") + .ForAggregate(); + m.Events.RegisterTagType("faculty"); + + m.Projections.LiveStreamAggregation(); + + m.Events.AddEventType(); + m.Events.AddEventType(); + m.Events.AddEventType(); + m.Events.AddEventType(); + m.Events.AddEventType(); + + m.Events.StreamIdentity = StreamIdentity.AsString; + + m.DisableNpgsqlLogging = true; + }) + .UseLightweightSessions() + .IntegrateWithWolverine(); + + opts.Discovery.DisableConventionalDiscovery() + .IncludeType(typeof(TwoBoundaryModelParamsHandler)); + opts.Durability.Mode = DurabilityMode.Solo; + opts.Services.AddResourceSetupOnStartup(); + }).StartAsync(); + + theStore = theHost.Services.GetRequiredService(); + } + + public async Task DisposeAsync() + { + await theHost.StopAsync(); + theHost.Dispose(); + } + + [Fact] + public async Task chain_with_two_boundary_model_parameters_compiles_and_runs() + { + var courseId = CourseId.Random(); + var studentId = StudentId.Random(); + + await using (var session = theStore.LightweightSession()) + { + var courseCreated = session.Events.BuildEvent( + new CourseCreated(FacultyId.Default, courseId, "Math 101", 10)); + courseCreated.WithTag(courseId); + session.Events.Append(courseId.Value, courseCreated); + + var enrolled = session.Events.BuildEvent( + new StudentEnrolledInFaculty(FacultyId.Default, studentId, "Alice", "Smith")); + enrolled.WithTag(studentId); + session.Events.Append(studentId.Value, enrolled); + + await session.SaveChangesAsync(); + } + + // Pre-fix: this throws at handler-compilation with CS0128. + await theHost.InvokeMessageAndWaitAsync( + new TwoBoundaryModelParamsCommand(studentId, courseId)); + + await using var verifySession = theStore.LightweightSession(); + var events = await verifySession.Events.QueryByTagsAsync( + new EventTagQuery().Or(studentId)); + + events.ShouldContain(e => e.Data is StudentSubscribedToCourse); + } +} diff --git a/src/Persistence/Wolverine.Marten/BoundaryModelAttribute.cs b/src/Persistence/Wolverine.Marten/BoundaryModelAttribute.cs index adc7d5ae4..ef0b4d4f5 100644 --- a/src/Persistence/Wolverine.Marten/BoundaryModelAttribute.cs +++ b/src/Persistence/Wolverine.Marten/BoundaryModelAttribute.cs @@ -40,6 +40,8 @@ public OnMissing OnMissing set => _onMissing = value; } + [UnconditionalSuppressMessage("Trimming", "IL2062", + Justification = "aggregateType originates from parameter.ParameterType; AOT consumers preserve it via DynamicDependency / source-generator registration.")] [UnconditionalSuppressMessage("Trimming", "IL2065", Justification = "MakeGenericType closes IEventBoundary; GetProperty(nameof(IEventBoundary.Aggregate)) is statically referenced via nameof and the closed-generic IEventBoundary preserves the Aggregate property by virtue of being instantiated by codegen. AOT consumers pre-generate via TypeLoadMode.Static.")] [UnconditionalSuppressMessage("AOT", "IL3050", @@ -85,9 +87,16 @@ public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceC new MartenPersistenceFrameProvider().ApplyTransactionSupport(chain, container); - // The EventTagQuery variable will be resolved lazily from the Load method's return value - var loader = new LoadBoundaryFrame(aggregateType); - chain.Middleware.Add(loader); + // One fetch per (chain, aggregate type). A second [BoundaryModel] of + // the same type (e.g. on Validate plus Handle) reuses the same frame, + // otherwise both emit identically-named "var" declarations -> CS0128. + var loader = chain.Middleware.OfType() + .FirstOrDefault(f => f.AggregateType == aggregateType); + if (loader == null) + { + loader = new LoadBoundaryFrame(aggregateType); + chain.Middleware.Add(loader); + } var boundary = loader.Boundary; diff --git a/src/Persistence/Wolverine.Marten/Codegen/LoadBoundaryFrame.cs b/src/Persistence/Wolverine.Marten/Codegen/LoadBoundaryFrame.cs index 361000612..a0c8c5569 100644 --- a/src/Persistence/Wolverine.Marten/Codegen/LoadBoundaryFrame.cs +++ b/src/Persistence/Wolverine.Marten/Codegen/LoadBoundaryFrame.cs @@ -10,7 +10,6 @@ namespace Wolverine.Marten.Codegen; internal class LoadBoundaryFrame : AsyncFrame { - private readonly Type _aggregateType; private Variable? _query; private Variable? _session; private Variable? _token; @@ -18,12 +17,14 @@ internal class LoadBoundaryFrame : AsyncFrame public LoadBoundaryFrame(Type aggregateType, Variable? query = null) { - _aggregateType = aggregateType; + AggregateType = aggregateType; _query = query; _boundaryType = typeof(IEventBoundary<>).MakeGenericType(aggregateType); Boundary = new Variable(_boundaryType, this); } + public Type AggregateType { get; } + public Variable Boundary { get; } public override IEnumerable FindVariables(IMethodVariables chain) @@ -42,7 +43,7 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) { writer.WriteComment("Loading DCB boundary model via FetchForWritingByTags"); writer.WriteLine( - $"var {Boundary.Usage} = await {_session!.Usage}.Events.FetchForWritingByTags<{_aggregateType.FullNameInCode()}>({_query!.Usage}, {_token!.Usage});"); + $"var {Boundary.Usage} = await {_session!.Usage}.Events.FetchForWritingByTags<{AggregateType.FullNameInCode()}>({_query!.Usage}, {_token!.Usage});"); Next?.GenerateCode(method, writer); } diff --git a/src/Persistence/Wolverine.Polecat/BoundaryModelAttribute.cs b/src/Persistence/Wolverine.Polecat/BoundaryModelAttribute.cs index 07ad6f2bb..2ad46442b 100644 --- a/src/Persistence/Wolverine.Polecat/BoundaryModelAttribute.cs +++ b/src/Persistence/Wolverine.Polecat/BoundaryModelAttribute.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Reflection; using JasperFx; using JasperFx.CodeGeneration; @@ -36,6 +37,12 @@ public OnMissing OnMissing set => _onMissing = value; } + [UnconditionalSuppressMessage("Trimming", "IL2062", + Justification = "aggregateType originates from parameter.ParameterType; AOT consumers preserve it via DynamicDependency / source-generator registration.")] + [UnconditionalSuppressMessage("Trimming", "IL2065", + Justification = "MakeGenericType closes IEventBoundary; GetProperty(nameof(IEventBoundary.Aggregate)) is statically referenced via nameof and the closed-generic IEventBoundary preserves the Aggregate property by virtue of being instantiated by codegen. AOT consumers pre-generate via TypeLoadMode.Static.")] + [UnconditionalSuppressMessage("AOT", "IL3050", + Justification = "MakeGenericType closes IEventBoundary at codegen time; AOT consumers pre-generate via TypeLoadMode.Static.")] public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceContainer container, GenerationRules rules) { @@ -73,8 +80,13 @@ public override Variable Modify(IChain chain, ParameterInfo parameter, IServiceC new PolecatPersistenceFrameProvider().ApplyTransactionSupport(chain, container); - var loader = new LoadBoundaryFrame(aggregateType); - chain.Middleware.Add(loader); + var loader = chain.Middleware.OfType() + .FirstOrDefault(f => f.AggregateType == aggregateType); + if (loader == null) + { + loader = new LoadBoundaryFrame(aggregateType); + chain.Middleware.Add(loader); + } var boundary = loader.Boundary; diff --git a/src/Persistence/Wolverine.Polecat/Codegen/LoadBoundaryFrame.cs b/src/Persistence/Wolverine.Polecat/Codegen/LoadBoundaryFrame.cs index 52d5c7a5f..bb1e3c6e5 100644 --- a/src/Persistence/Wolverine.Polecat/Codegen/LoadBoundaryFrame.cs +++ b/src/Persistence/Wolverine.Polecat/Codegen/LoadBoundaryFrame.cs @@ -10,7 +10,6 @@ namespace Wolverine.Polecat.Codegen; internal class LoadBoundaryFrame : AsyncFrame { - private readonly Type _aggregateType; private Variable? _query; private Variable? _session; private Variable? _token; @@ -18,12 +17,14 @@ internal class LoadBoundaryFrame : AsyncFrame public LoadBoundaryFrame(Type aggregateType, Variable? query = null) { - _aggregateType = aggregateType; + AggregateType = aggregateType; _query = query; _boundaryType = typeof(IEventBoundary<>).MakeGenericType(aggregateType); Boundary = new Variable(_boundaryType, this); } + public Type AggregateType { get; } + public Variable Boundary { get; } public override IEnumerable FindVariables(IMethodVariables chain) @@ -42,7 +43,7 @@ public override void GenerateCode(GeneratedMethod method, ISourceWriter writer) { writer.WriteComment("Loading DCB boundary model via FetchForWritingByTags"); writer.WriteLine( - $"var {Boundary.Usage} = await {_session!.Usage}.Events.FetchForWritingByTags<{_aggregateType.FullNameInCode()}>({_query!.Usage}, {_token!.Usage});"); + $"var {Boundary.Usage} = await {_session!.Usage}.Events.FetchForWritingByTags<{AggregateType.FullNameInCode()}>({_query!.Usage}, {_token!.Usage});"); Next?.GenerateCode(method, writer); }