Skip to content

Commit

Permalink
Encodable and Decodable support for choice elements (CoreOffice#119)
Browse files Browse the repository at this point in the history
## Introduction

This PR introduces the `XMLChoiceCodingKey` protocol, which enables the encoding and decoding of union-type–like enums with associated values to and from `XML` choice elements.

Resolves CoreOffice#25.
Resolves CoreOffice#91.

## Motivation

XML schemas support [choice](https://www.w3schools.com/xml/el_choice.asp) elements, which constrain their contents to a single instance of a member of a known set of types. Choice elements exhibit the properties of [union types](https://en.wikipedia.org/wiki/Union_type) and can be represented in Swift as enums with associated values, wherein each case of the enum carries with it a single associated value that is one of the types representable.

An example of how such a type is implemented in Swift:
   
```Swift
enum IntOrString {
    case int(Int)
    case string(String)
}
```

There is currently no automatic synthesis of the `Codable` protocol requirements for enums with assocated types in today's Swift. As such, it is required to provide custom implementations of the `init(from: Decoder)` initializer and the `encode(to: Encoder)` method to conform to the `Encodable` and `Decodable` protocols, respectively.

When encoding to and decoding from `JSON`, a single-element keyed container is created that uses the enum case name as the single key.

An example of adding Codable conformance to such a type when working with JSON
   
```Swift
extension IntOrString: Codable {
    enum CodingKeys: String, CodingKey { case int, 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))
        }
    }
    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)
        }
    }
}
```

This may not be the most handsome approach, but it does the job without imprinting any format-specfic logic onto otherwise format-agnostic types.

This pattern works out of the box with the `JSONEncoder` and `JSONDecoder` provided by the `Foundation` framework. However, due to syntactic characteristics of the `XML` format, this pattern will **_not_** work automatically for encoding and decoding `XML`-formatted data, regardless of the tool used.

## Proposed solution

The proposed solution is to define a new `XMLChoiceCodingKey` protocol:

```Swift
/// An empty marker protocol that can be used in place of `CodingKey`.
/// It must be used when conforming a union-type–like enum with associated values to `Codable`
/// when the encoded format is `XML`.
public protocol XMLChoiceCodingKey: CodingKey {}
```

The `XMLChoiceCodingKey` protocol inherits from `CodingKey` and adds no new requirements. This conformance can be made retroactively, without additional implementation.

An example usage:
    
```Swift
extension IntOrString.CodingKeys: XMLChoiceCodingKey {}
``` 

## Detailed design

This proposal adds a single `public` `protocol` `XMLChoiceCodingKey`, as well as several `internal` types.

Under the hood, the `XMLChoiceEncodingContainer` and `XMLChoiceDecodingContainer` are used to provide `encode` and `decode` methods tuned for `XML` choice elements.

Because of the characteristics of the `XML` format, there are some ambiguities (from an encoding and decoding perpsective) between unkeyed container elements that contain choice elements and those that contain nested unkeyed container elements.

In order to untangle these ambiguities, the new container types utilize a couple of new `Box` types to redirect elements along the encoding and decoding process.

## Source compatibility

This is purely an additive change.
  • Loading branch information
jsbean authored and MaxDesiatov committed Jul 30, 2019
1 parent 3ae4113 commit ab9fef0
Show file tree
Hide file tree
Showing 23 changed files with 1,988 additions and 722 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,14 @@ Starting with [version 0.5](https://github.com/MaxDesiatov/XMLCoder/releases/tag
you can now set a property `trimValueWhitespaces` to `false` (the default value is `true`) on
`XMLDecoder` instance to preserve all whitespaces in decoded strings.

### Choice element coding

Starting with [version 0.8](https://github.com/MaxDesiatov/XMLCoder/releases/tag/0.8.0), you
now encode and decode union-type–like enums with associated values by conforming your
`CodingKey` type additionally to `XMLChoiceCodingKey`.

For more information, see the [pull request](https://github.com/MaxDesiatov/XMLCoder/pull/119).

## Installation

### Requirements
Expand Down
40 changes: 40 additions & 0 deletions Sources/XMLCoder/Auxiliaries/Box/ChoiceBox.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// ChoiceBox.swift
// XMLCoder
//
// Created by James Bean on 7/18/19.
//

/// A `Box` which represents an element which is known to contain an XML choice element.
struct ChoiceBox {
var key: String = ""
var element: Box = NullBox()
}

extension ChoiceBox: Box {
var isNull: Bool {
return false
}

func xmlString() -> String? {
return nil
}
}

extension ChoiceBox: SimpleBox {}

extension ChoiceBox {
init?(_ keyedBox: KeyedBox) {
guard
let firstKey = keyedBox.elements.keys.first,
let firstElement = keyedBox.elements[firstKey].first
else {
return nil
}
self.init(key: firstKey, element: firstElement)
}

init(_ singleKeyedBox: SingleKeyedBox) {
self.init(key: singleKeyedBox.key, element: singleKeyedBox.element)
}
}
24 changes: 24 additions & 0 deletions Sources/XMLCoder/Auxiliaries/Box/SingleKeyedBox.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// SingleKeyedBox.swift
// XMLCoder
//
// Created by James Bean on 7/15/19.
//

/// A `Box` which contains a single `key` and `element` pair. This is useful for disambiguating elements which could either represent
/// an element nested in a keyed or unkeyed container, or an choice between multiple known-typed values (implemented in Swift using
/// enums with associated values).
struct SingleKeyedBox: SimpleBox {
var key: String
var element: Box
}

extension SingleKeyedBox: Box {
var isNull: Bool {
return false
}

func xmlString() -> String? {
return nil
}
}
58 changes: 58 additions & 0 deletions Sources/XMLCoder/Auxiliaries/XMLChoiceCodingKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// XMLChoiceCodingKey.swift
// XMLCoder
//
// Created by Benjamin Wetherfield on 7/17/19.
//

/// An empty marker protocol that can be used in place of `CodingKey`. It must be used when
/// attempting to encode and decode union-type–like enums with associated values to and from `XML`
/// choice elements.
///
/// - Important: In order for your `XML`-destined `Codable` type to be encoded and/or decoded
/// properly, you must conform your custom `CodingKey` type additionally to `XMLChoiceCodingKey`.
///
/// For example, say you have defined a type which can hold _either_ an `Int` _or_ a `String`:
///
/// enum IntOrString {
/// case int(Int)
/// case string(String)
/// }
///
/// Implementing the requirements for the `Codable` protocol like this:
///
/// 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))
/// }
/// }
/// }
///
/// Retroactively conform the `CodingKeys` enum to `XMLChoiceCodingKey` when targeting `XML` as your
/// encoded format.
///
/// extension IntOrString.CodingKeys: XMLChoiceCodingKey {}
///
/// - Note: The `XMLChoiceCodingKey` marker protocol allows the `XMLEncoder` / `XMLDecoder` to
/// resolve ambiguities particular to the `XML` format between nested unkeyed container elements and
/// choice elements.
public protocol XMLChoiceCodingKey: CodingKey {}
22 changes: 16 additions & 6 deletions Sources/XMLCoder/Auxiliaries/XMLCoderElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,7 @@ struct XMLCoderElement: Equatable {
if elements.isEmpty, let value = value {
elements.append(StringBox(value), at: "value")
}
let keyedBox = KeyedBox(elements: elements, attributes: attributes)

return keyedBox
return KeyedBox(elements: elements, attributes: attributes)
}

func toXMLString(with header: XMLHeader? = nil,
Expand Down Expand Up @@ -245,9 +243,17 @@ struct XMLCoderElement: Equatable {

extension XMLCoderElement {
init(key: String, box: UnkeyedBox) {
self.init(key: key, elements: box.map {
XMLCoderElement(key: key, box: $0)
})
if let containsChoice = box as? [ChoiceBox] {
self.init(key: key, elements: containsChoice.map {
XMLCoderElement(key: $0.key, box: $0.element)
})
} else {
self.init(key: key, elements: box.map { XMLCoderElement(key: key, box: $0) })
}
}

init(key: String, box: ChoiceBox) {
self.init(key: key, elements: [XMLCoderElement(key: box.key, box: box.element)])
}

init(key: String, box: KeyedBox) {
Expand Down Expand Up @@ -302,10 +308,14 @@ extension XMLCoderElement {
self.init(key: key, box: sharedUnkeyedBox.unboxed)
case let sharedKeyedBox as SharedBox<KeyedBox>:
self.init(key: key, box: sharedKeyedBox.unboxed)
case let sharedChoiceBox as SharedBox<ChoiceBox>:
self.init(key: key, box: sharedChoiceBox.unboxed)
case let unkeyedBox as UnkeyedBox:
self.init(key: key, box: unkeyedBox)
case let keyedBox as KeyedBox:
self.init(key: key, box: keyedBox)
case let choiceBox as ChoiceBox:
self.init(key: key, box: choiceBox)
case let simpleBox as SimpleBox:
self.init(key: key, box: simpleBox)
case let box:
Expand Down
91 changes: 91 additions & 0 deletions Sources/XMLCoder/Decoder/XMLChoiceDecodingContainer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// XMLChoiceDecodingContainer.swift
// XMLCoder
//
// Created by James Bean on 7/18/19.
//

/// Container specialized for decoding XML choice elements.
struct XMLChoiceDecodingContainer<K: CodingKey>: KeyedDecodingContainerProtocol {
typealias Key = K

// MARK: Properties

/// A reference to the decoder we're reading from.
private let decoder: XMLDecoderImplementation

/// A reference to the container we're reading from.
private let container: SharedBox<ChoiceBox>

/// The path of coding keys taken to get to this point in decoding.
public private(set) var codingPath: [CodingKey]

// MARK: - Initialization

/// Initializes `self` by referencing the given decoder and container.
init(referencing decoder: XMLDecoderImplementation, wrapping container: SharedBox<ChoiceBox>) {
self.decoder = decoder
container.withShared { $0.key = decoder.keyTransform($0.key) }
self.container = container
codingPath = decoder.codingPath
}

// MARK: - KeyedDecodingContainerProtocol Methods

public var allKeys: [Key] {
return container.withShared { [Key(stringValue: $0.key)!] }
}

public func contains(_ key: Key) -> Bool {
return container.withShared { $0.key == key.stringValue }
}

public func decodeNil(forKey key: Key) throws -> Bool {
return container.withShared { $0.element.isNull }
}

public func decode<T: Decodable>(_ type: T.Type, forKey key: Key) throws -> T {
guard container.withShared({ $0.key == key.stringValue }), key is XMLChoiceCodingKey else {
throw DecodingError.typeMismatch(
at: codingPath,
expectation: type,
reality: container
)
}
return try decoder.unbox(container.withShared { $0.element })
}

public func nestedContainer<NestedKey>(
keyedBy _: NestedKey.Type, forKey key: Key
) throws -> KeyedDecodingContainer<NestedKey> {
throw DecodingError.typeMismatch(
at: codingPath,
expectation: NestedKey.self,
reality: container
)
}

public func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer {
throw DecodingError.typeMismatch(
at: codingPath,
expectation: Key.self,
reality: container
)
}

public func superDecoder() throws -> Decoder {
throw DecodingError.typeMismatch(
at: codingPath,
expectation: Key.self,
reality: container
)
}

public func superDecoder(forKey key: Key) throws -> Decoder {
throw DecodingError.typeMismatch(
at: codingPath,
expectation: Key.self,
reality: container
)
}
}
66 changes: 60 additions & 6 deletions Sources/XMLCoder/Decoder/XMLDecoderImplementation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,15 @@ class XMLDecoderImplementation: Decoder {
return topContainer
}

public func container<Key>(
keyedBy keyType: Key.Type
) throws -> KeyedDecodingContainer<Key> {
public func container<Key>(keyedBy keyType: Key.Type) throws -> KeyedDecodingContainer<Key> {
if Key.self is XMLChoiceCodingKey.Type {
return try choiceContainer(keyedBy: keyType)
} else {
return try keyedContainer(keyedBy: keyType)
}
}

public func keyedContainer<Key>(keyedBy _: Key.Type) throws -> KeyedDecodingContainer<Key> {
let topContainer = try self.topContainer()

switch topContainer {
Expand Down Expand Up @@ -118,6 +124,34 @@ class XMLDecoderImplementation: Decoder {
}
}

/// - Returns: A `KeyedDecodingContainer` for an XML choice element.
public func choiceContainer<Key>(keyedBy _: Key.Type) throws -> KeyedDecodingContainer<Key> {
let topContainer = try self.topContainer()
let choiceBox: ChoiceBox?
switch topContainer {
case let choice as ChoiceBox:
choiceBox = choice
case let singleKeyed as SingleKeyedBox:
choiceBox = ChoiceBox(singleKeyed)
case let keyed as SharedBox<KeyedBox>:
choiceBox = ChoiceBox(keyed.withShared { $0 })
default:
choiceBox = nil
}
guard let box = choiceBox else {
throw DecodingError.typeMismatch(
at: codingPath,
expectation: [String: Any].self,
reality: topContainer
)
}
let container = XMLChoiceDecodingContainer<Key>(
referencing: self,
wrapping: SharedBox(box)
)
return KeyedDecodingContainer(container)
}

public func unkeyedContainer() throws -> UnkeyedDecodingContainer {
let topContainer = try self.topContainer()

Expand All @@ -138,9 +172,10 @@ class XMLDecoderImplementation: Decoder {
case let unkeyed as SharedBox<UnkeyedBox>:
return XMLUnkeyedDecodingContainer(referencing: self, wrapping: unkeyed)
case let keyed as SharedBox<KeyedBox>:
guard let firstKey = keyed.withShared({ $0.elements.keys.first }) else { fallthrough }

return XMLUnkeyedDecodingContainer(referencing: self, wrapping: SharedBox(keyed.withShared { $0.elements[firstKey] }))
return XMLUnkeyedDecodingContainer(
referencing: self,
wrapping: SharedBox(keyed.withShared { $0.elements.map(SingleKeyedBox.init) })
)
default:
throw DecodingError.typeMismatch(
at: codingPath,
Expand Down Expand Up @@ -420,3 +455,22 @@ extension XMLDecoderImplementation {
return result
}
}

extension XMLDecoderImplementation {
var keyTransform: (String) -> String {
switch options.keyDecodingStrategy {
case .convertFromSnakeCase:
return XMLDecoder.KeyDecodingStrategy._convertFromSnakeCase
case .convertFromCapitalized:
return XMLDecoder.KeyDecodingStrategy._convertFromCapitalized
case .convertFromKebabCase:
return XMLDecoder.KeyDecodingStrategy._convertFromKebabCase
case .useDefaultKeys:
return { key in key }
case let .custom(converter):
return { key in
converter(self.codingPath + [XMLKey(stringValue: key, intValue: nil)]).stringValue
}
}
}
}

0 comments on commit ab9fef0

Please sign in to comment.