Skip to content

Commit c2e9b21

Browse files
authored
feat(entity): Add support for enum (#51)
1 parent 774f51b commit c2e9b21

File tree

7 files changed

+178
-61
lines changed

7 files changed

+178
-61
lines changed

README.md

Lines changed: 42 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,8 @@ To store objects containing nested identity objects you need to make them confor
133133
struct AuthorBooks: Aggregate {
134134
var id: Author.ID { author.id }
135135

136-
let author: Author
137-
let books: [Book]
136+
var author: Author
137+
var books: [Book]
138138

139139
// `nestedEntitiesKeyPaths` must list all Identifiable/Aggregate this object contain
140140
var nestedEntitiesKeyPaths: [PartialIdentifiableKeyPath<Self>] {
@@ -193,6 +193,46 @@ Sometimes both can be used but they each have a different purpose:
193193

194194
## Advanced topics
195195

196+
### Enum support
197+
198+
Starting with 0.13 library has support for enum types. Note that you'll need to conform to `EntityEnumWrapper` and provide computed getter/setter for each entity you'd like to store.
199+
200+
```swift
201+
enum MediaType: EntityEnumWrapper {
202+
case book(Book)
203+
case game(Game)
204+
case tvShow(TvShow)
205+
206+
func wrappedEntitiesKeyPaths<Root>(relativeTo parent: WritableKeyPath<Root, Self>) -> [PartialIdentifiableKeyPath<Root>] {
207+
[.init(parent.appending(\.book)), .init(parent.appending(\.game)), .init(parent.appending(\.tvShow))]
208+
}
209+
210+
var book: Book? {
211+
get { ... }
212+
set... }
213+
}
214+
215+
var game: Game? {
216+
get { ... }
217+
set... }
218+
}
219+
220+
var tvShow: TvShow? {
221+
get { ... }
222+
set... }
223+
}
224+
}
225+
226+
struct AuthorMedia: Aggregate {
227+
var author: Author
228+
var media: MediaType
229+
230+
var nestedEntitiesKeyPaths: [PartialIdentifiableKeyPath<Self>] {
231+
[.init(\.author), .init(wrapper: \.media)]
232+
}
233+
}
234+
```
235+
196236
### Aliases
197237

198238
Sometimes you need to retrieve data without knowing the object id. Common case is current user.
@@ -267,49 +307,6 @@ cancellable = nil
267307
identityMap.find(Book.self, id: "ACK") // return "A Clash of Kings" because cancellable2 still observe this book
268308
```
269309

270-
## Known limitations
271-
272-
### Associated value enums require double update
273-
274-
Let's say you have an enum with `Identifiable`/`Aggregate`:
275-
276-
```swift
277-
enum MediaType: Identifiable {
278-
case book(Book)
279-
case game(Game)
280-
case tvShow(TvShow)
281-
}
282-
283-
struct AuthorMedia: Aggregate {
284-
let author: Author
285-
let media: [MediaType]
286-
}
287-
288-
let lastOfUsPart1 = Game(id: xx, title: "The Last Of Us", supportedPlatforms: [.ps3, .ps4])
289-
290-
let lastOfUs = TvShow(title: "The Last Of Us", releasedYear: 2023)
291-
292-
let naughtyDog = Author(
293-
author: .naughtyDog,
294-
media: [.game(theLastOfUsPart1), .movie(theLastOfUst)]
295-
)
296-
297-
identityMap.store(naughtyDog)
298-
```
299-
300-
If associated value changes you might need to do a double update inside the lib in order to properly propagate the modifications:
301-
302-
```swift
303-
304-
let lastOfUsPart1 = Game(id: xx, title: "The Last Of Us", supportedPlatforms: [.ps3, .ps4, .ps5, .pc])
305-
306-
identityMap.store(lastOfUsPart1) // this only notifies objects direct Game reference, not objects using MovieType.game (like our previous `naughtyDog`)
307-
identityMap.store(MovieType.game(lastOfUsPart1)) // on the other hand this one notifies objects like naughtyDog but not those using a plain Game
308-
```
309-
310-
Note that in this context CohesionKit stores the value twice: once as `Game` and once as `MediaType.game` hence the double update.
311-
312-
313310
# License
314311

315312
This project is released under the MIT License. Please see the LICENSE file for details.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/// a type wrapping one or more Identifiable types. As the name indicates you should use this type **only** on enums:
2+
/// this is the easiest way to extract types they are containing. If you facing a non enum case where you feel you need
3+
/// this type: rethink about it ;)
4+
public protocol EntityEnumWrapper {
5+
/// Entities contained by all cases relative to the parent container
6+
/// - Returns: entities contained in the enum
7+
////
8+
/// Example:
9+
//// ```swift
10+
/// enum MyEnum: EntityEnumWrapper {
11+
/// case a(A)
12+
/// case b(B)
13+
///
14+
/// // you would also need to create computed getter/setter for a and b
15+
/// func wrappedEntitiesKeyPaths<Root>(relativeTo root: WritableKeyPath<Root, Self>) -> [PartialIdentifiableKeyPath<Root>] {
16+
/// [.init(root.appending(\.a)), .init(root.appending(\.b))]
17+
/// }
18+
/// }
19+
/// ```
20+
func wrappedEntitiesKeyPaths<Root>(relativeTo parent: WritableKeyPath<Root, Self>) -> [PartialIdentifiableKeyPath<Root>]
21+
}

Sources/CohesionKit/KeyPath/PartialIdentifiableKeyPath.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,30 @@ public struct PartialIdentifiableKeyPath<Root> {
6767
)
6868
}
6969
}
70+
71+
public init<W: EntityEnumWrapper>(wrapper keyPath: WritableKeyPath<Root, W>) {
72+
self.keyPath = keyPath
73+
self.accept = { parent, root, stamp, visitor in
74+
for wrappedKeyPath in root[keyPath: keyPath].wrappedEntitiesKeyPaths(relativeTo: keyPath) {
75+
wrappedKeyPath.accept(parent, root, stamp, visitor)
76+
}
77+
}
78+
}
79+
80+
public init<W: EntityEnumWrapper>(wrapper keyPath: WritableKeyPath<Root, W?>) {
81+
self.keyPath = keyPath
82+
self.accept = { parent, root, stamp, visitor in
83+
if let wrapper = root[keyPath: keyPath] {
84+
for wrappedKeyPath in wrapper.wrappedEntitiesKeyPaths(relativeTo: keyPath.unwrapped()) {
85+
wrappedKeyPath.accept(parent, root, stamp, visitor)
86+
}
87+
}
88+
}
89+
}
90+
}
91+
92+
private extension WritableKeyPath {
93+
func unwrapped<Wrapped>() -> WritableKeyPath<Root, Wrapped> where Value == Optional<Wrapped> {
94+
self.appending(path: \.self!)
95+
}
7096
}

