Skip to content

Commit b9a2bba

Browse files
authored
Merge pull request #2295 from Cratis:fix/removed-with
Fixing RemovedWith to support class level
2 parents 56edccc + 8177ce7 commit b9a2bba

File tree

15 files changed

+439
-13
lines changed

15 files changed

+439
-13
lines changed

Documentation/projections/model-bound/children.md

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ When a `QuantityIncreased` event occurs later:
132132

133133
## Removing Children
134134

135-
Use `RemovedWith` to remove children from collections:
135+
Use `RemovedWith` to remove children from collections. You can apply it either on the collection property or on the child type class:
136+
137+
### Property-Level Removal
136138

137139
```csharp
138140
public record Order(
@@ -148,9 +150,29 @@ public record OrderLine(
148150
string Description);
149151
```
150152

153+
### Class-Level Removal
154+
155+
Apply `RemovedWith` directly on the child type for better separation of concerns:
156+
157+
```csharp
158+
public record Order(
159+
[Key]
160+
Guid OrderId,
161+
162+
[ChildrenFrom<LineItemAdded>(key: nameof(LineItemAdded.ItemId))]
163+
IEnumerable<OrderLine> Lines);
164+
165+
[RemovedWith<LineItemRemoved>(
166+
key: nameof(LineItemRemoved.ItemId),
167+
parentKey: nameof(LineItemRemoved.OrderId))]
168+
public record OrderLine(
169+
[Key] Guid Id,
170+
string Description);
171+
```
172+
151173
### RemovedWithJoin
152174

153-
For more complex removal scenarios using joins:
175+
For removal based on events from different streams (joins), use `RemovedWithJoin`:
154176

155177
```csharp
156178
public record Subscription(
@@ -162,6 +184,17 @@ public record Subscription(
162184
IEnumerable<Feature> Features);
163185
```
164186

187+
Or at the class level:
188+
189+
```csharp
190+
[RemovedWithJoin<FeatureDeactivated>(key: nameof(FeatureDeactivated.FeatureId))]
191+
public record Feature(
192+
[Key] Guid FeatureId,
193+
string Name);
194+
```
195+
196+
> **Note**: For comprehensive documentation on removal options including removing root read models, see [Removal](./removal.md).
197+
165198
## Complete Example
166199

167200
Here's a comprehensive example showing children with full attribute support:

