Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<Project>
<PropertyGroup>
<JasperFxVersion>2.28.0</JasperFxVersion>
<JasperFxVersion>2.28.1</JasperFxVersion>
<LangVersion>13</LangVersion>
<NoWarn>1570;1571;1572;1573;1574;1587;1591;1701;1702;1711;1735;0618</NoWarn>
<Authors>Jeremy D. Miller;Jaedyn Tonee</Authors>
Expand Down
52 changes: 52 additions & 0 deletions src/EventTests/Projections/NaturalKeySourceDiscoveryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────
Expand Down Expand Up @@ -109,3 +136,28 @@ public class NkSeparateProjectionClass : SingleStreamProjection<NkSeparateProjec
public static NkSeparateProjectionAggregate Create(NkSeparateProjectionCreatedEvent e)
=> 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<NkEvolvingKeyAggregate, NkAggregateKey>
{
}
Original file line number Diff line number Diff line change
Expand Up @@ -738,22 +738,56 @@ private static void discoverNaturalKeySourceMethods(
return Expression.Lambda<Func<object, object?>>(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<T> 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<Func<object, object?>>(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<Func<object, object?>>(body, eventParam).Compile();
}
}

// For static methods on the projection class, we can't safely call them
Expand Down
Loading