Tests/CohesionKitTests/IdentityMapTests.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,31 @@ class IdentityMapTests: XCTestCase {
5050
XCTAssertEqual((node.value as! RootFixture).listNodes, nestedArray)
5151
}
5252

53+
func test_storeAggregate_nestedWrapperChanged_aggregateIsUpdated() {
54+
let identityMap = IdentityMap()
55+
let root = RootFixture(id: 1, primitive: "", singleNode: SingleNodeFixture(id: 1), optional: OptionalNodeFixture(id: 1), listNodes: [], enumWrapper: .single(SingleNodeFixture(id: 2)))
56+
let updatedValue = SingleNodeFixture(id: 2, primitive: "updated")
57+
58+
withExtendedLifetime(identityMap.store(entity: root)) {
59+
_ = identityMap.store(entity: updatedValue)
60+
XCTAssertEqual(identityMap.find(RootFixture.self, id: 1)!.value.enumWrapper, .single(updatedValue))
61+
}
62+
}
63+
64+
func test_storeAggregate_nestedOptionalWrapperNullified_aggregateIsNullified() {
65+
let identityMap = IdentityMap()
66+
var root = RootFixture(id: 1, primitive: "", singleNode: SingleNodeFixture(id: 1), optional: OptionalNodeFixture(id: 1), listNodes: [], enumWrapper: .single(SingleNodeFixture(id: 2)))
67+
68+
withExtendedLifetime(identityMap.store(entity: root)) {
69+
root.enumWrapper = nil
70+
71+
_ = identityMap.store(entity: root)
72+
_ = identityMap.store(entity: SingleNodeFixture(id: 2, primitive: "deleted"))
73+
74+
XCTAssertNil(identityMap.find(RootFixture.self, id: 1)!.value.enumWrapper)
75+
}
76+
}
77+
5378
func test_storeIdentifiable_entityIsInsertedForThe1stTime_loggerIsCalled() {
5479
let logger = LoggerMock()
5580
let identityMap = IdentityMap(logger: logger)

Tests/CohesionKitTests/KeyPath/PartialIdentifiableKeyPathTests.swift

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,34 +11,56 @@ class PartialIdentifiableKeyPathTests: XCTestCase {
1111

1212
XCTAssertTrue(visitMock.visitAggregateCalled)
1313
}
14+
15+
func test_accept_wrapper_visitWrappedValues() {
16+
let visitMock = VisitMock()
17+
let partialIdentifiable = PartialIdentifiableKeyPath(wrapper: \RootFixture.enumWrapper)
18+
let entity = RootFixture(
19+
id: 1,
20+
primitive: "",
21+
singleNode: SingleNodeFixture(id: 1),
22+
optional: nil,
23+
listNodes: [],
24+
enumWrapper: .single(SingleNodeFixture(id: 2))
25+
)
26+
27+
visitMock.visitorCalledWith = { visitedEntity, keyPath in
28+
XCTAssertEqual(visitedEntity as? SingleNodeFixture, entity.enumWrapper?.singleNode)
29+
XCTAssertEqual(keyPath, \RootFixture.enumWrapper!.singleNode)
30+
}
31+
32+
partialIdentifiable.accept(EntityNode(entity, modifiedAt: 0), entity, 0, visitMock)
33+
}
1434
}
1535

