diff --git a/README.md b/README.md index 7b9f805..21aad9e 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,18 @@ You can also use any other single-character delimiter when loading CSV data. A c ### Reading Data +For files without a header row, supply the column names to `NamedCSV`: + +```swift +let csv = try NamedCSV(string: "1,Alice\n2,Bob", header: ["id", "name"]) +// Both input rows are data; csv.rows[0]["name"] is "Alice". +``` + +The `url:header:` initializer also accepts headerless files. Delimiters are guessed +unless supplied explicitly. Rows with fewer fields are padded with empty strings; +rows with more fields than supplied names throw `CSVParseError`. `rowLimit` counts +data rows, and serialization includes the supplied header row. + ```swift // Recognized the comma delimiter automatically: let csv = CSV(string: "id,name,age\n1,Alice,18\n2,Bob,19") diff --git a/SwiftCSV/CSV.swift b/SwiftCSV/CSV.swift index 511c2ca..102f1a5 100644 --- a/SwiftCSV/CSV.swift +++ b/SwiftCSV/CSV.swift @@ -76,6 +76,16 @@ open class CSV { /// Underlying data representation of the CSV contents. public let content: DataView + let firstDataRow: Int + + init(text: String, header: [String], delimiter: CSVDelimiter, content: DataView) { + self.text = text + self.header = header + self.delimiter = delimiter + self.content = content + self.firstDataRow = 0 + } + public var rows: [DataView.Row] { return content.rows } @@ -101,6 +111,7 @@ open class CSV { self.text = string } self.delimiter = delimiter + self.firstDataRow = 1 self.header = try Parser.array(text: self.text, delimiter: delimiter, rowLimit: 1).first ?? [] self.content = try DataView(header: header, text: self.text, delimiter: delimiter, loadColumns: loadColumns, rowLimit: rowLimit) } @@ -133,6 +144,43 @@ extension CSV: CustomStringConvertible { } } +extension CSV where DataView == Named { + /// Load headerless CSV contents using caller-supplied column names. + /// + /// - Parameters: + /// - string: CSV data, including the first data row. + /// - header: Column names. Every parsed field must have a corresponding name. + /// - delimiter: Cell separator; guessed from the input when omitted. + /// - loadColumns: Whether to populate columns. + /// - rowLimit: Maximum number of data rows to parse. + /// - Throws: `CSVParseError` for invalid CSV or insufficient column names. + public convenience init(string: String, header: [String], delimiter: CSVDelimiter? = nil, + loadColumns: Bool = true, rowLimit: Int? = nil) throws { + let text = string.hasPrefix(byteOrderMark) ? String(string.dropFirst()) : string + let delimiter = delimiter ?? CSVDelimiter.guessed(string: text) + let content = try Named(header: header, text: text, delimiter: delimiter, + loadColumns: loadColumns, rowLimit: rowLimit, startAt: 0) + self.init(text: text, header: header, delimiter: delimiter, content: content) + } + + /// Load a headerless CSV file with caller-supplied column names. + /// + /// - Parameters: + /// - url: File to read. + /// - header: Column names corresponding to the fields in each row. + /// - delimiter: Cell separator; guessed when omitted. + /// - encoding: File encoding. + /// - loadColumns: Whether to populate columns. + /// - rowLimit: Maximum number of data rows to parse. + /// - Throws: File-loading errors or `CSVParseError`. + public convenience init(url: URL, header: [String], delimiter: CSVDelimiter? = nil, + encoding: String.Encoding = .utf8, loadColumns: Bool = true, + rowLimit: Int? = nil) throws { + try self.init(string: String(contentsOf: url, encoding: encoding), header: header, + delimiter: delimiter, loadColumns: loadColumns, rowLimit: rowLimit) + } +} + extension CSV { /// Load a CSV file from `url`. /// diff --git a/SwiftCSV/NamedCSVView.swift b/SwiftCSV/NamedCSVView.swift index a2807be..fa39eae 100644 --- a/SwiftCSV/NamedCSVView.swift +++ b/SwiftCSV/NamedCSVView.swift @@ -15,10 +15,17 @@ public struct Named: CSVView { public var columns: Columns? public init(header: [String], text: String, delimiter: CSVDelimiter, loadColumns: Bool = false, rowLimit: Int? = nil) throws { + try self.init(header: header, text: text, delimiter: delimiter, + loadColumns: loadColumns, rowLimit: rowLimit, startAt: 1) + } + + init(header: [String], text: String, delimiter: CSVDelimiter, loadColumns: Bool, + rowLimit: Int?, startAt: Int) throws { self.rows = try { var rows: [Row] = [] - try Parser.enumerateAsDict(header: header, content: text, delimiter: delimiter, rowLimit: rowLimit) { dict in + try Parser.enumerateAsDict(header: header, content: text, delimiter: delimiter, + rowLimit: rowLimit, startAt: startAt) { dict in rows.append(dict) } return rows diff --git a/SwiftCSV/Parser.swift b/SwiftCSV/Parser.swift index e6db465..46e4e96 100644 --- a/SwiftCSV/Parser.swift +++ b/SwiftCSV/Parser.swift @@ -30,7 +30,8 @@ extension CSV { public func enumerateAsDict(_ block: @escaping ([String : String]) throws -> ()) throws { - try Parser.enumerateAsDict(header: self.header, content: self.text, delimiter: self.delimiter, block: block) + try Parser.enumerateAsDict(header: self.header, content: self.text, delimiter: self.delimiter, + startAt: firstDataRow, block: block) } } @@ -125,12 +126,16 @@ enum Parser { } } - static func enumerateAsDict(header: [String], content: String, delimiter: CSVDelimiter, rowLimit: Int? = nil, block: @escaping ([String : String]) throws -> ()) throws { + static func enumerateAsDict(header: [String], content: String, delimiter: CSVDelimiter, + rowLimit: Int? = nil, startAt: Int = 1, + block: @escaping ([String: String]) throws -> Void) throws { let enumeratedHeader = header.enumerated() - // Start after the header - try enumerateAsArray(text: content, delimiter: delimiter, startAt: 1, rowLimit: rowLimit) { fields in + try enumerateAsArray(text: content, delimiter: delimiter, startAt: startAt, rowLimit: rowLimit) { fields in + if startAt == 0 && fields.count > header.count { + throw CSVParseError.generic(message: "Not enough column names for \(fields.count) fields") + } var dict = [String: String]() for (index, head) in enumeratedHeader { dict[head] = index < fields.count ? fields[index] : "" diff --git a/SwiftCSVTests/NamedCSVViewTests.swift b/SwiftCSVTests/NamedCSVViewTests.swift index f3fdad2..48f98b4 100644 --- a/SwiftCSVTests/NamedCSVViewTests.swift +++ b/SwiftCSVTests/NamedCSVViewTests.swift @@ -10,6 +10,40 @@ import XCTest @testable import SwiftCSV class NamedViewTests: XCTestCase { + func testSuppliedHeadersRetainFirstRow() throws { + let csv = try NamedCSV(string: "1,Alice\n2,Bob", header: ["id", "name"]) + XCTAssertEqual(csv.rows, [["id": "1", "name": "Alice"], ["id": "2", "name": "Bob"]]) + XCTAssertEqual(csv.columns, ["id": ["1", "2"], "name": ["Alice", "Bob"]]) + XCTAssertEqual(csv.text, "1,Alice\n2,Bob") + var enumerated: [[String: String]] = [] + try csv.enumerateAsDict { enumerated.append($0) } + XCTAssertEqual(enumerated, csv.rows) + let roundTrip = try NamedCSV(string: csv.serialized) + XCTAssertEqual(roundTrip.rows, csv.rows) + } + + func testSuppliedHeadersWithBOMQuotesAndCustomDelimiter() throws { + let csv = try NamedCSV(string: "\u{FEFF}1;\"Alice; A\"\n2;Bob", header: ["id", "name"], + delimiter: .semicolon, loadColumns: false, rowLimit: 1) + XCTAssertEqual(csv.rows, [["id": "1", "name": "Alice; A"]]) + XCTAssertNil(csv.columns) + XCTAssertFalse(csv.text.hasPrefix("\u{FEFF}")) + } + + func testSuppliedHeadersValidateEachRow() throws { + XCTAssertThrowsError(try NamedCSV(string: "1\n2,Bob", header: ["id"])) + let csv = try NamedCSV(string: "1,Alice\n2", header: ["id", "name", "age"]) + XCTAssertEqual(csv.rows, [ + ["id": "1", "name": "Alice", "age": ""], + ["id": "2", "name": "", "age": ""] + ]) + } + + func testSuppliedHeadersWithEmptyInputAndZeroLimit() throws { + XCTAssertEqual(try NamedCSV(string: "", header: ["id"]).rows, []) + XCTAssertEqual(try NamedCSV(string: "1\n2", header: ["id"], rowLimit: 0).rows, []) + } + let string = "id,name,age\n1,Alice,18\n2,Bob,19\n3,Charlie,20" var csv: CSV! diff --git a/SwiftCSVTests/URLTests.swift b/SwiftCSVTests/URLTests.swift index 8524029..ecd0a3e 100644 --- a/SwiftCSVTests/URLTests.swift +++ b/SwiftCSVTests/URLTests.swift @@ -10,6 +10,14 @@ import XCTest import SwiftCSV class URLTests: XCTestCase { + func testSuppliedHeadersFromURL() throws { + let url = try XCTUnwrap( + ResourceHelper.url(forResource: "TestData/empty_fields", withExtension: "csv") + ) + let csv = try NamedCSV(url: url, header: ["first", "second", "third"], rowLimit: 1) + XCTAssertEqual(csv.rows, [["first": "id", "second": "name", "third": "age"]]) + } + var csv: CSV! func testEmptyFields() throws {