Skip to content

Commit ffe34c9

Browse files
authored
Merge pull request raymccrae#2 from ChiellieNL/master
Ignore non existent values
2 parents cd703b7 + d63090c commit ffe34c9

7 files changed

Lines changed: 294 additions & 24 deletions

File tree

JSONPatch.xcodeproj/project.pbxproj

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
4DDA51EB219AAD8D00BA7704 /* NSArray+DeepCopy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDA51EA219AAD8D00BA7704 /* NSArray+DeepCopy.swift */; };
3232
4DDA51ED219AADE000BA7704 /* NSDictionary+DeepCopy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDA51EC219AADE000BA7704 /* NSDictionary+DeepCopy.swift */; };
3333
4DF8B18621B1C5E700CAABEE /* JSONPatchGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF8B18521B1C5E700CAABEE /* JSONPatchGenerator.swift */; };
34+
CC4BF61423DB0BD100485D08 /* JSONCodableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC4BF61323DB0BD100485D08 /* JSONCodableTests.swift */; };
3435
/* End PBXBuildFile section */
3536

3637
/* Begin PBXContainerItemProxy section */
@@ -82,6 +83,7 @@
8283
4DDA51EA219AAD8D00BA7704 /* NSArray+DeepCopy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSArray+DeepCopy.swift"; sourceTree = "<group>"; };
8384
4DDA51EC219AADE000BA7704 /* NSDictionary+DeepCopy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSDictionary+DeepCopy.swift"; sourceTree = "<group>"; };
8485
4DF8B18521B1C5E700CAABEE /* JSONPatchGenerator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONPatchGenerator.swift; sourceTree = "<group>"; };
86+
CC4BF61323DB0BD100485D08 /* JSONCodableTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONCodableTests.swift; sourceTree = "<group>"; };
8587
/* End PBXFileReference section */
8688

8789
/* Begin PBXFrameworksBuildPhase section */
@@ -141,10 +143,11 @@
141143
4D22EB33219879EF00729769 /* JSONPatchTests */ = {
142144
isa = PBXGroup;
143145
children = (
146+
4D5242CF21CD6F8100030720 /* ArrayTests.swift */,
144147
4D471D9221A3545900E43468 /* Bundle.swift */,
145148
4D452743219B60A2002637CF /* extra.json */,
146149
4D22EB36219879EF00729769 /* Info.plist */,
147-
4D5242CF21CD6F8100030720 /* ArrayTests.swift */,
150+
CC4BF61323DB0BD100485D08 /* JSONCodableTests.swift */,
148151
4D305DBC21A020F800CE9C84 /* JSONElementTests.swift */,
149152
4D7E4D0621A6063800BFE359 /* JSONFileTestCase.swift */,
150153
4D093AFE21B42A7B0097F14B /* JSONPatchGeneratorTests.swift */,
@@ -288,6 +291,7 @@
288291
4D5242D021CD6F8100030720 /* ArrayTests.swift in Sources */,
289292
4D0AF308219885A400E7F86B /* JSONPointerTests.swift in Sources */,
290293
4D7E4D0921A60F2D00BFE359 /* TestFileTestCase.swift in Sources */,
294+
CC4BF61423DB0BD100485D08 /* JSONCodableTests.swift in Sources */,
291295
);
292296
runOnlyForDeploymentPostprocessing = 0;
293297
};