1636
private class VisitMock: NestedEntitiesVisitor {
1737
var visitAggregateCalled = false
38+
var visitorCalledWith: ((Any?, AnyKeyPath) -> Void)?
1839
func visit<Root, T>(context: EntityContext<Root, T>, entity: T) where T : Aggregate {
1940
visitAggregateCalled = true
41+
visitorCalledWith?(entity, context.keyPath)
2042
}
21-
43+
2244
func visit<Root, T>(context: EntityContext<Root, T>, entity: T) where T : Identifiable {
23-
45+
visitorCalledWith?(entity, context.keyPath)
2446
}
25-
47+
2648
func visit<Root, T>(context: EntityContext<Root, T?>, entity: T?) where T : Identifiable {
27-
49+
visitorCalledWith?(entity, context.keyPath)
2850
}
29-
51+
3052
func visit<Root, T>(context: EntityContext<Root, T?>, entity: T?) where T : Aggregate {
31-
53+
visitorCalledWith?(entity, context.keyPath)
3254
}
33-
55+
3456
func visit<Root, C>(context: EntityContext<Root, C>, entities: C)
35-
where C : Collection, C.Element : Aggregate, C.Index : Hashable {
36-
57+
where C : MutableCollection, C.Element : Aggregate, C.Index : Hashable {
58+
visitorCalledWith?(entities, context.keyPath)
3759
}
38-
60+
3961
func visit<Root, C>(context: EntityContext<Root, C>, entities: C)
40-
where C : Collection, C.Element : Identifiable, C.Index : Hashable {
41-
62+
where C : MutableCollection, C.Element : Identifiable, C.Index : Hashable {
63+
visitorCalledWith?(entities, context.keyPath)
4264
}
43-
65+
4466
}

Tests/CohesionKitTests/RootFixture.swift

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ struct RootFixture: Aggregate, Equatable {
77
var singleNode: SingleNodeFixture
88
var optional: OptionalNodeFixture?
99
var listNodes: [ListNodeFixture]
10-
11-
var nestedEntitiesKeyPaths: [PartialIdentifiableKeyPath<RootFixture>] {
10+
var enumWrapper: EnumFixture?
11+
12+
var nestedEntitiesKeyPaths: [PartialIdentifiableKeyPath<Self>] {
1213
[
1314
.init(\.singleNode),
1415
.init(\.optional),
15-
.init(\.listNodes)
16+
.init(\.listNodes),
17+
.init(wrapper: \.enumWrapper)
1618
]
1719
}
1820
}
@@ -31,3 +33,27 @@ struct ListNodeFixture: Identifiable, Equatable {
3133
let id: Int
3234
var key = ""
3335
}
36+
37+
enum EnumFixture: Equatable, EntityEnumWrapper {
38+
case single(SingleNodeFixture)
39+
40+
var singleNode: SingleNodeFixture? {
41+
get {
42+
switch self {
43+
case .single(let value):
44+
return value
45+
}
46+
}
47+
set {
48+
if let newValue {
49+
self = .single(newValue)
50+
}
51+
}
52+
}
53+
54+
func wrappedEntitiesKeyPaths<Root>(relativeTo parent: WritableKeyPath<Root, Self>) -> [PartialIdentifiableKeyPath<Root>] {
55+
[
56+
PartialIdentifiableKeyPath(parent.appending(path: \.singleNode))
57+
]
58+
}
59+
}

0 commit comments

Comments
 (0)