Skip to content

Commit 05c5b78

Browse files
authored
Store nil JSON assignments as null (#10)
- Add removeValue(forKey:) for explicit dictionary key removal - Distinguish missing optional JSON values from explicit NSNull comparisons - Document and test nil subscript assignment behavior
1 parent 79efe7b commit 05c5b78

4 files changed

Lines changed: 163 additions & 25 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ on: push
44

55
jobs:
66
test:
7-
runs-on: macos-14
8-
7+
runs-on: macos-26
8+
99
steps:
10-
- uses: actions/checkout@v3
11-
- name: Select Xcode 16
12-
run: sudo xcode-select -s /Applications/Xcode_16.0.app
10+
- uses: actions/checkout@v4
11+
- name: Select Xcode 26
12+
run: sudo xcode-select -s /Applications/Xcode_26.5.app
1313
- name: Test
1414
run: swift test

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ json["one"].integerValue // 2
2727
json["object"]["four_text"].stringValue // "four"
2828
```
2929

30+
Swift `nil` maps to JSON `null` in literals and subscript assignments:
31+
32+
```swift
33+
var object: JSON = ["name": "Blob"]
34+
object["deleted_at"] = nil as JSON? // stores JSON.null
35+
object.removeValue(forKey: "name") // removes the key
36+
```
37+
38+
Missing keys return `nil` and are distinct from explicit JSON `null`.
39+
3040
## Installation
3141

3242
### Swift Package Manager

Sources/JSON/JSON.swift

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,7 @@ public enum JSON:
2727
}
2828
set {
2929
guard case var .dictionary(dict) = self else { return }
30-
if let newValue {
31-
dict[key] = newValue
32-
} else {
33-
dict.removeValue(forKey: key)
34-
}
30+
dict[key] = newValue ?? .null
3531
self = .dictionary(dict)
3632
}
3733
}
@@ -46,7 +42,7 @@ public enum JSON:
4642
if let newValue {
4743
dict[key] = .array(newValue.compactMap(\.json))
4844
} else {
49-
dict.removeValue(forKey: key)
45+
dict[key] = .null
5046
}
5147
self = .dictionary(dict)
5248
}
@@ -62,7 +58,7 @@ public enum JSON:
6258
if let newValue {
6359
dict[key] = .boolean(newValue)
6460
} else {
65-
dict.removeValue(forKey: key)
61+
dict[key] = .null
6662
}
6763
self = .dictionary(dict)
6864
}
@@ -78,7 +74,7 @@ public enum JSON:
7874
if let newValue {
7975
dict[key] = .dictionary(newValue.compactMapValues(\.json))
8076
} else {
81-
dict.removeValue(forKey: key)
77+
dict[key] = .null
8278
}
8379
self = .dictionary(dict)
8480
}
@@ -94,7 +90,7 @@ public enum JSON:
9490
if let newValue {
9591
dict[key] = .number(newValue)
9692
} else {
97-
dict.removeValue(forKey: key)
93+
dict[key] = .null
9894
}
9995
self = .dictionary(dict)
10096
}
@@ -110,7 +106,7 @@ public enum JSON:
110106
if let newValue {
111107
dict[key] = .number(Double(newValue))
112108
} else {
113-
dict.removeValue(forKey: key)
109+
dict[key] = .null
114110
}
115111
self = .dictionary(dict)
116112
}
@@ -126,12 +122,20 @@ public enum JSON:
126122
if let newValue {
127123
dict[key] = .string(newValue)
128124
} else {
129-
dict.removeValue(forKey: key)
125+
dict[key] = .null
130126
}
131127
self = .dictionary(dict)
132128
}
133129
}
134130

131+
@discardableResult
132+
public mutating func removeValue(forKey key: String) -> JSON? {
133+
guard case var .dictionary(dict) = self else { return nil }
134+
let removedValue = dict.removeValue(forKey: key)
135+
self = .dictionary(dict)
136+
return removedValue
137+
}
138+
135139
public subscript(index: Int) -> JSON? {
136140
get {
137141
guard case let .array(arr) = self, index < arr.count
@@ -373,7 +377,7 @@ public extension JSON? {
373377
}
374378
set {
375379
guard case var .dictionary(dict) = self else { return }
376-
dict[key] = newValue
380+
dict[key] = newValue ?? .null
377381
self = .dictionary(dict)
378382
}
379383
}
@@ -388,7 +392,7 @@ public extension JSON? {
388392
if let newValue {
389393
dict[key] = .array(newValue.compactMap(\.json))
390394
} else {
391-
dict.removeValue(forKey: key)
395+
dict[key] = .null
392396
}
393397
self = .dictionary(dict)
394398
}
@@ -404,7 +408,7 @@ public extension JSON? {
404408
if let newValue {
405409
dict[key] = .boolean(newValue)
406410
} else {
407-
dict.removeValue(forKey: key)
411+
dict[key] = .null
408412
}
409413
self = .dictionary(dict)
410414
}
@@ -420,7 +424,7 @@ public extension JSON? {
420424
if let newValue {
421425
dict[key] = .dictionary(newValue.compactMapValues(\.json))
422426
} else {
423-
dict.removeValue(forKey: key)
427+
dict[key] = .null
424428
}
425429
self = .dictionary(dict)
426430
}
@@ -436,7 +440,7 @@ public extension JSON? {
436440
if let newValue {
437441
dict[key] = .number(newValue)
438442
} else {
439-
dict.removeValue(forKey: key)
443+
dict[key] = .null
440444
}
441445
self = .dictionary(dict)
442446
}
@@ -452,7 +456,7 @@ public extension JSON? {
452456
if let newValue {
453457
dict[key] = .number(Double(newValue))
454458
} else {
455-
dict.removeValue(forKey: key)
459+
dict[key] = .null
456460
}
457461
self = .dictionary(dict)
458462
}
@@ -468,12 +472,20 @@ public extension JSON? {
468472
if let newValue {
469473
dict[key] = .string(newValue)
470474
} else {
471-
dict.removeValue(forKey: key)
475+
dict[key] = .null
472476
}
473477
self = .dictionary(dict)
474478
}
475479
}
476480

481+
@discardableResult
482+
mutating func removeValue(forKey key: String) -> JSON? {
483+
guard case var .dictionary(dict) = self else { return nil }
484+
let removedValue = dict.removeValue(forKey: key)
485+
self = .dictionary(dict)
486+
return removedValue
487+
}
488+
477489
subscript(index: Int) -> JSON? {
478490
get {
479491
guard case let .array(arr) = self, index < arr.count
@@ -570,13 +582,13 @@ public extension JSON? {
570582
}
571583

572584
static func == (_: NSNull, _ arg2: JSON?) -> Bool {
573-
guard let arg2 else { return true }
585+
guard let arg2 else { return false }
574586
guard case .null = arg2 else { return false }
575587
return true
576588
}
577589

578590
static func == (_ arg1: JSON?, _: NSNull) -> Bool {
579-
guard let arg1 else { return true }
591+
guard let arg1 else { return false }
580592
guard case .null = arg1 else { return false }
581593
return true
582594
}

Tests/JSONTests/JSONTests.swift

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,109 @@ final class JSONTests: XCTestCase {
151151
)
152152
}
153153

154+
func testNilDictionarySubscriptAssignmentStoresNull() throws {
155+
var object: JSON = [
156+
"one": 1,
157+
]
158+
159+
object["json_nil"] = nil as JSON?
160+
object["array_nil"] = nil as [Any?]?
161+
object["bool_nil"] = nil as Bool?
162+
object["dictionary_nil"] = nil as [String: Any?]?
163+
object["double_nil"] = nil as Double?
164+
object["int_nil"] = nil as Int?
165+
object["string_nil"] = nil as String?
166+
167+
XCTAssertEqual(JSON.null, object["json_nil"])
168+
XCTAssertEqual(JSON.null, object["array_nil"])
169+
XCTAssertEqual(JSON.null, object["bool_nil"])
170+
XCTAssertEqual(JSON.null, object["dictionary_nil"])
171+
XCTAssertEqual(JSON.null, object["double_nil"])
172+
XCTAssertEqual(JSON.null, object["int_nil"])
173+
XCTAssertEqual(JSON.null, object["string_nil"])
174+
XCTAssertNil(object["missing"] as JSON?)
175+
}
176+
177+
func testOptionalNilDictionarySubscriptAssignmentStoresNull() throws {
178+
var object: JSON? = [
179+
"one": 1,
180+
]
181+
182+
object["json_nil"] = nil as JSON?
183+
object["array_nil"] = nil as [Any?]?
184+
object["bool_nil"] = nil as Bool?
185+
object["dictionary_nil"] = nil as [String: Any?]?
186+
object["double_nil"] = nil as Double?
187+
object["int_nil"] = nil as Int?
188+
object["string_nil"] = nil as String?
189+
190+
XCTAssertEqual(JSON.null, object["json_nil"])
191+
XCTAssertEqual(JSON.null, object["array_nil"])
192+
XCTAssertEqual(JSON.null, object["bool_nil"])
193+
XCTAssertEqual(JSON.null, object["dictionary_nil"])
194+
XCTAssertEqual(JSON.null, object["double_nil"])
195+
XCTAssertEqual(JSON.null, object["int_nil"])
196+
XCTAssertEqual(JSON.null, object["string_nil"])
197+
XCTAssertNil(object["missing"] as JSON?)
198+
}
199+
200+
func testRemoveValueForKey() throws {
201+
var object: JSON = [
202+
"one": 1,
203+
"null": nil,
204+
]
205+
206+
let number = object.removeValue(forKey: "one")
207+
XCTAssertEqual(JSON.number(1), try XCTUnwrap(number))
208+
XCTAssertNil(object["one"] as JSON?)
209+
210+
let null = object.removeValue(forKey: "null")
211+
XCTAssertEqual(JSON.null, try XCTUnwrap(null))
212+
XCTAssertNil(object["null"] as JSON?)
213+
214+
XCTAssertNil(object.removeValue(forKey: "missing"))
215+
216+
var string: JSON = "text"
217+
XCTAssertNil(string.removeValue(forKey: "missing"))
218+
XCTAssertEqual("text", string)
219+
}
220+
221+
func testOptionalRemoveValueForKey() throws {
222+
var object: JSON? = [
223+
"one": 1,
224+
"null": nil,
225+
]
226+
227+
let number = object.removeValue(forKey: "one")
228+
XCTAssertEqual(JSON.number(1), try XCTUnwrap(number))
229+
XCTAssertNil(object["one"] as JSON?)
230+
231+
let null = object.removeValue(forKey: "null")
232+
XCTAssertEqual(JSON.null, try XCTUnwrap(null))
233+
XCTAssertNil(object["null"] as JSON?)
234+
235+
XCTAssertNil(object.removeValue(forKey: "missing"))
236+
237+
var missingObject: JSON?
238+
XCTAssertNil(missingObject.removeValue(forKey: "missing"))
239+
XCTAssertNil(missingObject)
240+
}
241+
242+
func testRemoveValueForKeyThroughNestedSubscript() throws {
243+
var object: JSON = [
244+
"dict": [
245+
"key": "value",
246+
"keep": true,
247+
],
248+
]
249+
250+
let removedValue = object["dict"].removeValue(forKey: "key")
251+
252+
XCTAssertEqual("value", try XCTUnwrap(removedValue))
253+
XCTAssertNil(object["dict"]["key"] as JSON?)
254+
XCTAssertEqual(true, object["dict"]["keep"])
255+
}
256+
154257
func testRawValue() throws {
155258
let arr = try XCTUnwrap(JSON(["one", nil, 123, 1.23]).rawValue as? [Any?])
156259
XCTAssertEqual("one", arr[0] as? String)
@@ -259,6 +362,19 @@ final class JSONTests: XCTestCase {
259362
XCTAssertTrue(JSON.string("boom") as JSON? == "boom")
260363
}
261364

365+
func testNSNullComparisonDistinguishesExplicitNullFromMissingValue() throws {
366+
let object: JSON = [
367+
"explicit_null": nil,
368+
]
369+
370+
XCTAssertTrue(NSNull() == object["explicit_null"])
371+
XCTAssertTrue(object["explicit_null"] == NSNull())
372+
373+
XCTAssertNil(object["missing"] as JSON?)
374+
XCTAssertFalse(NSNull() == object["missing"])
375+
XCTAssertFalse(object["missing"] == NSNull())
376+
}
377+
262378
func testCodable() throws {
263379
let json: JSON = [
264380
"one": 2,

0 commit comments

Comments
 (0)