diff --git a/Directory.Packages.props b/Directory.Packages.props index 0b5b1b4902..392de79f0b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,9 +13,9 @@ - - - + + + @@ -79,8 +79,8 @@ - - + + diff --git a/src/EventSourcingTests/Bugs/Bug_4199_natural_key_table_not_found.cs b/src/EventSourcingTests/Bugs/Bug_4199_natural_key_table_not_found.cs new file mode 100644 index 0000000000..c4a441c0db --- /dev/null +++ b/src/EventSourcingTests/Bugs/Bug_4199_natural_key_table_not_found.cs @@ -0,0 +1,106 @@ +using System; +using System.Threading.Tasks; +using JasperFx.Events.Aggregation; +using Marten; +using Marten.Events.Projections; +using Marten.Testing.Harness; +using Shouldly; +using Xunit; + +namespace EventSourcingTests.Bugs; + +public class Bug_4199_natural_key_table_not_found : OneOffConfigurationsContext +{ + public sealed record OrderNumber(string Value); + + public sealed record OrderPlaced(Guid OrderId, string OrderNumber); + + public sealed record OrderShipped(Guid OrderId, string TrackingNumber); + + public sealed class OrderAggregate + { + public Guid Id { get; set; } + + [NaturalKey] + public OrderNumber Number { get; set; } + + public string? TrackingNumber { get; set; } + + [NaturalKeySource] + public void Apply(OrderPlaced e) + { + Id = e.OrderId; + Number = new OrderNumber(e.OrderNumber); + } + + public void Apply(OrderShipped e) + { + TrackingNumber = e.TrackingNumber; + } + } + + [Fact] + public async Task should_auto_create_natural_key_table_on_fetch_for_writing() + { + // This is the exact scenario from issue #4199: + // No explicit projection registration, no ApplyAllConfiguredChangesToDatabaseAsync(), + // just FetchForWriting with a natural key type on a self-aggregating aggregate. + // The natural key table should be auto-created. + StoreOptions(opts => + { + // Deliberately no projection registration - relying on auto-discovery + }); + + var orderId = Guid.NewGuid(); + var orderNumber = new OrderNumber("ORD-12345"); + + // Trigger auto-discovery of the natural key projection by calling FetchForWriting. + // This is the pattern the user would follow: first attempt triggers registration, + // then subsequent writes include the inline projection. + await using var session0 = theStore.LightweightSession(); + var preCheck = await session0.Events.FetchForWriting( + new OrderNumber("nonexistent")); + preCheck.Aggregate.ShouldBeNull(); // No stream exists yet, that's fine + + // Now start a stream — the inline projection is registered, so the natural key + // mapping will be written alongside the events + await using var session1 = theStore.LightweightSession(); + session1.Events.StartStream(orderId, + new OrderPlaced(orderId, orderNumber.Value)); + await session1.SaveChangesAsync(); + + // Fetch by natural key — should find the aggregate + await using var session2 = theStore.LightweightSession(); + var stream = await session2.Events.FetchForWriting(orderNumber); + + stream.ShouldNotBeNull(); + stream.Aggregate.ShouldNotBeNull(); + stream.Aggregate.Number.ShouldBe(orderNumber); + stream.Aggregate.Id.ShouldBe(orderId); + } + + [Fact] + public async Task should_work_with_explicit_inline_projection() + { + // Verify the explicit registration path still works + StoreOptions(opts => + { + opts.Projections.Snapshot(SnapshotLifecycle.Inline); + }); + + var orderId = Guid.NewGuid(); + var orderNumber = new OrderNumber("ORD-67890"); + + await using var session1 = theStore.LightweightSession(); + session1.Events.StartStream(orderId, + new OrderPlaced(orderId, orderNumber.Value)); + await session1.SaveChangesAsync(); + + await using var session2 = theStore.LightweightSession(); + var stream = await session2.Events.FetchForWriting(orderNumber); + + stream.ShouldNotBeNull(); + stream.Aggregate.ShouldNotBeNull(); + stream.Aggregate.Number.ShouldBe(orderNumber); + } +} diff --git a/src/Marten/DocumentStore.cs b/src/Marten/DocumentStore.cs index a7a3f400fc..3345946ec7 100644 --- a/src/Marten/DocumentStore.cs +++ b/src/Marten/DocumentStore.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using System.Transactions; @@ -11,12 +12,14 @@ using JasperFx.Core.Reflection; using JasperFx.Descriptors; using JasperFx.Events; +using JasperFx.Events.Aggregation; using JasperFx.Events.Daemon; using JasperFx.Events.Descriptors; using JasperFx.Events.Projections; using JasperFx.MultiTenancy; using Marten.Events; using Marten.Events.Daemon; +using Marten.Events.Projections; using Marten.Events.Daemon.HighWater; using Marten.Exceptions; using Marten.Internal.Sessions; @@ -62,6 +65,7 @@ public DocumentStore(StoreOptions options) StorageFeatures.PostProcessConfiguration(); Events.Initialize(this); Options.Projections.DiscoverGeneratedEvolvers(AppDomain.CurrentDomain.GetAssemblies()); + DiscoverNaturalKeyAggregates(AppDomain.CurrentDomain.GetAssemblies()); Options.Projections.AssertValidity(Options); if (Options.LogFactory != null) @@ -471,6 +475,43 @@ public IProjectionDaemon BuildProjectionDaemon( return new ProjectionDaemon(this, (MartenDatabase)database, logger, detector); } + /// + /// Scan loaded assemblies for types marked with [NaturalKeyAggregate] by the source generator + /// and auto-register Inline snapshot projections for any that don't already have a projection. + /// + private void DiscoverNaturalKeyAggregates(Assembly[] assemblies) + { + foreach (var assembly in assemblies) + { + IEnumerable attrs; + try + { + attrs = assembly.GetCustomAttributes(); + } + catch + { + continue; + } + + foreach (var attr in attrs) + { + if (!Options.Projections.TryFindAggregate(attr.AggregateType, out _)) + { + // Register Inline snapshot via reflection — this activates the full natural key pipeline: + // NaturalKeyDefinition discovery, NaturalKeyTable creation, NaturalKeyProjection + var snapshotMethod = typeof(ProjectionOptions) + .GetMethods() + .First(m => m.Name == "Snapshot" && m.IsGenericMethod && + m.GetParameters().Length == 2 && + m.GetParameters()[0].ParameterType == typeof(SnapshotLifecycle)); + + snapshotMethod.MakeGenericMethod(attr.AggregateType) + .Invoke(Options.Projections, new object?[] { SnapshotLifecycle.Inline, null }); + } + } + } + } + /// /// Quick way to stand up a DocumentStore to the given database connection /// in the "development" mode for auto-creating schema objects as needed diff --git a/src/Marten/Events/EventStore.FetchForWriting.cs b/src/Marten/Events/EventStore.FetchForWriting.cs index c6122439c2..2a3db152b7 100644 --- a/src/Marten/Events/EventStore.FetchForWriting.cs +++ b/src/Marten/Events/EventStore.FetchForWriting.cs @@ -18,6 +18,7 @@ using Marten.Internal; using Marten.Internal.Sessions; using Marten.Internal.Storage; +using Marten.Storage; using Marten.Linq.QueryHandlers; using Weasel.Postgresql; using Weasel.Postgresql.SqlGeneration; @@ -252,7 +253,17 @@ private IAggregateFetchPlan determineFetchPlan(StoreOption { // Auto-discover natural key from [NaturalKey] attribute on the aggregate type // BEFORE iterating planners, so the projection is registered and available - tryAutoRegisterNaturalKeyProjection(options); + if (tryAutoRegisterNaturalKeyProjection(options)) + { + // The projection was just auto-registered, which adds a NaturalKeyTable + // to the IEvent feature schema. Reset the schema existence check so + // EnsureStorageExistsAsync(typeof(IEvent)) will re-evaluate and create + // the natural key table. + if (_session.Database is MartenDatabase martenDb) + { + martenDb.ResetSchemaExistenceChecks(); + } + } foreach (var planner in options.Projections.allPlanners()) { @@ -284,13 +295,14 @@ private IAggregateFetchPlan determineFetchPlan(StoreOption /// This enables FetchForWriting with natural keys on self-aggregating types /// without requiring explicit projection registration. /// - private static void tryAutoRegisterNaturalKeyProjection(StoreOptions options) + /// True if a projection was newly registered + private static bool tryAutoRegisterNaturalKeyProjection(StoreOptions options) where TDoc : class where TId : notnull { // Skip if a projection is already registered for this aggregate type if (options.Projections.TryFindAggregate(typeof(TDoc), out _)) { - return; + return false; } var naturalKeyProp = typeof(TDoc).GetProperties(BindingFlags.Public | BindingFlags.Instance) @@ -298,12 +310,13 @@ private static void tryAutoRegisterNaturalKeyProjection(StoreOptions if (naturalKeyProp == null || naturalKeyProp.PropertyType != typeof(TId)) { - return; + return false; } // Register an Inline snapshot projection so the natural key infrastructure // (natural key table, inline projection, NaturalKeyFetchPlanner) all activate options.Projections.Snapshot(SnapshotLifecycle.Inline); + return true; } }