Skip to content

Commit

Permalink
Merge pull request #124 from AntVil/master
Browse files Browse the repository at this point in the history
add keyDecodingStrategy option
  • Loading branch information
yaslab authored Jul 28, 2024
2 parents 3753e4e + 1981399 commit 898c695
Show file tree
Hide file tree
Showing 4 changed files with 271 additions and 28 deletions.
150 changes: 139 additions & 11 deletions Sources/CSV/CSVRowDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ open class CSVRowDecoder {
case custom((_ value: String) throws -> Bool)
}

/// The strategy to use for decoding `String` values.
public enum StringDecodingStrategy {
/// Decode empty String to `nil`
case `default`

/// Decode empty String to `""`
case allowEmpty

/// Decode the `Bool` as a custom value decoded by the given closure.
case custom((_ value: String) throws -> String)
}

/// The strategy to use for decoding `Date` values.
public enum DateDecodingStrategy {
/// Defer to `Date` for decoding. This is the default strategy.
Expand Down Expand Up @@ -54,7 +66,65 @@ open class CSVRowDecoder {
/// Decode the `Data` as a custom value decoded by the given closure.
case custom((_ value: String) throws -> Data)
}


public enum KeyDecodingStrategy {
case useDefaultKeys
case convertFromSnakeCase
case custom((_ codingPath: String) -> String)

func call(_ key: String) -> String {
switch self {
case .useDefaultKeys:
return key
case .convertFromSnakeCase:
return Self._convertFromSnakeCase(key)
case .custom(let converter):
return converter(key)
}
}

/// convert snake-case to camelCase
///
/// `oneTwoThree` -> `oneTwoThree`
///
/// `one_two_three` -> `oneTwoThree`
///
/// `_one_two_three_` -> `_oneTwoThree_`
///
/// `__one__two__three__` -> `__oneTwoThree__`
///
/// `ONE_TWO_THREE` -> `oneTwoThree`
///
/// `ONE` -> `ONE`
///
/// - Parameter key: key in snake case format
/// - Returns: key in camel case format
private static func _convertFromSnakeCase(_ key: String) -> String {
// match anything but underscore
let nonUnderscore = try! NSRegularExpression(pattern: "[^_]+")

let matches = nonUnderscore.matches(in: key, range: NSRange(key.startIndex..., in: key))

var keyParts = matches.map {
String(key[Range($0.range, in: key)!])
}

if keyParts.count <= 1 {
return key
}

keyParts[0] = keyParts[0].lowercased()
for i in 1..<keyParts.count {
keyParts[i] = keyParts[i].capitalized
}

let pre = String(key.prefix(while: { $0 == "_" }))
let post = String(key.suffix(while: { $0 == "_" }))

return pre + keyParts.joined() + post
}
}

/// The strategy to use for decoding `nil` values.
public enum NilDecodingStrategy {
case empty
Expand All @@ -64,12 +134,18 @@ open class CSVRowDecoder {
/// The strategy to use in decoding bools. Defaults to `.default`.
open var boolDecodingStrategy: BoolDecodingStrategy = .default

/// The strategy to use in decoding strings. Defaults to `.default`.
open var stringDecodingStrategy: StringDecodingStrategy = .default

/// The strategy to use in decoding dates. Defaults to `.deferredToDate`.
open var dateDecodingStrategy: DateDecodingStrategy = .deferredToDate

/// The strategy to use in decoding binary data. Defaults to `.base64`.
open var dataDecodingStrategy: DataDecodingStrategy = .base64


/// The strategy to use in decoding keys. Defaults to `.useDefaultKeys`
open var keyDecodingStrategy: KeyDecodingStrategy = .useDefaultKeys

/// The strategy to use in decoding nil data. Defaults to `.empty`.
open var nilDecodingStrategy: NilDecodingStrategy = .empty

Expand All @@ -79,17 +155,21 @@ open class CSVRowDecoder {
/// Options set on the top-level encoder to pass down the decoding hierarchy.
fileprivate struct _Options {
let boolDecodingStrategy: BoolDecodingStrategy
let stringDecodingStrategy: StringDecodingStrategy
let dateDecodingStrategy: DateDecodingStrategy
let dataDecodingStrategy: DataDecodingStrategy
let keyDecodingStrategy: KeyDecodingStrategy
let nilDecodingStrategy: NilDecodingStrategy
let userInfo: [CodingUserInfoKey: Any]
}

/// The options set on the top-level decoder.
fileprivate var options: _Options {
return _Options(boolDecodingStrategy: boolDecodingStrategy,
stringDecodingStrategy: stringDecodingStrategy,
dateDecodingStrategy: dateDecodingStrategy,
dataDecodingStrategy: dataDecodingStrategy,
keyDecodingStrategy: keyDecodingStrategy,
nilDecodingStrategy: nilDecodingStrategy,
userInfo: userInfo)
}
Expand All @@ -105,12 +185,24 @@ open class CSVRowDecoder {

}

fileprivate extension String {
func suffix(while predicate: (Element) throws -> Bool) rethrows -> SubSequence {
var index = self.index(endIndex, offsetBy: -1)
while index >= startIndex, try predicate(self[index]) {
index = self.index(before: index)
}
return index < startIndex ? self[self.index(after: index)...] : ""
}
}

fileprivate final class _CSVRowDecoder: Decoder {

fileprivate let reader: CSVReader

fileprivate let options: CSVRowDecoder._Options

fileprivate let headerRow: [String]?

public var codingPath: [CodingKey] = []

public var userInfo: [CodingUserInfoKey: Any] {
Expand All @@ -120,6 +212,12 @@ fileprivate final class _CSVRowDecoder: Decoder {
fileprivate init(referencing reader: CSVReader, options: CSVRowDecoder._Options) {
self.reader = reader
self.options = options

if let headerRow = reader.headerRow {
self.headerRow = headerRow.map { options.keyDecodingStrategy.call($0) }
} else {
self.headerRow = nil
}
}

public func container<Key: CodingKey>(keyedBy type: Key.Type) throws -> KeyedDecodingContainer<Key> {
Expand Down Expand Up @@ -150,7 +248,7 @@ fileprivate final class CSVKeyedDecodingContainer<K: CodingKey>: KeyedDecodingCo
}

public var allKeys: [Key] {
guard let headerRow = self.decoder.reader.headerRow else { return [] }
guard let headerRow = self.decoder.headerRow else { return [] }
return headerRow.compactMap { Key(stringValue: $0) }
}

Expand All @@ -164,9 +262,9 @@ fileprivate final class CSVKeyedDecodingContainer<K: CodingKey>: KeyedDecodingCo
}

if let index = key.intValue {
return self.decoder.reader.currentRow![index]
return self.decoder[index]!
} else {
return self.decoder.reader[key.stringValue]!
return self.decoder[key.stringValue]!
}
}

Expand All @@ -181,7 +279,7 @@ fileprivate final class CSVKeyedDecodingContainer<K: CodingKey>: KeyedDecodingCo
if let index = key.intValue {
return index < row.count
} else {
guard let headerRow = self.decoder.reader.headerRow else {
guard let headerRow = self.decoder.headerRow else {
return false
}
return headerRow.contains(key.stringValue)
Expand Down Expand Up @@ -411,14 +509,38 @@ fileprivate final class CSVKeyedDecodingContainer<K: CodingKey>: KeyedDecodingCo

}

extension _CSVRowDecoder {

public subscript(index: Int) -> String? {
return reader.currentRow![index]
}

public subscript(key: String) -> String? {
guard let header = headerRow else {
fatalError("CSVReader.headerRow must not be nil")
}
guard let index = header.firstIndex(of: key) else {
return nil
}
guard let row = reader.currentRow else {
fatalError("CSVReader.currentRow must not be nil")
}
guard index < row.count else {
return ""
}
return row[index]
}

}

extension _CSVRowDecoder: SingleValueDecodingContainer {

private var value: String {
let key = self.codingPath.last!
if let index = key.intValue {
return self.reader.currentRow![index]
} else {
return self.reader[key.stringValue]!
return self[key.stringValue]!
}
}

Expand All @@ -436,7 +558,6 @@ extension _CSVRowDecoder: SingleValueDecodingContainer {
case .custom(let customClosure):
return customClosure(self.value)
}

}

public func decode(_ type: Bool.Type) throws -> Bool {
Expand Down Expand Up @@ -647,9 +768,16 @@ extension _CSVRowDecoder {
}

fileprivate func unbox(_ value: String, as type: String.Type) throws -> String? {
if value.isEmpty { return nil }

return value
switch self.options.stringDecodingStrategy {
case .default:
if value.isEmpty { return nil }
return value
case .allowEmpty:
if value.isEmpty { return "" }
return value
case .custom(let closure):
return try closure(value)
}
}

private func unbox(_ value: String, as type: Date.Type) throws -> Date? {
Expand Down
115 changes: 115 additions & 0 deletions Tests/CSVTests/CSVRowDecoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,120 @@ class CSVRowDecoderTests: XCTestCase {
XCTAssertEqual(records[1], exampleRecords[1])
}

func testConvertFromSnakeCase() {
let csv = """
first_column,SECOND_COLUMN
first_value,SECOND_VALUE
"""

struct SnakeCaseCsvRow: Codable, Equatable {
let firstColumn: String
let secondColumn: String
}

let reader = try! CSVReader(string: csv, hasHeaderRow: true)

let decoder = CSVRowDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

var records = [SnakeCaseCsvRow]()

do {
while reader.next() != nil {
try records.append(decoder.decode(SnakeCaseCsvRow.self, from: reader))
}
} catch {
XCTFail("decode<T>() threw error: \(error)")
return
}
XCTAssertEqual(records.count, 1)
XCTAssertEqual(records[0], SnakeCaseCsvRow(firstColumn: "first_value", secondColumn: "SECOND_VALUE"))
}

func testConvertFromCustom() {
let csv = """
first Column,second Column
first_value,second_value
"""

struct CustomCsvRow: Codable, Equatable {
let firstColumn: String
let secondColumn: String
}

let reader = try! CSVReader(string: csv, hasHeaderRow: true)

let decoder = CSVRowDecoder()
decoder.keyDecodingStrategy = .custom({ $0.replacingOccurrences(of: " ", with: "") })

var records = [CustomCsvRow]()

do {
while reader.next() != nil {
try records.append(decoder.decode(CustomCsvRow.self, from: reader))
}
} catch {
XCTFail("decode<T>() threw error: \(error)")
return
}
XCTAssertEqual(records.count, 1)
XCTAssertEqual(records[0], CustomCsvRow(firstColumn: "first_value", secondColumn: "second_value"))
}

func testEmptyStringDecodingFail() throws {
let csv = """
a,"b"
,""
"""

struct EmptyStringCsvRow: Decodable {
let a: String
let b: String
}

let reader = try! CSVReader(string: csv, hasHeaderRow: true)
let decoder = CSVRowDecoder()

var records = [EmptyStringCsvRow]()

do {
while reader.next() != nil {
try records.append(decoder.decode(EmptyStringCsvRow.self, from: reader))
}
} catch {}
XCTAssertEqual(records.count, 0)
}

func testEmptyStringDecodingSuccess() throws {
let csv = """
a,"b"
,""
"""

struct EmptyStringCsvRow: Decodable {
let a: String
let b: String
}

let reader = try! CSVReader(string: csv, hasHeaderRow: true)
let decoder = CSVRowDecoder()
decoder.stringDecodingStrategy = .allowEmpty

var records = [EmptyStringCsvRow]()

do {
while reader.next() != nil {
try records.append(decoder.decode(EmptyStringCsvRow.self, from: reader))
}
} catch {
XCTFail("decode<T>() threw error: \(error)")
return
}
XCTAssertEqual(records.count, 1)
XCTAssertEqual(records[0].a, "")
XCTAssertEqual(records[0].b, "")
}

func testTypeInvalidDateFormat() {
let invalidFieldTypeStr = """
dateKey,stringKey,optionalStringKey,intKey,ignored
Expand Down Expand Up @@ -194,6 +308,7 @@ class CSVRowDecoderTests: XCTestCase {
let exampleRecords = IntKeyedDecodableExample.examples

let allRows = IntKeyedDecodableExample.examples.reduce(into: "") { $0 += $1.toRow() }
print(allRows)

let headerCSV = try! CSVReader(string: allRows, hasHeaderRow: false)

Expand Down
Loading

0 comments on commit 898c695

Please sign in to comment.