Documentation/projections/model-bound/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ See the following pages for detailed information on each feature:
5757
- [FromEvery](./from-every.md) - Update properties from all events
5858
- [Counters](./counters.md) - Increment, Decrement, Count
5959
- [Children](./children.md) - Managing child collections
60+
- [Removal](./removal.md) - Removing read models and children with RemovedWith, RemovedWithJoin
6061
- [Joins](./joins.md) - Joining with other events
6162
- [Event Sequence Source](./event-sequence-source.md) - Reading from specific event sequences
6263
- [Not Rewindable](./not-rewindable.md) - Forward-only projections
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
# Removing Read Models
2+
3+
Model-bound projections support removing read models and child items through the `RemovedWith` and `RemovedWithJoin` attributes. These can be applied at both the class level (for root read models and child types) and on properties/parameters (for child collections).
4+
5+
## Removing Root Read Models
6+
7+
Use `RemovedWith` at the class level to specify which event removes the entire read model instance:
8+
9+
```csharp
10+
using Cratis.Chronicle.Keys;
11+
using Cratis.Chronicle.Projections.ModelBound;
12+
13+
[RemovedWith<AccountClosed>]
14+
public record Account(
15+
[Key]
16+
Guid Id,
17+
18+
[SetFrom<AccountOpened>(nameof(AccountOpened.Name))]
19+
string Name,
20+
21+
[SetFrom<AccountOpened>(nameof(AccountOpened.Balance))]
22+
decimal Balance);
23+
```
24+
25+
When an `AccountClosed` event occurs, the corresponding `Account` read model is removed from the store.
26+
27+
### With Custom Key
28+
29+
You can specify which property on the event identifies the read model to remove:
30+
31+
```csharp
32+
[RemovedWith<AccountClosed>(key: nameof(AccountClosed.AccountId))]
33+
public record Account(
34+
[Key]
35+
Guid Id,
36+
37+
[SetFrom<AccountOpened>(nameof(AccountOpened.Name))]
38+
string Name);
39+
```
40+
41+
## Multiple Removal Options
42+
43+
A read model can be removed by multiple different events:
44+
45+
```csharp
46+
[RemovedWith<AccountClosed>]
47+
[RemovedWith<AccountMerged>(key: nameof(AccountMerged.SourceAccountId))]
48+
[RemovedWithJoin<OrganizationClosed>]
49+
public record Account(
50+
[Key]
51+
Guid Id,
52+
53+
[SetFrom<AccountOpened>(nameof(AccountOpened.Name))]
54+
string Name);
55+
```
56+
57+
In this example, an account can be removed by:
58+
59+
- An `AccountClosed` event (direct removal)
60+
- An `AccountMerged` event when it's the source account
61+
- An `OrganizationClosed` event through a join relationship
62+
63+
## RemovedWithJoin
64+
65+
Use `RemovedWithJoin` when the removal event comes from a different stream (join relationship):
66+
67+
```csharp
68+
[RemovedWithJoin<CompanyDissolved>]
69+
public record Employee(
70+
[Key]
71+
Guid Id,
72+
73+
[SetFrom<EmployeeHired>(nameof(EmployeeHired.Name))]
74+
string Name,
75+
76+
[Join<CompanyRegistered>]
77+
string CompanyName);
78+
```
79+
80+
When the company is dissolved, all employees associated with that company are removed.
81+
82+
## Removing Children
83+
84+
Children can be removed in two ways:
85+
86+
### Property-Level Removal
87+
88+
Apply `RemovedWith` on the collection property alongside `ChildrenFrom`:
89+
90+
```csharp
91+
public record Order(
92+
[Key]
93+
Guid OrderId,
94+
95+
[ChildrenFrom<LineItemAdded>(key: nameof(LineItemAdded.ItemId))]
96+
[RemovedWith<LineItemRemoved>(key: nameof(LineItemRemoved.ItemId))]
97+
IEnumerable<OrderLine> Lines);
98+
99+
public record OrderLine(
100+
[Key] Guid Id,
101+
string Description);
102+
```
103+
104+
### Class-Level Removal on Child Types
105+
106+
Apply `RemovedWith` directly on the child type. This is particularly useful when the same child model is used in multiple parents or when you want to keep removal logic with the child definition:
107+
108+
```csharp
109+
public record Order(
110+
[Key]
111+
Guid OrderId,
112+
113+
[ChildrenFrom<LineItemAdded>(key: nameof(LineItemAdded.ItemId))]
114+
IEnumerable<OrderLine> Lines);
115+
116+
[RemovedWith<LineItemRemoved>(
117+
key: nameof(LineItemRemoved.ItemId),
118+
parentKey: nameof(LineItemRemoved.OrderId))]
119+
public record OrderLine(
120+
[Key] Guid Id,
121+
string Description);
122+
```
123+
124+
Both approaches produce the same result. The class-level approach keeps the removal definition with the child type, while the property-level approach keeps it with the parent.
125+
126+
### Parameters
127+
128+
For child removal, you can specify:
129+
130+
- **key**: Property on the event that identifies which child to remove
131+
- **parentKey**: Property on the event that identifies the parent (defaults to EventSourceId)
132+
133+
## Children with RemovedWithJoin
134+
135+
For children that should be removed based on join events:
136+
137+
```csharp
138+
public record UserProfile(
139+
[Key]
140+
Guid UserId,
141+
142+
[ChildrenFrom<UserJoinedGroup>(key: nameof(UserJoinedGroup.GroupId))]
143+
[RemovedWithJoin<GroupDeleted>]
144+
IEnumerable<GroupMembership> Groups);
145+
```
146+
147+
Or at the class level:
148+
149+
```csharp
150+
public record UserProfile(
151+
[Key]
152+
Guid UserId,
153+
154+
[ChildrenFrom<UserJoinedGroup>(key: nameof(UserJoinedGroup.GroupId))]
155+
IEnumerable<GroupMembership> Groups);
156+
157+
[RemovedWithJoin<GroupDeleted>(key: nameof(GroupDeleted.GroupId))]
158+
public record GroupMembership(
159+
[Key] Guid GroupId,
160+
string GroupName);
161+
```
162+
163+
## Complete Example
164+
165+
Here's a comprehensive example showing both root and child removal:
166+
167+
```csharp
168+
using Cratis.Chronicle.Events;
169+
using Cratis.Chronicle.Keys;
170+
using Cratis.Chronicle.Projections.ModelBound;
171+
172+
// Events
173+
[EventType]
174+
public record ShoppingCartCreated(string CustomerName);
175+
176+
[EventType]
177+
public record ItemAddedToCart(Guid ItemId, string ProductName, decimal Price);
178+
179+
[EventType]
180+
public record ItemRemovedFromCart(Guid CartId, Guid ItemId);
181+
182+
[EventType]
183+
public record CartCheckedOut();
184+
185+
[EventType]
186+
public record CartAbandoned();
187+
188+
// Read Models
189+
[RemovedWith<CartCheckedOut>]
190+
[RemovedWith<CartAbandoned>]
191+
public record ShoppingCart(
192+
[Key]
193+
Guid Id,
194+
195+
[SetFrom<ShoppingCartCreated>(nameof(ShoppingCartCreated.CustomerName))]
196+
string Customer,
197+
198+
[ChildrenFrom<ItemAddedToCart>(key: nameof(ItemAddedToCart.ItemId))]
199+
IEnumerable<CartItem> Items);
200+
201+
[RemovedWith<ItemRemovedFromCart>(
202+
key: nameof(ItemRemovedFromCart.ItemId),
203+
parentKey: nameof(ItemRemovedFromCart.CartId))]
204+
public record CartItem(
205+
[Key] Guid Id,
206+
207+
[SetFrom<ItemAddedToCart>(nameof(ItemAddedToCart.ProductName))]
208+
string Product,
209+
210+
[SetFrom<ItemAddedToCart>(nameof(ItemAddedToCart.Price))]
211+
decimal Price);
212+
```
213+
214+
## Best Practices
215+
216+
1. **Use class-level removal** for root read models to keep the removal logic with the model definition
217+
2. **Choose property vs class-level removal for children** based on where the logic fits best:
218+
- Property-level if the removal is specific to how the child is used in that parent
219+
- Class-level if the removal logic applies universally to that child type
220+
3. **Always specify keys explicitly** when the default EventSourceId doesn't apply
221+
4. **Use RemovedWithJoin** for removal events from different streams (e.g., when a parent entity in another aggregate is deleted)
222+
5. **Combine multiple removal attributes** when a model can be removed by different events

