Skip to content

Commit b5aba64

Browse files
jeremydmillerclaude
andcommitted
Fix NaturalKeySource discovery to search projection class, not just aggregate
discoverNaturalKey() now also searches the projection class (GetType()) for [NaturalKeySource] methods, not just the aggregate type. This fixes the case where Create/Apply methods with [NaturalKeySource] are defined on the projection class (e.g., SingleStreamProjection<TDoc, TId>) rather than on the aggregate itself. For projection-class static methods, build extractors by finding a matching property on the event data type by natural key type (since calling the method directly may crash when it needs IEvent.StreamKey). For aggregate instance methods, preserve the original working pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ada7f24 commit b5aba64

1 file changed

Lines changed: 110 additions & 10 deletions

File tree

src/JasperFx.Events/Aggregation/JasperFxAggregationProjectionBase.cs

Lines changed: 110 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ protected JasperFxAggregationProjectionBase(AggregationScope scope)
5555
base.Version = att.Version;
5656
}
5757

58-
NaturalKeyDefinition = discoverNaturalKey();
58+
NaturalKeyDefinition = discoverNaturalKey(GetType());
5959
}
6060

6161
public NaturalKeyDefinition? NaturalKeyDefinition { get; }
@@ -402,7 +402,7 @@ public virtual Task EnrichEventsAsync(SliceGroup<TDoc, TId> group, TQuerySession
402402
return (lastEvent, aggregate);
403403
}
404404

405-
private static NaturalKeyDefinition? discoverNaturalKey()
405+
private static NaturalKeyDefinition? discoverNaturalKey(Type projectionType)
406406
{
407407
var docType = typeof(TDoc);
408408

@@ -414,19 +414,87 @@ public virtual Task EnrichEventsAsync(SliceGroup<TDoc, TId> group, TQuerySession
414414

415415
var definition = new NaturalKeyDefinition(docType, naturalKeyProp);
416416

417-
// Discover [NaturalKeySource] methods on the aggregate to build event mappings
418-
var methods = docType.GetMethods(BindingFlags.Public | BindingFlags.Instance)
417+
// Discover [NaturalKeySource] methods on the aggregate type (instance methods)
418+
discoverNaturalKeySourceMethods(definition, naturalKeyProp, docType,
419+
BindingFlags.Public | BindingFlags.Instance);
420+
421+
// Also discover [NaturalKeySource] methods on the projection type itself
422+
// (static methods on the projection class, e.g., Create/Apply methods)
423+
if (projectionType != docType)
424+
{
425+
discoverNaturalKeySourceMethods(definition, naturalKeyProp, projectionType,
426+
BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static);
427+
}
428+
429+
return definition;
430+
}
431+
432+
private static void discoverNaturalKeySourceMethods(
433+
NaturalKeyDefinition definition,
434+
PropertyInfo naturalKeyProp,
435+
Type searchType,
436+
BindingFlags bindingFlags)
437+
{
438+
var docType = typeof(TDoc);
439+
var methods = searchType.GetMethods(bindingFlags)
419440
.Where(m => m.GetCustomAttribute<NaturalKeySourceAttribute>() != null);
420441

421442
foreach (var method in methods)
422443
{
423444
var parameters = method.GetParameters();
424445
if (parameters.Length == 0) continue;
425446

426-
var eventType = parameters[0].ParameterType;
447+
// Determine the event type from the first parameter.
448+
// It can be the raw event type or IEvent<T>.
449+
var firstParamType = parameters[0].ParameterType;
450+
Type eventType;
451+
if (firstParamType.IsGenericType &&
452+
firstParamType.GetGenericTypeDefinition() == typeof(IEvent<>))
453+
{
454+
eventType = firstParamType.GetGenericArguments()[0];
455+
}
456+
else if (typeof(IEvent).IsAssignableFrom(firstParamType))
457+
{
458+
eventType = firstParamType;
459+
}
460+
else
461+
{
462+
eventType = firstParamType;
463+
}
464+
465+
// Skip if we already have a mapping for this event type
466+
if (definition.EventMappings.Any(m => m.EventType == eventType))
467+
continue;
468+
469+
try
470+
{
471+
var extractor = buildExtractor(method, naturalKeyProp, docType, parameters);
472+
if (extractor != null)
473+
{
474+
definition.EventMappings.Add(new NaturalKeyEventMapping(eventType, extractor));
475+
}
476+
}
477+
catch
478+
{
479+
// Silently skip methods we can't build extractors for
480+
}
481+
}
482+
}
483+
484+
private static Func<object, object?>? buildExtractor(
485+
MethodInfo method,
486+
PropertyInfo naturalKeyProp,
487+
Type docType,
488+
ParameterInfo[] parameters)
489+
{
490+
var eventParam = Expression.Parameter(typeof(object), "e");
491+
var firstParamType = parameters[0].ParameterType;
427492

428-
// Build a delegate that: creates aggregate, calls the method, reads the natural key property
429-
var eventParam = Expression.Parameter(typeof(object), "e");
493+
// For instance methods on the aggregate (the original working pattern):
494+
// Create a new TDoc, call the method, read the natural key property
495+
if (!method.IsStatic && method.DeclaringType == docType)
496+
{
497+
var eventType = firstParamType;
430498
var docParam = Expression.Variable(docType, "doc");
431499

432500
var body = Expression.Block(
@@ -436,10 +504,42 @@ public virtual Task EnrichEventsAsync(SliceGroup<TDoc, TId> group, TQuerySession
436504
Expression.Convert(Expression.Property(docParam, naturalKeyProp), typeof(object))
437505
);
438506

439-
var extractor = Expression.Lambda<Func<object, object?>>(body, eventParam).Compile();
440-
definition.EventMappings.Add(new NaturalKeyEventMapping(eventType, extractor));
507+
return Expression.Lambda<Func<object, object?>>(body, eventParam).Compile();
441508
}
442509

443-
return definition;
510+
// For static methods on the projection class, we can't safely call them
511+
// (they may need IEvent with StreamKey, etc.). Instead, find a matching
512+
// property on the event data type and read it directly.
513+
Type eventDataType;
514+
if (firstParamType.IsGenericType && firstParamType.GetGenericTypeDefinition() == typeof(IEvent<>))
515+
{
516+
eventDataType = firstParamType.GetGenericArguments()[0];
517+
}
518+
else if (firstParamType == docType && parameters.Length >= 2)
519+
{
520+
var secondType = parameters[1].ParameterType;
521+
eventDataType = secondType.IsGenericType && secondType.GetGenericTypeDefinition() == typeof(IEvent<>)
522+
? secondType.GetGenericArguments()[0]
523+
: secondType;
524+
}
525+
else
526+
{
527+
eventDataType = firstParamType;
528+
}
529+
530+
// Search for a property on the event data that matches the natural key by type
531+
var eventKeyProp = eventDataType
532+
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
533+
.FirstOrDefault(p => p.PropertyType == naturalKeyProp.PropertyType);
534+
535+
if (eventKeyProp == null) return null;
536+
537+
var body2 = Expression.Convert(
538+
Expression.Property(
539+
Expression.Convert(eventParam, eventDataType),
540+
eventKeyProp),
541+
typeof(object));
542+
543+
return Expression.Lambda<Func<object, object?>>(body2, eventParam).Compile();
444544
}
445545
}

0 commit comments

Comments
 (0)