Skip to content

Commit c8e92a5

Browse files
authored
Merge pull request #2251 from Cratis:fix/auto-map-children
Fix/auto-map-children
2 parents 02783c0 + 566fd69 commit c8e92a5

18 files changed

+304
-28
lines changed

Documentation/projections/model-bound/children.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,59 @@ public record LineItem(
3131
- **key** (optional): Property on the event that identifies the child. Defaults to `EventSourceId`
3232
- **identifiedBy** (optional): Property on the child model that identifies it. Defaults to `Id`
3333
- **parentKey** (optional): Property that identifies the parent. Defaults to `EventSourceId`
34+
- **autoMap** (optional): Whether to automatically map matching properties from the event to the child model. Defaults to `true`
35+
36+
### Auto-Mapping
37+
38+
By default, `ChildrenFrom` automatically maps properties from the event to the child model when property names match. This behavior is similar to the `FromEvent` attribute:
39+
40+
```csharp
41+
public record Order(
42+
[Key]
43+
Guid OrderId,
44+
45+
[ChildrenFrom<LineItemAdded>(key: nameof(LineItemAdded.ItemId))]
46+
IEnumerable<LineItem> Items);
47+
48+
public record LineItem(
49+
[Key] Guid Id,
50+
string ProductName, // Automatically mapped from LineItemAdded.ProductName
51+
int Quantity, // Automatically mapped from LineItemAdded.Quantity
52+
decimal Price); // Automatically mapped from LineItemAdded.Price
53+
54+
[EventType]
55+
public record LineItemAdded(
56+
Guid ItemId,
57+
string ProductName,
58+
int Quantity,
59+
decimal Price);
60+
```
61+
62+
You can disable auto-mapping if you want to control property mapping explicitly:
63+
64+
```csharp
65+
public record Order(
66+
[Key]
67+
Guid OrderId,
68+
69+
[ChildrenFrom<LineItemAdded>(
70+
key: nameof(LineItemAdded.ItemId),
71+
autoMap: false)]
72+
IEnumerable<LineItem> Items);
73+
74+
public record LineItem(
75+
[Key] Guid Id,
76+
77+
// Now you must use SetFrom for each property
78+
[SetFrom<LineItemAdded>(nameof(LineItemAdded.ProductName))]
79+
string ProductName,
80+
81+
[SetFrom<LineItemAdded>(nameof(LineItemAdded.Quantity))]
82+
int Quantity,
83+
84+
[SetFrom<LineItemAdded>(nameof(LineItemAdded.Price))]
85+
decimal Price);
86+
```
3487

3588
## Recursive Attribute Processing
3689

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright (c) Cratis. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
namespace Cratis.Chronicle.Projections.ModelBound.for_ChildrenFromAttribute;
5+
6+
public class when_creating_with_auto_map_explicitly_set_to_true : Specification
7+
{
8+
ChildrenFromAttribute<SomeEvent> _attribute;
9+
10+
void Because() => _attribute = new ChildrenFromAttribute<SomeEvent>(autoMap: true);
11+
12+
[Fact] void should_have_auto_map_set_to_true() => _attribute.AutoMap.ShouldBeTrue();
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright (c) Cratis. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
namespace Cratis.Chronicle.Projections.ModelBound.for_ChildrenFromAttribute;
5+
6+
public class when_creating_with_auto_map_set_to_false : Specification
7+
{
8+
ChildrenFromAttribute<SomeEvent> _attribute;
9+
10+
void Because() => _attribute = new ChildrenFromAttribute<SomeEvent>(autoMap: false);
11+
12+
[Fact] void should_have_auto_map_set_to_false() => _attribute.AutoMap.ShouldBeFalse();
13+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright (c) Cratis. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
#pragma warning disable SA1649 // File name should match first type name
5+
#pragma warning disable SA1402 // File may only contain a single type
6+
7+
namespace Cratis.Chronicle.Projections.ModelBound.for_ChildrenFromAttribute;
8+
9+
public class when_creating_with_default_parameters : Specification
10+
{
11+
ChildrenFromAttribute<SomeEvent> _attribute;
12+
13+
void Because() => _attribute = new ChildrenFromAttribute<SomeEvent>();
14+
15+
[Fact] void should_have_auto_map_set_to_true() => _attribute.AutoMap.ShouldBeTrue();
16+
}
17+
18+
public record SomeEvent(Guid ItemId, string Name);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) Cratis. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
#pragma warning disable SA1649 // File name should match first type name
5+
#pragma warning disable SA1402 // File may only contain a single type
6+
7+
using Cratis.Chronicle.Contracts.Projections;
8+
using Cratis.Chronicle.Events;
9+
using Cratis.Chronicle.Keys;
10+
11+
namespace Cratis.Chronicle.Projections.ModelBound.for_ModelBoundProjectionBuilder;
12+
13+
public class when_building_model_with_children_from_with_auto_map : given.a_model_bound_projection_builder
14+
{
15+
ProjectionDefinition _result;
16+
17+
void Establish()
18+
{
19+
event_types = new EventTypesForSpecifications([
20+
typeof(DebitAccountOpened),
21+
typeof(DepositToDebitAccountPerformed),
22+
typeof(WithdrawalFromDebitAccountPerformed),
23+
typeof(ItemAddedToCart),
24+
typeof(LineItemAdded)
25+
]);
26+
27+
builder = new ModelBoundProjectionBuilder(naming_policy, event_types);
28+
}
29+
30+
void Because() => _result = builder.Build(typeof(OrderWithAutoMappedChildren));
31+
32+
[Fact] void should_return_definition() => _result.ShouldNotBeNull();
33+
[Fact] void should_have_children_definition() => _result.Children.Count.ShouldEqual(1);
34+
[Fact] void should_have_children_for_items() => _result.Children.Keys.ShouldContain(nameof(OrderWithAutoMappedChildren.Items));
35+
36+
[Fact] void should_have_from_definition_for_line_item_added()
37+
{
38+
var eventType = event_types.GetEventTypeFor(typeof(LineItemAdded)).ToContract();
39+
var childrenDef = _result.Children[nameof(OrderWithAutoMappedChildren.Items)];
40+
childrenDef.From.Keys.ShouldContain(et => et.IsEqual(eventType));
41+
}
42+
43+
[Fact] void should_auto_map_product_name()
44+
{
45+
var eventType = event_types.GetEventTypeFor(typeof(LineItemAdded)).ToContract();
46+
var childrenDef = _result.Children[nameof(OrderWithAutoMappedChildren.Items)];
47+
var fromDef = childrenDef.From.Single(kvp => kvp.Key.IsEqual(eventType)).Value;
48+
fromDef.Properties.Keys.ShouldContain(nameof(OrderLineItem.ProductName));
49+
}
50+
51+
[Fact] void should_auto_map_quantity()
52+
{
53+
var eventType = event_types.GetEventTypeFor(typeof(LineItemAdded)).ToContract();
54+
var childrenDef = _result.Children[nameof(OrderWithAutoMappedChildren.Items)];
55+
var fromDef = childrenDef.From.Single(kvp => kvp.Key.IsEqual(eventType)).Value;
56+
fromDef.Properties.Keys.ShouldContain(nameof(OrderLineItem.Quantity));
57+
}
58+
59+
[Fact] void should_auto_map_price()
60+
{
61+
var eventType = event_types.GetEventTypeFor(typeof(LineItemAdded)).ToContract();
62+
var childrenDef = _result.Children[nameof(OrderWithAutoMappedChildren.Items)];
63+
var fromDef = childrenDef.From.Single(kvp => kvp.Key.IsEqual(eventType)).Value;
64+
fromDef.Properties.Keys.ShouldContain(nameof(OrderLineItem.Price));
65+
}
66+
}
67+
68+
[EventType]
69+
public record LineItemAdded(Guid ItemId, string ProductName, int Quantity, double Price);
70+
71+
public record OrderLineItem([Key] Guid Id, string ProductName, int Quantity, double Price);
72+
73+
public record OrderWithAutoMappedChildren(
74+
[Key] Guid Id,
75+
[ChildrenFrom<LineItemAdded>(key: nameof(LineItemAdded.ItemId))] IEnumerable<OrderLineItem> Items);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) Cratis. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
#pragma warning disable SA1649 // File name should match first type name
5+
#pragma warning disable SA1402 // File may only contain a single type
6+
7+
using Cratis.Chronicle.Contracts.Projections;
8+
using Cratis.Chronicle.Events;
9+
using Cratis.Chronicle.Keys;
10+
11+
namespace Cratis.Chronicle.Projections.ModelBound.for_ModelBoundProjectionBuilder;
12+
13+
public class when_building_model_with_children_from_with_auto_map_disabled : given.a_model_bound_projection_builder
14+
{
15+
ProjectionDefinition _result;
16+
17+
void Establish()
18+
{
19+
event_types = new EventTypesForSpecifications([
20+
typeof(DebitAccountOpened),
21+
typeof(DepositToDebitAccountPerformed),
22+
typeof(WithdrawalFromDebitAccountPerformed),
23+
typeof(ItemAddedToCart),
24+
typeof(ProductItemAdded)
25+
]);
26+
27+
builder = new ModelBoundProjectionBuilder(naming_policy, event_types);
28+
}
29+
30+
void Because() => _result = builder.Build(typeof(OrderWithExplicitMappedChildren));
31+
32+
[Fact] void should_return_definition() => _result.ShouldNotBeNull();
33+
[Fact] void should_have_children_definition() => _result.Children.Count.ShouldEqual(1);
34+
35+
[Fact] void should_not_auto_map_properties()
36+
{
37+
var eventType = event_types.GetEventTypeFor(typeof(ProductItemAdded)).ToContract();
38+
var childrenDef = _result.Children[nameof(OrderWithExplicitMappedChildren.Items)];
39+
var fromDef = childrenDef.From.Single(kvp => kvp.Key.IsEqual(eventType)).Value;
40+
41+
// With autoMap disabled, properties should not be automatically mapped
42+
// Only the Id should be empty since we don't have explicit SetFrom attributes
43+
fromDef.Properties.Count.ShouldEqual(0);
44+
}
45+
}
46+
47+
[EventType]
48+
public record ProductItemAdded(Guid ItemId, string ProductName, int Quantity, double Price);
49+
50+
public record ProductLineItem([Key] Guid Id, string ProductName, int Quantity, double Price);
51+
52+
public record OrderWithExplicitMappedChildren(
53+
[Key] Guid Id,
54+
[ChildrenFrom<ProductItemAdded>(key: nameof(ProductItemAdded.ItemId), autoMap: false)]
55+
IEnumerable<ProductLineItem> Items);

Source/Clients/DotNET/Projections/ModelBound/ChildrenDefinitionExtensions.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,12 @@ internal static void ProcessChildrenFromAttribute(
4242
var keyProperty = attr.GetType().GetProperty(nameof(ChildrenFromAttribute<object>.Key));
4343
var identifiedByProperty = attr.GetType().GetProperty(nameof(ChildrenFromAttribute<object>.IdentifiedBy));
4444
var parentKeyProperty = attr.GetType().GetProperty(nameof(ChildrenFromAttribute<object>.ParentKey));
45+
var autoMapProperty = attr.GetType().GetProperty(nameof(ChildrenFromAttribute<object>.AutoMap));
4546

4647
var key = keyProperty?.GetValue(attr) as string ?? WellKnownExpressions.EventSourceId;
4748
var identifiedBy = identifiedByProperty?.GetValue(attr) as string ?? WellKnownExpressions.Id;
4849
var parentKey = parentKeyProperty?.GetValue(attr) as string ?? WellKnownExpressions.EventSourceId;
50+
var autoMap = autoMapProperty?.GetValue(attr) as bool? ?? true;
4951

5052
if (!definition.Children.TryGetValue(propertyName, out var childrenDef))
5153
{
@@ -73,6 +75,13 @@ internal static void ProcessChildrenFromAttribute(
7375
var childType = GetChildType(memberType);
7476
if (childType is not null)
7577
{
78+
// If autoMap is enabled, map matching properties from event to child model
79+
if (autoMap)
80+
{
81+
var fromDefinition = childrenDef.From[eventTypeId];
82+
fromDefinition.AutoMapMatchingProperties(namingPolicy, eventType, childType);
83+
}
84+
7685
foreach (var childProperty in childType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
7786
{
7887
processMember(childProperty, definition, [], false, childrenDef);

Source/Clients/DotNET/Projections/ModelBound/ChildrenFromAttribute.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ namespace Cratis.Chronicle.Projections.ModelBound;
1313
/// <param name="key">Optional property name on the event that identifies the child. Defaults to WellKnownExpressions.EventSourceId.</param>
1414
/// <param name="identifiedBy">Optional property name on the child model that identifies it. Defaults to WellKnownExpressions.Id.</param>
1515
/// <param name="parentKey">Optional property name that identifies the parent. Defaults to WellKnownExpressions.EventSourceId.</param>
16+
/// <param name="autoMap">Whether to automatically map matching properties from the event to the child model. Defaults to true.</param>
1617
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)]
1718
public sealed class ChildrenFromAttribute<TEvent>(
1819
string? key = default,
1920
string? identifiedBy = default,
20-
string? parentKey = default) : Attribute, IProjectionAnnotation, IChildrenFromAttribute
21+
string? parentKey = default,
22+
bool autoMap = true) : Attribute, IProjectionAnnotation, IChildrenFromAttribute
2123
{
2224
/// <inheritdoc/>
2325
public Type EventType => typeof(TEvent);
@@ -30,4 +32,7 @@ public sealed class ChildrenFromAttribute<TEvent>(
3032

3133
/// <inheritdoc/>
3234
public string IdentifiedBy { get; } = identifiedBy ?? WellKnownExpressions.Id;
35+
36+
/// <inheritdoc/>
37+
public bool AutoMap { get; } = autoMap;
3338
}

Source/Clients/DotNET/Projections/ModelBound/FromDefinitionExtensions.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,28 @@ internal static void AddContextPropertyMapping(this IDictionary<EventType, FromD
150150
fromDefinition.Properties[propertyName] = $"{WellKnownExpressions.EventContext}({convertedContextPropertyName})";
151151
}
152152

153+
/// <summary>
154+
/// Auto-maps properties from event type to model type by matching property names.
155+
/// </summary>
156+
/// <param name="fromDefinition">The FromDefinition to add property mappings to.</param>
157+
/// <param name="namingPolicy">The naming policy for converting property names.</param>
158+
/// <param name="eventType">The event type to map from.</param>
159+
/// <param name="modelType">The model type to map to.</param>
160+
internal static void AutoMapMatchingProperties(this FromDefinition fromDefinition, INamingPolicy namingPolicy, Type eventType, Type modelType)
161+
{
162+
foreach (var modelProperty in modelType.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance))
163+
{
164+
var eventProperty = eventType.GetProperty(modelProperty.Name);
165+
if (eventProperty is not null)
166+
{
167+
var modelPropertyPath = new PropertyPath(modelProperty.Name);
168+
var modelPropertyName = namingPolicy.GetPropertyName(modelPropertyPath);
169+
var eventPropertyPath = new PropertyPath(eventProperty.Name);
170+
fromDefinition.Properties[modelPropertyName] = namingPolicy.GetPropertyName(eventPropertyPath);
171+
}
172+
}
173+
}
174+
153175
static FromDefinition GetOrCreateFromDefinition(this IDictionary<EventType, FromDefinition> targetFrom, EventType eventTypeId)
154176
{
155177
if (!targetFrom.TryGetValue(eventTypeId, out var fromDefinition))

Source/Clients/DotNET/Projections/ModelBound/IAddFromAttribute.cs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,4 @@ namespace Cratis.Chronicle.Projections.ModelBound;
66
/// <summary>
77
/// Defines an attribute that indicates that a property value should be added from an event property.
88
/// </summary>
9-
public interface IAddFromAttribute : IEventBoundAttribute
10-
{
11-
/// <summary>
12-
/// Gets the name of the property on the event.
13-
/// </summary>
14-
string? EventPropertyName { get; }
15-
}
9+
public interface IAddFromAttribute : IEventBoundAttribute, ICanMapToEventProperty;

0 commit comments

Comments
 (0)