diff --git a/ConsistencyManager.xcodeproj/project.pbxproj b/ConsistencyManager.xcodeproj/project.pbxproj index 4af9660..987ef45 100644 --- a/ConsistencyManager.xcodeproj/project.pbxproj +++ b/ConsistencyManager.xcodeproj/project.pbxproj @@ -43,6 +43,8 @@ 6149D1271DF9DBEA00908B8D /* WeakHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6149D1261DF9DBEA00908B8D /* WeakHolder.swift */; }; 6149D1291DF9DD0200908B8D /* WeakBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6149D1281DF9DD0200908B8D /* WeakBox.swift */; }; 6149D12B1DF9E60400908B8D /* TypeErasedWeakHolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6149D12A1DF9E60400908B8D /* TypeErasedWeakHolders.swift */; }; + 614E1E791E0370180063BAD1 /* ModelChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614E1E781E0370180063BAD1 /* ModelChange.swift */; }; + 614E1E7B1E047ABE0063BAD1 /* ModelChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 614E1E7A1E047ABE0063BAD1 /* ModelChangeTests.swift */; }; 615B9CA91D4FFD430091F71A /* ProjectionTestModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615B9CA81D4FFD430091F71A /* ProjectionTestModel.swift */; }; 615C17631E030B80002EC200 /* ModelUpdatesListenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615C17621E030B80002EC200 /* ModelUpdatesListenerTests.swift */; }; 615C17651E030BA5002EC200 /* TestUpdatesListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 615C17641E030BA5002EC200 /* TestUpdatesListener.swift */; }; @@ -104,6 +106,8 @@ 6149D1261DF9DBEA00908B8D /* WeakHolder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WeakHolder.swift; sourceTree = ""; }; 6149D1281DF9DD0200908B8D /* WeakBox.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WeakBox.swift; sourceTree = ""; }; 6149D12A1DF9E60400908B8D /* TypeErasedWeakHolders.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypeErasedWeakHolders.swift; sourceTree = ""; }; + 614E1E781E0370180063BAD1 /* ModelChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModelChange.swift; sourceTree = ""; }; + 614E1E7A1E047ABE0063BAD1 /* ModelChangeTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModelChangeTests.swift; sourceTree = ""; }; 615B9CA81D4FFD430091F71A /* ProjectionTestModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProjectionTestModel.swift; sourceTree = ""; }; 615C17621E030B80002EC200 /* ModelUpdatesListenerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModelUpdatesListenerTests.swift; sourceTree = ""; }; 615C17641E030BA5002EC200 /* TestUpdatesListener.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestUpdatesListener.swift; sourceTree = ""; }; @@ -155,6 +159,7 @@ 6144A7EA1CB6DED300025127 /* BatchListener.swift */, 6144A7EB1CB6DED300025127 /* BatchUpdateModel.swift */, 6144A7EC1CB6DED300025127 /* ModelUpdates.swift */, + 614E1E781E0370180063BAD1 /* ModelChange.swift */, 6144A7ED1CB6DED300025127 /* WeakArray.swift */, 6149D1241DF9DBB300908B8D /* Array+Weak.swift */, 6149D1261DF9DBEA00908B8D /* WeakHolder.swift */, @@ -217,6 +222,7 @@ isa = PBXGroup; children = ( 6144A8181CB6DF1B00025127 /* WeakArrayTests.swift */, + 614E1E7A1E047ABE0063BAD1 /* ModelChangeTests.swift */, ); path = DataStructureTests; sourceTree = ""; @@ -379,6 +385,7 @@ 6144A8061CB6DED300025127 /* ConsistencyManagerListener.swift in Sources */, 6144A7FD1CB6DED300025127 /* ModelUpdates.swift in Sources */, 6144A8071CB6DED300025127 /* ConsistencyManagerModel.swift in Sources */, + 614E1E791E0370180063BAD1 /* ModelChange.swift in Sources */, 6144A7FE1CB6DED300025127 /* WeakArray.swift in Sources */, 6149D12B1DF9E60400908B8D /* TypeErasedWeakHolders.swift in Sources */, 6144A8011CB6DED300025127 /* ErrorManager.swift in Sources */, @@ -402,6 +409,7 @@ 6144A8271CB6DF1B00025127 /* MemoryWarningTests.swift in Sources */, 6144A82C1CB6DF1B00025127 /* UpdateFlowTests.swift in Sources */, 6144A8361CB6DF1B00025127 /* ModelUpdatesTests.swift in Sources */, + 614E1E7B1E047ABE0063BAD1 /* ModelChangeTests.swift in Sources */, 6144A8311CB6DF1B00025127 /* TestListener.swift in Sources */, 6144A8341CB6DF1B00025127 /* TestRequiredModel.swift in Sources */, 6179D1A81D5137A100A5209E /* ProjectionTests.swift in Sources */, diff --git a/ConsistencyManager/ConsistencyManager.swift b/ConsistencyManager/ConsistencyManager.swift index 5eec4e6..78457e9 100644 --- a/ConsistencyManager/ConsistencyManager.swift +++ b/ConsistencyManager/ConsistencyManager.swift @@ -315,11 +315,10 @@ open class ConsistencyManager { */ open func updateModel(_ model: ConsistencyManagerModel, context: Any? = nil) { dispatchTask { cancelled in - let tuple = self.childrenAndListenersForModel(model) - let optionalModelUpdates = CollectionHelpers.optionalValueDictionaryFromDictionary(tuple.modelUpdates) + let (modelUpdates, listeners) = self.childrenAndListenersForModel(model) self.updateListeners( - tuple.listeners, - withUpdatedModels: optionalModelUpdates, + listeners, + with: modelUpdates, context: context, originalModel: model, cancelled: cancelled) @@ -356,10 +355,10 @@ open class ConsistencyManager { }() // A simple update dictionary. We're just deleting a model with this id. Nothing else. - let updatesDictionary: [String: [ConsistencyManagerModel]?] = [ id: nil ] + let updatesDictionary = [id: ModelChange.deleted] self.updateListeners( listenersArray, - withUpdatedModels: updatesDictionary, + with: updatesDictionary, context: context, originalModel: model, cancelled: cancelled) @@ -536,8 +535,8 @@ open class ConsistencyManager { If the application is not using projections, it will always just contain one model. It also has an array of listeners that need to be updated because of this model change. */ - private func childrenAndListenersForModel(_ model: ConsistencyManagerModel) -> (modelUpdates: [String: [ConsistencyManagerModel]], listeners: [ConsistencyManagerListener]) { - let updates = DictionaryHolder() + private func childrenAndListenersForModel(_ model: ConsistencyManagerModel) -> (modelUpdates: [String: ModelChange], listeners: [ConsistencyManagerListener]) { + let updates = DictionaryHolder() let listenersArray = ArrayHolder() childrenAndListenersForModel(model, modelUpdates: updates, listenersArray: listenersArray) return (updates.dictionary, listenersArray.array) @@ -549,14 +548,14 @@ open class ConsistencyManager { I tried doing this with inout instead of making it truly functional, but turns out that inout doesn't work very well. Changing to inout helped me about 10%, but after changing to a DictionaryHolder and ArrayHolder, performance was improved ~50x. */ - private func childrenAndListenersForModel(_ model: ConsistencyManagerModel, modelUpdates: DictionaryHolder, listenersArray: ArrayHolder) { + private func childrenAndListenersForModel(_ model: ConsistencyManagerModel, modelUpdates: DictionaryHolder, listenersArray: ArrayHolder) { if let id = model.modelIdentifier { // Here, we want to store a list of all the projections for a model // Normally, this will just be one element as all models with the same id have the same projection // However, if we have multiple versions of the same model, we want to make sure they are all used to merge a new model let projections: [ConsistencyManagerModel] - if var currentUpdates = modelUpdates.dictionary[id] { + if let currentChanges = modelUpdates.dictionary[id], case .updated(var currentUpdates) = currentChanges { let alreadyContainsProjection = currentUpdates.contains { currentProjection in return currentProjection.projectionIdentifier == model.projectionIdentifier } @@ -569,7 +568,7 @@ open class ConsistencyManager { // If we don't have any models from this ID yet, we should just add the new model projections = [model] } - modelUpdates.dictionary[id] = projections + modelUpdates.dictionary[id] = .updated(projections) // Here, we're going to take all the listeners to this model and add it to our listeners array // We're not going to prune the listeners array because of performance reasons (we want updates to go fast) @@ -617,7 +616,7 @@ open class ConsistencyManager { the listener's PausedListener struct accordingly, without notifying the delegate. */ private func updateListeners(_ listeners: [ConsistencyManagerListener], - withUpdatedModels updatedModels: [String: [ConsistencyManagerModel]?], + with updatedModels: [String: ModelChange], context: Any?, originalModel: ConsistencyManagerModel, cancelled: () -> Bool) { @@ -687,7 +686,7 @@ open class ConsistencyManager { updatesListener?.consistencyManager( self, updatedModel: originalModel, - flattenedChildren: updatedModels, + changes: updatedModels, context: context) } } @@ -699,16 +698,15 @@ open class ConsistencyManager { This function uses the map functionality of the models to generate a new model given a list of modelUpdates. It returns a new model, a list of changes (ModelUpdates) and a list of any new models which were not contained in the old model. */ - private func updatedModelFromOriginalModel(_ model: ConsistencyManagerModel, updatedModels: [String: [ConsistencyManagerModel]?], context: Any?) -> (model: ConsistencyManagerModel?, updates: ModelUpdates, newModels: [ConsistencyManagerModel]) { + private func updatedModelFromOriginalModel(_ model: ConsistencyManagerModel, updatedModels: [String: ModelChange], context: Any?) -> (model: ConsistencyManagerModel?, updates: ModelUpdates, newModels: [ConsistencyManagerModel]) { if let id = model.modelIdentifier { - if let replacementModel = updatedModels[id] { + if let modelChange = updatedModels[id] { // The id matches, so we should replace this model with a different model - // Important: replacementModel could actually be nil here. This is because modelUpdates[id] is actually type: ConsistencyManagerModel??. - // So, the let statement only unwraps it once. This is a good thing since if it is nil, we want to delete the model. // At the point, we know that this is an id we care about. Let's see if it's an update or a delete. - if let replacementModel = replacementModel { + switch modelChange { + case .updated(let replacementModels): // It's an update. Let's apply the changes. - let mergedReplacementModel = mergedModelFromModel(model, withUpdates: replacementModel) + let mergedReplacementModel = mergedModelFromModel(model, withUpdates: replacementModels) if !mergedReplacementModel.isEqualToModel(model) { // We've found something to replace, and there's actually an update delegate?.consistencyManager(self, willReplaceModel: model, withModel: mergedReplacementModel, context: context) @@ -720,7 +718,7 @@ open class ConsistencyManager { // We've found there's an update here, but there's no actual change. So let's short curcuit here so we don't waste time recursing. return (model, ModelUpdates(changedModelIds: [], deletedModelIds: []), []) } - } else { + case .deleted: // It was a delete. // nil was an update, so returning it in updates return (nil, ModelUpdates(changedModelIds: [], deletedModelIds: [id]), []) @@ -767,14 +765,13 @@ open class ConsistencyManager { It does not include models which have been deleted. It's useful for detecting the full set of updates for an UpdateModel. */ - private func changedSubmodelIdsFromModel(_ model: ConsistencyManagerModel, modelUpdates: [String: [ConsistencyManagerModel]?]) -> Set { + private func changedSubmodelIdsFromModel(_ model: ConsistencyManagerModel, modelUpdates: [String: ModelChange]) -> Set { var changedModels = Set() model.forEach { child in if let id = child.modelIdentifier, let update = modelUpdates[id] { - // Update is still an optional because the value of model updates is optional // We can ignore deletes because we are only looking for updated models. // Here, we should merge and check for equality to see if anything has actually changed. - if let update = update, !self.mergedModelFromModel(child, withUpdates: update).isEqualToModel(child) { + if case .updated(let models) = update, !self.mergedModelFromModel(child, withUpdates: models).isEqualToModel(child) { // There's another update here changedModels.insert(id) } diff --git a/ConsistencyManager/DataStructures/ModelChange.swift b/ConsistencyManager/DataStructures/ModelChange.swift new file mode 100644 index 0000000..665a57e --- /dev/null +++ b/ConsistencyManager/DataStructures/ModelChange.swift @@ -0,0 +1,44 @@ +// © 2016 LinkedIn Corp. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +import Foundation + +/** + This class is used to show which child models have changed. This object is usually in a dictionary: `[String: ModelChange]`. + The strings represent IDs and it shows what's changed in this ID. Either the ID has been deleted or updated. + If it's been updated, there are potentially several models that have updated if you are using projections. + */ +public enum ModelChange: Equatable { + /** + This indicates a model has been updated and lists the new models. + If you are using projections, there may be multiple models that represent this change. + Otherwise, there will just be one model here. + */ + case updated([ConsistencyManagerModel]) + /** + This indicates a model has been deleted. + */ + case deleted + + public static func ==(lhs: ModelChange, rhs: ModelChange) -> Bool { + switch (lhs, rhs) { + case (.updated(let l), .updated(let r)): + guard l.count == r.count else { + return false + } + return zip(l, r).reduce(true) { isEqual, tuple in + return isEqual && tuple.0.isEqualToModel(tuple.1) + } + case (.deleted, .deleted): + return true + default: + return false + } + } +} diff --git a/ConsistencyManager/Helpers/CollectionHelpers.swift b/ConsistencyManager/Helpers/CollectionHelpers.swift index d2dc5bd..7ceebfa 100644 --- a/ConsistencyManager/Helpers/CollectionHelpers.swift +++ b/ConsistencyManager/Helpers/CollectionHelpers.swift @@ -9,19 +9,6 @@ import Foundation -class CollectionHelpers { - /** - This function essentially does a cast, but this cast isn't allowed in swift so we have to manually create a new dictionary. - */ - class func optionalValueDictionaryFromDictionary(_ dictionary: [A: B]) -> [A: B?] { - var newDictionary = [A: B?]() - for (key, value) in dictionary { - newDictionary[key] = value - } - return newDictionary - } -} - /** This class creates a reference counted dictionary instead of doing structs. It's used for a specific part of the consistency manager for performance reasons. diff --git a/ConsistencyManager/Protocols/ConsistencyManagerUpdatesListener.swift b/ConsistencyManager/Protocols/ConsistencyManagerUpdatesListener.swift index 1934ba1..507a379 100644 --- a/ConsistencyManager/Protocols/ConsistencyManagerUpdatesListener.swift +++ b/ConsistencyManager/Protocols/ConsistencyManagerUpdatesListener.swift @@ -18,16 +18,19 @@ public protocol ConsistencyManagerUpdatesListener: class { This method is run on the main thread. If you have any extensive processing, it's highly recommended to do this on a background thread since this will be called for every single consistency update. + This method passes back a list of all the updates made as a result of this change. + It is a dictionary of `[modelIdentifier: change]`. All of the model's children will be in this dictionary if it was an update. + - parameter consistencyManager: The consistency manager which has received the change. - parameter model: The model which has been updated (NOTE: This model may have been deleted). - To check if it has been deleted, check `flattenedChildren[model.modelIdentifier] == nil`. - - parameter flattenedChildren: This is a flattened representation of all the children of the model that was updated. + To check if it has been deleted, check `changes[model.modelIdentifier] == .deleted`. + - parameter changes: This is a flattened representation of all the children of the model that was updated. It is a dictionary from ID to model. If it is nil, it has been deleted. The value is an array because multiple models with the same ID may have been updated. This only applies if you're using projections. - parameter context: The context passed in with this update */ func consistencyManager(_ consistencyManager: ConsistencyManager, updatedModel model: ConsistencyManagerModel, - flattenedChildren: [String: [ConsistencyManagerModel]?], + changes: [String: ModelChange], context: Any?) } diff --git a/ConsistencyManagerTests/DataStructureTests/ModelChangeTests.swift b/ConsistencyManagerTests/DataStructureTests/ModelChangeTests.swift new file mode 100644 index 0000000..ac5ece1 --- /dev/null +++ b/ConsistencyManagerTests/DataStructureTests/ModelChangeTests.swift @@ -0,0 +1,29 @@ +// © 2016 LinkedIn Corp. All rights reserved. +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +import XCTest +import ConsistencyManager + +class ModelChangeTests: ConsistencyManagerTestCase { + + func testDeleteEquality() { + XCTAssertEqual(ModelChange.deleted, ModelChange.deleted) + XCTAssertNotEqual(ModelChange.deleted, ModelChange.updated([])) + } + + func testUpdatedEquality() { + let model = TestModel(id: "0", data: nil, children: [], requiredModel: TestRequiredModel(id: nil, data: 0)) + let otherModel = TestModel(id: "1", data: nil, children: [], requiredModel: TestRequiredModel(id: nil, data: 0)) + + XCTAssertEqual(ModelChange.updated([model]), ModelChange.updated([model])) + XCTAssertNotEqual(ModelChange.updated([model]), ModelChange.updated([otherModel])) + XCTAssertNotEqual(ModelChange.updated([model]), ModelChange.updated([model, model])) + XCTAssertNotEqual(ModelChange.updated([model]), ModelChange.deleted) + } +} diff --git a/ConsistencyManagerTests/HelperClasses/TestUpdatesListener.swift b/ConsistencyManagerTests/HelperClasses/TestUpdatesListener.swift index 26c2edc..9780b66 100644 --- a/ConsistencyManagerTests/HelperClasses/TestUpdatesListener.swift +++ b/ConsistencyManagerTests/HelperClasses/TestUpdatesListener.swift @@ -12,12 +12,26 @@ import ConsistencyManager class TestUpdatesListener: ConsistencyManagerUpdatesListener { - var updateClosure: ((ConsistencyManagerModel, [String : [ConsistencyManagerModel]?], Any?) -> Void)? + var updateClosure: ((ConsistencyManagerModel, [String : ModelChange], Any?) -> Void)? func consistencyManager(_ consistencyManager: ConsistencyManager, updatedModel model: ConsistencyManagerModel, - flattenedChildren: [String : [ConsistencyManagerModel]?], + changes: [String : ModelChange], context: Any?) { - updateClosure?(model, flattenedChildren, context) + updateClosure?(model, changes, context) + } +} + +extension ModelChange { + /** + Simple helper to get out the models from a ModelChange object assuming it's .updated. + */ + var models: [ConsistencyManagerModel] { + switch self { + case .updated(let models): + return models + case .deleted: + return [] + } } } diff --git a/ConsistencyManagerTests/ModelUpdatesListenerTests.swift b/ConsistencyManagerTests/ModelUpdatesListenerTests.swift index b248151..1f557e8 100644 --- a/ConsistencyManagerTests/ModelUpdatesListenerTests.swift +++ b/ConsistencyManagerTests/ModelUpdatesListenerTests.swift @@ -26,8 +26,8 @@ class ModelUpdatesListenerTests: ConsistencyManagerTestCase { calledUpdateClosure += 1 XCTAssertEqual(model as? TestModel, testModel) XCTAssertEqual(children.count, 2) - XCTAssertEqual(children["0"]??[0] as? TestModel, testModel) - XCTAssertEqual(children["1"]??[0] as? TestRequiredModel, requiredModel) + XCTAssertEqual(children["0"]?.models[0] as? TestModel, testModel) + XCTAssertEqual(children["1"]?.models[0] as? TestRequiredModel, requiredModel) XCTAssertEqual(context as? String, "context") } @@ -52,8 +52,8 @@ class ModelUpdatesListenerTests: ConsistencyManagerTestCase { calledUpdateClosure += 1 XCTAssertEqual(model as? TestModel, testModel) XCTAssertEqual(children.count, 2) - XCTAssertEqual(children["0"]??[0] as? TestModel, testModel) - XCTAssertEqual(children["1"]??[0] as? TestRequiredModel, requiredModel) + XCTAssertEqual(children["0"]?.models[0] as? TestModel, testModel) + XCTAssertEqual(children["1"]?.models[0] as? TestRequiredModel, requiredModel) XCTAssertEqual(context as? String, "context") } @@ -62,8 +62,8 @@ class ModelUpdatesListenerTests: ConsistencyManagerTestCase { calledUpdate2Closure += 1 XCTAssertEqual(model as? TestModel, testModel) XCTAssertEqual(children.count, 2) - XCTAssertEqual(children["0"]??[0] as? TestModel, testModel) - XCTAssertEqual(children["1"]??[0] as? TestRequiredModel, requiredModel) + XCTAssertEqual(children["0"]?.models[0] as? TestModel, testModel) + XCTAssertEqual(children["1"]?.models[0] as? TestRequiredModel, requiredModel) XCTAssertEqual(context as? String, "context") } @@ -87,7 +87,7 @@ class ModelUpdatesListenerTests: ConsistencyManagerTestCase { calledUpdateClosure += 1 XCTAssertEqual(model as? TestModel, testModel) XCTAssertEqual(children.count, 1) - XCTAssertNil(children["0"]!) + XCTAssertEqual(children["0"], ModelChange.deleted) XCTAssertEqual(context as? String, "context") }