diff --git a/SwiftCSV.xcodeproj/project.pbxproj b/SwiftCSV.xcodeproj/project.pbxproj index 0c8d882..dcc6891 100644 --- a/SwiftCSV.xcodeproj/project.pbxproj +++ b/SwiftCSV.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ DF94FE462898F3A3008FD3F9 /* utf8_with_bom.csv in Resources */ = {isa = PBXBuildFile; fileRef = DF94FE452898F3A3008FD3F9 /* utf8_with_bom.csv */; }; DF94FE472898F3A3008FD3F9 /* utf8_with_bom.csv in Resources */ = {isa = PBXBuildFile; fileRef = DF94FE452898F3A3008FD3F9 /* utf8_with_bom.csv */; }; DF94FE482898F3A3008FD3F9 /* utf8_with_bom.csv in Resources */ = {isa = PBXBuildFile; fileRef = DF94FE452898F3A3008FD3F9 /* utf8_with_bom.csv */; }; + DFAD8B7B28B601EB0042BB56 /* Serializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFAD8B7A28B601EB0042BB56 /* Serializer.swift */; }; E46085921CCB1E8F00385286 /* large.csv in Resources */ = {isa = PBXBuildFile; fileRef = E46085911CCB1E8F00385286 /* large.csv */; }; E46085941CCB1F5C00385286 /* PerformanceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E46085931CCB1F5C00385286 /* PerformanceTest.swift */; }; F5C19F502283243C00920B06 /* ResourceHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5C19F4F2283243C00920B06 /* ResourceHelper.swift */; }; @@ -153,6 +154,7 @@ BE6C86061CB5CE44009A351D /* QuotedTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QuotedTests.swift; sourceTree = ""; }; BE9B02D71CBE57B8009FE424 /* Parser.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Parser.swift; sourceTree = ""; }; DF94FE452898F3A3008FD3F9 /* utf8_with_bom.csv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = utf8_with_bom.csv; sourceTree = ""; }; + DFAD8B7A28B601EB0042BB56 /* Serializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Serializer.swift; sourceTree = ""; }; E46085911CCB1E8F00385286 /* large.csv */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = large.csv; sourceTree = ""; }; E46085931CCB1F5C00385286 /* PerformanceTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PerformanceTest.swift; sourceTree = ""; }; F5C19F4F2283243C00920B06 /* ResourceHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ResourceHelper.swift; sourceTree = ""; }; @@ -255,6 +257,7 @@ 3D444BCC1C7D88290001C60C /* String+Lines.swift */, BE9B02D71CBE57B8009FE424 /* Parser.swift */, 508975D61DBF34CF006F3DBE /* ParsingState.swift */, + DFAD8B7A28B601EB0042BB56 /* Serializer.swift */, ); path = SwiftCSV; sourceTree = ""; @@ -588,6 +591,7 @@ buildActionMask = 2147483647; files = ( 50B3EEA4286F8A84007B3956 /* CSVDelimiter.swift in Sources */, + DFAD8B7B28B601EB0042BB56 /* Serializer.swift in Sources */, 508975D21DBB897A006F3DBE /* NamedCSVView.swift in Sources */, 508CA0FB2771F2E70084C8E8 /* CSV+DelimiterGuessing.swift in Sources */, 3DAAEE9C1C74C7EC00A933DB /* CSV.swift in Sources */, diff --git a/SwiftCSV/CSV.swift b/SwiftCSV/CSV.swift index 9c1fdb0..a75039d 100644 --- a/SwiftCSV/CSV.swift +++ b/SwiftCSV/CSV.swift @@ -128,14 +128,6 @@ extension CSV: CustomStringConvertible { } } -func enquoteContentsIfNeeded(cell: String) -> String { - // Add quotes if value contains a comma - if cell.contains(",") { - return "\"\(cell)\"" - } - return cell -} - extension CSV { /// Load a CSV file from `url`. /// diff --git a/SwiftCSV/EnumeratedCSVView.swift b/SwiftCSV/EnumeratedCSVView.swift index 1586f0f..0e82735 100644 --- a/SwiftCSV/EnumeratedCSVView.swift +++ b/SwiftCSV/EnumeratedCSVView.swift @@ -44,19 +44,9 @@ public struct Enumerated: CSVView { } public func serialize(header: [String], delimiter: CSVDelimiter) -> String { - let separator = String(delimiter.rawValue) - - let head = header - .map(enquoteContentsIfNeeded(cell:)) - .joined(separator: separator) + "\n" - - let content = rows.map { row in - row.map(enquoteContentsIfNeeded(cell:)) - .joined(separator: separator) - }.joined(separator: "\n") - - return head + content + return Serializer.serialize(header: header, rows: rows, delimiter: delimiter) } + } extension Collection { diff --git a/SwiftCSV/NamedCSVView.swift b/SwiftCSV/NamedCSVView.swift index af3ad46..a2807be 100644 --- a/SwiftCSV/NamedCSVView.swift +++ b/SwiftCSV/NamedCSVView.swift @@ -35,19 +35,11 @@ public struct Named: CSVView { } public func serialize(header: [String], delimiter: CSVDelimiter) -> String { - let separator = String(delimiter.rawValue) + let rowsOrderingCellsByHeader = rows.map { row in + header.map { cellID in row[cellID]! } + } - let head = header - .map(enquoteContentsIfNeeded(cell:)) - .joined(separator: separator) + "\n" - - let content = rows.map { row in - header - .map { cellID in row[cellID]! } - .map(enquoteContentsIfNeeded(cell:)) - .joined(separator: separator) - }.joined(separator: "\n") - - return head + content + return Serializer.serialize(header: header, rows: rowsOrderingCellsByHeader, delimiter: delimiter) } + } diff --git a/SwiftCSV/Serializer.swift b/SwiftCSV/Serializer.swift new file mode 100644 index 0000000..603694c --- /dev/null +++ b/SwiftCSV/Serializer.swift @@ -0,0 +1,46 @@ +// +// Serializer.swift +// SwiftCSV +// + +import Foundation + +enum Serializer { + + static let newline = "\n" + + static func serialize(header: [String], rows: [[String]], delimiter: CSVDelimiter) -> String { + let head = serializeRow(row: header, delimiter: delimiter) + newline + + let content = rows.map { row in + serializeRow(row: row, delimiter: delimiter) + }.joined(separator: newline) + + return head + content + } + + + static func serializeRow(row: [String], delimiter: CSVDelimiter) -> String { + let separator = String(delimiter.rawValue) + + let content = row.map { cell in + cell.enquoted(whenContaining: separator) + }.joined(separator: separator) + + return content + } + +} + +fileprivate extension String { + + func enquoted(whenContaining separator: String) -> String { + // Add quotes if value contains a delimiter + if self.contains(separator) { + return "\"\(self)\"" + } + + return self + } + +} diff --git a/SwiftCSVTests/EnumeratedCSVViewTests.swift b/SwiftCSVTests/EnumeratedCSVViewTests.swift index 20e6cc0..83c7385 100644 --- a/SwiftCSVTests/EnumeratedCSVViewTests.swift +++ b/SwiftCSVTests/EnumeratedCSVViewTests.swift @@ -76,12 +76,24 @@ class EnumeratedViewTests: XCTestCase { XCTAssertEqual(csv.columns, expectedColumns) } - func testSerialization() { + func testSerialization() throws { + // Comma-separated values. XCTAssertEqual(csv.serialized, "id,name,age\n1,Alice,18\n2,Bob,19\n3,Charlie,20") - } - func testSerializationWithDoubleQuotes() throws { + // Comma-separated values with double quotes and embedded delimiters in cells. csv = try CSV(string: "id,\"the, name\",age\n1,\"Alice, In, Wonderland\",18\n2,Bob,19\n3,Charlie,20") XCTAssertEqual(csv.serialized, "id,\"the, name\",age\n1,\"Alice, In, Wonderland\",18\n2,Bob,19\n3,Charlie,20") + + // Tab-separated values with implicit delimiter (delimiter guessing). + csv = try CSV(string: "id\tname\tage\n1\tAlice\t18\n2\tBob\t19\n3\tCharlie\t20") + XCTAssertEqual(csv.serialized, "id\tname\tage\n1\tAlice\t18\n2\tBob\t19\n3\tCharlie\t20") + + // Tab-separated values with double quotes and embedded delimiters in cells. + csv = try CSV(string: "id\t\"the\t name\"\tage\n1\t\"Alice\t In\t Wonderland\"\t18\n2\tBob\t19\n3\tCharlie\t20") + XCTAssertEqual(csv.serialized, "id\t\"the\t name\"\tage\n1\t\"Alice\t In\t Wonderland\"\t18\n2\tBob\t19\n3\tCharlie\t20") + + // Tab-separated values with explicit alternate delimiter (tab) and embedded default delimiters (commas) in cells. + csv = try CSV(string: "id\tthe, name,age\n1\tAlice, In, Wonderland\t18\n2\tBob\t19\n3\tCharlie\t20", delimiter: .tab) + XCTAssertEqual(csv.serialized, "id\tthe, name,age\n1\tAlice, In, Wonderland\t18\n2\tBob\t19\n3\tCharlie\t20") } }