Skip to content

Commit

Permalink
Refactoring the ModelChange API to use a real model instead of option…
Browse files Browse the repository at this point in the history
…als (#48)
  • Loading branch information
plivesey authored Dec 22, 2016
1 parent aee678f commit 0c6bf9a
Show file tree
Hide file tree
Showing 8 changed files with 131 additions and 49 deletions.
8 changes: 8 additions & 0 deletions ConsistencyManager.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -104,6 +106,8 @@
6149D1261DF9DBEA00908B8D /* WeakHolder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WeakHolder.swift; sourceTree = "<group>"; };
6149D1281DF9DD0200908B8D /* WeakBox.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WeakBox.swift; sourceTree = "<group>"; };
6149D12A1DF9E60400908B8D /* TypeErasedWeakHolders.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TypeErasedWeakHolders.swift; sourceTree = "<group>"; };
614E1E781E0370180063BAD1 /* ModelChange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModelChange.swift; sourceTree = "<group>"; };
614E1E7A1E047ABE0063BAD1 /* ModelChangeTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModelChangeTests.swift; sourceTree = "<group>"; };
615B9CA81D4FFD430091F71A /* ProjectionTestModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProjectionTestModel.swift; sourceTree = "<group>"; };
615C17621E030B80002EC200 /* ModelUpdatesListenerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModelUpdatesListenerTests.swift; sourceTree = "<group>"; };
615C17641E030BA5002EC200 /* TestUpdatesListener.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestUpdatesListener.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -217,6 +222,7 @@
isa = PBXGroup;
children = (
6144A8181CB6DF1B00025127 /* WeakArrayTests.swift */,
614E1E7A1E047ABE0063BAD1 /* ModelChangeTests.swift */,
);
path = DataStructureTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand All @@ -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 */,
Expand Down
43 changes: 20 additions & 23 deletions ConsistencyManager/ConsistencyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<String, [ConsistencyManagerModel]>()
private func childrenAndListenersForModel(_ model: ConsistencyManagerModel) -> (modelUpdates: [String: ModelChange], listeners: [ConsistencyManagerListener]) {
let updates = DictionaryHolder<String, ModelChange>()
let listenersArray = ArrayHolder<ConsistencyManagerListener>()
childrenAndListenersForModel(model, modelUpdates: updates, listenersArray: listenersArray)
return (updates.dictionary, listenersArray.array)
Expand All @@ -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<String, [ConsistencyManagerModel]>, listenersArray: ArrayHolder<ConsistencyManagerListener>) {
private func childrenAndListenersForModel(_ model: ConsistencyManagerModel, modelUpdates: DictionaryHolder<String, ModelChange>, listenersArray: ArrayHolder<ConsistencyManagerListener>) {

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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -687,7 +686,7 @@ open class ConsistencyManager {
updatesListener?.consistencyManager(
self,
updatedModel: originalModel,
flattenedChildren: updatedModels,
changes: updatedModels,
context: context)
}
}
Expand All @@ -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)
Expand All @@ -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]), [])
Expand Down Expand Up @@ -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<String> {
private func changedSubmodelIdsFromModel(_ model: ConsistencyManagerModel, modelUpdates: [String: ModelChange]) -> Set<String> {
var changedModels = Set<String>()
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)
}
Expand Down
44 changes: 44 additions & 0 deletions ConsistencyManager/DataStructures/ModelChange.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
13 changes: 0 additions & 13 deletions ConsistencyManager/Helpers/CollectionHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<A, B>(_ 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
}
29 changes: 29 additions & 0 deletions ConsistencyManagerTests/DataStructureTests/ModelChangeTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading

0 comments on commit 0c6bf9a

Please sign in to comment.