Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 32 additions & 20 deletions Sources/JSON/JSON.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
116 changes: 116 additions & 0 deletions Tests/JSONTests/JSONTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Loading