Documentation/projections/model-bound/toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
href: counters.md
1111
- name: Children
1212
href: children.md
13+
- name: Removal
14+
href: removal.md
1315
- name: Joins
1416
href: joins.md
1517
- name: Event Sequence Source

Source/Clients/DotNET.Specs/Projections/ModelBound/TestEvents.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,17 @@ public record ItemsRemovedFromInventory(int Quantity, DateTimeOffset OccurredAt)
3737
[EventType]
3838
public record UserRegisteredWithCustomId(Guid UserId, string Email, string Name);
3939

40+
[EventType]
41+
public record ReadModelRemoved(Guid Id);
42+
43+
[EventType]
44+
public record ReadModelRemovedJoin(Guid Id);
45+
46+
[EventType]
47+
public record ChildItemRemoved(Guid ParentId, Guid ItemId);
48+
49+
[EventType]
50+
public record ChildItemRemovedJoin(Guid ItemId);
51+
4052
#pragma warning restore SA1402 // File may only contain a single type
4153
#pragma warning restore SA1649 // File name should match first type name

Source/Clients/DotNET.Specs/Projections/ModelBound/TestModels.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,5 +127,44 @@ public record UserProfile(
127127

128128
string Name);
129129

130+
[RemovedWith<ReadModelRemoved>]
131+
public record RemovableReadModel(
132+
[Key]
133+
Guid Id,
134+
135+
[SetFrom<DebitAccountOpened>(nameof(DebitAccountOpened.Name))]
136+
string Name);
137+
138+
[RemovedWith<ReadModelRemoved>]
139+
[RemovedWithJoin<ReadModelRemovedJoin>]
140+
public record ReadModelWithMultipleRemovalOptions(
141+
[Key]
142+
Guid Id,
143+
144+
[SetFrom<DebitAccountOpened>(nameof(DebitAccountOpened.Name))]
145+
string Name);
146+
147+
[RemovedWith<ReadModelRemoved>(key: nameof(ReadModelRemoved.Id))]
148+
public record RemovableReadModelWithKey(
149+
[Key]
150+
Guid Id,
151+
152+
[SetFrom<DebitAccountOpened>(nameof(DebitAccountOpened.Name))]
153+
string Name);
154+
155+
[RemovedWith<ChildItemRemoved>(key: nameof(ChildItemRemoved.ItemId), parentKey: nameof(ChildItemRemoved.ParentId))]
156+
public record RemovableChildItem(
157+
[Key]
158+
Guid Id,
159+
160+
string Name);
161+
162+
public record ParentWithRemovableChildren(
163+
[Key]
164+
Guid Id,
165+
166+
[ChildrenFrom<ItemAddedToCart>(key: nameof(ItemAddedToCart.ItemId), identifiedBy: nameof(RemovableChildItem.Id))]
167+
IEnumerable<RemovableChildItem> Items);
168+
130169
#pragma warning restore SA1402 // File may only contain a single type
131170
#pragma warning restore SA1649 // File name should match first type name

