Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Key coding strategy #200

Closed
wants to merge 3 commits into from
Closed
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
72 changes: 64 additions & 8 deletions Sources/Yams/Decoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import Foundation
/// `Codable`-style `Decoder` that can be used to decode a `Decodable` type from a given `String` and optional
/// user info mapping. Similar to `Foundation.JSONDecoder`.
public class YAMLDecoder {
/// Options to use when decoding from YAML.
public var options = Options()

/// Creates a `YAMLDecoder` instance.
///
/// - parameter encoding: Encoding, `.default` if omitted.
Expand All @@ -32,7 +35,7 @@ public class YAMLDecoder {
userInfo: [CodingUserInfoKey: Any] = [:]) throws -> T where T: Swift.Decodable {
do {
let node = try Parser(yaml: yaml, resolver: .basic, encoding: encoding).singleRoot() ?? ""
let decoder = _Decoder(referencing: node, userInfo: userInfo)
let decoder = _Decoder(referencing: node, options: options, userInfo: userInfo)
let container = try decoder.singleValueContainer()
return try container.decode(type)
} catch let error as DecodingError {
Expand All @@ -46,14 +49,48 @@ public class YAMLDecoder {

/// Encoding
public var encoding: Parser.Encoding

/// Configuration options to use when decoding YAML.
public struct Options {
/// The strategy to use for decoding keys. Defaults to `.useDefaultKeys`.
public var keyDecodingStrategy: KeyDecodingStrategy = .useDefaultKeys
}

/// The strategy to use for automatically changing the value of keys before decoding.
public enum KeyDecodingStrategy {
/// Use the keys specified by each type. This is the default strategy.
case useDefaultKeys

/// Use the snake cased keys in the decoded types to accessing encoded YAML payload.
case useSnakeCasedKeys

/// Provide a custom conversion from the keys in the decoded types to the keys encoded YAML payload.
/// The full path to the current decoding position is provided for context (in case you need to locate this
/// key within the payload). The returned key is used in place of the last component in the coding path before
/// decoding.
/// If the result of the conversion is a duplicate key, then only one value will be present in the container
/// for the type to decode from.
case useCustomizedKeys((_ codingPath: [CodingKey]) -> CodingKey)

/// Provide a custom conversion from the key in the encoded YAML to the keys specified by the decoded types.
/// The full path to the current decoding position is provided for context (in case you need to locate this
/// key within the payload). The returned key is used in place of the last component in the coding path before
/// decoding.
/// If the result of the conversion is a duplicate key, then only one value will be present in the container
/// for the type to decode from.
case custom((_ codingPath: [CodingKey]) -> CodingKey)
}
}

private struct _Decoder: Decoder {
typealias Options = YAMLDecoder.Options

private let node: Node
fileprivate let options: Options

init(referencing node: Node, userInfo: [CodingUserInfoKey: Any], codingPath: [CodingKey] = []) {
init(referencing node: Node, options: Options, userInfo: [CodingUserInfoKey: Any], codingPath: [CodingKey] = []) {
self.node = node
self.options = options
self.userInfo = userInfo
self.codingPath = codingPath
}
Expand Down Expand Up @@ -81,9 +118,18 @@ private struct _Decoder: Decoder {

// MARK: -

/// Returns `String` applied `KeyDecodingStrategy`
fileprivate func convert(_ key: CodingKey) -> String {
switch options.keyDecodingStrategy {
case .useDefaultKeys, .custom: return key.stringValue
case .useSnakeCasedKeys: return key.stringValue.snakecased
case let .useCustomizedKeys(converter): return converter(codingPath + [key]).stringValue
}
}

/// create a new `_Decoder` instance referencing `node` as `key` inheriting `userInfo`
func decoder(referencing node: Node, `as` key: CodingKey) -> _Decoder {
return .init(referencing: node, userInfo: userInfo, codingPath: codingPath + [key])
return .init(referencing: node, options: options, userInfo: userInfo, codingPath: codingPath + [key])
}

/// returns `Node.Scalar` or throws `DecodingError.typeMismatch`
Expand All @@ -106,14 +152,23 @@ private struct _KeyedDecodingContainer<Key: CodingKey> : KeyedDecodingContainerP

init(decoder: _Decoder, wrapping mapping: Node.Mapping) {
self.decoder = decoder
self.mapping = mapping
switch self.decoder.options.keyDecodingStrategy {
case let .custom(converter):
self.mapping = .init(mapping.map { (arg) -> (Node, Node) in
let (key, value) = arg
let convertedKey = converter(decoder.codingPath + [_YAMLCodingKey(stringValue: key.string!)!])
return (Node(convertedKey.stringValue), value)
}, mapping.tag, mapping.style, mapping.mark)
default:
self.mapping = mapping
}
}

// MARK: - Swift.KeyedDecodingContainerProtocol Methods

var codingPath: [CodingKey] { return decoder.codingPath }
var allKeys: [Key] { return mapping.keys.compactMap { $0.string.flatMap(Key.init(stringValue:)) } }
func contains(_ key: Key) -> Bool { return mapping[key.stringValue] != nil }
var allKeys: [Key] { return Set(mapping.keys.compactMap { $0.string }).compactMap(Key.init) }
func contains(_ key: Key) -> Bool { return mapping[decoder.convert(key)] != nil }

func decodeNil(forKey key: Key) throws -> Bool {
return try decoder(for: key).decodeNil()
Expand Down Expand Up @@ -142,8 +197,9 @@ private struct _KeyedDecodingContainer<Key: CodingKey> : KeyedDecodingContainerP
// MARK: -

private func node(for key: CodingKey) throws -> Node {
guard let node = mapping[key.stringValue] else {
throw _keyNotFound(at: codingPath, key, "No value associated with key \(key) (\"\(key.stringValue)\").")
let convertedKey = decoder.convert(key)
guard let node = mapping[convertedKey] else {
throw _keyNotFound(at: codingPath, key, "No value associated with key \(key) (\"\(convertedKey)\").")
}
return node
}
Expand Down
107 changes: 97 additions & 10 deletions Sources/Yams/Encoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@
/// `Codable`-style `Encoder` that can be used to encode an `Encodable` type to a YAML string using optional
/// user info mapping. Similar to `Foundation.JSONEncoder`.
public class YAMLEncoder {
/// Options to use when encoding to YAML.
public typealias Options = Emitter.Options

/// Options to use when encoding to YAML.
public var options = Options()

Expand All @@ -28,10 +25,10 @@ public class YAMLEncoder {
/// - throws: `EncodingError` if something went wrong while encoding.
public func encode<T: Swift.Encodable>(_ value: T, userInfo: [CodingUserInfoKey: Any] = [:]) throws -> String {
do {
let encoder = _Encoder(userInfo: userInfo)
let encoder = _Encoder(options: options, userInfo: userInfo)
var container = encoder.singleValueContainer()
try container.encode(value)
return try serialize(node: encoder.node, options: options)
return try serialize(node: encoder.node, options: options.emitterOptions)
} catch let error as EncodingError {
throw error
} catch {
Expand All @@ -42,12 +39,91 @@ public class YAMLEncoder {
throw EncodingError.invalidValue(value, context)
}
}

/// Configuration options to use when emitting YAML.
public struct Options {
/// Set if the output should be in the "canonical" format described in the YAML specification.
public var canonical: Bool = false
/// Set the indentation value.
public var indent: Int = 0
/// Set the preferred line width. -1 means unlimited.
public var width: Int = 0
/// Set if unescaped non-ASCII characters are allowed.
public var allowUnicode: Bool = false
/// Set the preferred line break.
public var lineBreak: Emitter.LineBreak = .ln

// internal since we don't know if these should be exposed.
var explicitStart: Bool = false
var explicitEnd: Bool = false

/// The `%YAML` directive value or nil.
public var version: (major: Int, minor: Int)?

/// Set if emitter should sort keys in lexicographic order.
public var sortKeys: Bool = false

/// The strategy to use for encoding keys. Defaults to `.useDefaultKeys`.
public var keyEncodingStrategy: KeyEncodingStrategy = .useDefaultKeys

fileprivate var emitterOptions: Emitter.Options {
return .init(canonical: canonical, indent: indent, width: width, allowUnicode: allowUnicode,
lineBreak: lineBreak, version: version, sortKeys: sortKeys)
}
}

/// The strategy to use for automatically changing the value of keys before encoding.
public enum KeyEncodingStrategy {
/// Use the keys specified by each type. This is the default strategy.
case useDefaultKeys

/// Convert from "camelCaseKeys" to "snake_case_keys" before writing a key to YAML payload.
case convertToSnakeCase

/// Provide a custom conversion to the key in the encoded YAML from the keys specified by the encoded types.
/// The full path to the current encoding position is provided for context (in case you need to locate this
/// key within the payload). The returned key is used in place of the last component in the coding path before
/// encoding.
/// If the result of the conversion is a duplicate key, then only one value will be present in the result.
case custom((_ codingPath: [CodingKey]) -> CodingKey)
}
}

extension YAMLEncoder.Options {
/// Create `YAMLEncoder.Options` with the specified values.
///
/// - parameter canonical: Set if the output should be in the "canonical" format described in the YAML
/// specification.
/// - parameter indent: Set the indentation value.
/// - parameter width: Set the preferred line width. -1 means unlimited.
/// - parameter allowUnicode: Set if unescaped non-ASCII characters are allowed.
/// - parameter lineBreak: Set the preferred line break.
/// - parameter explicitStart: Explicit document start `---`.
/// - parameter explicitEnd: Explicit document end `...`.
/// - parameter version: The `%YAML` directive value or nil.
/// - parameter sortKeys: Set if emitter should sort keys in lexicographic order.
public init(canonical: Bool = false, indent: Int = 0, width: Int = 0, allowUnicode: Bool = false,
lineBreak: Emitter.LineBreak = .ln, version: (major: Int, minor: Int)? = nil,
sortKeys: Bool = false,
keyEncodingStrategy: YAMLEncoder.KeyEncodingStrategy = .useDefaultKeys) {
self.canonical = canonical
self.indent = indent
self.width = width
self.allowUnicode = allowUnicode
self.lineBreak = lineBreak
self.version = version
self.sortKeys = sortKeys
self.keyEncodingStrategy = keyEncodingStrategy
}
}

private class _Encoder: Swift.Encoder {
typealias Options = YAMLEncoder.Options
let options: Options
var node: Node = .unused

init(userInfo: [CodingUserInfoKey: Any] = [:], codingPath: [CodingKey] = []) {
init(options: Options, userInfo: [CodingUserInfoKey: Any] = [:], codingPath: [CodingKey] = []) {
self.options = options
self.userInfo = userInfo
self.codingPath = codingPath
}
Expand Down Expand Up @@ -106,6 +182,15 @@ private class _Encoder: Swift.Encoder {
}

private var canEncodeNewValue: Bool { return node == .unused }

/// Returns `String` applied `KeyEncodingStrategy`
fileprivate func convert(_ key: CodingKey) -> String {
switch options.keyEncodingStrategy {
case .useDefaultKeys: return key.stringValue
case .convertToSnakeCase: return key.stringValue.snakecased
case let .custom(converter): return converter(codingPath + [key]).stringValue
}
}
}

private class _ReferencingEncoder: _Encoder {
Expand All @@ -116,14 +201,16 @@ private class _ReferencingEncoder: _Encoder {

init(referencing encoder: _Encoder, key: CodingKey) {
self.encoder = encoder
reference = .mapping(key.stringValue)
super.init(userInfo: encoder.userInfo, codingPath: encoder.codingPath + [key])
reference = .mapping(encoder.convert(key))
super.init(options: encoder.options, userInfo: encoder.userInfo, codingPath: encoder.codingPath + [key])
}

init(referencing encoder: _Encoder, at index: Int) {
self.encoder = encoder
reference = .sequence(index)
super.init(userInfo: encoder.userInfo, codingPath: encoder.codingPath + [_YAMLCodingKey(index: index)])
super.init(options: encoder.options,
userInfo: encoder.userInfo,
codingPath: encoder.codingPath + [_YAMLCodingKey(index: index)])
}

deinit {
Expand All @@ -147,7 +234,7 @@ private struct _KeyedEncodingContainer<Key: CodingKey> : KeyedEncodingContainerP
// MARK: - Swift.KeyedEncodingContainerProtocol Methods

var codingPath: [CodingKey] { return encoder.codingPath }
func encodeNil(forKey key: Key) throws { encoder.mapping[key.stringValue] = .null }
func encodeNil(forKey key: Key) throws { encoder.mapping[encoder.convert(key)] = .null }
func encode<T>(_ value: T, forKey key: Key) throws where T: YAMLEncodable { try encoder(for: key).encode(value) }
func encode<T>(_ value: T, forKey key: Key) throws where T: Encodable { try encoder(for: key).encode(value) }

Expand Down
38 changes: 38 additions & 0 deletions Sources/Yams/String+Yams.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,42 @@ extension String {
return self + "\n"
}
}

/// Returns snake cased string
///
/// Capital characters are determined by testing membership in `CharacterSet.uppercaseLetters` and
/// `CharacterSet.lowercaseLetters` (Unicode General Categories Lu and Lt).
/// The conversion to lower case uses `Locale.system`, also known as the ICU "root" locale. This means the result is
/// consistent regardless of the current user's locale and language preferences.
///
/// Converting from camel case to snake case:
/// 1. Splits words at the boundary of lower-case to upper-case
/// 2. Inserts `_` between words
/// 3. Lowercases the entire string
/// 4. Preserves starting and ending `_`.
///
/// For example, `oneTwoThree` becomes `one_two_three`. `_oneTwoThree_` becomes `_one_two_three_`.
var snakecased: String {
guard !isEmpty else { return self }
var words = [Range<Index>](), wordStart = startIndex, searchStart = startIndex
while let upperCaseRange = rangeOfCharacter(from: .uppercaseLetters, range: searchStart..<endIndex) {
if upperCaseRange.lowerBound != startIndex {
words.append(wordStart..<upperCaseRange.lowerBound)
}
guard let lowerCaseRange = rangeOfCharacter(from: .lowercaseLetters,
range: upperCaseRange.upperBound..<endIndex) else {
wordStart = upperCaseRange.lowerBound
break
}
if upperCaseRange.upperBound == lowerCaseRange.lowerBound {
wordStart = upperCaseRange.lowerBound
} else {
wordStart = index(before: lowerCaseRange.lowerBound)
words.append(upperCaseRange.lowerBound..<wordStart)
}
searchStart = lowerCaseRange.upperBound
}
words.append(wordStart..<endIndex)
return words.map({ self[$0] }).joined(separator: "_").lowercased()
}
}
Loading