Skip to content

Commit 3fd2dea

Browse files
authored
Merge pull request #2338 from Cratis:fix/projections-fixing
Fix/projections-fixing
2 parents a666c4e + df6ce36 commit 3fd2dea

File tree

46 files changed

+1101
-75
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+1101
-75
lines changed

Documentation/projections/declarative/auto-map.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,12 @@ public void Define(IProjectionBuilderFor<Company> builder) => builder
168168
// AutoMap inherited from parent
169169
.From<DepartmentCreated>(_ => _
170170
.UsingKey(e => e.DepartmentId))
171+
// No parent key specified - uses EventSourceId (CompanyId) by default
171172
.Children(dm => dm.Employees, employees => employees
172173
.IdentifiedBy(e => e.EmployeeId)
173174
// AutoMap still applies at this nested level
174175
.From<EmployeeAssignedToDepartment>(_ => _
175-
.UsingParentKey(e => e.DepartmentId)
176+
.UsingParentKey(e => e.DepartmentId) // Extracts from event content
176177
.UsingKey(e => e.EmployeeId))));
177178
```
178179

Documentation/projections/declarative/children.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,71 @@ public record UserRemovedFromGroup(string UserId);
6666
4. `UsingKey()` tells the projection which property contains the child identifier
6767
5. Child items are created, updated, or remain unchanged based on the events
6868

69+
## Parent key resolution
70+
71+
By default, when a child event is processed, the framework uses the **EventSourceId** to identify the parent. This works well when the event is appended with the parent's identifier as the EventSourceId.
72+
73+
### Default behavior (EventSourceId as parent key)
74+
75+
In most scenarios, you don't need to specify the parent key explicitly:
76+
77+
```csharp
78+
.Children(m => m.Members, children => children
79+
.IdentifiedBy(e => e.UserId)
80+
.AutoMap()
81+
.From<UserAddedToGroup>(b => b
82+
.UsingKey(e => e.UserId)))
83+
// No UsingParentKey needed - uses EventSourceId by default
84+
```
85+
86+
When you append the event:
87+
```csharp
88+
await EventStore.EventLog.Append(groupId, new UserAddedToGroup(userId, role));
89+
```
90+
91+
The `groupId` (EventSourceId) is automatically used to find the parent `Group`.
92+
93+
### Extracting parent key from event content
94+
95+
If your event contains the parent key as a property (instead of using EventSourceId), use `UsingParentKey()`:
96+
97+
```csharp
98+
.Children(m => m.Members, children => children
99+
.IdentifiedBy(e => e.UserId)
100+
.AutoMap()
101+
.From<UserAddedToGroup>(b => b
102+
.UsingParentKey(e => e.GroupId) // Extract from event content
103+
.UsingKey(e => e.UserId)))
104+
```
105+
106+
When you append the event:
107+
```csharp
108+
await EventStore.EventLog.Append(userId, new UserAddedToGroup(userId, groupId, role));
109+
```
110+
111+
The `groupId` property from the event content is used to find the parent `Group`.
112+
113+
### Using EventSourceId explicitly with UsingParentKeyFromContext
114+
115+
In some advanced scenarios, you might want to explicitly indicate that the EventSourceId should be used as the parent key (e.g., for documentation clarity):
116+
117+
```csharp
118+
.Children(m => m.Members, children => children
119+
.IdentifiedBy(e => e.UserId)
120+
.AutoMap()
121+
.From<UserAddedToGroup>(b => b
122+
.UsingParentKeyFromContext(ctx => ctx.EventSourceId) // Explicit
123+
.UsingKey(e => e.UserId)))
124+
```
125+
126+
This is functionally equivalent to not specifying the parent key at all, but can make the intent clearer in complex projections.
127+
128+
### When to use each approach
129+
130+
- **No parent key specified** (default): Use when EventSourceId represents the parent identifier
131+
- **`UsingParentKey(e => e.Property)`**: Use when parent identifier is in the event content
132+
- **`UsingParentKeyFromContext(ctx => ctx.EventSourceId)`**: Use for explicit documentation of default behavior
133+
69134
## Child lifecycle
70135

71136
- **Adding children**: When a new event arrives with a previously unseen key, a new child is created

Documentation/projections/declarative/index.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,14 @@ Declarative Projections in Cratis allow you to create read models from events st
4040

4141
### Keys and identification
4242

43-
- Event source ID is used as the default key for read models
44-
- Child collections use custom identifiers for individual items
45-
- Joins use keys to link data from different streams
43+
- **EventSourceId** is used as the default key for both read models and parent identification in child collections
44+
- **Child identifiers**: Use `.IdentifiedBy()` to specify how child items are uniquely identified within collections
45+
- **Parent key resolution**:
46+
- By default, the EventSourceId is used to identify the parent when adding children
47+
- Use `.UsingParentKey(e => e.Property)` when the parent identifier is in the event content
48+
- Use `.UsingParentKeyFromContext(ctx => ctx.EventSourceId)` to explicitly document default behavior
49+
- **Child key specification**: Use `.UsingKey(e => e.Property)` to extract the child identifier from event content
50+
- **Joins**: Use keys to link data from different event streams using `.On(m => m.Property)`
4651

4752
### Performance
4853

Documentation/projections/declarative/remove-with-join.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@ public class UserProjection : IProjectionFor<User>
3232
In this example:
3333

3434
- When a `UserAddedToGroup` event occurs, a group is added to the user's collection
35+
- `UsingParentKey(e => e.UserId)` extracts the parent (user) identifier from the event content
3536
- When a `GroupDeleted` event occurs anywhere in the system, that group is removed from all users
3637
- The removal is based on the group ID that was used to join the data
3738

39+
> **Note**: If you don't specify `UsingParentKey()`, the framework uses the EventSourceId as the parent identifier by default. Use `UsingParentKey()` when the parent identifier is a property in the event content rather than the EventSourceId.
40+
3841
## How RemoveWithJoin works
3942

4043
When using `RemovedWithJoin<>()`:

Documentation/projections/model-bound/children.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ In this example, the `[Key]` attribute on the `LineItem.Id` property is automati
3434
2. Look for a property named `Id` (case-insensitive)
3535
3. Fall back to `EventSourceId` if neither is found
3636
- **parentKey** (optional): Property that identifies the parent. Defaults to `EventSourceId`
37+
- Use this when the parent identifier is a property in the event content rather than the EventSourceId
38+
- Example: `parentKey: nameof(LineItemAdded.OrderId)` when OrderId is in the event
3739
- **autoMap** (optional): Whether to automatically map matching properties from the event to the child model. Defaults to `true`
3840

3941
> **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.

Integration/DotNET.InProcess/Projections/Scenarios/when_projecting_with_children_within_children_using_parent_key_from_context/Events/HubAddedToSimulationConfiguration.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
namespace Cratis.Chronicle.InProcess.Integration.Projections.Scenarios.when_projecting_with_children_within_children_using_parent_key_from_context.Events;
88

99
[EventType]
10-
public record HubAddedToSimulationConfiguration(ConfigurationId ConfigurationId, HubId HubId, string Name);
10+
public record HubAddedToSimulationConfiguration(HubId HubId, string Name);
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+
using Cratis.Chronicle.Events;
5+
6+
namespace Cratis.Chronicle.InProcess.Integration.Projections.Scenarios.when_projecting_with_children_within_children_using_parent_key_from_context.Events;
7+
8+
[EventType]
9+
public record WeightsSetForSimulationConfiguration(
10+
double Distance,
11+
double Time,
12+
double Cost,
13+
double Waste);

Integration/DotNET.InProcess/Projections/Scenarios/when_projecting_with_children_within_children_using_parent_key_from_context/ReadModels/SimulationConfiguration.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,11 @@ public class SimulationConfiguration
1212
public ConfigurationId ConfigurationId { get; set; } = ConfigurationId.NotSet;
1313

1414
public string Name { get; set; } = string.Empty;
15+
16+
public double Distance { get; set; }
17+
public double Time { get; set; }
18+
public double Cost { get; set; }
19+
public double Waste { get; set; }
20+
1521
public IList<Hub> Hubs { get; set; } = [];
1622
}

Integration/DotNET.InProcess/Projections/Scenarios/when_projecting_with_children_within_children_using_parent_key_from_context/SimulationDashboardProjection.cs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,9 @@ public void Define(IProjectionBuilderFor<SimulationDashboard> builder) => builde
1818
.From<SimulationAdded>()
1919
.Children(m => m.Configurations, m => m
2020
.IdentifiedBy(r => r.ConfigurationId)
21-
.From<SimulationConfigurationAdded>(b => b
22-
.UsingParentKeyFromContext(ctx => ctx.EventSourceId)
23-
.UsingKey(e => e.ConfigurationId))
21+
.From<SimulationConfigurationAdded>(b => b.UsingKey(e => e.ConfigurationId))
22+
.From<WeightsSetForSimulationConfiguration>()
2423
.Children(m => m.Hubs, m => m
2524
.IdentifiedBy(r => r.HubId)
26-
.From<HubAddedToSimulationConfiguration>(e => e
27-
.UsingParentKey(e => e.ConfigurationId)
28-
.UsingKey(e => e.HubId))));
25+
.From<HubAddedToSimulationConfiguration>(e => e.UsingKey(e => e.HubId))));
2926
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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+
using Cratis.Chronicle.Auditing;
5+
using Cratis.Chronicle.Events;
6+
using Cratis.Chronicle.EventSequences;
7+
using Cratis.Chronicle.InProcess.Integration.Projections.Scenarios.when_projecting_with_children_within_children_using_parent_key_from_context.Concepts;
8+
using Cratis.Chronicle.InProcess.Integration.Projections.Scenarios.when_projecting_with_children_within_children_using_parent_key_from_context.Events;
9+
using Cratis.Chronicle.InProcess.Integration.Projections.Scenarios.when_projecting_with_children_within_children_using_parent_key_from_context.ReadModels;
10+
using Cratis.Chronicle.Observation;
11+
using MongoDB.Driver;
12+
using context = Cratis.Chronicle.InProcess.Integration.Projections.Scenarios.when_projecting_with_children_within_children_using_parent_key_from_context.and_all_events_are_appended_in_one_transaction.context;
13+
14+
namespace Cratis.Chronicle.InProcess.Integration.Projections.Scenarios.when_projecting_with_children_within_children_using_parent_key_from_context;
15+
16+
[Collection(ChronicleCollection.Name)]
17+
public class and_all_events_are_appended_in_one_transaction(context context) : Given<context>(context)
18+
{
19+
const string SimulationName = "Test Simulation";
20+
const string ConfigurationName = "Test Configuration";
21+
const string HubName = "Test Hub";
22+
23+
const double Distance = 100.0;
24+
const double Time = 2.5;
25+
const double Cost = 50.0;
26+
const double Waste = 10.0;
27+
28+
public class context(ChronicleInProcessFixture fixture) : Specification(fixture)
29+
{
30+
public SimulationId SimulationId;
31+
public ConfigurationId ConfigurationId;
32+
public HubId HubId;
33+
public IEnumerable<FailedPartition> FailedPartitions = [];
34+
public SimulationDashboard Result;
35+
public EventSequenceNumber LastEventSequenceNumber = EventSequenceNumber.First;
36+
37+
public override IEnumerable<Type> EventTypes => [typeof(SimulationAdded), typeof(SimulationConfigurationAdded), typeof(HubAddedToSimulationConfiguration), typeof(WeightsSetForSimulationConfiguration)];
38+
public override IEnumerable<Type> Projections => [typeof(SimulationDashboardProjection)];
39+
40+
protected override void ConfigureServices(IServiceCollection services)
41+
{
42+
services.AddSingleton(new SimulationDashboardProjection());
43+
}
44+
45+
void Establish()
46+
{
47+
SimulationId = Guid.Parse("0089d57f-9095-4b56-b948-ab170de8e0ee");
48+
ConfigurationId = Guid.Parse("754fd741-adae-4fbd-8c47-2d622cc0b274");
49+
HubId = Guid.Parse("eff2cf7a-4121-438b-94fd-139775a09f57");
50+
}
51+
52+
async Task Because()
53+
{
54+
var projection = EventStore.Projections.GetHandlerFor<SimulationDashboardProjection>();
55+
await projection.WaitTillActive();
56+
57+
var events = new EventForEventSourceId[]
58+
{
59+
new(ConfigurationId, new WeightsSetForSimulationConfiguration(Distance, Time, Cost, Waste), Causation.Unknown()),
60+
new(ConfigurationId, new HubAddedToSimulationConfiguration(HubId, HubName), Causation.Unknown()),
61+
new(SimulationId, new SimulationAdded(SimulationName), Causation.Unknown()),
62+
new(SimulationId, new SimulationConfigurationAdded(ConfigurationId, ConfigurationName), Causation.Unknown())
63+
};
64+
65+
var appendResult = await EventStore.EventLog.AppendMany(events);
66+
LastEventSequenceNumber = appendResult.SequenceNumbers.Last();
67+
68+
await projection.WaitTillReachesEventSequenceNumber(LastEventSequenceNumber);
69+
70+
FailedPartitions = await projection.GetFailedPartitions();
71+
72+
var collection = ChronicleFixture.ReadModels.Database.GetCollection<SimulationDashboard>();
73+
var queryResult = await collection.FindAsync(_ => true);
74+
var allResults = await queryResult.ToListAsync();
75+
Result = allResults.FirstOrDefault();
76+
}
77+
}
78+
79+
[Fact]
80+
void should_have_no_failed_partitions()
81+
{
82+
if (Context.FailedPartitions.Any())
83+
{
84+
var failures = Context.FailedPartitions.ToList();
85+
var messages = failures.SelectMany(f => f.Attempts.Select(a => a.Messages)).SelectMany(m => m).ToList();
86+
var stackTraces = failures.SelectMany(f => f.Attempts.Select(a => a.StackTrace)).ToList();
87+
var combined = string.Join("\n\n", messages.Zip(stackTraces, (msg, stack) => $"Message: {msg}\nStack: {stack}"));
88+
throw new Xunit.Sdk.XunitException($"Failed partitions:\n{combined}");
89+
}
90+
}
91+
92+
[Fact] void should_return_model() => Context.Result.ShouldNotBeNull();
93+
[Fact] void should_have_simulation_name() => Context.Result.Name.ShouldEqual(SimulationName);
94+
[Fact] void should_have_one_configuration() => Context.Result.Configurations.Count.ShouldEqual(1);
95+
[Fact] void should_have_configuration_id_on_child() => Context.Result.Configurations[0].ConfigurationId.ShouldEqual(Context.ConfigurationId);
96+
[Fact] void should_have_configuration_name_on_child() => Context.Result.Configurations[0].Name.ShouldEqual(ConfigurationName);
97+
[Fact] void should_have_one_hub_on_configuration() => Context.Result.Configurations[0].Hubs.Count.ShouldEqual(1);
98+
[Fact] void should_have_hub_id_on_nested_child() => Context.Result.Configurations[0].Hubs[0].HubId.ShouldEqual(Context.HubId);
99+
[Fact] void should_have_hub_name_on_nested_child() => Context.Result.Configurations[0].Hubs[0].Name.ShouldEqual(HubName);
100+
[Fact] void should_set_the_event_sequence_number_to_last_event() => Context.Result.__lastHandledEventSequenceNumber.ShouldEqual(Context.LastEventSequenceNumber);
101+
[Fact] void should_set_distance_on_configuration() => Context.Result.Configurations[0].Distance.ShouldEqual(Distance);
102+
[Fact] void should_set_time_on_configuration() => Context.Result.Configurations[0].Time.ShouldEqual(Time);
103+
[Fact] void should_set_cost_on_configuration() => Context.Result.Configurations[0].Cost.ShouldEqual(Cost);
104+
[Fact] void should_set_waste_on_configuration() => Context.Result.Configurations[0].Waste.ShouldEqual(Waste);
105+
}

0 commit comments

Comments
 (0)