From 2c0ca282d21b723cda3a878d3cd133b2cd1ccad5 Mon Sep 17 00:00:00 2001 From: pjechris Date: Thu, 26 Sep 2024 18:54:28 +0200 Subject: [PATCH] tech(entity): Introduce metadata system (#72) --- Sources/CohesionKit/EntityStore.swift | 30 ++++- .../CohesionKit/Storage/AliasStorage.swift | 5 +- .../CohesionKit/Storage/EntitiesStorage.swift | 5 + Sources/CohesionKit/Storage/EntityNode.swift | 112 ++++++++++++++---- Tests/CohesionKitTests/EntityStoreTests.swift | 18 ++- Tests/CohesionKitTests/RootFixture.swift | 20 +++- .../Storage/EntityNodeTests.swift | 30 +++-- .../Visitor/EntityStoreVisitorTests.swift | 4 +- 8 files changed, 179 insertions(+), 45 deletions(-) diff --git a/Sources/CohesionKit/EntityStore.swift b/Sources/CohesionKit/EntityStore.swift index c6fbe12..dd78b36 100644 --- a/Sources/CohesionKit/EntityStore.swift +++ b/Sources/CohesionKit/EntityStore.swift @@ -162,12 +162,15 @@ public class EntityStore { do { try node.updateEntity(entity, modifiedAt: modifiedAt) + registry.enqueueChange(for: node) logger?.didStore(T.self, id: entity.id) } catch { logger?.didFailedToStore(T.self, id: entity.id, error: error) } + updateParents(of: node) + return node } @@ -180,6 +183,14 @@ public class EntityStore { return node } + for (childRef, _) in node.metadata.childrenRefs { + guard let childNode = storage[childRef]?.unwrap() as? any AnyEntityNode else { + continue + } + + childNode.removeParent(node) + } + // clear all children to avoid a removed child to be kept as child node.removeAllChildren() @@ -191,15 +202,30 @@ public class EntityStore { do { try node.updateEntity(entity, modifiedAt: modifiedAt) + registry.enqueueChange(for: node) logger?.didStore(T.self, id: entity.id) } catch { logger?.didFailedToStore(T.self, id: entity.id, error: error) } + updateParents(of: node) + return node } + func updateParents(of node: some AnyEntityNode) { + for parentRef in node.metadata.parentsRefs { + guard let parentNode = storage[parentRef]?.unwrap() as? any AnyEntityNode ?? refAliases[parentRef] else { + continue + } + + parentNode.updateEntityRelationship(node) + parentNode.enqueue(in: registry) + updateParents(of: parentNode) + } + } + private func storeAlias(content: T?, key: AliasKey, modifiedAt: Stamp?) { let aliasNode = refAliases[safe: key, onChange: registry.enqueueChange(for:)] let aliasContainer = AliasContainer(key: key, content: content) @@ -386,7 +412,9 @@ extension EntityStore { private func removeAliases() { for (_, node) in refAliases { - node.nullify() + if node.nullify() { + node.enqueue(in: registry) + } } } } diff --git a/Sources/CohesionKit/Storage/AliasStorage.swift b/Sources/CohesionKit/Storage/AliasStorage.swift index a8a341e..f46cf9a 100644 --- a/Sources/CohesionKit/Storage/AliasStorage.swift +++ b/Sources/CohesionKit/Storage/AliasStorage.swift @@ -1,5 +1,5 @@ /// Keep a strong reference on each aliased node -typealias AliasStorage = [String: AnyEntityNode] +typealias AliasStorage = [String: any AnyEntityNode] extension AliasStorage { subscript(_ aliasKey: AliasKey) -> EntityNode>? { @@ -9,7 +9,8 @@ extension AliasStorage { subscript(safe key: AliasKey, onChange onChange: ((EntityNode>) -> Void)? = nil) -> EntityNode> { mutating get { - self[key: key, default: EntityNode(AliasContainer(key: key), modifiedAt: nil, onChange: onChange)] + let storeKey = buildKey(for: T.self, key: key) + return self[key: key, default: EntityNode(AliasContainer(key: key), key: storeKey, modifiedAt: nil, onChange: onChange)] } } diff --git a/Sources/CohesionKit/Storage/EntitiesStorage.swift b/Sources/CohesionKit/Storage/EntitiesStorage.swift index 6679ac5..935ac24 100644 --- a/Sources/CohesionKit/Storage/EntitiesStorage.swift +++ b/Sources/CohesionKit/Storage/EntitiesStorage.swift @@ -19,6 +19,11 @@ struct EntitiesStorage { set { indexes[key(for: T.self, id: id)] = Weak(value: newValue) } } + subscript(_ key: String) -> AnyWeak? { + get { indexes[key] } + set { indexes[key] = newValue } + } + private func key(for type: T.Type, id: Any) -> String { "\(type)-\(id)" } diff --git a/Sources/CohesionKit/Storage/EntityNode.swift b/Sources/CohesionKit/Storage/EntityNode.swift index ac77322..d11d391 100644 --- a/Sources/CohesionKit/Storage/EntityNode.swift +++ b/Sources/CohesionKit/Storage/EntityNode.swift @@ -1,30 +1,64 @@ import Foundation import Combine +struct EntityMetadata { + /// children this entity is referencing/using + // TODO: change key to a ObjectKey + var childrenRefs: [String: AnyKeyPath] = [:] + + /// parents referencing this entity. This means this entity should be listed inside its parents `EntityMetadata.childrenRefs` attribute + // TODO: Change value to ObjectKey + var parentsRefs: Set = [] + /// alias referencing this entity + var aliasesRefs: Set = [] + + /// number of observers + var observersCount: Int = 0 + + var isActivelyUsed: Bool { + observersCount > 0 || !parentsRefs.isEmpty || !aliasesRefs.isEmpty + } +} + /// Typed erased protocol protocol AnyEntityNode: AnyObject { + associatedtype Value + + var ref: Observable { get } var value: Any { get } + var metadata: EntityMetadata { get } + var storageKey: String { get } - func nullify() + func nullify() -> Bool + func removeParent(_ node: any AnyEntityNode) + func updateEntityRelationship(_ child: some AnyEntityNode) + func enqueue(in: ObserverRegistry) } /// A graph node representing a entity of type `T` and its children. Anytime one of its children is updated the node /// will reflect the change on its own value. class EntityNode: AnyEntityNode { + typealias Value = T /// A child subscription used by its EntityNode parent struct SubscribedChild { /// the child subscription. Use it to unsubscribe to child upates let subscription: Subscription /// the child node value - let node: AnyEntityNode + let node: any AnyEntityNode } var value: Any { ref.value } + var metadata = EntityMetadata() + // FIXME: to delete, it's "just" to have a strong ref and avoid nodes to be deleted. Need a better memory management + private var childrenNodes: [any AnyEntityNode] = [] + var applyChildrenChanges = true /// An observable entity reference let ref: Observable + let storageKey: String + private let onChange: ((EntityNode) -> Void)? /// last time the ref.value was changed. Any subsequent change must have a higher value to be applied /// if nil ref has no stamp and any change will be accepted @@ -32,14 +66,20 @@ class EntityNode: AnyEntityNode { /// entity children private(set) var children: [PartialKeyPath: SubscribedChild] = [:] - init(ref: Observable, modifiedAt: Stamp?, onChange: ((EntityNode) -> Void)? = nil) { + init(ref: Observable, key: String, modifiedAt: Stamp?, onChange: ((EntityNode) -> Void)? = nil) { self.ref = ref self.modifiedAt = modifiedAt self.onChange = onChange + self.storageKey = key + } + + convenience init(_ entity: T, key: String, modifiedAt: Stamp?, onChange: ((EntityNode) -> Void)? = nil) { + self.init(ref: Observable(value: entity), key: key, modifiedAt: modifiedAt, onChange: onChange) } - convenience init(_ entity: T, modifiedAt: Stamp?, onChange: ((EntityNode) -> Void)? = nil) { - self.init(ref: Observable(value: entity), modifiedAt: modifiedAt, onChange: onChange) + convenience init(_ entity: T, modifiedAt: Stamp?, onChange: ((EntityNode) -> Void)? = nil) where T: Identifiable { + let key = "\(T.self)-\(entity.id)" + self.init(entity, key: key, modifiedAt: modifiedAt, onChange: onChange) } /// change the entity to a new value. If modifiedAt is nil or > to previous date update the value will be changed @@ -52,17 +92,56 @@ class EntityNode: AnyEntityNode { modifiedAt = newModifiedAt ?? modifiedAt ref.value = newEntity - onChange?(self) } - func nullify() { + func nullify() -> Bool { if let value = ref.value as? Nullable { - try? updateEntity(value.nullified() as! T, modifiedAt: nil) + do { + try updateEntity(value.nullified() as! T, modifiedAt: nil) + return true + } + catch { + return false + } } + + return false } func removeAllChildren() { children = [:] + metadata.childrenRefs = [:] + childrenNodes = [] + } + + func removeParent(_ node: any AnyEntityNode) { + metadata.parentsRefs.remove(node.storageKey) + } + + func updateEntityRelationship(_ child: U) { + guard applyChildrenChanges else { + return + } + + guard let keyPath = metadata.childrenRefs[child.storageKey] else { + return + } + + if let writableKeyPath = keyPath as? WritableKeyPath { + ref.value[keyPath: writableKeyPath] = child.ref.value + return + } + + if let optionalWritableKeyPath = keyPath as? WritableKeyPath { + ref.value[keyPath: optionalWritableKeyPath] = child.ref.value + return + } + + print("CohesionKit: cannot convert \(type(of: keyPath)) to WritableKeyPath<\(T.self), \(U.Value.self)>") + } + + func enqueue(in registry: ObserverRegistry) { + registry.enqueueChange(for: self) } /// observe one of the node child @@ -88,20 +167,9 @@ class EntityNode: AnyEntityNode { identity keyPath: KeyPath, update: @escaping (inout T, Element) -> Void ) { - if let subscribedChild = children[keyPath]?.node as? EntityNode, subscribedChild == childNode { - return - } - - let subscription = childNode.ref.addObserver { [unowned self] newValue in - guard self.applyChildrenChanges else { - return - } - - update(&self.ref.value, newValue) - self.onChange?(self) - } - - children[keyPath] = SubscribedChild(subscription: subscription, node: childNode) + metadata.childrenRefs[childNode.storageKey] = keyPath + childNode.metadata.parentsRefs.insert(storageKey) + childrenNodes.append(childNode) } } diff --git a/Tests/CohesionKitTests/EntityStoreTests.swift b/Tests/CohesionKitTests/EntityStoreTests.swift index d2c0661..fffbe58 100644 --- a/Tests/CohesionKitTests/EntityStoreTests.swift +++ b/Tests/CohesionKitTests/EntityStoreTests.swift @@ -411,7 +411,23 @@ extension EntityStoreTests { XCTAssertTrue(registry.hasPendingChange(for: AliasContainer.self)) } - func test_update_entityIsIndirectlyUsedByAlias_itEnqueuesAliasInRegistry() { + // make sure that when we have A -> B -> C and update C, we enqueue parents B AND A. + func test_update_entityIsNested_itEnqueuesAllParents() { + let a = AFixture(b: BFixture(c: SingleNodeFixture(id: 1))) + let registry = ObserverRegistryStub() + let entityStore = EntityStore(registry: registry) + + withExtendedLifetime(entityStore.store(entity: a)) { + registry.clearPendingChangesStub() + + _ = entityStore.nodeStore(entity: SingleNodeFixture(id: 1, primitive: "updated"), modifiedAt: nil) + } + + XCTAssertTrue(registry.hasPendingChange(for: BFixture.self)) + XCTAssertTrue(registry.hasPendingChange(for: AFixture.self)) + } + + func test_update_entityIsInsideAggregagte_aggreateIsAliased_itEnqueuesAliasInRegistry() { let aggregate = RootFixture(id: 1, primitive: "", singleNode: SingleNodeFixture(id: 1), listNodes: []) let registry = ObserverRegistryStub() let entityStore = EntityStore(registry: registry) diff --git a/Tests/CohesionKitTests/RootFixture.swift b/Tests/CohesionKitTests/RootFixture.swift index 7f353aa..e81a614 100644 --- a/Tests/CohesionKitTests/RootFixture.swift +++ b/Tests/CohesionKitTests/RootFixture.swift @@ -1,6 +1,24 @@ import Foundation import CohesionKit +struct AFixture: Aggregate { + var id: BFixture.ID { b.id } + var b: BFixture + + var nestedEntitiesKeyPaths: [PartialIdentifiableKeyPath] { + [.init(\.b)] + } +} + +struct BFixture: Aggregate { + var id: SingleNodeFixture.ID { c.id } + var c: SingleNodeFixture + + var nestedEntitiesKeyPaths: [PartialIdentifiableKeyPath] { + [.init(\.c)] + } +} + struct RootFixture: Aggregate, Equatable { let id: Int let primitive: String @@ -56,4 +74,4 @@ struct ListNodeFixture: Identifiable, Equatable { PartialIdentifiableKeyPath(parent.appending(path: \.singleNode)) ] } -} \ No newline at end of file +} diff --git a/Tests/CohesionKitTests/Storage/EntityNodeTests.swift b/Tests/CohesionKitTests/Storage/EntityNodeTests.swift index 9635061..20849ed 100644 --- a/Tests/CohesionKitTests/Storage/EntityNodeTests.swift +++ b/Tests/CohesionKitTests/Storage/EntityNodeTests.swift @@ -68,35 +68,33 @@ class EntityNodeTests: XCTestCase { } } - func test_observeChild_childChange_entityIsUpdated() throws { + func test_observeChild_nodeIsAddedAsParentMetadata() { let childNode = EntityNode(startEntity.singleNode, modifiedAt: nil) - let newChild = SingleNodeFixture(id: 1, primitive: "updated") node.observeChild(childNode, for: \.singleNode) - try childNode.updateEntity(newChild, modifiedAt: nil) + XCTAssertTrue(childNode.metadata.parentsRefs.contains(node.storageKey)) + } + + func test_observeChild_childrenMetadataIsUpdated() { + let childNode = EntityNode(startEntity.singleNode, modifiedAt: nil) - XCTAssertEqual((node.value as? RootFixture)?.singleNode, newChild) + node.observeChild(childNode, for: \.singleNode) + + XCTAssertTrue(node.metadata.childrenRefs.keys.contains(childNode.storageKey)) } - func test_observeChild_childChange_entityObserversAreCalled() throws { + func test_updateEntityRelationship_childIsUpdated() throws { let childNode = EntityNode(startEntity.singleNode, modifiedAt: startTimestamp) let newChild = SingleNodeFixture(id: 1, primitive: "updated") - let entityRef = Observable(value: startEntity) - var observerCalled = false - - let subscription = entityRef.addObserver { _ in - observerCalled = true - } - node = EntityNode(ref: entityRef, modifiedAt: startTimestamp) node.observeChild(childNode, for: \.singleNode) try childNode.updateEntity(newChild, modifiedAt: nil) - subscription.unsubscribe() + node.updateEntityRelationship(childNode) - XCTAssertTrue(observerCalled) + XCTAssertEqual(node.ref.value.singleNode, newChild) } func test_observeChild_childIsCollection_eachChildIsAdded() { @@ -104,11 +102,11 @@ class EntityNodeTests: XCTestCase { let child2 = EntityNode(ListNodeFixture(id: 2), modifiedAt: startTimestamp) let node = EntityNode(startEntity, modifiedAt: startTimestamp) - XCTAssertEqual(node.children.count, 0) + XCTAssertEqual(node.metadata.childrenRefs.count, 0) node.observeChild(child1, for: \.listNodes[0]) node.observeChild(child2, for: \.listNodes[1]) - XCTAssertEqual(node.children.count, 2) + XCTAssertEqual(node.metadata.childrenRefs.count, 2) } } diff --git a/Tests/CohesionKitTests/Visitor/EntityStoreVisitorTests.swift b/Tests/CohesionKitTests/Visitor/EntityStoreVisitorTests.swift index e2e9984..e0ea9e1 100644 --- a/Tests/CohesionKitTests/Visitor/EntityStoreVisitorTests.swift +++ b/Tests/CohesionKitTests/Visitor/EntityStoreVisitorTests.swift @@ -59,8 +59,8 @@ class EntityStoreStoreVisitorTests: XCTestCase { } private class EntityNodeStub: EntityNode { - var observeChildKeyPathCalled: (AnyEntityNode, PartialKeyPath) -> Void = { _, _ in } - var observeChildKeyPathOptionalCalled: (AnyEntityNode, PartialKeyPath) -> Void = { _, _ in } + var observeChildKeyPathCalled: (any AnyEntityNode, PartialKeyPath) -> Void = { _, _ in } + var observeChildKeyPathOptionalCalled: (any AnyEntityNode, PartialKeyPath) -> Void = { _, _ in } override func observeChild(_ childNode: EntityNode, for keyPath: WritableKeyPath) { observeChildKeyPathCalled(childNode, keyPath)