Source/Clients/DotNET.Specs/Projections/ModelBound/for_ModelBoundProjectionBuilder/given/a_model_bound_projection_builder.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@ void Establish()
1919
typeof(DebitAccountOpened),
2020
typeof(DepositToDebitAccountPerformed),
2121
typeof(WithdrawalFromDebitAccountPerformed),
22-
typeof(ItemAddedToCart)
22+
typeof(ItemAddedToCart),
23+
typeof(ReadModelRemoved),
24+
typeof(ReadModelRemovedJoin),
25+
typeof(ChildItemRemoved),
26+
typeof(ChildItemRemovedJoin)
2327
]);
2428

2529
builder = new ModelBoundProjectionBuilder(naming_policy, event_types);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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.Contracts.Projections;
5+
6+
namespace Cratis.Chronicle.Projections.ModelBound.for_ModelBoundProjectionBuilder.when_building;
7+
8+
public class with_children_having_class_level_removed_with : given.a_model_bound_projection_builder
9+
{
10+
ProjectionDefinition _result;
11+
12+
void Because() => _result = builder.Build(typeof(ParentWithRemovableChildren));
13+
14+
[Fact] void should_return_definition() => _result.ShouldNotBeNull();
15+
[Fact] void should_have_one_child_definition() => _result.Children.Count.ShouldEqual(1);
16+
[Fact] void should_have_child_with_correct_name() => _result.Children.ContainsKey(nameof(ParentWithRemovableChildren.Items)).ShouldBeTrue();
17+
[Fact] void should_have_removed_with_on_children() => _result.Children[nameof(ParentWithRemovableChildren.Items)].RemovedWith.Count.ShouldEqual(1);
18+
[Fact] void should_have_removed_with_for_correct_event_type() => _result.Children[nameof(ParentWithRemovableChildren.Items)].RemovedWith.Keys.First().Id.ShouldEqual(event_types.GetEventTypeFor(typeof(ChildItemRemoved)).Id.ToString());
19+
[Fact] void should_use_specified_key_on_removed_with() => _result.Children[nameof(ParentWithRemovableChildren.Items)].RemovedWith.Values.First().Key.ShouldEqual(nameof(ChildItemRemoved.ItemId));
20+
[Fact] void should_use_specified_parent_key_on_removed_with() => _result.Children[nameof(ParentWithRemovableChildren.Items)].RemovedWith.Values.First().ParentKey.ShouldEqual(nameof(ChildItemRemoved.ParentId));
21+
}

0 commit comments

Comments
 (0)