Skip to content

Commit b9856b2

Browse files
authored
Merge pull request #3 from SwiftRex/recursiveDiff
An attempt to implement a recursive diff for any StateType
2 parents cf3404a + 21e88a0 commit b9856b2

File tree

2 files changed

+152
-5
lines changed

2 files changed

+152
-5
lines changed

Sources/LoggerMiddleware/LoggerMiddleware.swift

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ public final class LoggerMiddleware<M: Middleware>: Middleware where M.StateType
6767
self.queue.async {
6868
let actionMessage = self.actionTransform.transform(action: action, source: dispatcher)
6969
self.actionPrinter.log(action: actionMessage)
70-
self.stateDiffPrinter.log(state: self.stateDiffTransform.transform(oldState: stateBefore, newState: stateAfter))
70+
if let diffString = self.stateDiffTransform.transform(oldState: stateBefore, newState: stateAfter) {
71+
self.stateDiffPrinter.log(state: diffString)
72+
}
7173
}
7274
}
7375
}
@@ -123,9 +125,10 @@ extension LoggerMiddleware {
123125
public enum StateDiffTransform {
124126
case diff(linesOfContext: Int = 2, prefixLines: String = "🏛 ")
125127
case newStateOnly
126-
case custom((StateType?, StateType) -> String)
128+
case recursive(prefixLines: String = "🏛 ", stateName: String)
129+
case custom((StateType?, StateType) -> String?)
127130

128-
func transform(oldState: StateType?, newState: StateType) -> String {
131+
func transform(oldState: StateType?, newState: StateType) -> String? {
129132
switch self {
130133
case let .diff(linesOfContext, prefixLines):
131134
let stateBefore = dumpToString(oldState)
@@ -134,11 +137,93 @@ extension LoggerMiddleware {
134137
?? "\(prefixLines) No state mutation"
135138
case .newStateOnly:
136139
return dumpToString(newState)
140+
case let .recursive(prefixLines, stateName):
141+
return recursiveDiff(prefixLines: prefixLines, stateName: stateName, before: oldState, after: newState)
137142
case let .custom(closure):
138143
return closure(oldState, newState)
139144
}
140145
}
141146
}
147+
148+
public static func recursiveDiff(prefixLines: String, stateName: String, before: StateType?, after: StateType) -> String? {
149+
// cuts the redundant newline character from the output
150+
diff(prefix: prefixLines, name: stateName, lhs: before, rhs: after)?.trimmingCharacters(in: .whitespacesAndNewlines)
151+
}
152+
153+
private static func diff<A>(prefix: String, name: String, level: Int = 0, lhs: A?, rhs: A?) -> String? {
154+
155+
guard let rightHandSide = rhs, let leftHandSide = lhs else {
156+
if let rightHandSide = rhs {
157+
return "\(prefix).\(name): nil → \(rightHandSide)"
158+
}
159+
160+
if let leftHandSide = lhs {
161+
return "\(prefix).\(name): \(leftHandSide) → nil"
162+
}
163+
164+
// nil == lhs == rhs
165+
return nil
166+
}
167+
168+
// special handling for Dictionaries: stringify and order the keys before comparing
169+
if let left = leftHandSide as? Dictionary<AnyHashable, Any>, let right = rightHandSide as? Dictionary<AnyHashable, Any> {
170+
171+
let leftSorted = left.sorted { a, b in "\(a.key)" < "\(b.key)" }
172+
let rightSorted = right.sorted { a, b in "\(a.key)" < "\(b.key)" }
173+
174+
let leftPrintable = leftSorted.map { key, value in "\(key): \(value)" }.joined(separator: ", ")
175+
let rightPrintable = rightSorted.map { key, value in "\(key): \(value)" }.joined(separator: ", ")
176+
177+
// .difference(from:) gives unpleasant results
178+
if leftPrintable == rightPrintable {
179+
return nil
180+
}
181+
182+
return "\(prefix).\(name): 📦 [\(leftPrintable)] → [\(rightPrintable)]"
183+
}
184+
185+
// special handling for sets as well: order the contents, compare as strings
186+
if let left = leftHandSide as? Set<AnyHashable>, let right = rightHandSide as? Set<AnyHashable> {
187+
let leftSorted = left.map { "\($0)" }.sorted { a, b in a < b }
188+
let rightSorted = right.map { "\($0)" }.sorted { a, b in a < b }
189+
190+
let leftPrintable = leftSorted.joined(separator: ", ")
191+
let rightPrintable = rightSorted.joined(separator: ", ")
192+
193+
// .difference(from:) gives unpleasant results
194+
if leftPrintable == rightPrintable {
195+
return nil
196+
}
197+
return "\(prefix).\(name): 📦 <\(leftPrintable)> → <\(rightPrintable)>"
198+
}
199+
200+
let leftMirror = Mirror(reflecting: leftHandSide)
201+
let rightMirror = Mirror(reflecting: rightHandSide)
202+
203+
// if there are no children, compare leftHandSide and rightHandSide directly
204+
if 0 == leftMirror.children.count {
205+
if "\(leftHandSide)" == "\(rightHandSide)" {
206+
return nil
207+
} else {
208+
return "\(prefix).\(name): \(leftHandSide)\(rightHandSide)"
209+
}
210+
}
211+
212+
// there are children -> diff the object graph recursively
213+
let strings: [String] = leftMirror.children.map({ leftChild in
214+
let toDotOrNotToDot = (level > 0) ? "." : " "
215+
return Self.diff(prefix: "\(prefix)\(toDotOrNotToDot)\(name)",
216+
name: leftChild.label ?? "#", // label might be missing for items in collections, # represents a collection element
217+
level: level + 1,
218+
lhs: leftChild.value,
219+
rhs: rightMirror.children.first(where: { $0.label == leftChild.label })?.value)
220+
}).compactMap { $0 }
221+
222+
if strings.count > 0 {
223+
return strings.joined(separator: "\n")
224+
}
225+
return nil
226+
}
142227
}
143228

144229
// MARK: - Action
Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,73 @@
11
import XCTest
2+
import SwiftRex
23
@testable import LoggerMiddleware
34

5+
struct TestState: Equatable {
6+
public let a: Substate
7+
public let b: [Int]
8+
public let c: String
9+
public let d: String?
10+
public let e: String?
11+
}
12+
13+
struct Substate: Equatable {
14+
public let x: Set<String>
15+
public let y1: [String: Int]
16+
public let y2: [String: Int?]
17+
public let z: Bool
18+
}
19+
20+
struct TestMiddleware: Middleware {
21+
func receiveContext(getState: @escaping GetState<TestState>, output: AnyActionHandler<Int>) {
22+
}
23+
24+
func handle(action: Int, from dispatcher: ActionSource, afterReducer: inout AfterReducer) {
25+
}
26+
27+
typealias InputActionType = Int
28+
typealias OutputActionType = Int
29+
typealias StateType = TestState
30+
}
31+
432
final class LoggerMiddlewareTests: XCTestCase {
5-
func testExample() {
33+
34+
func testStateDiff() {
35+
// given
36+
let beforeState: LoggerMiddleware<TestMiddleware>.StateType = TestState(a: Substate(x: ["SetB", "SetA"],
37+
y1: ["one": 1, "eleven": 11],
38+
y2: ["one": 1, "eleven": 11, "zapp": 42],
39+
z: true),
40+
b: [0, 1],
41+
c: "Foo",
42+
d: "",
43+
e: nil)
44+
let afterState: LoggerMiddleware<TestMiddleware>.StateType = TestState(a: Substate(x: ["SetB", "SetC"],
45+
y1: ["one": 1, "twelve": 12],
46+
y2: ["one": 1, "twelve": 12, "zapp": nil],
47+
z: false),
48+
b: [0],
49+
c: "Bar",
50+
d: nil,
51+
e: "🥚")
52+
53+
// when
54+
let result: String? = LoggerMiddleware<TestMiddleware>.recursiveDiff(prefixLines: "🏛", stateName: "TestState", before: beforeState, after: afterState)
55+
56+
// then
57+
let expected = """
58+
🏛 TestState.a.x: 📦 <SetA, SetB> → <SetB, SetC>
59+
🏛 TestState.a.y1: 📦 [eleven: 11, one: 1] → [one: 1, twelve: 12]
60+
🏛 TestState.a.y2: 📦 [eleven: Optional(11), one: Optional(1), zapp: Optional(42)] → [one: Optional(1), twelve: Optional(12), zapp: nil]
61+
🏛 TestState.a.z: true → false
62+
🏛 TestState.b.#: 1 → 0
63+
🏛 TestState.c: Foo → Bar
64+
🏛 TestState.d.some: ✨ → nil
65+
🏛 TestState.e: nil → Optional("🥚")
66+
"""
67+
XCTAssertEqual(result, expected)
668
}
769

870
static var allTests = [
9-
("testExample", testExample),
71+
("testStateDiff", testStateDiff),
1072
]
1173
}

0 commit comments

Comments
 (0)