diff --git a/Directory.Build.props b/Directory.Build.props index 06dd1f97..6ecce04d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 2.28.0 + 2.28.1 13 1570;1571;1572;1573;1574;1587;1591;1701;1702;1711;1735;0618 Jeremy D. Miller;Jaedyn Tonee diff --git a/src/EventTests/Projections/NaturalKeySourceDiscoveryTests.cs b/src/EventTests/Projections/NaturalKeySourceDiscoveryTests.cs index 1b0f76ca..312683a7 100644 --- a/src/EventTests/Projections/NaturalKeySourceDiscoveryTests.cs +++ b/src/EventTests/Projections/NaturalKeySourceDiscoveryTests.cs @@ -57,6 +57,33 @@ public void discovers_natural_key_source_on_static_method_of_separate_projection var extracted = mapping!.Extractor(new NkSeparateProjectionCreatedEvent(new NkAggregateKey("separate"))); extracted.ShouldBe(new NkAggregateKey("separate")); } + + [Fact] + public void discovers_natural_key_source_on_static_evolve_method_that_changes_the_key() + { + // Regression for https://github.com/JasperFx/marten/issues/4966: a static + // [NaturalKeySource] method that EVOLVES the aggregate and changes the natural key — + // public static TDoc Apply(TEvent e, TDoc current) => current with { Key = ... }; + // — must also produce an event mapping. Previously buildExtractor only knew how to call + // a one-arg static factory, so the two-arg evolve method threw while building the call + // and was silently skipped, leaving the mt_natural_key table stale after the key changed + // (never inserting the new key on live append OR rebuild). + var projection = new NkEvolvingKeyProjection(); + + projection.NaturalKeyDefinition.ShouldNotBeNull(); + + // The create factory still maps. + var created = projection.NaturalKeyDefinition!.EventMappings + .SingleOrDefault(m => m.EventType == typeof(NkCreatedEvent)); + created.ShouldNotBeNull(); + created!.Extractor(new NkCreatedEvent("first")).ShouldBe(new NkAggregateKey("first")); + + // ...and so does the two-arg evolve method that changes the key. + var changed = projection.NaturalKeyDefinition.EventMappings + .SingleOrDefault(m => m.EventType == typeof(NkKeyChangedEvent)); + changed.ShouldNotBeNull(); + changed!.Extractor(new NkKeyChangedEvent("second")).ShouldBe(new NkAggregateKey("second")); + } } // ───────────────────────── fixtures ───────────────────────── @@ -109,3 +136,28 @@ public class NkSeparateProjectionClass : SingleStreamProjection new() { Key = e.Key }; } + +// #4966 fixture: a self-aggregating record (settable-property record → has a public +// parameterless ctor) whose natural key is set by a create factory and later CHANGED by a +// two-arg static evolve method. This is the shape from the reported repro. +public record NkKeyChangedEvent(string NewKey); + +public sealed record NkEvolvingKeyAggregate +{ + public Guid Id { get; set; } + + [NaturalKey] + public NkAggregateKey Key { get; set; } = default!; + + [NaturalKeySource] + public static NkEvolvingKeyAggregate Create(NkCreatedEvent e) + => new() { Key = new NkAggregateKey(e.Key) }; + + [NaturalKeySource] + public static NkEvolvingKeyAggregate Apply(NkKeyChangedEvent e, NkEvolvingKeyAggregate current) + => current with { Key = new NkAggregateKey(e.NewKey) }; +} + +public class NkEvolvingKeyProjection : SingleStreamProjection +{ +} diff --git a/src/JasperFx.Events/Aggregation/JasperFxAggregationProjectionBase.cs b/src/JasperFx.Events/Aggregation/JasperFxAggregationProjectionBase.cs index cafac405..11f48579 100644 --- a/src/JasperFx.Events/Aggregation/JasperFxAggregationProjectionBase.cs +++ b/src/JasperFx.Events/Aggregation/JasperFxAggregationProjectionBase.cs @@ -738,22 +738,56 @@ private static void discoverNaturalKeySourceMethods( return Expression.Lambda>(body, eventParam).Compile(); } - // For static factory methods on the aggregate itself that return a new TDoc - // (the self-aggregating pattern — see JasperFx/marten#4277): - // public static TDoc Create(TEvent e) => new TDoc(...); - // We can safely call the method with the raw event data and read the natural - // key property off the returned aggregate. + // For static methods on the aggregate itself that return a TDoc, call the method + // and read the natural key property off the returned aggregate. This covers BOTH: + // * the self-aggregating create factory (JasperFx/marten#4277): + // public static TDoc Create(TEvent e) => new TDoc(...); + // * an evolve/update method that CHANGES the natural key (JasperFx/marten#4966): + // public static TDoc Apply(TEvent e, TDoc current) => current with { Key = ... }; + // Build one argument per parameter: the event parameter receives the raw event data + // (converted); a TDoc parameter (the prior aggregate in an evolve method) receives a + // fresh default aggregate, mirroring the instance-method branch above. Only the event + // data reaches the extractor (NaturalKeyProjection passes @event.Data), so a parameter + // that needs IEvent metadata — or a doc type without a public parameterless ctor — + // can't be satisfied here; in that case fall through to the property-matching fallback. if (method.IsStatic && method.DeclaringType == docType && method.ReturnType == docType) { - var eventType = firstParamType; + var callArgs = new Expression[parameters.Length]; + var eventArgBound = false; + var canCall = true; - var body = Expression.Convert( - Expression.Property( - Expression.Call(method, Expression.Convert(eventParam, eventType)), - naturalKeyProp), - typeof(object)); + for (var i = 0; i < parameters.Length; i++) + { + var paramType = parameters[i].ParameterType; + var isIEvent = paramType.IsGenericType + && paramType.GetGenericTypeDefinition() == typeof(IEvent<>); - return Expression.Lambda>(body, eventParam).Compile(); + if (paramType == docType && docType.GetConstructor(System.Type.EmptyTypes) != null) + { + callArgs[i] = Expression.New(docType); + } + else if (!eventArgBound && paramType != docType && !isIEvent) + { + callArgs[i] = Expression.Convert(eventParam, paramType); + eventArgBound = true; + } + else + { + canCall = false; + break; + } + } + + if (canCall && eventArgBound) + { + var body = Expression.Convert( + Expression.Property( + Expression.Call(method, callArgs), + naturalKeyProp), + typeof(object)); + + return Expression.Lambda>(body, eventParam).Compile(); + } } // For static methods on the projection class, we can't safely call them