From 5cfe55b84df2f60001e9b73b3a118e32f998f1d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20S=C3=A1nchez-Dehesa=20Carballo?= Date: Thu, 7 May 2020 00:45:45 +0200 Subject: [PATCH] LazyEncoder added --- README.md | 67 ++++- sources/Delimiter.swift | 2 +- sources/Deprecated.swift | 17 ++ sources/declarative/decodable/Decoder.swift | 18 +- .../declarative/decodable/DecoderLazy.swift | 52 +++- sources/declarative/encodable/Encoder.swift | 38 ++- .../declarative/encodable/EncoderLazy.swift | 81 +++++ .../declarative/encodable/internal/Sink.swift | 15 +- .../encodable/internal/SinkBuffer.swift | 2 +- sources/imperative/writer/Writer.swift | 13 +- sources/imperative/writer/WriterAPI.swift | 8 +- tests/ActiveTests/WriterTests.swift | 16 +- tests/CodableTests/EncodingLazyTests.swift | 282 ++++++++++++++++++ .../EncodingRegularUsageTests.swift | 10 +- 14 files changed, 561 insertions(+), 60 deletions(-) create mode 100644 sources/declarative/encodable/EncoderLazy.swift create mode 100644 tests/CodableTests/EncodingLazyTests.swift diff --git a/README.md b/README.md index 120ec7b..a0b2b96 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ A `CSVWriter` encodes CSV information into a specified target (i.e. a `String`, for row in input { try writer.write(row: row) } - try writer.endFile() + try writer.endEncoding() ``` Alternatively, you may write directly to a buffer in memory and access its `Data` representation. @@ -223,7 +223,7 @@ A `CSVWriter` encodes CSV information into a specified target (i.e. a `String`, for row in input.dropFirst() { try writer.write(row: row) } - try writer.endFile() + try writer.endEncoding() let result = try writer.data() ``` @@ -241,7 +241,7 @@ A `CSVWriter` encodes CSV information into a specified target (i.e. a `String`, try writer.write(fields: input[2]) try writer.endRow() - try writer.endFile() + try writer.endEncoding() ``` `CSVWriter` has a wealth of low-level imperative APIs, that let you write one field, several fields at a time, end a row, write an empty row, etc. @@ -330,7 +330,7 @@ let decoder = CSVDecoder() let result = try decoder.decode(CustomType.self, from: data) ``` -`CSVDecoder` can decode CSVs represented as a `Data` blob, a `String`, or an actual file in the file system. +`CSVDecoder` can decode CSVs represented as a `Data` blob, a `String`, an actual file in the file system, or an `InputStream` (e.g. `stdin`). ```swift let decoder = CSVDecoder { $0.bufferingStrategy = .sequential } @@ -377,33 +377,33 @@ decoder.decimalStrategy = .custom { (decoder) in

-
CSVDecoder.LazySequence.

+

CSVDecoder.LazyDecoder.

A CSV input can be decoded _on demand_ with the decoder's `lazy(from:)` function. ```swift -var sequence = CSVDecoder().lazy(from: fileURL) +let lazyDecoder = CSVDecoder().lazy(from: fileURL) while let row = sequence.next() { let student = try row.decode(Student.self) // Do something here } ``` -`LazySequence` conforms to Swift's [`Sequence` protocol](https://developer.apple.com/documentation/swift/sequence), letting you use functionality such as `map()`, `allSatisfy()`, etc. Please note, `LazySequence` cannot be used for repeated access. It _consumes_ the input CSV. +`LazyDecoder` conforms to Swift's [`Sequence` protocol](https://developer.apple.com/documentation/swift/sequence), letting you use functionality such as `map()`, `allSatisfy()`, etc. Please note, `LazyDecoder` cannot be used for repeated access. It _consumes_ the input CSV. ```swift -var sequence = decoder.lazy(from: fileData) -let students = try sequence.map { try $0.decode(Student.self) } +let lazyDecoder = CSVDecoder(configuration: config).lazy(from: fileData) +let students = try lazyDecoder.map { try $0.decode(Student.self) } ``` A nice benefit of using the _lazy_ operation, is that it lets you switch how a row is decoded at any point. For example: ```swift -var sequence = decoder.lazy(from: fileString) -let students = zip( 0..<100, sequence) { (_, row) in row.decode(Student.self) } -let teachers = zip(100..<110, sequence) { (_, row) in row.decode(Teacher.self) } +let lazyDecoder = decoder.lazy(from: fileString) +let students = ( 0..<100).map { _ in try lazyDecoder.decode(Student.self) } +let teachers = (100..<110).map { _ in try lazyDecoder.decode(Teacher.self) } ``` -Since `LazySequence` exclusively provides sequential access; setting the buffering strategy to `.sequential` will reduce the decoder's memory usage. +Since `LazyDecoder` exclusively provides sequential access; setting the buffering strategy to `.sequential` will reduce the decoder's memory usage. ```swift let decoder = CSVDecoder { @@ -420,7 +420,7 @@ let decoder = CSVDecoder { ```swift let encoder = CSVEncoder() -let data: Data = try encoder.encode(value) +let data = try encoder.encode(value, into: Data.self) ``` The `Encoder`'s `encode()` function creates a CSV file as a `Data` blob, a `String`, or an actual file in the file system. @@ -472,6 +472,45 @@ encoder.dataStrategy = .custom { (data, encoder) in > The `.headers` configuration is required if you are using keyed encoding container. +

+ +
CSVEncoder.LazyEncoder.

+ +A series of codable types can be encoded _on demand_ with the encoder's `lazy(into:)` function. + +```swift +let lazyEncoder = CSVEncoder().lazy(into: Data.self) +for student in students { + try lazyEncoder.encode(student) +} +let data = try lazyEncoder.endEncoding() +``` + +Call `endEncoding()` once there is no more values to be encoded. The function will return the encoded CSV. +```swift +let lazyEncoder = CSVEncoder().lazy(into: String.self) +students.forEach { + try lazyEncoder.encode($0) +} +let string = try lazyEncoder.endEncoding() +``` + +A nice benefit of using the _lazy_ operation, is that it lets you switch how a row is encoded at any point. For example: +```swift +let lazyEncoder = CSVEncoder(configuration: config).lazy(into: fileURL) +students.forEach { try lazyEncoder.encode($0) } +teachers.forEach { try lazyEncoder.encode($0) } +try lazyEncoder.endEncoding() +``` + +Since `LazyEncoder` exclusively provides sequential encoding; setting the buffering strategy to `.sequential` will reduce the encoder's memory usage. + +```swift +let lazyEncoder = CSVEncoder { + $0.bufferingStrategy = .sequential +}.lazy(into: String.self) +``` +

diff --git a/sources/Delimiter.swift b/sources/Delimiter.swift index c3b27ca..9e0de31 100644 --- a/sources/Delimiter.swift +++ b/sources/Delimiter.swift @@ -7,7 +7,7 @@ public enum Delimiter { } extension Delimiter { - /// The delimiter between fields/vlaues. + /// The delimiter between fields/values. public struct Field: ExpressibleByNilLiteral, ExpressibleByStringLiteral, RawRepresentable { public let rawValue: String.UnicodeScalarView diff --git a/sources/Deprecated.swift b/sources/Deprecated.swift index 8c3e285..93f9203 100644 --- a/sources/Deprecated.swift +++ b/sources/Deprecated.swift @@ -75,4 +75,21 @@ extension CSVWriter { public static func serialize(row: S, into fileURL: URL, append: Bool, setter: (_ configuration: inout Configuration) -> Void) throws where S.Element==C, C.Element==String { try self.encode(rows: row, into: fileURL, append: append, setter: setter) } + + @available(*, deprecated, renamed: "endEncoding()") + public func endFile() throws { + try self.endEncoding() + } +} + +extension CSVEncoder { + @available(*, deprecated, renamed: "CSVDecoder.LazyDecoder") + typealias LazySequence = CSVDecoder.LazyDecoder +} + +extension CSVEncoder { + @available(*, deprecated, renamed: "encode(_:into:)") + open func encode(_ value: T) throws -> Data { + try self.encode(value, into: Data.self) + } } diff --git a/sources/declarative/decodable/Decoder.swift b/sources/declarative/decodable/Decoder.swift index 7cfd262..f6f7f91 100644 --- a/sources/declarative/decodable/Decoder.swift +++ b/sources/declarative/decodable/Decoder.swift @@ -69,31 +69,31 @@ extension CSVDecoder { } extension CSVDecoder { - /// Returns a sequence for decoding each row from a CSV file (given as a `Data` blob). + /// Returns a sequence for decoding row-by-row from a CSV file (given as a `Data` blob). /// - parameter data: The data blob representing a CSV file. /// - throws: `CSVError` exclusively. - open func lazy(from data: Data) throws -> LazySequence { + open func lazy(from data: Data) throws -> LazyDecoder { let reader = try CSVReader(input: data, configuration: self._configuration.readerConfiguration) let source = ShadowDecoder.Source(reader: reader, configuration: self._configuration, userInfo: self.userInfo) - return LazySequence(source: source) + return LazyDecoder(source: source) } - /// Returns a sequence for decoding each row from a CSV file (given as a `String`). + /// Returns a sequence for decoding row-by-row from a CSV file (given as a `String`). /// - parameter string: A Swift string representing a CSV file. /// - throws: `CSVError` exclusively. - open func lazy(from string: String) throws -> LazySequence { + open func lazy(from string: String) throws -> LazyDecoder { let reader = try CSVReader(input: string, configuration: self._configuration.readerConfiguration) let source = ShadowDecoder.Source(reader: reader, configuration: self._configuration, userInfo: self.userInfo) - return LazySequence(source: source) + return LazyDecoder(source: source) } - /// Returns a sequence for decoding each row from a CSV file (being pointed by `url`). + /// Returns a sequence for decoding row-by-row from a CSV file (being pointed by `url`). /// - parameter url: The URL pointing to the file to decode. /// - throws: `CSVError` exclusively. - open func lazy(from url: URL) throws -> LazySequence { + open func lazy(from url: URL) throws -> LazyDecoder { let reader = try CSVReader(input: url, configuration: self._configuration.readerConfiguration) let source = ShadowDecoder.Source(reader: reader, configuration: self._configuration, userInfo: self.userInfo) - return LazySequence(source: source) + return LazyDecoder(source: source) } } diff --git a/sources/declarative/decodable/DecoderLazy.swift b/sources/declarative/decodable/DecoderLazy.swift index f331474..90cec08 100644 --- a/sources/declarative/decodable/DecoderLazy.swift +++ b/sources/declarative/decodable/DecoderLazy.swift @@ -1,20 +1,47 @@ extension CSVDecoder { - /// Swift sequence type giving access to all the "undecoded" CSV rows. + /// Lazy decoder allowing declarative row-by-row decoding. /// /// The CSV rows are read _on-demand_ and only decoded when explicitly told so (unlike the default _decode_ functions). - public struct LazySequence: IteratorProtocol, Sequence { + public final class LazyDecoder: IteratorProtocol, Sequence { /// The source of the CSV data. private let _source: ShadowDecoder.Source /// The row to be read (not decoded) next. - private var _currentIndex: Int = 0 + private var _currentIndex: Int + /// A dictionary you use to customize the decoding process by providing contextual information. + public var userInfo: [CodingUserInfoKey:Any] { self._source.userInfo } + /// Designated initalizer passing all the required components. /// - parameter source: The data source for the decoder. internal init(source: ShadowDecoder.Source) { self._source = source + self._currentIndex = 0 + } + + /// Returns a value of the type you specify, decoded from a CSV row. + /// + /// This function will throw an error if the file has reached the end. If you are unsure where the CSV file ends, use the `next()` function instead. + /// - parameter type: The type of the value to decode from the supplied file. + /// - returns: A CSV row decoded as a type `T`. + public func decode(_ type: T.Type) throws -> T { + guard let rowDecoder = self.next() else { throw CSVDecoder.Error._unexpectedEnd() } + return try rowDecoder.decode(type) + } + + /// Returns a value of the type you specify, decoded from a CSV row (if there are still rows to be decoded in the file). + /// - parameter type: The type of the value to decode from the supplied file. + /// - returns: A CSV row decoded as a type `T` or `nil` if the CSV file doesn't contain any more rows. + public func decodeIfPresent(_ type: T.Type) throws -> T? { + guard let rowDecoder = self.next() else { return nil } + return try rowDecoder.decode(type) + } + + /// Ignores the subsequent row. + public func ignoreRow() { + let _ = self.next() } - /// Advances to the next row and returns a `LazySequence.Row`, or `nil` if no next row exists. - public mutating func next() -> RowDecoder? { + /// Advances to the next row and returns a `LazyDecoder.Row`, or `nil` if no next row exists. + public func next() -> RowDecoder? { guard !self._source.isRowAtEnd(index: self._currentIndex) else { return nil } defer { self._currentIndex += 1 } @@ -24,7 +51,7 @@ extension CSVDecoder { } } -extension CSVDecoder.LazySequence { +extension CSVDecoder.LazyDecoder { /// Pointer to a row within a CSV file that is able to decode it to a custom type. public struct RowDecoder { /// The representation of the decoding process point-in-time. @@ -36,10 +63,21 @@ extension CSVDecoder.LazySequence { self._decoder = decoder } - /// Returns a value of the type you specify, decoded from CSV row. + /// Returns a value of the type you specify, decoded from a CSV row. /// - parameter type: The type of the value to decode from the supplied file. @inline(__always) public func decode(_ type: T.Type) throws -> T { return try T(from: self._decoder) } } } + +// MARK: - + +fileprivate extension CSVDecoder.Error { + /// Error raised when the end of the file has been reached unexpectedly. + static func _unexpectedEnd() -> CSVError { + .init(.invalidPath, + reason: "There are no more rows to decode. The file is at the end.", + help: "Use next() or decodeIfPresent(_:) instead of decode(_:) if you are unsure where the file ends.") + } +} diff --git a/sources/declarative/encodable/Encoder.swift b/sources/declarative/encodable/Encoder.swift index 680ae66..b65323e 100644 --- a/sources/declarative/encodable/Encoder.swift +++ b/sources/declarative/encodable/Encoder.swift @@ -33,8 +33,9 @@ import Foundation extension CSVEncoder { /// Returns a CSV-encoded representation of the value you supply. /// - parameter value: The value to encode as CSV. + /// - parameter type: The Swift type for a data blob. /// - returns: `Data` blob with the CSV representation of `value`. - open func encode(_ value: T) throws -> Data { + open func encode(_ value: T, into type: Data.Type) throws -> Data { let writer = try CSVWriter(configuration: self._configuration.writerConfiguration) let sink = try ShadowEncoder.Sink(writer: writer, configuration: self._configuration, userInfo: self.userInfo) try value.encode(to: ShadowEncoder(sink: sink, codingPath: [])) @@ -44,9 +45,10 @@ extension CSVEncoder { /// Returns a CSV-encoded representation of the value you supply. /// - parameter value: The value to encode as CSV. + /// - parameter type: The Swift type for a string. /// - returns: `String` with the CSV representation of `value`. - open func encode(_ value: T, into: String.Type) throws -> String { - let data = try self.encode(value) + open func encode(_ value: T, into type: String.Type) throws -> String { + let data = try self.encode(value, into: Data.self) let encoding = self._configuration.writerConfiguration.encoding ?? .utf8 return String(data: data, encoding: encoding)! } @@ -63,6 +65,36 @@ extension CSVEncoder { } } +extension CSVEncoder { + /// Returns an instance to encode row-by-row the feeded values. + /// - parameter type: The Swift type for a data blob. + /// - returns: Instance used for _on demand_ encoding. + open func lazy(into type: Data.Type) throws -> LazyEncoder { + let writer = try CSVWriter(configuration: self._configuration.writerConfiguration) + let sink = try ShadowEncoder.Sink(writer: writer, configuration: self._configuration, userInfo: self.userInfo) + return LazyEncoder(sink: sink) + } + + /// Returns an instance to encode row-by-row the feeded values. + /// - parameter type: The Swift type for a data blob. + /// - returns: Instance used for _on demand_ encoding. + open func lazy(into type: String.Type) throws -> LazyEncoder { + let writer = try CSVWriter(configuration: self._configuration.writerConfiguration) + let sink = try ShadowEncoder.Sink(writer: writer, configuration: self._configuration, userInfo: self.userInfo) + return LazyEncoder(sink: sink) + } + + /// Returns an instance to encode row-by-row the feeded values. + /// - parameter fileURL: The file receiving the encoded values. + /// - parameter append: In case an existing file is under the given URL, this Boolean indicates that the information will be appended to the file (`true`), or the file will be overwritten (`false`). + /// - returns: Instance used for _on demand_ encoding. + open func lazy(into fileURL: URL, append: Bool = false) throws -> LazyEncoder { + let writer = try CSVWriter(fileURL: fileURL, append: append, configuration: self._configuration.writerConfiguration) + let sink = try ShadowEncoder.Sink(writer: writer, configuration: self._configuration, userInfo: self.userInfo) + return LazyEncoder(sink: sink) + } +} + #if canImport(Combine) import Combine diff --git a/sources/declarative/encodable/EncoderLazy.swift b/sources/declarative/encodable/EncoderLazy.swift new file mode 100644 index 0000000..d6e917c --- /dev/null +++ b/sources/declarative/encodable/EncoderLazy.swift @@ -0,0 +1,81 @@ +import Foundation + +extension CSVEncoder { + /// Lazy encoder allowing declarative row-by-row encoding. + public final class LazyEncoder { + /// The sink of the CSV data. + private let _sink: ShadowEncoder.Sink + /// The row to be written next. + private var _currentIndex: Int + /// A dictionary you use to customize the encoding process by providing contextual information. + public var userInfo: [CodingUserInfoKey:Any] { self._sink.userInfo } + + /// Designated initializer passing all the required components. + /// - parameter sink: The data _gatherer_ for the encoder. + internal init(sink: ShadowEncoder.Sink) { + self._sink = sink + self._currentIndex = 0 + } + + /// Encodes the given value as a CSV row. + /// - parameter value: The value to encode as CSV. + public func encode(_ value: T) throws { + let encoder = ShadowEncoder(sink: self._sink, codingPath: [IndexKey(self._currentIndex)]) + try value.encode(to: encoder) + self._currentIndex += 1 + } + + /// Encodes a row where all its fields are empty. + public func encodeEmptyRow() throws { + let numFields = self._sink.numExpectedFields + guard numFields > 0 else { throw CSVEncoder.Error._invalidRowCompletionOnEmptyFile() } + + let encoder = ShadowEncoder(sink: self._sink, codingPath: [IndexKey(self._currentIndex)]) + var container = ShadowEncoder.UnkeyedContainer(unsafeEncoder: encoder, rowIndex: self._currentIndex) + for _ in 0.. Data { + try self._sink.completeEncoding() + return try self._sink.data() + } +} + +extension CSVEncoder.LazyEncoder where Outcome==String { + /// Finish the encoding process and returns the CSV (as a string). + /// + /// Calls to `encode(_:)` after this function will throw an error. + public func endEncoding() throws -> String { + try self._sink.completeEncoding() + let data = try self._sink.data() + let encoding = self._sink.configuration.encoding ?? .utf8 + return String(data: data, encoding: encoding)! + } +} + +extension CSVEncoder.LazyEncoder where Outcome==URL { + /// Finish the encoding process and closes the output file. + /// + /// Calls to `encode(_:)` after this function will throw an error. + public func endEncoding() throws { + try self._sink.completeEncoding() + } +} + +// MARK: - + +fileprivate extension CSVEncoder.Error { + /// Error raised when a row is ended, but nothing has been written before. + static func _invalidRowCompletionOnEmptyFile() -> CSVError { + .init(.invalidPath, + reason: "An empty row cannot be encoded if the number of fields hold by the CSV file is unkwnown.", + help: "Specify a headers row or encode a row with content before encoding an empty row.") + } +} diff --git a/sources/declarative/encodable/internal/Sink.swift b/sources/declarative/encodable/internal/Sink.swift index 800b6fc..659d38e 100644 --- a/sources/declarative/encodable/internal/Sink.swift +++ b/sources/declarative/encodable/internal/Sink.swift @@ -1,3 +1,5 @@ +import Foundation + internal extension ShadowEncoder { /// Sink of all CSV data. final class Sink { @@ -106,6 +108,10 @@ internal extension ShadowEncoder { } } + + deinit { + try? self.completeEncoding() + } } } @@ -188,7 +194,14 @@ extension ShadowEncoder.Sink { } } // 10. Finish the file. - try self._writer.endFile() + try self._writer.endEncoding() + } + + /// Returns the generated blob of data if the `_writer` was initialized with a memory position (i.e. `String` or `Data`, but not a file nor a network socket). + /// - remark: Please notice that the `endEncoding()` function must be called before this function is used. + /// - throws: `CSVError` exclusively. + public func data() throws -> Data { + try self._writer.data() } } diff --git a/sources/declarative/encodable/internal/SinkBuffer.swift b/sources/declarative/encodable/internal/SinkBuffer.swift index c49bbb7..4a9a21e 100644 --- a/sources/declarative/encodable/internal/SinkBuffer.swift +++ b/sources/declarative/encodable/internal/SinkBuffer.swift @@ -3,7 +3,7 @@ extension ShadowEncoder.Sink { internal final class Buffer { /// The buffering strategy. let strategy: Strategy.EncodingBuffer - /// The number of expectedFields + /// The number of expectedFields. private let _expectedFields: Int /// The underlying storage. private var _storage: [Int: [Int:String]] diff --git a/sources/imperative/writer/Writer.swift b/sources/imperative/writer/Writer.swift index 0044ec6..de568d2 100644 --- a/sources/imperative/writer/Writer.swift +++ b/sources/imperative/writer/Writer.swift @@ -47,11 +47,11 @@ public final class CSVWriter { } deinit { - try? self.endFile() + try? self.endEncoding() } /// Returns the generated blob of data if the writer was initialized with a memory position (i.e. `String` or `Data`, but not a file nor a network socket). - /// - remark: Please notice that the `endFile()` function must be called before this function is used. + /// - remark: Please notice that the `endEncoding()` function must be called before this function is used. /// - throws: `CSVError` exclusively. public func data() throws -> Data { guard case .closed = self._stream.streamStatus else { @@ -69,7 +69,7 @@ public final class CSVWriter { extension CSVWriter { /// Finishes the file and closes the output stream (if not indicated otherwise in the initializer). /// - throws: `CSVError` exclusively. - public func endFile() throws { + public func endEncoding() throws { guard self._stream.streamStatus != .closed else { return } if self.fieldIndex > 0 { @@ -259,11 +259,10 @@ fileprivate extension CSVWriter.Error { help: "Always write the same amount of fields per row.", userInfo: ["Number of expected fields per row": expectedFields]) } - /// Error raised when a row is ended, but nothing has been written before. static func _invalidRowCompletionOnEmptyFile() -> CSVError { .init(.invalidOperation, - reason: "An empty row cannot be writen if the number of fields hold by the file is unkwnown.", + reason: "An empty row cannot be writen if the number of fields hold by the CSV file is unkwnown.", help: "Write a headers row or a row with content before writing an empty row.") } /// Error raised when the data was accessed before the stream was closed. @@ -272,7 +271,7 @@ fileprivate extension CSVWriter.Error { static func _invalidDataAccess(status: Stream.Status, error: Swift.Error?) -> CSVError { .init(.invalidOperation, underlying: error, reason: "The memory stream must be closed before the data can be accessed.", - help: "Call endFile() before accessing the data. Also remember, that only Data and String initializers can access memory data.", + help: "Call endEncoding() before accessing the data. Also remember, that only Data and String initializers can access memory data.", userInfo: ["Stream status": status]) } @@ -281,6 +280,6 @@ fileprivate extension CSVWriter.Error { static func _dataFailed(error: Swift.Error?) -> CSVError { .init(.streamFailure, underlying: error, reason: "The stream failed to returned the encoded data.", - help: "Call endFile() before accessing the data. Also remember, that only Data and String initializers can access memory data.") + help: "Call endEncoding() before accessing the data. Also remember, that only Data and String initializers can access memory data.") } } diff --git a/sources/imperative/writer/WriterAPI.swift b/sources/imperative/writer/WriterAPI.swift index 11178c4..25296d6 100644 --- a/sources/imperative/writer/WriterAPI.swift +++ b/sources/imperative/writer/WriterAPI.swift @@ -4,7 +4,7 @@ extension CSVWriter { /// Creates a writer instance that will be used to encode CSV-formatted data. /// /// The output data can be accessed at the end of the encoding process through the `CSVWriter`'s `data()` function. - /// Make sure `endFile()` is called before requesting `data()`. + /// Make sure `endEncoding()` is called before requesting `data()`. /// - parameter configuration: Configuration values specifying how the CSV output should look like. /// - throws: `CSVError` exclusively. public convenience init(configuration: Configuration = .init()) throws { @@ -20,7 +20,7 @@ extension CSVWriter { /// Creates a writer instance that will be used to encode CSV-formatted data into a file pointed by the given URL. /// - /// Make sure `endFile()` is called at the end of the encoding process. + /// Make sure `endEncoding()` is called at the end of the encoding process. /// - parameter fileURL: The URL pointing to the targeted file. /// - parameter append: In case an existing file is under the given URL, this Boolean indicates that the information will be appended to the file (`true`), or the file will be overwritten (`false`). /// - parameter configuration: Configuration values specifying how the CSV output should look like. @@ -76,7 +76,7 @@ extension CSVWriter { try writer.write(row: row) } - try writer.endFile() + try writer.endEncoding() return try writer.data() } @@ -103,7 +103,7 @@ extension CSVWriter { try writer.write(row: row) } - try writer.endFile() + try writer.endEncoding() } } diff --git a/tests/ActiveTests/WriterTests.swift b/tests/ActiveTests/WriterTests.swift index 54258b3..6890128 100644 --- a/tests/ActiveTests/WriterTests.swift +++ b/tests/ActiveTests/WriterTests.swift @@ -41,14 +41,14 @@ extension WriterTests { func testEmpty() throws { // 1. Tests the data encoder. let dataWriter = try CSVWriter() - try dataWriter.endFile() + try dataWriter.endEncoding() let data = try dataWriter.data() XCTAssertTrue(data.isEmpty) // 2. Tests the file encoder. let url = _TestData.generateTemporaryFileURL() XCTAssertTrue(FileManager.default.createFile(atPath: url.path, contents: data)) let fileWriter = try CSVWriter(fileURL: url) - try fileWriter.endFile() + try fileWriter.endEncoding() try FileManager.default.removeItem(at: url) } @@ -75,7 +75,7 @@ extension WriterTests { let writer = try CSVWriter() try writer.write(field: field) try writer.endRow() - try writer.endFile() + try writer.endEncoding() XCTAssertEqual(data, try writer.data()) } @@ -151,7 +151,7 @@ extension WriterTests { try writer.write(row: row) } - try writer.endFile() + try writer.endEncoding() let result = try writer.data() let data = _TestData.toCSV(input, delimiters: (",", "\n")).data(using: .utf8)! @@ -166,7 +166,7 @@ extension WriterTests { try writer.write(row: ["one", "two", "three"]) try writer.write(fields: ["four", "five", "six"]) try writer.endRow() - try writer.endFile() + try writer.endEncoding() try FileManager.default.removeItem(at: fileURL) } @@ -178,7 +178,7 @@ extension WriterTests { try writer.write(fields: ["four", "five", "six", "seven"]) XCTFail("The previous line shall throw an error") } catch { - try writer.endFile() + try writer.endEncoding() } } @@ -188,7 +188,7 @@ extension WriterTests { try writer.writeEmptyRow() try writer.write(row: ["four", "five", "six"]) try writer.writeEmptyRow() - try writer.endFile() + try writer.endEncoding() } /// Tests writing empty rows when the number of fields are unknown. @@ -198,7 +198,7 @@ extension WriterTests { try writer.writeEmptyRow() XCTFail("The previous line shall throw an error") } catch { - try writer.endFile() + try writer.endEncoding() } } } diff --git a/tests/CodableTests/EncodingLazyTests.swift b/tests/CodableTests/EncodingLazyTests.swift new file mode 100644 index 0000000..8f1cbaa --- /dev/null +++ b/tests/CodableTests/EncodingLazyTests.swift @@ -0,0 +1,282 @@ +import XCTest +import CodableCSV + +/// Tests checking the lazy encoding operation. +final class EncodingLazyTests: XCTestCase { + override func setUp() { + self.continueAfterFailure = false + } +} + +extension EncodingLazyTests { + /// Test data used throughout this `XCTestCase`. + private enum _TestData { + struct KeyedStudent: Encodable { + var name: String + var age: Int + var country: String + var hasPet: Bool + } + + struct UnkeyedStudent: Encodable { + var name: String + var age: Int + var country: String + var hasPet: Bool + + func encode(to encoder: Encoder) throws { + var container = encoder.unkeyedContainer() + try container.encode(self.name) + try container.encode(self.age) + try container.encode(self.country) + try container.encode(self.hasPet) + } + } + } +} + +// MARK: - + +extension EncodingLazyTests { + /// Test the encoding of an empty CSV file. + func testEmptyFile() throws { + // The configuration values to be tests. + let encoding: String.Encoding = .utf8 + let bomStrategy: Strategy.BOM = .never + let delimiters: Delimiter.Pair = (",", "\n") + let bufferStrategies: [Strategy.EncodingBuffer] = [.keepAll, .assembled, .sequential] + + for s in bufferStrategies { + var configuration = CSVEncoder.Configuration() + configuration.encoding = encoding + configuration.bomStrategy = bomStrategy + configuration.delimiters = delimiters + configuration.bufferingStrategy = s + + let lazyEncoder = try CSVEncoder(configuration: configuration).lazy(into: Data.self) + let data = try lazyEncoder.endEncoding() + XCTAssertTrue(data.isEmpty) + } + } + + /// Tests the encoding of a single empty field in a CSV file. + func testSingleEmptyField() throws { + // The configuration values to be tests. + let encoding: String.Encoding = .utf8 + let bomStrategy: Strategy.BOM = .never + let delimiters: Delimiter.Pair = (",", "\n") + let bufferStrategies: [Strategy.EncodingBuffer] = [.keepAll, .assembled, .sequential] + // The data used for testing. + let value = [String()] + + for s in bufferStrategies { + var configuration = CSVEncoder.Configuration() + configuration.encoding = encoding + configuration.bomStrategy = bomStrategy + configuration.delimiters = delimiters + configuration.bufferingStrategy = s + + let lazyEncoder = try CSVEncoder(configuration: configuration).lazy(into: String.self) + try lazyEncoder.encode(value) + let string = try lazyEncoder.endEncoding() + XCTAssertEqual(string, "\(delimiters.row.rawValue)") + } + } + + /// Tests a single custom type encoding (with headers). + func testSingleCustomType() throws { + // The configuration values to be tests. + let encoding: String.Encoding = .utf8 + let bomStrategy: Strategy.BOM = .never + let delimiters: Delimiter.Pair = (",", "\n") + let bufferStrategies: [Strategy.EncodingBuffer] = [.keepAll, .assembled, .sequential] + let headers = ["name", "age", "country", "hasPet"] + // The data used for testing. + let student = _TestData.KeyedStudent(name: "Marcos", age: 111, country: "Spain", hasPet: true) + + for s in bufferStrategies { + var configuration = CSVEncoder.Configuration() + configuration.encoding = encoding + configuration.bomStrategy = bomStrategy + configuration.delimiters = delimiters + configuration.bufferingStrategy = s + configuration.headers = headers + + let lazyEncoder = try CSVEncoder(configuration: configuration).lazy(into: String.self) + try lazyEncoder.encode(student) + let string = try lazyEncoder.endEncoding() + let content = string.split(separator: .init(delimiters.row.rawValue.first!)) + + let encodedHeaders = content[0].split(separator: Character(delimiters.field.rawValue.first!)).map { String($0) } + XCTAssertEqual(headers, encodedHeaders) + + let encodedValues = content[1].split(separator: Character(delimiters.field.rawValue.first!)).map { String($0) } + XCTAssertEqual(student.name, encodedValues[0]) + XCTAssertEqual(String(student.age), encodedValues[1]) + XCTAssertEqual(String(student.country), encodedValues[2]) + XCTAssertEqual(String(student.hasPet), encodedValues[3]) + } + } + + /// Tests a single custom type encoding (with NO headers). + func testSingleCustomTypeNoHeaders() throws { + // The configuration values to be tests. + let encoding: String.Encoding = .utf8 + let bomStrategy: Strategy.BOM = .never + let delimiters: Delimiter.Pair = (",", "\n") + let bufferStrategies: [Strategy.EncodingBuffer] = [.keepAll, .assembled, .sequential] + let headers: [String] = [] + // The data used for testing. + let student = _TestData.UnkeyedStudent(name: "Marcos", age: 111, country: "Spain", hasPet: true) + + for s in bufferStrategies { + var configuration = CSVEncoder.Configuration() + configuration.encoding = encoding + configuration.bomStrategy = bomStrategy + configuration.delimiters = delimiters + configuration.bufferingStrategy = s + configuration.headers = headers + + let lazyEncoder = try CSVEncoder(configuration: configuration).lazy(into: String.self) + try lazyEncoder.encode(student) + let string = try lazyEncoder.endEncoding() + let content = string.split(separator: .init(delimiters.row.rawValue.first!)) + + let encodedValues = content[0].split(separator: Character(delimiters.field.rawValue.first!)).map { String($0) } + XCTAssertEqual(student.name, encodedValues[0]) + XCTAssertEqual(String(student.age), encodedValues[1]) + XCTAssertEqual(String(student.country), encodedValues[2]) + XCTAssertEqual(String(student.hasPet), encodedValues[3]) + } + } + + /// Tests multiple custom types encoding. + func testMultipleKeyed() throws { + // The configuration values to be tests. + let encoding: String.Encoding = .utf8 + let bomStrategy: Strategy.BOM = .never + let delimiters: Delimiter.Pair = (",", "\n") + let bufferStrategies: [Strategy.EncodingBuffer] = [.keepAll, .assembled, .sequential] + let headers = ["name", "age", "country", "hasPet"] + // The data used for testing. + let students: [_TestData.KeyedStudent] = [ + .init(name: "Marcos", age: 1, country: "Spain", hasPet: true), + .init(name: "Anaïs", age: 2, country: "France", hasPet: false), + .init(name: "Alex", age: 3, country: "Canada", hasPet: false), + .init(name: "家豪", age: 4, country: "China", hasPet: true), + .init(name: "Дэниел", age: 5, country: "Russia", hasPet: true), + .init(name: "ももこ", age: 6, country: "Japan", hasPet: false) + ] + + for s in bufferStrategies { + let encoder = CSVEncoder() + encoder.encoding = encoding + encoder.bomStrategy = bomStrategy + encoder.delimiters = delimiters + encoder.bufferingStrategy = s + encoder.headers = headers + + let lazyEncoder = try encoder.lazy(into: String.self) + for student in students { + try lazyEncoder.encode(student) + } + + let string = try lazyEncoder.endEncoding() + let content = string.split(separator: Character(delimiters.row.rawValue.first!)).map { String($0) } + XCTAssertEqual(content.count, 1+students.count) + XCTAssertEqual(content[0], headers.joined(separator: String(delimiters.field.rawValue))) + } + } + + /// Tests multiple custom types encoding (with headers). + func testMultipleUnkeyed() throws { + // The configuration values to be tests. + let encoding: String.Encoding = .utf8 + let bomStrategy: Strategy.BOM = .never + let delimiters: Delimiter.Pair = (",", "\n") + let bufferStrategies: [Strategy.EncodingBuffer] = [.keepAll, .assembled, .sequential] + let headers = [[], ["name", "age", "country", "hasPet"]] + // The data used for testing. + let students: [_TestData.UnkeyedStudent] = [ + .init(name: "Marcos", age: 1, country: "Spain", hasPet: true), + .init(name: "Anaïs", age: 2, country: "France", hasPet: false), + .init(name: "Alex", age: 3, country: "Canada", hasPet: false), + .init(name: "家豪", age: 4, country: "China", hasPet: true), + .init(name: "Дэниел", age: 5, country: "Russia", hasPet: true), + .init(name: "ももこ", age: 6, country: "Japan", hasPet: false) + ] + + for s in bufferStrategies { + for h in headers { + let encoder = CSVEncoder() + encoder.encoding = encoding + encoder.bomStrategy = bomStrategy + encoder.delimiters = delimiters + encoder.bufferingStrategy = s + encoder.headers = h + + let lazyEncoder = try encoder.lazy(into: String.self) + for student in students { + try lazyEncoder.encode(student) + } + let string = try lazyEncoder.endEncoding() + XCTAssertFalse(string.isEmpty) + + let content = string.split(separator: Character(delimiters.row.rawValue.first!)).map { String($0) } + if h.isEmpty { + XCTAssertEqual(content.count, students.count) + } else { + XCTAssertEqual(content.count, 1+students.count) + XCTAssertEqual(content[0], h.joined(separator: String(delimiters.field.rawValue))) + } + } + } + } + + /// Tests multiple custom types encoding (with headers). + func testEncodingEmptyRows() throws { + // The configuration values to be tests. + let encoding: String.Encoding = .utf8 + let bomStrategy: Strategy.BOM = .never + let delimiters: Delimiter.Pair = (",", "\n") + let bufferStrategies: [Strategy.EncodingBuffer] = [/*.keepAll, .assembled, */.sequential] + let headers = [[], ["name", "age", "country", "hasPet"]] + // The data used for testing. + let studentsA: [_TestData.UnkeyedStudent] = [ + .init(name: "Marcos", age: 1, country: "Spain", hasPet: true), + .init(name: "Anaïs", age: 2, country: "France", hasPet: false) + ] + + let studentsB: [_TestData.UnkeyedStudent] = [ + .init(name: "Alex", age: 3, country: "Canada", hasPet: false), + .init(name: "家豪", age: 4, country: "China", hasPet: true), + .init(name: "Дэниел", age: 5, country: "Russia", hasPet: true), + .init(name: "ももこ", age: 6, country: "Japan", hasPet: false) + ] + + for s in bufferStrategies { + for h in headers { + let encoder = CSVEncoder() + encoder.encoding = encoding + encoder.bomStrategy = bomStrategy + encoder.delimiters = delimiters + encoder.bufferingStrategy = s + encoder.headers = h + + let lazyEncoder = try encoder.lazy(into: String.self) + for student in studentsA { + try lazyEncoder.encode(student) + } + try lazyEncoder.encodeEmptyRow() + for student in studentsB { + try lazyEncoder.encode(student) + } + + let string = try lazyEncoder.endEncoding() + XCTAssertFalse(string.isEmpty) + + print(string) + } + } + } +} diff --git a/tests/CodableTests/EncodingRegularUsageTests.swift b/tests/CodableTests/EncodingRegularUsageTests.swift index 2439e7e..7712169 100644 --- a/tests/CodableTests/EncodingRegularUsageTests.swift +++ b/tests/CodableTests/EncodingRegularUsageTests.swift @@ -75,7 +75,7 @@ extension EncodingRegularUsageTests { // MARK: - extension EncodingRegularUsageTests { - /// Tests the encoding of an empty. + /// Tests the encoding of an empty CSV file. func testEmptyFile() throws { // The configuration values to be tests. let encoding: String.Encoding = .utf8 @@ -93,7 +93,7 @@ extension EncodingRegularUsageTests { configuration.bufferingStrategy = s let encoder = CSVEncoder(configuration: configuration) - let data = try encoder.encode(value) + let data = try encoder.encode(value, into: Data.self) XCTAssertTrue(data.isEmpty) } } @@ -106,7 +106,7 @@ extension EncodingRegularUsageTests { let delimiters: Delimiter.Pair = (",", "\n") let bufferStrategies: [Strategy.EncodingBuffer] = [.keepAll, .assembled, .sequential] // The data used for testing. - let value: [[String]] = [[.init()]] + let value = [[String()]] for s in bufferStrategies { var configuration = CSVEncoder.Configuration() @@ -116,7 +116,7 @@ extension EncodingRegularUsageTests { configuration.bufferingStrategy = s let encoder = CSVEncoder(configuration: configuration) - let data = try encoder.encode(value) + let data = try encoder.encode(value, into: Data.self) let string = String(data: data, encoding: encoding)! XCTAssertEqual(string, "\(delimiters.row.rawValue)") } @@ -140,7 +140,7 @@ extension EncodingRegularUsageTests { configuration.bufferingStrategy = s let encoder = CSVEncoder(configuration: configuration) - let data = try encoder.encode(school) + let data = try encoder.encode(school, into: Data.self) XCTAssertTrue(data.isEmpty) } }