Sources/JSONPatch/JSONElement.swift

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -435,20 +435,29 @@ extension JSONElement {
435435
///
436436
/// - Parameters:
437437
/// - operation: The operation to apply.
438-
public mutating func apply(_ operation: JSONPatch.Operation) throws {
439-
switch operation {
440-
case let .add(path, value):
441-
try add(value: value, to: path)
442-
case let .remove(path):
443-
try remove(at: path)
444-
case let .replace(path, value):
445-
try replace(value: value, to: path)
446-
case let .move(from, path):
447-
try move(from: from, to: path)
448-
case let .copy(from, path):
449-
try copy(from: from, to: path)
450-
case let .test(path, value):
451-
try test(value: value, at: path)
438+
public mutating func apply(_ operation: JSONPatch.Operation, ignoreNonexistentValues: Bool = false) throws {
439+
do {
440+
switch operation {
441+
case let .add(path, value):
442+
try add(value: value, to: path)
443+
case let .remove(path):
444+
try remove(at: path)
445+
case let .replace(path, value):
446+
try replace(value: value, to: path)
447+
case let .move(from, path):
448+
try move(from: from, to: path)
449+
case let .copy(from, path):
450+
try copy(from: from, to: path)
451+
case let .test(path, value):
452+
try test(value: value, at: path)
453+
}
454+
} catch {
455+
if let error = error as? JSONError,
456+
error == .referencesNonexistentValue && ignoreNonexistentValues {
457+
// Don't throw, just continue
458+
} else {
459+
throw error
460+
}
452461
}
453462
}
454463

@@ -459,15 +468,15 @@ extension JSONElement {
459468
/// - Parameters:
460469
/// - patch: The json-patch to be applied.
461470
/// - path: If present then the patch is applied to the child element at the path.
462-
public mutating func apply(patch: JSONPatch, relativeTo path: JSONPointer? = nil) throws {
471+
public mutating func apply(patch: JSONPatch, relativeTo path: JSONPointer? = nil, ignoreNonexistentValues: Bool = false) throws {
463472
if let path = path, let parent = path.parent {
464473
var parentElement = try makePathMutable(parent)
465474
var relativeRoot = try parentElement.value(for: path.lastComponent!)
466-
try relativeRoot.apply(patch: patch)
475+
try relativeRoot.apply(patch: patch, ignoreNonexistentValues: ignoreNonexistentValues)
467476
try parentElement.setValue(relativeRoot, component: path.lastComponent!, replace: true)
468477
} else {
469478
for operation in patch.operations {
470-
try apply(operation)
479+
try apply(operation, ignoreNonexistentValues: ignoreNonexistentValues)
471480
}
472481
}
473482
}

Sources/JSONPatch/JSONError.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,48 @@ public enum JSONError: Error {
2929
case missingRequiredPatchField(op: String, index: Int, field: String)
3030
case patchTestFailed(path: String, expected: Any, found: Any?)
3131
}
32+
33+
extension JSONError: Equatable {
34+
public static func ==(lhs: JSONError, rhs: JSONError) -> Bool {
35+
switch lhs {
36+
case .invalidObjectType:
37+
if case .invalidObjectType = rhs {
38+
return true
39+
}
40+
41+
case .invalidPointerSyntax:
42+
if case .invalidPointerSyntax = rhs {
43+
return true
44+
}
45+
46+
case .invalidPatchFormat:
47+
if case .invalidPatchFormat = rhs {
48+
return true
49+
}
50+
51+
case .referencesNonexistentValue:
52+
if case .referencesNonexistentValue = rhs {
53+
return true
54+
}
55+
56+
case .unknownPatchOperation:
57+
if case .unknownPatchOperation = rhs {
58+
return true
59+
}
60+
61+
case .missingRequiredPatchField(let op1, let index1, let field1):
62+
if case .missingRequiredPatchField(let op2, let index2, let field2) = rhs,
63+
op1 == op2 && index1 == index2 && field1 == field2 {
64+
return true
65+
}
66+
67+
case .patchTestFailed(let path1, _, _):
68+
if case .patchTestFailed(let path2, _, _) = rhs,
69+
path1 == path2 {
70+
return true
71+
}
72+
}
73+
74+
return false
75+
}
76+
}

Sources/JSONPatch/JSONPatch.swift

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ import Foundation
2828
/// https://tools.ietf.org/html/rfc6902
2929
public class JSONPatch: Codable {
3030

31+
/**
32+
Default setting for ignoring non existent values (Default: false)
33+
34+
When set to true, all JSONError.referencesNonexistentValue errors will be ignored
35+
*/
36+
public static var ignoreNonexistentValues: Bool = false
37+
3138
/// The mimetype for json-patch
3239
public static let mimetype = "application/json-patch+json"
3340

@@ -128,15 +135,17 @@ public class JSONPatch: Codable {
128135
/// a patch inplace the result is not atomic, if an error occurs then the
129136
/// json object may be left in a partial state. If false then a copy of
130137
/// the json document is created and the patch applied to the copy.
138+
/// - ignoreNonexistentValues: if true, all references to non existent values will be ignored and the patch will continue applying all remaining operations
131139
/// - Returns: A transformed json document with the patch applied.
132140
public func apply(to jsonObject: Any,
133141
relativeTo path: JSONPointer? = nil,
134-
inplace: Bool = true) throws -> Any {
142+
inplace: Bool = true,
143+
ignoreNonexistentValues: Bool = JSONPatch.ignoreNonexistentValues) throws -> Any {
135144
var jsonDocument = try JSONElement(any: jsonObject)
136145
if !inplace {
137146
jsonDocument = jsonDocument.copy()
138147
}
139-
try jsonDocument.apply(patch: self, relativeTo: path)
148+
try jsonDocument.apply(patch: self, relativeTo: path, ignoreNonexistentValues: ignoreNonexistentValues)
140149
return jsonDocument.rawValue
141150
}
142151

@@ -151,15 +160,17 @@ public class JSONPatch: Codable {
151160
/// If nil then the patch is applied directly to the whole json document.
152161
/// - readingOptions: The options given to JSONSerialization to parse the json data.
153162
/// - writingOptions: The options given to JSONSerialization to write the result to data.
163+
/// - ignoreNonexistentValues: if true, all references to non existent values will be ignored and the patch will continue applying all remaining operations
154164
/// - Returns: The transformed json document as data.
155165
public func apply(to data: Data,
156166
relativeTo path: JSONPointer? = nil,
157167
readingOptions: JSONSerialization.ReadingOptions = [.mutableContainers],
158-
writingOptions: JSONSerialization.WritingOptions = []) throws -> Data {
168+
writingOptions: JSONSerialization.WritingOptions = [],
169+
ignoreNonexistentValues: Bool = JSONPatch.ignoreNonexistentValues) throws -> Data {
159170
let jsonObject = try JSONSerialization.jsonObject(with: data,
160171
options: readingOptions)
161172
var jsonElement = try JSONElement(any: jsonObject)
162-
try jsonElement.apply(patch: self, relativeTo: path)
173+
try jsonElement.apply(patch: self, relativeTo: path, ignoreNonexistentValues: ignoreNonexistentValues)
163174
let transformedData = try JSONSerialization.data(with: jsonElement,
164175
options: writingOptions)
165176
return transformedData
@@ -301,3 +312,63 @@ extension JSONPatch: Equatable {
301312
}
302313

303314
}
315+
316+
// MARK: - Patch Codable
317+
318+
public extension JSONPatch {
319+
/**
320+
Applies this patch on the specified Codable object
321+
322+
The originated object won't be changed, a new object will be returned with this patch applied on it
323+
324+
e.g.
325+
326+
```swift
327+
let patch = ... // get your JSONPatch from somewhere
328+
329+
let patchedDevice = try! patch.applied(to: self.device)
330+
```
331+
332+
- parameter object: The object to apply the patch on
333+
334+
- returns: A new object with the applied patch
335+
*/
336+
func applied<T: Codable>(to object: T) throws -> T {
337+
let data = try JSONEncoder().encode(object)
338+
let patchedData = try self.apply(to: data)
339+
340+
return try JSONDecoder().decode(T.self, from: patchedData)
341+
}
342+
343+
/**
344+
Creates a patch from the source object to the target object
345+
346+
- note:
347+
Generic use case would be that the `from` object is **an older** version of the `to` object.
348+
349+
e.g.:
350+
351+
```swift
352+
self.device.state.isPowered = false
353+
var lastSent: Device = IOManager.send(self.device)
354+
355+
self.device.state.isPowered = true
356+
357+
let patch = try JSONPatch.createPatch(from: lastSent, to: self.device)
358+
359+
// Patch will now be a patch that changes
360+
// the state's `isPowered` from false to true
361+
```
362+
363+
- parameter source: The source object
364+
- parameter target: The target object
365+
366+
- returns: The JSONPatch to get from the source to the target object
367+
*/
368+
static func createPatch<T: Codable>(from source: T, to target: T) throws -> JSONPatch {
369+
let sourceData = try JSONEncoder().encode(source)
370+
let targetData = try JSONEncoder().encode(target)
371+
372+
return try JSONPatch(source: sourceData, target: targetData)
373+
}
374+
}

Sources/JSONPatch/JSONPointer.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,8 +178,8 @@ extension JSONPointer: Equatable {
178178
}
179179

180180
extension JSONPointer: Hashable {
181-
public var hashValue: Int {
182-
return components.hashValue
181+
public func hash(into hasher: inout Hasher) {
182+
hasher.combine(components)
183183
}
184184
}
185185

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//
2+
// JSONPatchTests.swift
3+
// JSONPatchTests
4+
//
5+
// Created by Michiel Horvers on 01/24/2020.
6+
// Copyright © 2020 Michiel Horvers.
7+
//
8+
// Licensed under the Apache License, Version 2.0 (the "License");
9+
// you may not use this file except in compliance with the License.
10+
// You may obtain a copy of the License at
11+
//
12+
// http://www.apache.org/licenses/LICENSE-2.0
13+
//
14+
// Unless required by applicable law or agreed to in writing, software
15+
// distributed under the License is distributed on an "AS IS" BASIS,
16+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17+
// See the License for the specific language governing permissions and
18+
// limitations under the License.
19+
//
20+
21+
import XCTest
22+
@testable import JSONPatch
23+
24+
fileprivate struct Person: Codable {
25+
var firstName: String
26+
var lastName: String
27+
var age: Int
28+
}
29+
30+
class JSONCodableTests: XCTestCase {
31+
32+
func testCreatePatch() throws {
33+
let source = Person(firstName: "Michiel", lastName: "Horvers", age: 99)
34+
let target = Person(firstName: "Michiel", lastName: "Horvers", age: 100)
35+
36+
let patch = try JSONPatch.createPatch(from: source, to: target)
37+
XCTAssert(patch.operations.count == 1, "Patch should have only 1 operation, but has \(patch.operations.count)")
38+
39+
guard patch.operations.count == 1 else { return }
40+
let dict = patch.operations[0].jsonObject
41+
42+
XCTAssert((dict["op"] as? String) == "replace", "Operation should be 'replace', but is: \(String(describing: dict["op"]))")
43+
XCTAssert((dict["path"] as? String) == "/age", "Path should be 'age', but is: \(String(describing: dict["path"]))")
44+
XCTAssert((dict["value"] as? Int) == 100, "Value should be 100, but is: \(String(describing: dict["value"]))")
45+
}
46+
47+
func testApplyPatch() throws {
48+
let person = Person(firstName: "Michiel", lastName: "Horvers", age: 99)
49+
let patchData = Data("""
50+
[
51+
{ "op": "replace", "path": "/age", "value": 100 }
52+
]
53+
""".utf8)
54+
let patch = try JSONDecoder().decode(JSONPatch.self, from: patchData)
55+
56+
let patchedPerson = try patch.applied(to: person)
57+
XCTAssert(patchedPerson.age == 100, "Age should be patchd to 100, but is: \(patchedPerson.age)")
58+
}
59+
}

0 commit comments

Comments
 (0)