Skip to content
Open
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Named>(string: "id,name,age\n1,Alice,18\n2,Bob,19")
Expand Down
48 changes: 48 additions & 0 deletions SwiftCSV/CSV.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@ open class CSV<DataView : CSVView> {
/// 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
}
Expand All @@ -101,6 +111,7 @@ open class CSV<DataView : CSVView> {
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)
}
Expand Down Expand Up @@ -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`.
///
Expand Down
9 changes: 8 additions & 1 deletion SwiftCSV/NamedCSVView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions SwiftCSV/Parser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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] : ""
Expand Down
34 changes: 34 additions & 0 deletions SwiftCSVTests/NamedCSVViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Named>!

Expand Down
8 changes: 8 additions & 0 deletions SwiftCSVTests/URLTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Named>!

func testEmptyFields() throws {
Expand Down