diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efefe16..47fad03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,11 +4,11 @@ on: push jobs: test: - runs-on: macos-14 - + runs-on: macos-26 + steps: - - uses: actions/checkout@v3 - - name: Select Xcode 16 - run: sudo xcode-select -s /Applications/Xcode_16.0.app + - uses: actions/checkout@v4 + - name: Select Xcode 26 + run: sudo xcode-select -s /Applications/Xcode_26.5.app - name: Test run: swift test diff --git a/README.md b/README.md index 60233f7..f2da0a9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,16 @@ json["one"].integerValue // 2 json["object"]["four_text"].stringValue // "four" ``` +Swift `nil` maps to JSON `null` in literals and subscript assignments: + +```swift +var object: JSON = ["name": "Blob"] +object["deleted_at"] = nil as JSON? // stores JSON.null +object.removeValue(forKey: "name") // removes the key +``` + +Missing keys return `nil` and are distinct from explicit JSON `null`. + ## Installation ### Swift Package Manager diff --git a/Sources/JSON/JSON.swift b/Sources/JSON/JSON.swift index 77e3cad..f1c1796 100644 --- a/Sources/JSON/JSON.swift +++ b/Sources/JSON/JSON.swift @@ -27,11 +27,7 @@ public enum JSON: } set { guard case var .dictionary(dict) = self else { return } - if let newValue { - dict[key] = newValue - } else { - dict.removeValue(forKey: key) - } + dict[key] = newValue ?? .null self = .dictionary(dict) } } @@ -46,7 +42,7 @@ public enum JSON: if let newValue { dict[key] = .array(newValue.compactMap(\.json)) } else { - dict.removeValue(forKey: key) + dict[key] = .null } self = .dictionary(dict) } @@ -62,7 +58,7 @@ public enum JSON: if let newValue { dict[key] = .boolean(newValue) } else { - dict.removeValue(forKey: key) + dict[key] = .null } self = .dictionary(dict) } @@ -78,7 +74,7 @@ public enum JSON: if let newValue { dict[key] = .dictionary(newValue.compactMapValues(\.json)) } else { - dict.removeValue(forKey: key) + dict[key] = .null } self = .dictionary(dict) } @@ -94,7 +90,7 @@ public enum JSON: if let newValue { dict[key] = .number(newValue) } else { - dict.removeValue(forKey: key) + dict[key] = .null } self = .dictionary(dict) } @@ -110,7 +106,7 @@ public enum JSON: if let newValue { dict[key] = .number(Double(newValue)) } else { - dict.removeValue(forKey: key) + dict[key] = .null } self = .dictionary(dict) } @@ -126,12 +122,20 @@ public enum JSON: if let newValue { dict[key] = .string(newValue) } else { - dict.removeValue(forKey: key) + dict[key] = .null } self = .dictionary(dict) } } + @discardableResult + public mutating func removeValue(forKey key: String) -> JSON? { + guard case var .dictionary(dict) = self else { return nil } + let removedValue = dict.removeValue(forKey: key) + self = .dictionary(dict) + return removedValue + } + public subscript(index: Int) -> JSON? { get { guard case let .array(arr) = self, index < arr.count @@ -373,7 +377,7 @@ public extension JSON? { } set { guard case var .dictionary(dict) = self else { return } - dict[key] = newValue + dict[key] = newValue ?? .null self = .dictionary(dict) } } @@ -388,7 +392,7 @@ public extension JSON? { if let newValue { dict[key] = .array(newValue.compactMap(\.json)) } else { - dict.removeValue(forKey: key) + dict[key] = .null } self = .dictionary(dict) } @@ -404,7 +408,7 @@ public extension JSON? { if let newValue { dict[key] = .boolean(newValue) } else { - dict.removeValue(forKey: key) + dict[key] = .null } self = .dictionary(dict) } @@ -420,7 +424,7 @@ public extension JSON? { if let newValue { dict[key] = .dictionary(newValue.compactMapValues(\.json)) } else { - dict.removeValue(forKey: key) + dict[key] = .null } self = .dictionary(dict) } @@ -436,7 +440,7 @@ public extension JSON? { if let newValue { dict[key] = .number(newValue) } else { - dict.removeValue(forKey: key) + dict[key] = .null } self = .dictionary(dict) } @@ -452,7 +456,7 @@ public extension JSON? { if let newValue { dict[key] = .number(Double(newValue)) } else { - dict.removeValue(forKey: key) + dict[key] = .null } self = .dictionary(dict) } @@ -468,12 +472,20 @@ public extension JSON? { if let newValue { dict[key] = .string(newValue) } else { - dict.removeValue(forKey: key) + dict[key] = .null } self = .dictionary(dict) } } + @discardableResult + mutating func removeValue(forKey key: String) -> JSON? { + guard case var .dictionary(dict) = self else { return nil } + let removedValue = dict.removeValue(forKey: key) + self = .dictionary(dict) + return removedValue + } + subscript(index: Int) -> JSON? { get { guard case let .array(arr) = self, index < arr.count @@ -570,13 +582,13 @@ public extension JSON? { } static func == (_: NSNull, _ arg2: JSON?) -> Bool { - guard let arg2 else { return true } + guard let arg2 else { return false } guard case .null = arg2 else { return false } return true } static func == (_ arg1: JSON?, _: NSNull) -> Bool { - guard let arg1 else { return true } + guard let arg1 else { return false } guard case .null = arg1 else { return false } return true } diff --git a/Tests/JSONTests/JSONTests.swift b/Tests/JSONTests/JSONTests.swift index 0d3eeed..ac1d6dc 100644 --- a/Tests/JSONTests/JSONTests.swift +++ b/Tests/JSONTests/JSONTests.swift @@ -151,6 +151,109 @@ final class JSONTests: XCTestCase { ) } + func testNilDictionarySubscriptAssignmentStoresNull() throws { + var object: JSON = [ + "one": 1, + ] + + object["json_nil"] = nil as JSON? + object["array_nil"] = nil as [Any?]? + object["bool_nil"] = nil as Bool? + object["dictionary_nil"] = nil as [String: Any?]? + object["double_nil"] = nil as Double? + object["int_nil"] = nil as Int? + object["string_nil"] = nil as String? + + XCTAssertEqual(JSON.null, object["json_nil"]) + XCTAssertEqual(JSON.null, object["array_nil"]) + XCTAssertEqual(JSON.null, object["bool_nil"]) + XCTAssertEqual(JSON.null, object["dictionary_nil"]) + XCTAssertEqual(JSON.null, object["double_nil"]) + XCTAssertEqual(JSON.null, object["int_nil"]) + XCTAssertEqual(JSON.null, object["string_nil"]) + XCTAssertNil(object["missing"] as JSON?) + } + + func testOptionalNilDictionarySubscriptAssignmentStoresNull() throws { + var object: JSON? = [ + "one": 1, + ] + + object["json_nil"] = nil as JSON? + object["array_nil"] = nil as [Any?]? + object["bool_nil"] = nil as Bool? + object["dictionary_nil"] = nil as [String: Any?]? + object["double_nil"] = nil as Double? + object["int_nil"] = nil as Int? + object["string_nil"] = nil as String? + + XCTAssertEqual(JSON.null, object["json_nil"]) + XCTAssertEqual(JSON.null, object["array_nil"]) + XCTAssertEqual(JSON.null, object["bool_nil"]) + XCTAssertEqual(JSON.null, object["dictionary_nil"]) + XCTAssertEqual(JSON.null, object["double_nil"]) + XCTAssertEqual(JSON.null, object["int_nil"]) + XCTAssertEqual(JSON.null, object["string_nil"]) + XCTAssertNil(object["missing"] as JSON?) + } + + func testRemoveValueForKey() throws { + var object: JSON = [ + "one": 1, + "null": nil, + ] + + let number = object.removeValue(forKey: "one") + XCTAssertEqual(JSON.number(1), try XCTUnwrap(number)) + XCTAssertNil(object["one"] as JSON?) + + let null = object.removeValue(forKey: "null") + XCTAssertEqual(JSON.null, try XCTUnwrap(null)) + XCTAssertNil(object["null"] as JSON?) + + XCTAssertNil(object.removeValue(forKey: "missing")) + + var string: JSON = "text" + XCTAssertNil(string.removeValue(forKey: "missing")) + XCTAssertEqual("text", string) + } + + func testOptionalRemoveValueForKey() throws { + var object: JSON? = [ + "one": 1, + "null": nil, + ] + + let number = object.removeValue(forKey: "one") + XCTAssertEqual(JSON.number(1), try XCTUnwrap(number)) + XCTAssertNil(object["one"] as JSON?) + + let null = object.removeValue(forKey: "null") + XCTAssertEqual(JSON.null, try XCTUnwrap(null)) + XCTAssertNil(object["null"] as JSON?) + + XCTAssertNil(object.removeValue(forKey: "missing")) + + var missingObject: JSON? + XCTAssertNil(missingObject.removeValue(forKey: "missing")) + XCTAssertNil(missingObject) + } + + func testRemoveValueForKeyThroughNestedSubscript() throws { + var object: JSON = [ + "dict": [ + "key": "value", + "keep": true, + ], + ] + + let removedValue = object["dict"].removeValue(forKey: "key") + + XCTAssertEqual("value", try XCTUnwrap(removedValue)) + XCTAssertNil(object["dict"]["key"] as JSON?) + XCTAssertEqual(true, object["dict"]["keep"]) + } + func testRawValue() throws { let arr = try XCTUnwrap(JSON(["one", nil, 123, 1.23]).rawValue as? [Any?]) XCTAssertEqual("one", arr[0] as? String) @@ -259,6 +362,19 @@ final class JSONTests: XCTestCase { XCTAssertTrue(JSON.string("boom") as JSON? == "boom") } + func testNSNullComparisonDistinguishesExplicitNullFromMissingValue() throws { + let object: JSON = [ + "explicit_null": nil, + ] + + XCTAssertTrue(NSNull() == object["explicit_null"]) + XCTAssertTrue(object["explicit_null"] == NSNull()) + + XCTAssertNil(object["missing"] as JSON?) + XCTAssertFalse(NSNull() == object["missing"]) + XCTAssertFalse(object["missing"] == NSNull()) + } + func testCodable() throws { let json: JSON = [ "one": 2,