Skip to content

Commit

Permalink
#7 Improve encoding of null values
Browse files Browse the repository at this point in the history
  • Loading branch information
JARMourato committed Jul 27, 2021
2 parents 6d8f825 + ace5849 commit 4ecaf11
Show file tree
Hide file tree
Showing 7 changed files with 170 additions and 20 deletions.
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Table of contents
* [Overriding Values](#overriding-values)
* [Validating Values](#validating-values)
* [Custom Wrapper](#custom-wrapper)
* [Encode null values](#encode-null-values)
* [Contributions](#contributions)
* [License](#license)
* [Contact](#contact)
Expand Down Expand Up @@ -348,6 +349,49 @@ struct Test: Kodable {
}
```

### Encode Null Values

By default optional values won't be encoded so:

```swift
struct User: Kodable {
@Coding var firstName: String
@Coding var lastName: String?
}

let user = User()
user.firstName = "João"
```

When encoded will output:

```js
{
"firstName": "João"
}
```

However, if you want to explicitly encode null values, then you can set `encodeAsNullIfNil` property to be true:

```swift
struct User: Kodable {
@Coding var firstName: String
@Coding(encodeAsNullIfNil: true) var lastName: String?
}

let user = User()
user.firstName = "João"
```

Which will then output:

```js
{
"firstName": "João",
"lastName": null
}
```

## Contributions

If you feel like something is missing or you want to add any new functionality, please open an issue requesting it and/or submit a pull request with passing tests 🙌
Expand Down
19 changes: 13 additions & 6 deletions Sources/Extensions/Optional+Utilities.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import Foundation

// MARK: Verify whether a type is optional

internal protocol OptionalProtocol {}

extension Optional: OptionalProtocol {}

// MARK: Remove double optionals

private protocol Flattenable {
Expand All @@ -24,3 +18,16 @@ extension Optional: Flattenable {
}
}
}

// MARK: Verify whether a type is optional

internal protocol OptionalProtocol {
var isNil: Bool { get }
}

extension Optional: OptionalProtocol {
var isNil: Bool {
guard case .none = flattened() else { return false }
return true
}
}
4 changes: 2 additions & 2 deletions Sources/Kodable/Codable+AnyCodingKey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ extension DecodeContainer {
}

extension EncodeContainer {
mutating func encodeIfPresent<T>(_ value: T?, with stringKey: String) throws where T: Encodable {
try encodeIfPresent(value, forKey: AnyCodingKey(stringKey))
mutating func encode<T>(_ value: T, with stringKey: String) throws where T: Encodable {
try encode(value, forKey: AnyCodingKey(stringKey))
}

mutating func nestedContainer(forKey stringKey: String) -> EncodeContainer {
Expand Down
8 changes: 6 additions & 2 deletions Sources/Kodable/KodableTransformable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public protocol KodableTransform {
private let modifiers: [KodableModifier<TargetType>]
public private(set) var key: String?
public private(set) var decoding: PropertyDecoding = .enforceType
public private(set) var encodeAsNullIfNil: Bool = false

public typealias OriginalType = T.From
public typealias TargetType = T.To
Expand All @@ -38,10 +39,11 @@ public protocol KodableTransform {
set { _value = newValue }
}

internal init(key: String? = nil, decoding: PropertyDecoding, modifiers: [KodableModifier<TargetType>], defaultValue: TargetType?) {
internal init(key: String? = nil, decoding: PropertyDecoding, encodeAsNullIfNil: Bool, modifiers: [KodableModifier<TargetType>], defaultValue: TargetType?) {
self.key = key
self.modifiers = modifiers
self.decoding = decoding
self.encodeAsNullIfNil = encodeAsNullIfNil
_value = defaultValue
}

Expand Down Expand Up @@ -163,7 +165,9 @@ extension KodableTransformable: EncodableProperty where OriginalType: Encodable
func encodeValueFromProperty(with propertyName: String, to container: inout EncodeContainer) throws {
var (relevantContainer, relavantKey) = try container.nestedContainerAndKey(for: key ?? propertyName)
let encodableValue = try transformer.transformToJSON(value: wrappedValue)
try relevantContainer.encodeIfPresent(encodableValue, with: relavantKey)
let isValueNil = (encodableValue as? OptionalProtocol)?.isNil ?? false
guard !isValueNil || encodeAsNullIfNil else { return }
try relevantContainer.encode(encodableValue, with: relavantKey)
}
}

Expand Down
20 changes: 14 additions & 6 deletions Sources/Wrappers/CodableDate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@ import Foundation
super.init()
}

public init(decoding: PropertyDecoding = .enforceType, _ modifiers: KodableModifier<T>..., default value: T? = nil) {
super.init(key: nil, decoding: decoding, modifiers: modifiers, defaultValue: value)
public init(decoding: PropertyDecoding = .enforceType, encodeAsNullIfNil: Bool = false, _ modifiers: KodableModifier<T>..., default value: T? = nil) {
super.init(key: nil, decoding: decoding, encodeAsNullIfNil: encodeAsNullIfNil, modifiers: modifiers, defaultValue: value)
}

public init(_ key: String, decoding: PropertyDecoding = .enforceType, _ modifiers: KodableModifier<T>..., default value: T? = nil) {
super.init(key: key, decoding: decoding, modifiers: modifiers, defaultValue: value)
public init(_ key: String, decoding: PropertyDecoding = .enforceType, encodeAsNullIfNil: Bool = false, _ modifiers: KodableModifier<T>..., default value: T? = nil) {
super.init(key: key, decoding: decoding, encodeAsNullIfNil: encodeAsNullIfNil, modifiers: modifiers, defaultValue: value)
}

public init(_ strategy: DateCodingStrategy, _ key: String? = nil, decoding: PropertyDecoding = .enforceType, _ modifiers: KodableModifier<T>..., default value: T? = nil) {
super.init(key: key, decoding: decoding, modifiers: modifiers, defaultValue: value)
public init(_ strategy: DateCodingStrategy, _ key: String? = nil, decoding: PropertyDecoding = .enforceType, encodeAsNullIfNil: Bool = false, _ modifiers: KodableModifier<T>..., default value: T? = nil) {
super.init(key: key, decoding: decoding, encodeAsNullIfNil: encodeAsNullIfNil, modifiers: modifiers, defaultValue: value)
transformer.strategy = strategy
}

Expand Down Expand Up @@ -115,3 +115,11 @@ public enum DateCodingStrategy {
return dateFormatter
}
}

// MARK: Equatable Conformance

extension CodableDate: Equatable where T: Equatable {
public static func == (lhs: CodableDate<T>, rhs: CodableDate<T>) -> Bool {
lhs.wrappedValue == rhs.wrappedValue
}
}
16 changes: 12 additions & 4 deletions Sources/Wrappers/Coding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ import Foundation

/// - Parameters:
/// - decoding: Changes the decoding method used. Defaults to `decoding(.enforceType)`.
public convenience init(decoding: PropertyDecoding = .enforceType, _ modifiers: KodableModifier<TargetType>..., default value: TargetType? = nil) {
self.init(key: nil, decoding: decoding, modifiers: modifiers, defaultValue: value)
public convenience init(decoding: PropertyDecoding = .enforceType, encodeAsNullIfNil: Bool = false, _ modifiers: KodableModifier<TargetType>..., default value: TargetType? = nil) {
self.init(key: nil, decoding: decoding, encodeAsNullIfNil: encodeAsNullIfNil, modifiers: modifiers, defaultValue: value)
}

/// - Parameters:
/// - key: Customize the string key used to decode the value. Nested values are supported through the usage of the `.` notation.
/// - decoding: Changes the decoding method used. Defaults to `decoding(.enforceType)`.
public convenience init(_ key: String, decoding: PropertyDecoding = .enforceType, _ modifiers: KodableModifier<TargetType>..., default value: TargetType? = nil) {
self.init(key: key, decoding: decoding, modifiers: modifiers, defaultValue: value)
public convenience init(_ key: String, decoding: PropertyDecoding = .enforceType, encodeAsNullIfNil: Bool = false, _ modifiers: KodableModifier<TargetType>..., default value: TargetType? = nil) {
self.init(key: key, decoding: decoding, encodeAsNullIfNil: encodeAsNullIfNil, modifiers: modifiers, defaultValue: value)
}
}

Expand All @@ -33,3 +33,11 @@ public struct Passthrough<T: Codable>: KodableTransform {
public func transformToJSON(value: T) -> T { value }
public init() {}
}

// MARK: Equatable Conformance

extension Coding: Equatable where T: Equatable {
public static func == (lhs: Coding<T>, rhs: Coding<T>) -> Bool {
lhs.wrappedValue == rhs.wrappedValue
}
}
79 changes: 79 additions & 0 deletions Tests/KodableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,19 @@ final class KodableTests: XCTestCase {
}
}

func testEncodingNullValuesOutput() throws {
struct Strings: Kodable {
@Coding var optionalString: String?
@Coding(encodeAsNullIfNil: true) var nullOptionalString: String?
}

let strings = Strings()
let data = try strings.encodeJSON()
let dic = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
XCTAssertFalse(dic!.keys.contains("optionalString"))
XCTAssertEqual(dic!["nullOptionalString"] as? NSNull, NSNull())
}

func testEncodingAndDecodingUsingCoder() {
struct User: Kodable {
@Coding("first_name") var firstName: String
Expand Down Expand Up @@ -555,6 +568,29 @@ final class KodableTests: XCTestCase {
}
}

// MARK: - Equatable

func testCodableConformsToEquatable() {
struct User: Kodable, Equatable {
@Coding var name: String

static func with(name: String) -> User {
let user = User()
user.name = name
return user
}
}

let a = User.with(name: "João")
let b = User.with(name: "Roger")
let c = a

XCTAssertNotEqual(a.name, b.name)
XCTAssertNotEqual(a, b)
XCTAssertEqual(a.name, c.name)
XCTAssertEqual(a, c)
}

// MARK: - CodableDate

func testDateTransformerFailedToParseError() {
Expand Down Expand Up @@ -637,6 +673,33 @@ final class KodableTests: XCTestCase {
assert(try Dates.decodeJSON(from: KodableTests.json), throws: KodableError.failedToParseDate(source: "123456789987654321"))
}

// MARK: - Equatable

func testCodableDateConformsToEquatable() throws {
struct RFCDate: Kodable, Equatable {
@CodableDate(.rfc2822, "rfc2822") var date: Date

static func fromJSON() throws -> RFCDate {
try RFCDate.decodeJSON(from: KodableTests.json)
}

static func now() -> RFCDate {
let now = RFCDate()
now.date = Date()
return now
}
}

let a = try RFCDate.fromJSON()
let b = RFCDate.now()
let c = a

XCTAssertNotEqual(a.date, b.date)
XCTAssertNotEqual(a, b)
XCTAssertEqual(a.date, c.date)
XCTAssertEqual(a, c)
}

// MARK: - Flattened Tests

// https://gist.github.com/rogerluan/ee04febd80371f88f9435e98032b3042
Expand All @@ -662,6 +725,22 @@ final class KodableTests: XCTestCase {
XCTAssert(isEqual(type: Int?.self, a: _20levelsNested.flattened(), b: Optional(20)))
}

// MARK: - OptionalProtocol Tests

func testOptionalProtocolIsNil() {
let nonNilValue: String? = ""
let doubleNonNilValue: String?? = ""
let nilValue: String? = nil
let doubleNilValue: String?? = nil
let optionalEnum: String?? = .some(nil)

XCTAssertFalse(nonNilValue.isNil)
XCTAssertFalse(doubleNonNilValue.isNil)
XCTAssertTrue(nilValue.isNil)
XCTAssertTrue(doubleNilValue.isNil)
XCTAssertTrue(optionalEnum.isNil)
}

// MARK: - Utilities

/// Utility to compare `Any?` elements.
Expand Down

0 comments on commit 4ecaf11

Please sign in to comment.