Skip to content

Commit

Permalink
Mixed choice/non-choice encoding (#154)
Browse files Browse the repository at this point in the history
## Overview

Fixes bug encountered when encoding structs that hold a mixture of choice-element and non-choice-element (or multiple choice-element) properties.

## Example

Given a structure that stores both a choice and non-choice property,
```swift
private struct IntOrStringAndDouble: Equatable {
    let intOrString: IntOrString
    let decimal: Double
}
```

the natural encoding approach (now available) is 
```swift
extension IntOrStringAndDouble: Encodable {
    enum CodingKeys: String, CodingKey {
        case decimal
    }

    func encode(to encoder: Encoder) {
        try intOrString.encode(to: encoder)
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(decimal, forKey: .decimal)
    }
}
```

The following `encode` implementation also works:

```swift
extension IntOrStringAndDouble: Encodable {
    enum CodingKeys: String, CodingKey {
        case decimal
    }

    func encode(to encoder: Encoder) {
        var singleValueContainer = encoder.singleValueContainer()
        try singleValueContainer.encode(intOrString)
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(decimal, forKey: .decimal)
    }
}
```

`IntOrString` as defined in #119:

```swift 
enum IntOrString: Equatable {
    case int(Int)
    case string(String)
}

extension IntOrString: Encodable {
    enum CodingKeys: String, CodingKey {
        case int
        case string
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        switch self {
        case let .int(value):
            try container.encode(value, forKey: .int)
        case let .string(value):
            try container.encode(value, forKey: .string)
        }
    }
}

extension IntOrString.CodingKeys: XMLChoiceCodingKey {} // signifies that `IntOrString` is a choice element
```

## Implementation Details

In cases where choice and non-choice elements (or multiple choice elements) co-exist in a keyed container, we merge them into a single `XMLKeyedEncodingContainer` (wrapping a `SharedBox<KeyedBox>`). Arrays of choice elements (using `XMLUnkeyedEncodingContainer` under the hood) are encoded the same way as before, as we do not hit the merging cases. For the array case, we still need the `XMLChoiceEncodingContainer` structure.

## Source Compatibility

This is an additive change.

* Add breaking case
* Add choice and keyed merging encode functionality
* Refactor
* Fix commented code
* Fix misnamed file
* Fix xcode project
* Fix precondition catch
* Use switch syntax
* Add multiple choice element case
* Add explicit types in KeyedBox initialization
* Add explicitly empty parameter to KeyedBox initializer
* Use more concise type inference
* Unify switch syntax
* Cut down code duplication
* Fix formatting
  • Loading branch information
bwetherfield authored and MaxDesiatov committed Nov 27, 2019
1 parent e7c0562 commit deb2ab2
Show file tree
Hide file tree
Showing 7 changed files with 224 additions and 83 deletions.
96 changes: 51 additions & 45 deletions Sources/XMLCoder/Encoder/XMLEncoderImplementation.swift
Expand Up @@ -64,58 +64,16 @@ class XMLEncoderImplementation: Encoder {
// MARK: - Encoder Methods

public func container<Key>(keyedBy _: Key.Type) -> KeyedEncodingContainer<Key> {
guard canEncodeNewValue else {
return mergeWithExistingKeyedContainer(keyedBy: Key.self)
}
if Key.self is XMLChoiceCodingKey.Type {
return choiceContainer(keyedBy: Key.self)
} else {
return keyedContainer(keyedBy: Key.self)
}
}

public func keyedContainer<Key>(keyedBy _: Key.Type) -> KeyedEncodingContainer<Key> {
// If an existing keyed container was already requested, return that one.
let topContainer: SharedBox<KeyedBox>
if canEncodeNewValue {
// We haven't yet pushed a container at this level; do so here.
topContainer = storage.pushKeyedContainer()
} else {
guard let container = storage.lastContainer as? SharedBox<KeyedBox> else {
preconditionFailure(
"""
Attempt to push new keyed encoding container when already previously encoded \
at this path.
"""
)
}

topContainer = container
}

let container = XMLKeyedEncodingContainer<Key>(referencing: self, codingPath: codingPath, wrapping: topContainer)
return KeyedEncodingContainer(container)
}

public func choiceContainer<Key>(keyedBy _: Key.Type) -> KeyedEncodingContainer<Key> {
let topContainer: SharedBox<ChoiceBox>
if canEncodeNewValue {
// We haven't yet pushed a container at this level; do so here.
topContainer = storage.pushChoiceContainer()
} else {
guard let container = storage.lastContainer as? SharedBox<ChoiceBox> else {
preconditionFailure(
"""
Attempt to push new (single element) keyed encoding container when already \
previously encoded at this path.
"""
)
}

topContainer = container
}

let container = XMLChoiceEncodingContainer<Key>(referencing: self, codingPath: codingPath, wrapping: topContainer)
return KeyedEncodingContainer(container)
}

public func unkeyedContainer() -> UnkeyedEncodingContainer {
// If an existing unkeyed container was already requested, return that one.
let topContainer: SharedBox<UnkeyedBox>
Expand All @@ -141,6 +99,54 @@ class XMLEncoderImplementation: Encoder {
public func singleValueContainer() -> SingleValueEncodingContainer {
return self
}

private func keyedContainer<Key>(keyedBy _: Key.Type) -> KeyedEncodingContainer<Key> {
let container = XMLKeyedEncodingContainer<Key>(
referencing: self,
codingPath: codingPath,
wrapping: storage.pushKeyedContainer()
)
return KeyedEncodingContainer(container)
}

private func choiceContainer<Key>(keyedBy _: Key.Type) -> KeyedEncodingContainer<Key> {
let container = XMLChoiceEncodingContainer<Key>(
referencing: self,
codingPath: codingPath,
wrapping: storage.pushChoiceContainer()
)
return KeyedEncodingContainer(container)
}

private func mergeWithExistingKeyedContainer<Key>(keyedBy _: Key.Type) -> KeyedEncodingContainer<Key> {
switch storage.lastContainer {
case let keyed as SharedBox<KeyedBox>:
let container = XMLKeyedEncodingContainer<Key>(
referencing: self,
codingPath: codingPath,
wrapping: keyed
)
return KeyedEncodingContainer(container)
case let choice as SharedBox<ChoiceBox>:
_ = storage.popContainer()
let keyed = KeyedBox(
elements: KeyedBox.Elements([choice.withShared { ($0.key, $0.element) }]),
attributes: []
)
let container = XMLKeyedEncodingContainer<Key>(
referencing: self,
codingPath: codingPath,
wrapping: storage.pushKeyedContainer(keyed)
)
return KeyedEncodingContainer(container)
default:
preconditionFailure(
"""
No existing keyed encoding container to merge with.
"""
)
}
}
}

extension XMLEncoderImplementation {
Expand Down
4 changes: 2 additions & 2 deletions Sources/XMLCoder/Encoder/XMLEncodingStorage.swift
Expand Up @@ -31,8 +31,8 @@ struct XMLEncodingStorage {
return containers.last
}

mutating func pushKeyedContainer() -> SharedBox<KeyedBox> {
let container = SharedBox(KeyedBox())
mutating func pushKeyedContainer(_ keyedBox: KeyedBox = KeyedBox()) -> SharedBox<KeyedBox> {
let container = SharedBox(keyedBox)
containers.append(container)
return container
}
Expand Down
41 changes: 41 additions & 0 deletions Tests/XMLCoderTests/IntOrString.swift
@@ -0,0 +1,41 @@
//
// IntOrString.swift
// XMLCoderTests
//
// Created by Benjamin Wetherfield on 11/24/19.
//

import XMLCoder

internal enum IntOrString: Equatable {
case int(Int)
case string(String)
}

extension IntOrString: Codable {
enum CodingKeys: String, CodingKey {
case int
case string
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case let .int(value):
try container.encode(value, forKey: .int)
case let .string(value):
try container.encode(value, forKey: .string)
}
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
do {
self = .int(try container.decode(Int.self, forKey: .int))
} catch {
self = .string(try container.decode(String.self, forKey: .string))
}
}
}

extension IntOrString.CodingKeys: XMLChoiceCodingKey {}
107 changes: 107 additions & 0 deletions Tests/XMLCoderTests/MixedChoiceAndNonChoiceTests.swift
@@ -0,0 +1,107 @@
//
// MixedChoiceAndNonChoiceTests.swift
// XMLCoderTests
//
// Created by Benjamin Wetherfield on 11/24/19.
//

import XCTest
import XMLCoder

private struct MixedIntOrStringFirst: Equatable {
let intOrString: IntOrString
let otherValue: String
}

extension MixedIntOrStringFirst: Encodable {
enum CodingKeys: String, CodingKey {
case otherValue = "other-value"
}

func encode(to encoder: Encoder) throws {
try intOrString.encode(to: encoder)
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(otherValue, forKey: .otherValue)
}
}

private struct MixedOtherFirst: Equatable {
let intOrString: IntOrString
let otherValue: String
}

extension MixedOtherFirst: Encodable {
enum CodingKeys: String, CodingKey {
case otherValue = "other-value"
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(otherValue, forKey: .otherValue)
try intOrString.encode(to: encoder)
}
}

private struct MixedEitherSide {
let leading: String
let intOrString: IntOrString
let trailing: String
}

extension MixedEitherSide: Encodable {
enum CodingKeys: String, CodingKey {
case leading
case trailing
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(leading, forKey: .leading)
try intOrString.encode(to: encoder)
try container.encode(trailing, forKey: .trailing)
}
}

private struct TwoChoiceElements {
let first: IntOrString
let second: IntOrString
}

extension TwoChoiceElements: Encodable {
func encode(to encoder: Encoder) throws {
try first.encode(to: encoder)
try second.encode(to: encoder)
}
}

class MixedChoiceAndNonChoiceTests: XCTestCase {
func testMixedChoiceFirstEncode() throws {
let first = MixedIntOrStringFirst(intOrString: .int(4), otherValue: "other")
let firstEncoded = try XMLEncoder().encode(first, withRootKey: "container")
let firstExpectedXML = "<container><int>4</int><other-value>other</other-value></container>"
XCTAssertEqual(String(data: firstEncoded, encoding: .utf8), firstExpectedXML)
}

func testMixedChoiceSecondEncode() throws {
let second = MixedOtherFirst(intOrString: .int(4), otherValue: "other")
let secondEncoded = try XMLEncoder().encode(second, withRootKey: "container")
let secondExpectedXML = "<container><other-value>other</other-value><int>4</int></container>"
XCTAssertEqual(String(data: secondEncoded, encoding: .utf8), secondExpectedXML)
}

func testMixedChoiceFlankedEncode() throws {
let flanked = MixedEitherSide(leading: "first", intOrString: .string("then"), trailing: "second")
let flankedEncoded = try XMLEncoder().encode(flanked, withRootKey: "container")
let flankedExpectedXML = """
<container><leading>first</leading><string>then</string><trailing>second</trailing></container>
"""
XCTAssertEqual(String(data: flankedEncoded, encoding: .utf8), flankedExpectedXML)
}

func testTwoChoiceElementsEncode() throws {
let twoChoiceElements = TwoChoiceElements(first: .int(1), second: .string("one"))
let encoded = try XMLEncoder().encode(twoChoiceElements, withRootKey: "container")
let expectedXML = "<container><int>1</int><string>one</string></container>"
XCTAssertEqual(String(data: encoded, encoding: .utf8), expectedXML)
}
}
31 changes: 0 additions & 31 deletions Tests/XMLCoderTests/SimpleChoiceTests.swift
Expand Up @@ -8,37 +8,6 @@
import XCTest
import XMLCoder

private enum IntOrString: Equatable {
case int(Int)
case string(String)
}

extension IntOrString: Codable {
enum CodingKeys: String, XMLChoiceCodingKey {
case int
case string
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case let .int(value):
try container.encode(value, forKey: .int)
case let .string(value):
try container.encode(value, forKey: .string)
}
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
do {
self = .int(try container.decode(Int.self, forKey: .int))
} catch {
self = .string(try container.decode(String.self, forKey: .string))
}
}
}

class SimpleChoiceTests: XCTestCase {
func testIntOrStringIntDecoding() throws {
let xml = """
Expand Down
13 changes: 13 additions & 0 deletions Tests/XMLCoderTests/XCTestManifests.swift
Expand Up @@ -414,6 +414,18 @@ extension KeyedTests {
]
}

extension MixedChoiceAndNonChoiceTests {
// DO NOT MODIFY: This is autogenerated, use:
// `swift test --generate-linuxmain`
// to regenerate.
static let __allTests__MixedChoiceAndNonChoiceTests = [
("testMixedChoiceFirstEncode", testMixedChoiceFirstEncode),
("testMixedChoiceFlankedEncode", testMixedChoiceFlankedEncode),
("testMixedChoiceSecondEncode", testMixedChoiceSecondEncode),
("testTwoChoiceElementsEncode", testTwoChoiceElementsEncode),
]
}

extension MixedContainerTest {
// DO NOT MODIFY: This is autogenerated, use:
// `swift test --generate-linuxmain`
Expand Down Expand Up @@ -840,6 +852,7 @@ public func __allTests() -> [XCTestCaseEntry] {
testCase(KeyedBoxTests.__allTests__KeyedBoxTests),
testCase(KeyedIntTests.__allTests__KeyedIntTests),
testCase(KeyedTests.__allTests__KeyedTests),
testCase(MixedChoiceAndNonChoiceTests.__allTests__MixedChoiceAndNonChoiceTests),
testCase(MixedContainerTest.__allTests__MixedContainerTest),
testCase(NameSpaceTest.__allTests__NameSpaceTest),
testCase(NestedAttributeChoiceTests.__allTests__NestedAttributeChoiceTests),
Expand Down

0 comments on commit deb2ab2

Please sign in to comment.