diff --git a/src/CoreTests/Bugs/Bug_4185_codegen_conflict_projection_with_secondary_store_dependency.cs b/src/CoreTests/Bugs/Bug_4185_codegen_conflict_projection_with_secondary_store_dependency.cs new file mode 100644 index 0000000000..5d5d25bcd8 --- /dev/null +++ b/src/CoreTests/Bugs/Bug_4185_codegen_conflict_projection_with_secondary_store_dependency.cs @@ -0,0 +1,223 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using JasperFx.CodeGeneration; +using JasperFx.Core; +using JasperFx.Events.Projections; +using JasperFx.RuntimeCompiler; +using Marten; +using Marten.Events.Aggregation; +using Marten.Testing.Harness; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Xunit; + +namespace CoreTests.Bugs; + +public interface IBug4185Store : IDocumentStore { } +public interface IBug4185OtherStore : IDocumentStore { } + +public record OrderPlaced4185(string ProductName, decimal UnitPrice, int Quantity); +public record OrderShipped4185(DateTime ShippedAt); + +public class OrderSummary4185 +{ + public Guid Id { get; set; } + public string ProductName { get; set; } + public decimal Total { get; set; } + public bool Shipped { get; set; } +} + +/// +/// Projection on the primary store that directly injects a secondary store. +/// This is the pattern that causes the codegen conflict. +/// +public class OrderProjection4185 : SingleStreamProjection +{ + private readonly IBug4185Store _secondaryStore; + + public OrderProjection4185(IBug4185Store secondaryStore) + { + _secondaryStore = secondaryStore; + } + + public OrderSummary4185 Create(OrderPlaced4185 e) + { + return new OrderSummary4185 + { + ProductName = e.ProductName, + Total = e.UnitPrice * e.Quantity + }; + } + + public void Apply(OrderShipped4185 e, OrderSummary4185 summary) + { + summary.Shipped = true; + } +} + +/// +/// Reproduces https://github.com/JasperFx/marten/issues/4185 +/// +/// When a secondary document store is registered, the codegen write command +/// (DynamicCodeBuilder.WriteGeneratedCode) iterates all ICodeFileCollection +/// instances. The SecondaryDocumentStores collection writes the store +/// implementation to {base}/Stores/{StoreImpl}.cs. +/// +/// Separately, at runtime, SecondaryStoreConfig.Build() calls +/// InitializeSynchronously() with rules from CreateGenerationRules() which +/// include the StoreName in the path, writing the same class to +/// {base}/{StoreName}/Stores/{StoreImpl}.cs. +/// +/// Both files share namespace Marten.Generated.Stores and the same class name, +/// causing CS0101 duplicate type definition when using TypeLoadMode.Static. +/// +public class Bug_4185_codegen_conflict_projection_with_secondary_store_dependency +{ + /// + /// Simulates what "dotnet run -- codegen write" does by using + /// DynamicCodeBuilder.WriteGeneratedCode(), then verifies that + /// no duplicate store implementation files are produced. + /// + [Fact] + public async Task codegen_write_should_not_produce_duplicate_secondary_store_implementations() + { + var tempDir = Path.Combine(Path.GetTempPath(), "bug4185_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(tempDir); + + try + { + using var host = await Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddMartenStore(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "bug4185_sec"; + opts.GeneratedCodeMode = TypeLoadMode.Auto; + opts.GeneratedCodeOutputPath = tempDir; + }); + + services.AddMartenStore(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "bug4185_oth"; + opts.GeneratedCodeMode = TypeLoadMode.Auto; + opts.GeneratedCodeOutputPath = tempDir; + }); + + services.AddMarten(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "bug4185_pri"; + opts.GeneratedCodeMode = TypeLoadMode.Auto; + opts.GeneratedCodeOutputPath = tempDir; + }); + }) + .StartAsync(); + + // Resolve the secondary stores — this triggers Build() which calls + // InitializeSynchronously() and writes generated code to + // {tempDir}/{StoreName}/Stores/ when SourceCodeWritingEnabled is true + host.Services.GetRequiredService(); + host.Services.GetRequiredService(); + + // Now simulate "dotnet run -- codegen write" using DynamicCodeBuilder. + // This writes generated code via each ICodeFileCollection's Rules, + // including SecondaryDocumentStores which writes to {tempDir}/Stores/ + var collections = host.Services.GetServices().ToArray(); + var codeBuilder = new DynamicCodeBuilder(host.Services, collections); + codeBuilder.WriteGeneratedCode(_ => { }); + + // Collect all generated .cs files + var allFiles = Directory.GetFiles(tempDir, "*.cs", SearchOption.AllDirectories); + + // Check that each generated file's fully qualified type name is unique. + // Read each file, extract the namespace + class name, and detect conflicts + // where the same fully-qualified type is generated to multiple locations. + var typeLocations = allFiles + .Select(f => new + { + Path = f.Replace(tempDir, ""), + Content = File.ReadAllText(f) + }) + .Select(f => + { + var nsMatch = System.Text.RegularExpressions.Regex.Match(f.Content, @"namespace\s+([\w.]+)"); + var classMatch = System.Text.RegularExpressions.Regex.Match(f.Content, @"class\s+(\w+)"); + return new + { + f.Path, + FullyQualifiedName = nsMatch.Success && classMatch.Success + ? $"{nsMatch.Groups[1].Value}.{classMatch.Groups[1].Value}" + : null + }; + }) + .Where(f => f.FullyQualifiedName != null) + .ToList(); + + var duplicates = typeLocations + .GroupBy(f => f.FullyQualifiedName) + .Where(g => g.Count() > 1) + .ToList(); + + duplicates.ShouldBeEmpty( + "codegen write produced duplicate types at different locations:\n" + + string.Join("\n", duplicates.Select(g => + $" {g.Key}:\n" + + string.Join("\n", g.Select(f => " " + f.Path))))); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, true); + } + } + } + + [Fact] + public async Task projection_with_secondary_store_dependency_should_work_at_runtime() + { + using var host = await Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + services.AddMartenStore(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "bug4185_sec"; + opts.GeneratedCodeMode = TypeLoadMode.Auto; + }); + + services.AddMarten(opts => + { + opts.Connection(ConnectionSource.ConnectionString); + opts.DatabaseSchemaName = "bug4185_pri"; + opts.GeneratedCodeMode = TypeLoadMode.Auto; + }) + .AddProjectionWithServices( + ProjectionLifecycle.Inline, + ServiceLifetime.Singleton) + .ApplyAllDatabaseChangesOnStartup(); + }) + .StartAsync(); + + var store = host.Services.GetRequiredService(); + var streamId = Guid.NewGuid(); + await using (var session = store.LightweightSession()) + { + session.Events.StartStream(streamId, new OrderPlaced4185("Widget", 9.99m, 3)); + await session.SaveChangesAsync(); + } + + await using (var session = store.QuerySession()) + { + var summary = await session.LoadAsync(streamId); + summary.ShouldNotBeNull(); + summary.ProductName.ShouldBe("Widget"); + summary.Total.ShouldBe(29.97m); + } + } +} diff --git a/src/Marten/Internal/SecondaryStoreConfig.cs b/src/Marten/Internal/SecondaryStoreConfig.cs index dbeb3af038..b78f1bbe5b 100644 --- a/src/Marten/Internal/SecondaryStoreConfig.cs +++ b/src/Marten/Internal/SecondaryStoreConfig.cs @@ -121,6 +121,11 @@ public T Build(IServiceProvider provider) var rules = options.CreateGenerationRules(); rules.GeneratedNamespace = SchemaConstants.MartenGeneratedNamespace; + // CreateGenerationRules() appends StoreName to the output path, but + // SecondaryDocumentStores.Rules (used by codegen write) strips it via + // ParentDirectory(). Align the paths to avoid writing duplicate files + // with the same namespace and class name to different directories (#4185) + rules.GeneratedCodeOutputPath = rules.GeneratedCodeOutputPath.ParentDirectory(); this.InitializeSynchronously(rules, Parent, provider); var store = (T)Activator.CreateInstance(_storeType!, options)!;