Skip to content

Commit 09cd06a

Browse files
authored
Merge pull request #2277 from Cratis:fix/minor-fixes
Fix/minor-fixes
2 parents 7deb4d5 + ba6ab0e commit 09cd06a

File tree

8 files changed

+199
-11
lines changed

8 files changed

+199
-11
lines changed

Documentation/projections/model-bound/children.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,30 @@ public record Order(
1414
[Key]
1515
Guid OrderId,
1616

17-
[ChildrenFrom<LineItemAdded>(
18-
key: nameof(LineItemAdded.ItemId),
19-
identifiedBy: nameof(LineItem.Id))]
17+
[ChildrenFrom<LineItemAdded>(key: nameof(LineItemAdded.ItemId))]
2018
IEnumerable<LineItem> Items);
2119

2220
public record LineItem(
23-
[Key] Guid Id,
21+
[Key] Guid Id, // Chronicle automatically discovers this as the key
2422
string ProductName,
2523
int Quantity,
2624
decimal Price);
2725
```
2826

27+
In this example, the `[Key]` attribute on the `LineItem.Id` property is automatically discovered by Chronicle, so you don't need to specify `identifiedBy` explicitly in the `ChildrenFrom` attribute.
28+
2929
### Parameters
3030

3131
- **key** (optional): Property on the event that identifies the child. Defaults to `EventSourceId`
32-
- **identifiedBy** (optional): Property on the child model that identifies it. Defaults to `Id`
32+
- **identifiedBy** (optional): Property on the child model that identifies it. If not specified, Chronicle will:
33+
1. Look for a property with the `[Key]` attribute
34+
2. Look for a property named `Id` (case-insensitive)
35+
3. Fall back to `EventSourceId` if neither is found
3336
- **parentKey** (optional): Property that identifies the parent. Defaults to `EventSourceId`
3437
- **autoMap** (optional): Whether to automatically map matching properties from the event to the child model. Defaults to `true`
3538

39+
> **Note**: With automatic key discovery, you typically don't need to specify `identifiedBy` explicitly. Just mark your child model's key property with `[Key]` attribute, or name it `Id`, and Chronicle will automatically discover it.
40+
3641
### Auto-Mapping
3742

3843
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:

Source/Clients/ApplicationModel/ReadModels/ReadModelServiceCollectionExtensions.cs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,22 @@ public static class ReadModelServiceCollectionExtensions
2323
/// <returns>The service collection for continuation.</returns>
2424
public static IServiceCollection AddReadModels(this IServiceCollection services, IClientArtifactsProvider clientArtifactsProvider)
2525
{
26-
var readModelTypes = clientArtifactsProvider.Projections
26+
var readModelTypesFromProjections = clientArtifactsProvider.Projections
2727
.Select(projectionType =>
2828
{
2929
var projectionInterface = projectionType.GetInterfaces()
3030
.FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IProjectionFor<>));
3131
return projectionInterface?.GetGenericArguments()[0];
3232
})
3333
.Where(type => type?.IsClass == true && !type.IsAbstract)
34-
.Cast<Type>()
34+
.Cast<Type>();
35+
36+
var modelBoundReadModels = clientArtifactsProvider.ModelBoundProjections
37+
.Where(type => type.IsClass && !type.IsAbstract);
38+
39+
var readModelTypes = readModelTypesFromProjections
40+
.Concat(modelBoundReadModels)
41+
.Distinct()
3542
.ToArray();
3643

3744
foreach (var readModelType in readModelTypes)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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_and_child_has_id_by_convention : given.a_model_bound_projection_builder
14+
{
15+
ProjectionDefinition _result;
16+
17+
void Establish()
18+
{
19+
event_types = new EventTypesForSpecifications([typeof(ItemAddedById)]);
20+
builder = new ModelBoundProjectionBuilder(naming_policy, event_types);
21+
}
22+
23+
void Because() => _result = builder.Build(typeof(OrderWithChildHavingIdByConvention));
24+
25+
[Fact] void should_return_definition() => _result.ShouldNotBeNull();
26+
[Fact] void should_have_children_definition() => _result.Children.Count.ShouldEqual(1);
27+
28+
[Fact] void should_use_id_property_as_identified_by()
29+
{
30+
var childrenDef = _result.Children[nameof(OrderWithChildHavingIdByConvention.Items)];
31+
childrenDef.IdentifiedBy.ShouldEqual(nameof(ItemWithIdByConvention.Id));
32+
}
33+
}
34+
35+
[EventType]
36+
public record ItemAddedById(Guid ItemId, string Name);
37+
38+
public record ItemWithIdByConvention(Guid Id, string Name);
39+
40+
public record OrderWithChildHavingIdByConvention(
41+
[Key] Guid Id,
42+
[ChildrenFrom<ItemAddedById>(key: nameof(ItemAddedById.ItemId))] IEnumerable<ItemWithIdByConvention> Items);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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_and_child_has_key_attribute : given.a_model_bound_projection_builder
14+
{
15+
ProjectionDefinition _result;
16+
17+
void Establish()
18+
{
19+
event_types = new EventTypesForSpecifications([typeof(ItemAddedWithKey)]);
20+
builder = new ModelBoundProjectionBuilder(naming_policy, event_types);
21+
}
22+
23+
void Because() => _result = builder.Build(typeof(OrderWithChildHavingKeyAttribute));
24+
25+
[Fact] void should_return_definition() => _result.ShouldNotBeNull();
26+
[Fact] void should_have_children_definition() => _result.Children.Count.ShouldEqual(1);
27+
28+
[Fact] void should_use_key_property_as_identified_by()
29+
{
30+
var childrenDef = _result.Children[nameof(OrderWithChildHavingKeyAttribute.Items)];
31+
childrenDef.IdentifiedBy.ShouldEqual(nameof(ItemWithKeyAttribute.ItemId));
32+
}
33+
}
34+
35+
[EventType]
36+
public record ItemAddedWithKey(Guid ItemId, string Name);
37+
38+
public record ItemWithKeyAttribute([Key] Guid ItemId, string Name);
39+
40+
public record OrderWithChildHavingKeyAttribute(
41+
[Key] Guid Id,
42+
[ChildrenFrom<ItemAddedWithKey>(key: nameof(ItemAddedWithKey.ItemId))] IEnumerable<ItemWithKeyAttribute> Items);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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_and_no_key_property_defaults_to_event_source_id : given.a_model_bound_projection_builder
14+
{
15+
ProjectionDefinition _result;
16+
17+
void Establish()
18+
{
19+
event_types = new EventTypesForSpecifications([typeof(ItemAddedWithoutKey)]);
20+
builder = new ModelBoundProjectionBuilder(naming_policy, event_types);
21+
}
22+
23+
void Because() => _result = builder.Build(typeof(OrderWithChildHavingNoKey));
24+
25+
[Fact] void should_return_definition() => _result.ShouldNotBeNull();
26+
[Fact] void should_have_children_definition() => _result.Children.Count.ShouldEqual(1);
27+
28+
[Fact] void should_default_to_event_source_id()
29+
{
30+
var childrenDef = _result.Children[nameof(OrderWithChildHavingNoKey.Items)];
31+
childrenDef.IdentifiedBy.ShouldEqual(WellKnownExpressions.EventSourceId);
32+
}
33+
}
34+
35+
[EventType]
36+
public record ItemAddedWithoutKey(Guid ItemId, string Name);
37+
38+
public record ItemWithoutKey(string Name, string Description);
39+
40+
public record OrderWithChildHavingNoKey(
41+
[Key] Guid Id,
42+
[ChildrenFrom<ItemAddedWithoutKey>(key: nameof(ItemAddedWithoutKey.ItemId))] IEnumerable<ItemWithoutKey> Items);

Source/Clients/DotNET/Events/EventSourceId.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ public record EventSourceId(string Value) : ConceptAs<string>(Value)
1414
/// </summary>
1515
public static readonly EventSourceId Unspecified = new(string.Empty);
1616

17+
/// <summary>
18+
/// Initializes a new instance of the <see cref="EventSourceId"/> class.
19+
/// </summary>
20+
/// <param name="value">The <see cref="Guid"/> value.</param>
21+
public EventSourceId(Guid value) : this(value.ToString())
22+
{
23+
}
24+
1725
/// <summary>
1826
/// Check whether or not the <see cref="EventSourceId"/> is specified.
1927
/// </summary>

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

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Reflection;
55
using Cratis.Chronicle.Contracts.Projections;
6+
using Cratis.Chronicle.Keys;
67
using Cratis.Chronicle.Properties;
78
using Cratis.Serialization;
89
using EventType = Cratis.Chronicle.Contracts.Events.EventType;
@@ -45,10 +46,16 @@ internal static void ProcessChildrenFromAttribute(
4546
var autoMapProperty = attr.GetType().GetProperty(nameof(ChildrenFromAttribute<object>.AutoMap));
4647

4748
var key = keyProperty?.GetValue(attr) as string ?? WellKnownExpressions.EventSourceId;
48-
var identifiedBy = identifiedByProperty?.GetValue(attr) as string ?? WellKnownExpressions.Id;
4949
var parentKey = parentKeyProperty?.GetValue(attr) as string ?? WellKnownExpressions.EventSourceId;
5050
var autoMap = autoMapProperty?.GetValue(attr) as bool? ?? true;
5151

52+
var childType = GetChildType(memberType);
53+
var identifiedBy = identifiedByProperty?.GetValue(attr) as string;
54+
if (string.IsNullOrEmpty(identifiedBy))
55+
{
56+
identifiedBy = DiscoverKeyPropertyName(childType);
57+
}
58+
5259
if (!definition.Children.TryGetValue(propertyName, out var childrenDef))
5360
{
5461
childrenDef = new ChildrenDefinition
@@ -72,7 +79,6 @@ internal static void ProcessChildrenFromAttribute(
7279
Properties = new Dictionary<string, string>()
7380
};
7481

75-
var childType = GetChildType(memberType);
7682
if (childType is not null)
7783
{
7884
// If autoMap is enabled, map matching properties from event to child model
@@ -105,4 +111,40 @@ internal static void ProcessChildrenFromAttribute(
105111

106112
return enumerableInterface?.GetGenericArguments()[0];
107113
}
114+
115+
static string DiscoverKeyPropertyName(Type? childType)
116+
{
117+
if (childType is null)
118+
{
119+
return WellKnownExpressions.EventSourceId;
120+
}
121+
122+
var properties = childType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
123+
124+
var keyProperty = properties.FirstOrDefault(p => p.GetCustomAttribute<KeyAttribute>(true) is not null);
125+
if (keyProperty is not null)
126+
{
127+
return keyProperty.Name;
128+
}
129+
130+
// Check constructor parameters for record types
131+
var constructor = childType.GetConstructors().FirstOrDefault();
132+
if (constructor is not null)
133+
{
134+
var keyParameter = constructor.GetParameters().FirstOrDefault(p => p.GetCustomAttribute<KeyAttribute>(true) is not null);
135+
if (keyParameter is not null)
136+
{
137+
// Convert parameter name to property name (capitalize first letter)
138+
return char.ToUpperInvariant(keyParameter.Name![0]) + keyParameter.Name.Substring(1);
139+
}
140+
}
141+
142+
var idProperty = properties.FirstOrDefault(p => p.Name.Equals("Id", StringComparison.OrdinalIgnoreCase));
143+
if (idProperty is not null)
144+
{
145+
return idProperty.Name;
146+
}
147+
148+
return WellKnownExpressions.EventSourceId;
149+
}
108150
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace Cratis.Chronicle.Projections.ModelBound;
1111
/// Initializes a new instance of <see cref="ChildrenFromAttribute{TEvent}"/>.
1212
/// </remarks>
1313
/// <param name="key">Optional property name on the event that identifies the child. Defaults to WellKnownExpressions.EventSourceId.</param>
14-
/// <param name="identifiedBy">Optional property name on the child model that identifies it. Defaults to WellKnownExpressions.Id.</param>
14+
/// <param name="identifiedBy">Optional property name on the child model that identifies it. If not specified, will look for [Key] attribute, then an Id property by convention, finally defaulting to WellKnownExpressions.EventSourceId.</param>
1515
/// <param name="parentKey">Optional property name that identifies the parent. Defaults to WellKnownExpressions.EventSourceId.</param>
1616
/// <param name="autoMap">Whether to automatically map matching properties from the event to the child model. Defaults to true.</param>
1717
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = true)]
@@ -31,7 +31,7 @@ public sealed class ChildrenFromAttribute<TEvent>(
3131
public string ParentKey { get; } = parentKey ?? WellKnownExpressions.EventSourceId;
3232

3333
/// <inheritdoc/>
34-
public string IdentifiedBy { get; } = identifiedBy ?? WellKnownExpressions.Id;
34+
public string? IdentifiedBy { get; } = identifiedBy;
3535

3636
/// <inheritdoc/>
3737
public bool AutoMap { get; } = autoMap;

0 commit comments

Comments
 (0)