From c270d7580e2b485ea3210365ddac9e87dbc7a673 Mon Sep 17 00:00:00 2001 From: David Pitfield Date: Tue, 30 Aug 2022 13:12:08 -0700 Subject: [PATCH] Support for Decodable (#39) Thanks to @khanlou for the idea and initial code. Co-authored-by: Soroush Khanlou (@khanlou) --- Docs/API/Enums/PostgresError.html | 27 + Docs/API/Structs.html | 16 +- Docs/API/Structs/PostgresByteA.html | 40 +- Docs/API/Structs/PostgresDate.html | 40 +- Docs/API/Structs/PostgresTime.html | 40 +- .../API/Structs/PostgresTimeWithTimeZone.html | 39 +- Docs/API/Structs/PostgresTimestamp.html | 40 +- .../PostgresTimestampWithTimeZone.html | 39 +- Docs/API/Structs/Row.html | 413 ++++++++++++++ Docs/faq.md | 2 + .../Contents.swift | 39 ++ .../contents.xcplayground | 4 + .../Contents.swift | 39 ++ .../contents.xcplayground | 4 + Sources/PostgresClientKit/Connection.swift | 27 +- Sources/PostgresClientKit/PostgresByteA.swift | 21 +- Sources/PostgresClientKit/PostgresDate.swift | 21 +- Sources/PostgresClientKit/PostgresError.swift | 3 + Sources/PostgresClientKit/PostgresTime.swift | 21 +- .../PostgresTimeWithTimeZone.swift | 20 +- .../PostgresClientKit/PostgresTimestamp.swift | 21 +- .../PostgresTimestampWithTimeZone.swift | 22 +- Sources/PostgresClientKit/Row.swift | 184 +++++- Sources/PostgresClientKit/RowDecoder.swift | 452 +++++++++++++++ Tests/LinuxMain.swift | 8 - .../RowDecoderTest.swift | 522 ++++++++++++++++++ .../XCTestManifests.swift | 260 --------- 27 files changed, 2067 insertions(+), 297 deletions(-) create mode 100644 Playgrounds/DecodeRowByColumnIndex.playground/Contents.swift create mode 100644 Playgrounds/DecodeRowByColumnIndex.playground/contents.xcplayground create mode 100644 Playgrounds/DecodeRowByColumnName.playground/Contents.swift create mode 100644 Playgrounds/DecodeRowByColumnName.playground/contents.xcplayground create mode 100644 Sources/PostgresClientKit/RowDecoder.swift delete mode 100644 Tests/LinuxMain.swift create mode 100644 Tests/PostgresClientKitTests/RowDecoderTest.swift delete mode 100644 Tests/PostgresClientKitTests/XCTestManifests.swift diff --git a/Docs/API/Enums/PostgresError.html b/Docs/API/Enums/PostgresError.html index 2b61035..3736296 100644 --- a/Docs/API/Enums/PostgresError.html +++ b/Docs/API/Enums/PostgresError.html @@ -85,6 +85,33 @@

Declaration

+
  • +
    + + + + columnMetadataNotAvailable + +
    +
    +
    +
    +
    +
    +

    Cursor.columns is nil, indicating column metadata is not available.

    + +
    +
    +

    Declaration

    +
    +

    Swift

    +
    case columnMetadataNotAvailable
    + +
    +
    +
    +
    +
  • diff --git a/Docs/API/Structs.html b/Docs/API/Structs.html index 75a40f7..a07a9c0 100644 --- a/Docs/API/Structs.html +++ b/Docs/API/Structs.html @@ -274,7 +274,8 @@

    Declaration

    Declaration

    Swift

    -
    public struct PostgresByteA : PostgresValueConvertible, Equatable, CustomStringConvertible
    +
    public struct PostgresByteA:
    +    PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible
    @@ -310,7 +311,8 @@

    Declaration

    Declaration

    Swift

    -
    public struct PostgresDate : PostgresValueConvertible, Equatable, CustomStringConvertible
    +
    public struct PostgresDate:
    +    PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible
    @@ -351,7 +353,8 @@

    Declaration

    Declaration

    Swift

    -
    public struct PostgresTime : PostgresValueConvertible, Equatable, CustomStringConvertible
    +
    public struct PostgresTime:
    +    PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible
    @@ -397,7 +400,7 @@

    Declaration

    Swift

    public struct PostgresTimeWithTimeZone:
    -    PostgresValueConvertible, Equatable, CustomStringConvertible
    + PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible
    @@ -450,7 +453,8 @@

    Declaration

    Declaration

    Swift

    -
    public struct PostgresTimestamp : PostgresValueConvertible, Equatable, CustomStringConvertible
    +
    public struct PostgresTimestamp:
    +    PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible
    @@ -501,7 +505,7 @@

    Declaration

    Swift

    public struct PostgresTimestampWithTimeZone:
    -    PostgresValueConvertible, Equatable, CustomStringConvertible
    + PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible
    diff --git a/Docs/API/Structs/PostgresByteA.html b/Docs/API/Structs/PostgresByteA.html index 5d71d89..f4296ed 100644 --- a/Docs/API/Structs/PostgresByteA.html +++ b/Docs/API/Structs/PostgresByteA.html @@ -48,7 +48,8 @@

    PostgresByteA

    -
    public struct PostgresByteA : PostgresValueConvertible, Equatable, CustomStringConvertible
    +
    public struct PostgresByteA:
    +    PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible
    @@ -258,6 +259,43 @@

    Declaration

  • +
    + + +
    diff --git a/Docs/API/Structs/PostgresDate.html b/Docs/API/Structs/PostgresDate.html index 4ad53ed..3b96d3a 100644 --- a/Docs/API/Structs/PostgresDate.html +++ b/Docs/API/Structs/PostgresDate.html @@ -48,7 +48,8 @@

    PostgresDate

    -
    public struct PostgresDate : PostgresValueConvertible, Equatable, CustomStringConvertible
    +
    public struct PostgresDate:
    +    PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible
    @@ -421,6 +422,43 @@

    Declaration

    +
    + +
      +
    • +
      + + + + init(from:) + +
      +
      +
      +
      +
      +
      + +
      +
      +

      Declaration

      +
      +

      Swift

      +
      public init(from decoder: Decoder) throws
      + +
      +
      +
      +
      +
    • +
    +
    diff --git a/Docs/API/Structs/PostgresTime.html b/Docs/API/Structs/PostgresTime.html index cf89b57..911c544 100644 --- a/Docs/API/Structs/PostgresTime.html +++ b/Docs/API/Structs/PostgresTime.html @@ -48,7 +48,8 @@

    PostgresTime

    -
    public struct PostgresTime : PostgresValueConvertible, Equatable, CustomStringConvertible
    +
    public struct PostgresTime:
    +    PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible
    @@ -442,6 +443,43 @@

    Declaration

    +
    + +
      +
    • +
      + + + + init(from:) + +
      +
      +
      +
      +
      +
      + +
      +
      +

      Declaration

      +
      +

      Swift

      +
      public init(from decoder: Decoder) throws
      + +
      +
      +
      +
      +
    • +
    +
    diff --git a/Docs/API/Structs/PostgresTimeWithTimeZone.html b/Docs/API/Structs/PostgresTimeWithTimeZone.html index 85bdd23..5e32a14 100644 --- a/Docs/API/Structs/PostgresTimeWithTimeZone.html +++ b/Docs/API/Structs/PostgresTimeWithTimeZone.html @@ -49,7 +49,7 @@

    PostgresTimeWithTimeZone

    public struct PostgresTimeWithTimeZone:
    -    PostgresValueConvertible, Equatable, CustomStringConvertible
    + PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible
    @@ -499,6 +499,43 @@

    Declaration

    +
    + +
      +
    • +
      + + + + init(from:) + +
      +
      +
      +
      +
      +
      + +
      +
      +

      Declaration

      +
      +

      Swift

      +
      public init(from decoder: Decoder) throws
      + +
      +
      +
      +
      +
    • +
    +
    diff --git a/Docs/API/Structs/PostgresTimestamp.html b/Docs/API/Structs/PostgresTimestamp.html index 39b3051..5f6b76c 100644 --- a/Docs/API/Structs/PostgresTimestamp.html +++ b/Docs/API/Structs/PostgresTimestamp.html @@ -48,7 +48,8 @@

    PostgresTimestamp

    -
    public struct PostgresTimestamp : PostgresValueConvertible, Equatable, CustomStringConvertible
    +
    public struct PostgresTimestamp:
    +    PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible
    @@ -496,6 +497,43 @@

    Declaration

    +
    + +
      +
    • +
      + + + + init(from:) + +
      +
      +
      +
      +
      +
      + +
      +
      +

      Declaration

      +
      +

      Swift

      +
      public init(from decoder: Decoder) throws
      + +
      +
      +
      +
      +
    • +
    +
    diff --git a/Docs/API/Structs/PostgresTimestampWithTimeZone.html b/Docs/API/Structs/PostgresTimestampWithTimeZone.html index 20601ee..797c639 100644 --- a/Docs/API/Structs/PostgresTimestampWithTimeZone.html +++ b/Docs/API/Structs/PostgresTimestampWithTimeZone.html @@ -49,7 +49,7 @@

    PostgresTimestampWithTimeZone

    public struct PostgresTimestampWithTimeZone:
    -    PostgresValueConvertible, Equatable, CustomStringConvertible
    + PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible
    @@ -476,6 +476,43 @@

    Declaration

    +
    + +
      +
    • +
      + + + + init(from:) + +
      +
      +
      +
      +
      +
      + +
      +
      +

      Declaration

      +
      +

      Swift

      +
      public init(from decoder: Decoder) throws
      + +
      +
      +
      +
      +
    • +
    +
    diff --git a/Docs/API/Structs/Row.html b/Docs/API/Structs/Row.html index fdf711a..a1e427f 100644 --- a/Docs/API/Structs/Row.html +++ b/Docs/API/Structs/Row.html @@ -85,6 +85,419 @@

    Declaration

    +
  • + +
    +
    +
    +
    +
    +

    Decodes this Row to create an instance of the specified type.

    + +

    The type specified must conform to the Decodable protocol. This method uses the column +metadata provided by Cursor.columns to create a new instance of that type whose stored +properties are set to the values of like-named columns. (To make this column metadata +available, set retrieveColumnMetadata to true in calling +Statement.execute(parameterValues:retrieveColumnMetadata:).)

    + +

    The supported property types are a superset of the types supported by PostgresValue:

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Type of stored propertyConversion performed
    BoolpostgresValue.bool()
    StringpostgresValue.string()
    DoublepostgresValue.double()
    FloatFloat(postgresValue.double())
    IntpostgresValue.int()
    Int8Int8(postgresValue.string())
    Int16Int16(postgresValue.string())
    Int32Int32(postgresValue.string())
    Int64Int64(postgresValue.string())
    UIntUInt(postgresValue.string())
    UInt8UInt8(postgresValue.string())
    UInt16UInt16(postgresValue.string())
    UInt32UInt32(postgresValue.string())
    UInt64UInt64(postgresValue.string())
    PostgresByteApostgresValue.byteA()
    PostgresTimestampWithTimeZonepostgresValue.timestampWithTimeZone()
    PostgresTimestamppostgresValue.timestamp()
    PostgresDatepostgresValue.date()
    PostgresTimepostgresValue.time()
    PostgresTimeWithTimeZonepostgresValue.timeWithTimeZone()
    Datesee below
    + +

    Foundation Date stored properties are decoded as follows:

    + +
      +
    • postgresValue.timestampWithTimeZone().date, if successful;
    • +
    • otherwise postgresValue.timestamp().date(in: defaultTimeZone), if successful;
    • +
    • otherwise postgresValue.date().date(in: defaultTimeZone), if successful;
    • +
    • otherwise postgresValue.time().date(in: defaultTimeZone), if successful;
    • +
    • otherwise postgresValue.timeWithTimeZone().date, if successful
    • +
    + +

    (Instead of Date, consider using PostgresTimestampWithTimeZone, PostgresTimestamp, +PostgresDate, PostgresTime, and PostgresTimeWithTimeZone whenever possible.)

    + +

    Example:

    +
    struct Weather: Decodable {
    +    let date: PostgresDate
    +    let city: String
    +    let temp_lo: Int
    +    let temp_hi: Int
    +    let prcp: Double?
    +}
    +
    +let connection: Connection = ...
    +
    +// Note that the columns must have the same names as the Weather
    +// properties, but may be in a different order.
    +let text = "SELECT city, temp_lo, temp_hi, prcp, date FROM weather;"
    +let statement = try connection.prepareStatement(text: text)
    +let cursor = try statement.execute(retrieveColumnMetadata: true)
    +
    +for row in cursor {
    +    let weather = try row.get().decodeByColumnName(Weather.self)
    +    ...
    +}
    +
    +
    +

    Throws

    + PostgresError.columnMetadataNotAvailable if column metadata is not available; + DecodingError if the operation otherwise fails + +
    + +
    +
    +

    Declaration

    +
    +

    Swift

    +
    public func decodeByColumnName<T: Decodable>(_ type: T.Type,
    +                                             defaultTimeZone: TimeZone? = nil) throws -> T
    + +
    +
    +
    +

    Parameters

    + + + + + + + + + + + +
    + + type + + +
    +

    the type of instance to create

    +
    +
    + + defaultTimeZone + + +
    +

    the default time zone for certain conversions to Foundation Date +(see above); if nil then the UTC time zone is used

    +
    +
    +
    +
    +

    Return Value

    +

    an instance of the specified type

    +
    +
    +
    +
  • +
  • + +
    +
    +
    +
    +
    +

    Decodes this Row to create an instance of the specified type.

    + +

    The type specified must conform to the Decodable protocol. This method matches columns +to stored properties based on decoding order: the first property decoded is assigned the +value of columns[0], the second property is assigned the value of columns[1], and so +on. By default, a Decodable type decodes its properties in declaration order. This +default behavior can be overridden by providing implementations of the CodingKeys enum +and the init(from:) initializer. Refer to Apple’s developer documentation +for further information.

    + +

    The supported property types are a superset of the types supported by PostgresValue:

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Type of stored propertyConversion performed
    BoolpostgresValue.bool()
    StringpostgresValue.string()
    DoublepostgresValue.double()
    FloatFloat(postgresValue.double())
    IntpostgresValue.int()
    Int8Int8(postgresValue.string())
    Int16Int16(postgresValue.string())
    Int32Int32(postgresValue.string())
    Int64Int64(postgresValue.string())
    UIntUInt(postgresValue.string())
    UInt8UInt8(postgresValue.string())
    UInt16UInt16(postgresValue.string())
    UInt32UInt32(postgresValue.string())
    UInt64UInt64(postgresValue.string())
    PostgresByteApostgresValue.byteA()
    PostgresTimestampWithTimeZonepostgresValue.timestampWithTimeZone()
    PostgresTimestamppostgresValue.timestamp()
    PostgresDatepostgresValue.date()
    PostgresTimepostgresValue.time()
    PostgresTimeWithTimeZonepostgresValue.timeWithTimeZone()
    Datesee below
    + +

    Foundation Date stored properties are decoded as follows:

    + +
      +
    • postgresValue.timestampWithTimeZone().date, if successful;
    • +
    • otherwise postgresValue.timestamp().date(in: defaultTimeZone), if successful;
    • +
    • otherwise postgresValue.date().date(in: defaultTimeZone), if successful;
    • +
    • otherwise postgresValue.time().date(in: defaultTimeZone), if successful;
    • +
    • otherwise postgresValue.timeWithTimeZone().date, if successful
    • +
    + +

    (Instead of Date, consider using PostgresTimestampWithTimeZone, PostgresTimestamp, +PostgresDate, PostgresTime, and PostgresTimeWithTimeZone whenever possible.)

    + +

    Example:

    +
    struct Weather: Decodable {
    +    let city: String
    +    let lowestTemperature: Int
    +    let highestTemperature: Int
    +    let precipitation: Double?
    +    let date: PostgresDate
    +}
    +
    +let connection: Connection = ...
    +
    +// Notice that the columns must be in the same order as the Weather
    +// properties, but may have different names.
    +let text = "SELECT city, temp_lo, temp_hi, prcp, date FROM weather;"
    +let statement = try connection.prepareStatement(text: text)
    +let cursor = try statement.execute()
    +
    +for row in cursor {
    +    let weather = try row.get().decodeByColumnIndex(Weather.self)
    +    ...
    +}
    +
    +
    +

    Throws

    + DecodingError if the operation fails + +
    + +
    +
    +

    Declaration

    +
    +

    Swift

    +
    public func decodeByColumnIndex<T: Decodable>(_ type: T.Type,
    +                                              defaultTimeZone: TimeZone? = nil) throws -> T
    + +
    +
    +
    +

    Parameters

    + + + + + + + + + + + +
    + + type + + +
    +

    the type of instance to create

    +
    +
    + + defaultTimeZone + + +
    +

    the default time zone for certain conversions to Foundation Date +(see above); if nil then the UTC time zone is used

    +
    +
    +
    +
    +

    Return Value

    +

    an instance of the specified type

    +
    +
    +
    +
  • diff --git a/Docs/faq.md b/Docs/faq.md index 59ead2e..2e4310f 100644 --- a/Docs/faq.md +++ b/Docs/faq.md @@ -16,6 +16,8 @@ Concurrency is [an evolving area](https://gist.github.com/lattner/31ed37682ef157 Postgres doesn't require the columns returned by a `SELECT` to be uniquely named (for example, in queries with joins or computed columns). Name-based access is better left to a higher level, such as an object-relational mapper. +That said, as of v1.5.0, PostgresClientKit can decode a row into a Swift type that conforms to the `Decodable` protocol, mapping columns to stored properties by either by or by position. See the [API documentation](https://codewinsdotcom.github.io/PostgresClientKit/Docs/API/index.html) for `Row.decodeByColumnName(_:defaultTimeZone)` and `Row.decodeByColumnIndex(_:defaultTimeZone)`. + ### In retrieving the value of a column, why do I need to specify the Swift type? To make Postgres-to-Swift type conversion explicit and robust, PostgresClientKit defers to the developer. Should a SQL `NUMERIC` map to a Swift `Int`, `Double`, or `Decimal`? Should a SQL `VARCHAR` map to a Swift `String` or an `Optional`? Answering these questions requires domain knowledge, which may not be encoded in the SQL data model, but which the developer (hopefully) has. diff --git a/Playgrounds/DecodeRowByColumnIndex.playground/Contents.swift b/Playgrounds/DecodeRowByColumnIndex.playground/Contents.swift new file mode 100644 index 0000000..b81f1f5 --- /dev/null +++ b/Playgrounds/DecodeRowByColumnIndex.playground/Contents.swift @@ -0,0 +1,39 @@ +import PostgresClientKit + +struct Weather: Decodable { + let city: String + let lowestTemperature: Int + let highestTemperature: Int + let precipitation: Double? + let date: PostgresDate +} + +do { + var configuration = PostgresClientKit.ConnectionConfiguration() + configuration.host = "127.0.0.1" + configuration.ssl = true + configuration.database = "example" + configuration.user = "bob" + configuration.credential = .scramSHA256(password: "welcome1") + + let connection = try PostgresClientKit.Connection(configuration: configuration) + defer { connection.close() } + + // Notice that the columns must be in the same order as the Weather + // properties, but may have different names. + let text = "SELECT city, temp_lo, temp_hi, prcp, date FROM weather;" + let statement = try connection.prepareStatement(text: text) + defer { statement.close() } + + let cursor = try statement.execute() + defer { cursor.close() } + + for row in cursor { + let weather = try row.get().decodeByColumnIndex(Weather.self) + print(weather) + } +} catch { + print(error) // better error handling goes here +} + +// EOF diff --git a/Playgrounds/DecodeRowByColumnIndex.playground/contents.xcplayground b/Playgrounds/DecodeRowByColumnIndex.playground/contents.xcplayground new file mode 100644 index 0000000..1c968e7 --- /dev/null +++ b/Playgrounds/DecodeRowByColumnIndex.playground/contents.xcplayground @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Playgrounds/DecodeRowByColumnName.playground/Contents.swift b/Playgrounds/DecodeRowByColumnName.playground/Contents.swift new file mode 100644 index 0000000..cc097c9 --- /dev/null +++ b/Playgrounds/DecodeRowByColumnName.playground/Contents.swift @@ -0,0 +1,39 @@ +import PostgresClientKit + +struct Weather: Decodable { + let date: PostgresDate + let city: String + let temp_lo: Int + let temp_hi: Int + let prcp: Double? +} + +do { + var configuration = PostgresClientKit.ConnectionConfiguration() + configuration.host = "127.0.0.1" + configuration.ssl = true + configuration.database = "example" + configuration.user = "bob" + configuration.credential = .scramSHA256(password: "welcome1") + + let connection = try PostgresClientKit.Connection(configuration: configuration) + defer { connection.close() } + + // Note that the columns must have the same names as the Weather + // properties, but may be in a different order. + let text = "SELECT city, temp_lo, temp_hi, prcp, date FROM weather;" + let statement = try connection.prepareStatement(text: text) + defer { statement.close() } + + let cursor = try statement.execute(retrieveColumnMetadata: true) + defer { cursor.close() } + + for row in cursor { + let weather = try row.get().decodeByColumnName(Weather.self) + print(weather) + } +} catch { + print(error) // better error handling goes here +} + +// EOF diff --git a/Playgrounds/DecodeRowByColumnName.playground/contents.xcplayground b/Playgrounds/DecodeRowByColumnName.playground/contents.xcplayground new file mode 100644 index 0000000..1c968e7 --- /dev/null +++ b/Playgrounds/DecodeRowByColumnName.playground/contents.xcplayground @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Sources/PostgresClientKit/Connection.swift b/Sources/PostgresClientKit/Connection.swift index b037cfc..dd0cb09 100644 --- a/Sources/PostgresClientKit/Connection.swift +++ b/Sources/PostgresClientKit/Connection.swift @@ -490,14 +490,22 @@ public class Connection: CustomStringConvertible { let cursor = Cursor(statement: statement, columns: columns) + // Because RowDecoder computes some derived state from the column metadata, we re-use the + // same RowDecoder instance for all rows in a cursor in order to amortize this cost. + let columnNameRowDecoder = (columns == nil) ? nil : RowDecoder(columns: columns) + // (The CursorState enum cases capture the Cursor id, rather than the Cursor instance, to // avoid a reference cycle.) - cursorState = .open(cursorId: cursor.id, bufferedRow: nil) + cursorState = .open(cursorId: cursor.id, + columnNameRowDecoder: columnNameRowDecoder, + bufferedRow: nil) // Retrieve and buffer the first row of the cursor, if any. We do this to check whether // the execution failed, so we can throw an error from this method. if let firstRow = try nextRowOfCursor(cursor) { - cursorState = .open(cursorId: cursor.id, bufferedRow: firstRow) + cursorState = .open(cursorId: cursor.id, + columnNameRowDecoder: columnNameRowDecoder, + bufferedRow: firstRow) } return cursor @@ -563,7 +571,7 @@ public class Connection: CustomStringConvertible { case closed /// There is a currently open cursor, with an optional buffered row. - case open(cursorId: String, bufferedRow: Row?) + case open(cursorId: String, columnNameRowDecoder: RowDecoder?, bufferedRow: Row?) /// There is a currently open cursor, but all rows have been retrieved. case drained(cursorId: String) @@ -650,7 +658,9 @@ public class Connection: CustomStringConvertible { case .drained: row = nil - case let .open(cursorId: cursorId, bufferedRow: bufferedRow): + case let .open(cursorId: cursorId, + columnNameRowDecoder: columnNameRowDecoder, + bufferedRow: bufferedRow): do { // Do we have a row buffered? @@ -658,7 +668,9 @@ public class Connection: CustomStringConvertible { // Yes, so return it. row = bufferedRow - cursorState = .open(cursorId: cursorId, bufferedRow: nil) + cursorState = .open(cursorId: cursorId, + columnNameRowDecoder: columnNameRowDecoder, + bufferedRow: nil) } else { @@ -689,7 +701,8 @@ public class Connection: CustomStringConvertible { cursorState = .drained(cursorId: cursorId) case let dataRowResponse as DataRowResponse: - row = Row(columns: dataRowResponse.columns) + row = Row(columns: dataRowResponse.columns, + columnNameRowDecoder: columnNameRowDecoder) default: log(.warning, "Unexpected response: \(response)") @@ -741,7 +754,7 @@ public class Connection: CustomStringConvertible { case .closed: return true - case let .open(cursorId: cursorId, bufferedRow: _): + case let .open(cursorId: cursorId, columnNameRowDecoder: _, bufferedRow: _): return cursorId != cursor.id case let .drained(cursorId: cursorId): diff --git a/Sources/PostgresClientKit/PostgresByteA.swift b/Sources/PostgresClientKit/PostgresByteA.swift index 4f25154..43f3793 100644 --- a/Sources/PostgresClientKit/PostgresByteA.swift +++ b/Sources/PostgresClientKit/PostgresByteA.swift @@ -20,7 +20,8 @@ import Foundation /// Represents a Postgres `BYTEA` value (a byte array). -public struct PostgresByteA: PostgresValueConvertible, Equatable, CustomStringConvertible { +public struct PostgresByteA: + PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible { /// Creates a `PostgresByteA` from the specified `Data`. /// @@ -75,6 +76,24 @@ public struct PostgresByteA: PostgresValueConvertible, Equatable, CustomStringCo } + // + // MARK: Decodable + // + + public init(from decoder: Decoder) throws { + let rawValue = try decoder.singleValueContainer().decode(String.self) + + guard let value = PostgresByteA(rawValue) else { + throw DecodingError.typeMismatch( + PostgresByteA.self, + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Invalid value: \(rawValue)")) + } + + self = value + } + + // // MARK: CustomStringConvertible // diff --git a/Sources/PostgresClientKit/PostgresDate.swift b/Sources/PostgresClientKit/PostgresDate.swift index 8a3da9e..6e55d9a 100644 --- a/Sources/PostgresClientKit/PostgresDate.swift +++ b/Sources/PostgresClientKit/PostgresDate.swift @@ -26,7 +26,8 @@ import Foundation /// - day /// /// For example, `2019-03-14`. -public struct PostgresDate: PostgresValueConvertible, Equatable, CustomStringConvertible { +public struct PostgresDate: + PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible { /// Creates a `PostgresDate` from components. /// @@ -141,6 +142,24 @@ public struct PostgresDate: PostgresValueConvertible, Equatable, CustomStringCon } + // + // MARK: Decodable + // + + public init(from decoder: Decoder) throws { + let rawValue = try decoder.singleValueContainer().decode(String.self) + + guard let value = PostgresDate(rawValue) else { + throw DecodingError.typeMismatch( + PostgresDate.self, + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Invalid value: \(rawValue)")) + } + + self = value + } + + // // MARK: CustomStringConvertible // diff --git a/Sources/PostgresClientKit/PostgresError.swift b/Sources/PostgresClientKit/PostgresError.swift index 84fa4c8..893074b 100644 --- a/Sources/PostgresClientKit/PostgresError.swift +++ b/Sources/PostgresClientKit/PostgresError.swift @@ -23,6 +23,9 @@ public enum PostgresError: Error { /// The Postgres server requires a `Credential.cleartextPassword` for authentication. case cleartextPasswordCredentialRequired + /// `Cursor.columns` is nil, indicating column metadata is not available. + case columnMetadataNotAvailable + /// An attempt was made to operate on a closed connection. case connectionClosed diff --git a/Sources/PostgresClientKit/PostgresTime.swift b/Sources/PostgresClientKit/PostgresTime.swift index ad4f141..a7cc6a0 100644 --- a/Sources/PostgresClientKit/PostgresTime.swift +++ b/Sources/PostgresClientKit/PostgresTime.swift @@ -31,7 +31,8 @@ import Foundation /// However, [due to a bug](https://stackoverflow.com/questions/23684727) in the Foundation /// `DateFormatter` class, only 3 fractional digits are preserved (millisecond resolution) in /// values sent to and received from the Postgres server. -public struct PostgresTime: PostgresValueConvertible, Equatable, CustomStringConvertible { +public struct PostgresTime: + PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible { /// Creates a `PostgresTime` from components. /// @@ -152,6 +153,24 @@ public struct PostgresTime: PostgresValueConvertible, Equatable, CustomStringCon } + // + // MARK: Decodable + // + + public init(from decoder: Decoder) throws { + let rawValue = try decoder.singleValueContainer().decode(String.self) + + guard let value = PostgresTime(rawValue) else { + throw DecodingError.typeMismatch( + PostgresTime.self, + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Invalid value: \(rawValue)")) + } + + self = value + } + + // // MARK: CustomStringConvertible // diff --git a/Sources/PostgresClientKit/PostgresTimeWithTimeZone.swift b/Sources/PostgresClientKit/PostgresTimeWithTimeZone.swift index 72cad40..403ff58 100644 --- a/Sources/PostgresClientKit/PostgresTimeWithTimeZone.swift +++ b/Sources/PostgresClientKit/PostgresTimeWithTimeZone.swift @@ -36,7 +36,7 @@ import Foundation /// `DateFormatter` class, only 3 fractional digits are preserved (millisecond resolution) in /// values sent to and received from the Postgres server. public struct PostgresTimeWithTimeZone: - PostgresValueConvertible, Equatable, CustomStringConvertible { + PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible { /// Creates a `PostgresTimeWithTimeZone` from components. /// @@ -180,6 +180,24 @@ public struct PostgresTimeWithTimeZone: } + // + // MARK: Decodable + // + + public init(from decoder: Decoder) throws { + let rawValue = try decoder.singleValueContainer().decode(String.self) + + guard let value = PostgresTimeWithTimeZone(rawValue) else { + throw DecodingError.typeMismatch( + PostgresTimeWithTimeZone.self, + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Invalid value: \(rawValue)")) + } + + self = value + } + + // // MARK: CustomStringConvertible // diff --git a/Sources/PostgresClientKit/PostgresTimestamp.swift b/Sources/PostgresClientKit/PostgresTimestamp.swift index 3a4356b..19465b1 100644 --- a/Sources/PostgresClientKit/PostgresTimestamp.swift +++ b/Sources/PostgresClientKit/PostgresTimestamp.swift @@ -43,7 +43,8 @@ import Foundation /// However, [due to a bug](https://stackoverflow.com/questions/23684727) in the Foundation /// `DateFormatter` class, only 3 fractional digits are preserved (millisecond resolution) in /// values sent to and received from the Postgres server. -public struct PostgresTimestamp: PostgresValueConvertible, Equatable, CustomStringConvertible { +public struct PostgresTimestamp: + PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible { /// Creates a `PostgresTimestamp` from components. /// @@ -167,6 +168,24 @@ public struct PostgresTimestamp: PostgresValueConvertible, Equatable, CustomStri } + // + // MARK: Decodable + // + + public init(from decoder: Decoder) throws { + let rawValue = try decoder.singleValueContainer().decode(String.self) + + guard let value = PostgresTimestamp(rawValue) else { + throw DecodingError.typeMismatch( + PostgresTimestamp.self, + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Invalid value: \(rawValue)")) + } + + self = value + } + + // // MARK: CustomStringConvertible // diff --git a/Sources/PostgresClientKit/PostgresTimestampWithTimeZone.swift b/Sources/PostgresClientKit/PostgresTimestampWithTimeZone.swift index c3098a9..7ca5ded 100644 --- a/Sources/PostgresClientKit/PostgresTimestampWithTimeZone.swift +++ b/Sources/PostgresClientKit/PostgresTimestampWithTimeZone.swift @@ -41,7 +41,7 @@ import Foundation /// `DateFormatter` class, only 3 fractional digits are preserved (millisecond resolution) in /// values sent to and received from the Postgres server. public struct PostgresTimestampWithTimeZone: - PostgresValueConvertible, Equatable, CustomStringConvertible { + PostgresValueConvertible, Equatable, Decodable, CustomStringConvertible { /// Creates a `PostgresTimestampWithTimeZone` from components. /// @@ -160,8 +160,26 @@ public struct PostgresTimestampWithTimeZone: rhs: PostgresTimestampWithTimeZone) -> Bool { return lhs.postgresValue == rhs.postgresValue } - + + // + // MARK: Decodable + // + + public init(from decoder: Decoder) throws { + let rawValue = try decoder.singleValueContainer().decode(String.self) + + guard let value = PostgresTimestampWithTimeZone(rawValue) else { + throw DecodingError.typeMismatch( + PostgresTimestampWithTimeZone.self, + DecodingError.Context(codingPath: decoder.codingPath, + debugDescription: "Invalid value: \(rawValue)")) + } + + self = value + } + + // // MARK: CustomStringConvertible // diff --git a/Sources/PostgresClientKit/Row.swift b/Sources/PostgresClientKit/Row.swift index e96df94..e093631 100644 --- a/Sources/PostgresClientKit/Row.swift +++ b/Sources/PostgresClientKit/Row.swift @@ -17,20 +17,198 @@ // limitations under the License. // +import Foundation + /// A `Row` exposed by a `Cursor`. public struct Row: CustomStringConvertible { /// Creates a `Row`. /// - /// - Parameter columns: the column values - internal init(columns: [PostgresValue]) { + /// - Parameters: + /// - columns: the column values + /// - columnNameRowDecoder: the `RowDecoder` instance used to decode by column name + internal init(columns: [PostgresValue], columnNameRowDecoder: RowDecoder?) { self.columns = columns + self.columnNameRowDecoder = columnNameRowDecoder } /// The values of the columns for this `Row`. public var columns: [PostgresValue] - + /// The `RowDecoder` instance used to decode by column name. + private let columnNameRowDecoder: RowDecoder? + + /// Decodes this `Row` to create an instance of the specified type. + /// + /// The type specified must conform to the `Decodable` protocol. This method uses the column + /// metadata provided by `Cursor.columns` to create a new instance of that type whose stored + /// properties are set to the values of like-named `columns`. (To make this column metadata + /// available, set `retrieveColumnMetadata` to `true` in calling + /// `Statement.execute(parameterValues:retrieveColumnMetadata:)`.) + /// + /// The supported property types are a superset of the types supported by `PostgresValue`: + /// + /// | Type of stored property | Conversion performed | + /// | ------------------------------- | --------------------------------------- | + /// | `Bool` | `postgresValue.bool()` | + /// | `String` | `postgresValue.string()` | + /// | `Double` | `postgresValue.double()` | + /// | `Float` | `Float(postgresValue.double())` | + /// | `Int` | `postgresValue.int()` | + /// | `Int8` | `Int8(postgresValue.string())` | + /// | `Int16` | `Int16(postgresValue.string())` | + /// | `Int32` | `Int32(postgresValue.string())` | + /// | `Int64` | `Int64(postgresValue.string())` | + /// | `UInt` | `UInt(postgresValue.string())` | + /// | `UInt8` | `UInt8(postgresValue.string())` | + /// | `UInt16` | `UInt16(postgresValue.string())` | + /// | `UInt32` | `UInt32(postgresValue.string())` | + /// | `UInt64` | `UInt64(postgresValue.string())` | + /// | `PostgresByteA` | `postgresValue.byteA()` | + /// | `PostgresTimestampWithTimeZone` | `postgresValue.timestampWithTimeZone()` | + /// | `PostgresTimestamp` | `postgresValue.timestamp()` | + /// | `PostgresDate` | `postgresValue.date()` | + /// | `PostgresTime` | `postgresValue.time()` | + /// | `PostgresTimeWithTimeZone` | `postgresValue.timeWithTimeZone()` | + /// | `Date` | see below | + /// + /// Foundation `Date` stored properties are decoded as follows: + /// - `postgresValue.timestampWithTimeZone().date`, if successful; + /// - otherwise `postgresValue.timestamp().date(in: defaultTimeZone)`, if successful; + /// - otherwise `postgresValue.date().date(in: defaultTimeZone)`, if successful; + /// - otherwise `postgresValue.time().date(in: defaultTimeZone)`, if successful; + /// - otherwise `postgresValue.timeWithTimeZone().date`, if successful + /// + /// (Instead of `Date`, consider using `PostgresTimestampWithTimeZone`, `PostgresTimestamp`, + /// `PostgresDate`, `PostgresTime`, and `PostgresTimeWithTimeZone` whenever possible.) + /// + /// Example: + /// + /// struct Weather: Decodable { + /// let date: PostgresDate + /// let city: String + /// let temp_lo: Int + /// let temp_hi: Int + /// let prcp: Double? + /// } + /// + /// let connection: Connection = ... + /// + /// // Note that the columns must have the same names as the Weather + /// // properties, but may be in a different order. + /// let text = "SELECT city, temp_lo, temp_hi, prcp, date FROM weather;" + /// let statement = try connection.prepareStatement(text: text) + /// let cursor = try statement.execute(retrieveColumnMetadata: true) + /// + /// for row in cursor { + /// let weather = try row.get().decodeByColumnName(Weather.self) + /// ... + /// } + /// + /// - Parameters: + /// - type: the type of instance to create + /// - defaultTimeZone: the default time zone for certain conversions to Foundation `Date` + /// (see above); if `nil` then the UTC time zone is used + /// - Returns: an instance of the specified type + /// - Throws: `PostgresError.columnMetadataNotAvailable` if column metadata is not available; + /// `DecodingError` if the operation otherwise fails + public func decodeByColumnName(_ type: T.Type, + defaultTimeZone: TimeZone? = nil) throws -> T { + + guard let columnNameRowDecoder = columnNameRowDecoder else { + throw PostgresError.columnMetadataNotAvailable + } + + return try columnNameRowDecoder.decode( + type, from: columns, defaultTimeZone: defaultTimeZone ?? ISO8601.utcTimeZone) + } + + /// Decodes this `Row` to create an instance of the specified type. + /// + /// The type specified must conform to the `Decodable` protocol. This method matches `columns` + /// to stored properties based on decoding order: the first property decoded is assigned the + /// value of `columns[0]`, the second property is assigned the value of `columns[1]`, and so + /// on. By default, a `Decodable` type decodes its properties in declaration order. This + /// default behavior can be overridden by providing implementations of the `CodingKeys` enum + /// and the `init(from:)` initializer. Refer to [Apple's developer documentation]( + /// https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types) + /// for further information. + /// + /// The supported property types are a superset of the types supported by `PostgresValue`: + /// + /// | Type of stored property | Conversion performed | + /// | ------------------------------- | --------------------------------------- | + /// | `Bool` | `postgresValue.bool()` | + /// | `String` | `postgresValue.string()` | + /// | `Double` | `postgresValue.double()` | + /// | `Float` | `Float(postgresValue.double())` | + /// | `Int` | `postgresValue.int()` | + /// | `Int8` | `Int8(postgresValue.string())` | + /// | `Int16` | `Int16(postgresValue.string())` | + /// | `Int32` | `Int32(postgresValue.string())` | + /// | `Int64` | `Int64(postgresValue.string())` | + /// | `UInt` | `UInt(postgresValue.string())` | + /// | `UInt8` | `UInt8(postgresValue.string())` | + /// | `UInt16` | `UInt16(postgresValue.string())` | + /// | `UInt32` | `UInt32(postgresValue.string())` | + /// | `UInt64` | `UInt64(postgresValue.string())` | + /// | `PostgresByteA` | `postgresValue.byteA()` | + /// | `PostgresTimestampWithTimeZone` | `postgresValue.timestampWithTimeZone()` | + /// | `PostgresTimestamp` | `postgresValue.timestamp()` | + /// | `PostgresDate` | `postgresValue.date()` | + /// | `PostgresTime` | `postgresValue.time()` | + /// | `PostgresTimeWithTimeZone` | `postgresValue.timeWithTimeZone()` | + /// | `Date` | see below | + /// + /// Foundation `Date` stored properties are decoded as follows: + /// - `postgresValue.timestampWithTimeZone().date`, if successful; + /// - otherwise `postgresValue.timestamp().date(in: defaultTimeZone)`, if successful; + /// - otherwise `postgresValue.date().date(in: defaultTimeZone)`, if successful; + /// - otherwise `postgresValue.time().date(in: defaultTimeZone)`, if successful; + /// - otherwise `postgresValue.timeWithTimeZone().date`, if successful + /// + /// (Instead of `Date`, consider using `PostgresTimestampWithTimeZone`, `PostgresTimestamp`, + /// `PostgresDate`, `PostgresTime`, and `PostgresTimeWithTimeZone` whenever possible.) + /// + /// Example: + /// + /// struct Weather: Decodable { + /// let city: String + /// let lowestTemperature: Int + /// let highestTemperature: Int + /// let precipitation: Double? + /// let date: PostgresDate + /// } + /// + /// let connection: Connection = ... + /// + /// // Notice that the columns must be in the same order as the Weather + /// // properties, but may have different names. + /// let text = "SELECT city, temp_lo, temp_hi, prcp, date FROM weather;" + /// let statement = try connection.prepareStatement(text: text) + /// let cursor = try statement.execute() + /// + /// for row in cursor { + /// let weather = try row.get().decodeByColumnIndex(Weather.self) + /// ... + /// } + /// + /// - Parameters: + /// - type: the type of instance to create + /// - defaultTimeZone: the default time zone for certain conversions to Foundation `Date` + /// (see above); if `nil` then the UTC time zone is used + /// - Returns: an instance of the specified type + /// - Throws: `DecodingError` if the operation fails + public func decodeByColumnIndex(_ type: T.Type, + defaultTimeZone: TimeZone? = nil) throws -> T { + + // We cannot assume the decoding order is stable across successive rows of the cursor, + // so create a new RowDecoder instance for each row. + return try RowDecoder(columns: nil).decode( + type, from: columns, defaultTimeZone: defaultTimeZone ?? ISO8601.utcTimeZone) + } + + // // MARK: CustomStringConvertible // diff --git a/Sources/PostgresClientKit/RowDecoder.swift b/Sources/PostgresClientKit/RowDecoder.swift new file mode 100644 index 0000000..fbd58fb --- /dev/null +++ b/Sources/PostgresClientKit/RowDecoder.swift @@ -0,0 +1,452 @@ +// +// RowDecoder.swift +// PostgresClientKit +// +// Copyright 2022 Soroush Khanlou, David Pitfield, and the PostgresClientKit +// contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Decodes `Row` instances to instances of `Decodable` swift types. +/// +/// Refer to the associated public APIs for further information: +/// - `Row.decodeByColumnName(_:defaultTimeZone:)` +/// - `Row.decodeByColumnIndex(_:defaultTimeZone:)` +internal class RowDecoder { + + /// Creates a `RowDecoder`. + /// + /// - Parameter columns: the column metadata used to decode by column name, or `nil` to decode + /// by column index + init(columns: [ColumnMetadata]?) { + inferColumnNames = (columns == nil) // whether to decode by column index + + for column in (columns ?? []) { + _ = columnIndices.addColumn(name: column.name) + } + } + + /// Whether to infer the column names from the order in which the stored properties are decoded + /// ("decode by column index"). + private let inferColumnNames: Bool + + /// A map of columns' names (either explicitly provided by column metadata or inferred from the + /// decoding order) to the indices of those columns' values. + private let columnIndices = ColumnIndices() + + /// Decodes a row to create an instance of the specified type. + /// + /// - Parameters: + /// - type: the type of instance to create + /// - postgresValues: the values of the columns for the row + /// - defaultTimeZone: the default time zone for certain conversions to Foundation `Date` + /// - Returns: an instance of the specified type + /// - Throws: `DecodingError` if the operation fails + func decode(_ type: T.Type, + from postgresValues: [PostgresValue], + defaultTimeZone: TimeZone) throws -> T { + + return try T(from: _RowDecoder(outer: self, + postgresValues: postgresValues, + defaultTimeZone: defaultTimeZone)) + } + + private class ColumnIndices { + + // Postgres lowercases unquoted identifiers. We similarly lowercase column names here, + // making comparisons case-insensitive (for example, "birthdate" matches "birthDate"). + + // Note that a `Cursor` may have duplicated column names, so `indices.count` may not be + // equal to `count`. + + func addColumn(name: String) -> Int { + let index = count + indices[name.lowercased()] = index + count += 1 + return index + } + + func indexOf(name: String) -> Int? { + return indices[name.lowercased()] + } + + // How many times addColumn(name:) was called. + private(set) var count = 0 + + var columns: Set { + return Set(indices.keys) + } + + private var indices = [String: Int]() + } + + // Inner class to hold per-row state. + private class _RowDecoder: Decoder { + + init(outer: RowDecoder, postgresValues: [PostgresValue], defaultTimeZone: TimeZone) { + self.outer = outer + self.postgresValues = postgresValues + self.defaultTimeZone = defaultTimeZone + } + + let outer: RowDecoder + let postgresValues: [PostgresValue] + let defaultTimeZone: TimeZone + + // Gets the value of the column for the specified key. + func value(for key: CodingKey) throws -> T { + + var index = outer.columnIndices.indexOf(name: key.stringValue) + + if index == nil { + guard outer.inferColumnNames && + outer.columnIndices.count < postgresValues.count else { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: [ key ], + debugDescription: "No column named \(key.stringValue)")) + } + + // Add a new name-to-index mapping. + index = outer.columnIndices.addColumn(name: key.stringValue) + } + + let postgresValue = postgresValues[index!] + + // If we want a PostgresValue, then return the column's value as is. + if T.self is PostgresValue.Type { + return postgresValue as! T + } + + // Otherwise, if the column's value is null, report an error + // (Decoder handles optionals through a different code path). + if postgresValue.isNull { + throw DecodingError.valueNotFound( + T.self, + DecodingError.Context( + codingPath: [ key ], + debugDescription: "Value of column is null")) + } + + // Otherwise, convert the column's value to the requested type, reporting any + // conversion errors. + let value: T? + + do { + switch T.self { + case is Bool.Type: value = try postgresValue.bool() as? T + case is String.Type: value = try postgresValue.string() as? T + case is Double.Type: value = try postgresValue.double() as? T + case is Float.Type: value = try Float(postgresValue.double()) as? T + case is Int.Type: value = try postgresValue.int() as? T + case is Int8.Type: value = try Int8(postgresValue.string()) as? T + case is Int16.Type: value = try Int16(postgresValue.string()) as? T + case is Int32.Type: value = try Int32(postgresValue.string()) as? T + case is Int64.Type: value = try Int64(postgresValue.string()) as? T + case is UInt.Type: value = try UInt(postgresValue.string()) as? T + case is UInt8.Type: value = try UInt8(postgresValue.string()) as? T + case is UInt16.Type: value = try UInt16(postgresValue.string()) as? T + case is UInt32.Type: value = try UInt32(postgresValue.string()) as? T + case is UInt64.Type: value = try UInt64(postgresValue.string()) as? T + + default: + fatalError("Unexpected type: \(T.self)") // can't happen + } + } catch { + throw DecodingError.typeMismatch( + T.self, + DecodingError.Context(codingPath: [ key ], + debugDescription: "Invalid value: \(postgresValue.rawValue!)", + underlyingError: error)) + } + + // Some of these type conversions used optional initializers. If the coerced value is + // nil, report a conversion error. + guard let value = value else { + throw DecodingError.typeMismatch( + T.self, + DecodingError.Context(codingPath: [ key ], + debugDescription: "Invalid value: \(postgresValue.rawValue!)")) + } + + return value + } + + + // + // MARK: Decoder conformance + // + + var codingPath: [CodingKey] = [] + var userInfo: [CodingUserInfoKey: Any] = [:] // not used by this Decoder + + func container(keyedBy type: Key.Type) throws + -> KeyedDecodingContainer where Key: CodingKey { + + return KeyedDecodingContainer(RowKeyedDecodingContainer(decoder: self)) + } + + func unkeyedContainer() throws -> UnkeyedDecodingContainer { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: codingPath, + debugDescription: "Unkeyed containers not supported")) + } + + func singleValueContainer() throws -> SingleValueDecodingContainer { + return RowSingleValueDecodingContainer(decoder: self) + } + + struct RowKeyedDecodingContainer: KeyedDecodingContainerProtocol { + + let decoder: _RowDecoder + + var outer: RowDecoder { + decoder.outer + } + + var codingPath: [CodingKey] { + decoder.codingPath + } + + var allKeys: [Key] { + // When decoding by column index, we can only return "all known keys". + // In any case, this method doesn't appear to ever get called. + outer.columnIndices.columns.compactMap { Key(stringValue: $0) } + } + + func contains(_ key: Key) -> Bool { + // When decoding by column index, we return true if we're prepared to add a new + // name-to-index mapping, even if we haven't heard of this column name until now. + return outer.columnIndices.indexOf(name: key.stringValue) != nil || + (outer.inferColumnNames && + outer.columnIndices.count < decoder.postgresValues.count) + } + + func value(for key: Key) throws -> T { + guard codingPath.isEmpty else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: codingPath, + debugDescription: "Nested containers not supported")) + } + + return try decoder.value(for: key) + } + + func decodeNil(forKey key: Key) throws -> Bool { + return try (value(for: key) as PostgresValue).isNull + } + + func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { + return try value(for: key) + } + + func decode(_ type: String.Type, forKey key: Key) throws -> String { + return try value(for: key) + } + + func decode(_ type: Double.Type, forKey key: Key) throws -> Double { + return try value(for: key) + } + + func decode(_ type: Float.Type, forKey key: Key) throws -> Float { + return try value(for: key) + } + + func decode(_ type: Int.Type, forKey key: Key) throws -> Int { + return try value(for: key) + } + + func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { + return try value(for: key) + } + + func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { + return try value(for: key) + } + + func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { + return try value(for: key) + } + + func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { + return try value(for: key) + } + + func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { + return try value(for: key) + } + + func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { + return try value(for: key) + } + + func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { + return try value(for: key) + } + + func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { + return try value(for: key) + } + + func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { + return try value(for: key) + } + + func decode(_ type: T.Type, forKey key: Key) throws -> T where T: Decodable { + + if type == Date.self { + // For Foundation Dates, we override the default Decodable implementation + // and try converting the PostgresValue to each of the 5 PostgresClientKit + // types for dates/times. Three of these conversions also require the API + // consumer to supply a TimeZone to use. + let postgresValue: PostgresValue = try value(for: key) + + let value: T? = + (try? postgresValue.timestampWithTimeZone().date as? T) ?? + (try? postgresValue.timestamp().date(in: decoder.defaultTimeZone) as? T) ?? + (try? postgresValue.date().date(in: decoder.defaultTimeZone) as? T) ?? + (try? postgresValue.time().date(in: decoder.defaultTimeZone) as? T) ?? + (try? postgresValue.timeWithTimeZone().date as? T) + + guard let value = value else { + throw DecodingError.typeMismatch( + T.self, + DecodingError.Context( + codingPath: [ key ], + debugDescription: "Invalid value: \(postgresValue.rawValue!)")) + } + + return value + } else { + // For any other type, delegate to that type's Decodable implementation. + decoder.codingPath += [ key ] + defer { decoder.codingPath.removeLast() } + return try T(from: decoder) + } + } + + func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws + -> KeyedDecodingContainer where NestedKey: CodingKey { + + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: codingPath, + debugDescription: "Nested containers not supported")) + } + + func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: codingPath, + debugDescription: "Nested containers not supported")) + } + + func superDecoder() throws -> Decoder { + return decoder + } + + func superDecoder(forKey key: Key) throws -> Decoder { + return decoder + } + } + + struct RowSingleValueDecodingContainer: SingleValueDecodingContainer { + + let decoder: _RowDecoder + + var codingPath: [CodingKey] { + decoder.codingPath + } + + func value() throws -> T { + guard let key = codingPath.last else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: codingPath, + debugDescription: "SingleValueDecodingContainer requires codingPath")) + } + + return try decoder.value(for: key) + } + + func decodeNil() -> Bool { + let column: PostgresValue? = try? value() // this function doesn't throw + return column?.isNull ?? false + } + + func decode(_ type: Bool.Type) throws -> Bool { + return try value() + } + + func decode(_ type: String.Type) throws -> String { + return try value() + } + + func decode(_ type: Double.Type) throws -> Double { + return try value() + } + + func decode(_ type: Float.Type) throws -> Float { + return try value() + } + + func decode(_ type: Int.Type) throws -> Int { + return try value() + } + + func decode(_ type: Int8.Type) throws -> Int8 { + return try value() + } + + func decode(_ type: Int16.Type) throws -> Int16 { + return try value() + } + + func decode(_ type: Int32.Type) throws -> Int32 { + return try value() + } + + func decode(_ type: Int64.Type) throws -> Int64 { + return try value() + } + + func decode(_ type: UInt.Type) throws -> UInt { + return try value() + } + + func decode(_ type: UInt8.Type) throws -> UInt8 { + return try value() + } + + func decode(_ type: UInt16.Type) throws -> UInt16 { + return try value() + } + + func decode(_ type: UInt32.Type) throws -> UInt32 { + return try value() + } + + func decode(_ type: UInt64.Type) throws -> UInt64 { + return try value() + } + + func decode(_ type: T.Type) throws -> T where T: Decodable { + return try T(from: decoder) + } + } + } +} + +// EOF diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100644 index 1424c2e..0000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,8 +0,0 @@ -import XCTest - -import PostgresClientKitTests - -var tests = [XCTestCaseEntry]() -tests += PostgresClientKitTests.__allTests() - -XCTMain(tests) diff --git a/Tests/PostgresClientKitTests/RowDecoderTest.swift b/Tests/PostgresClientKitTests/RowDecoderTest.swift new file mode 100644 index 0000000..6cba68a --- /dev/null +++ b/Tests/PostgresClientKitTests/RowDecoderTest.swift @@ -0,0 +1,522 @@ +// +// RowDecoderTest.swift +// PostgresClientKit +// +// Copyright 2022 David Pitfield and the PostgresClientKit contributors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@testable import PostgresClientKit +import XCTest + +/// Tests RowDecoder. +class RowDecoderTest: PostgresClientKitTestCase { + + override func setUp() { + do { + try createWeatherTable() + } catch { + XCTFail(String(describing: error)) + } + } + + struct WeatherStruct: Decodable, Equatable { + let date: PostgresDate + let city: String + let temp_lo: Int + let temp_hi: Int + let prcp: Double? + } + + class WeatherClass: Decodable, Equatable { + + init(date: PostgresDate, city: String, temp_lo: Int, temp_hi: Int, prcp: Double?) { + self.date = date + self.city = city + self.temp_lo = temp_lo + self.temp_hi = temp_hi + self.prcp = prcp + } + + let date: PostgresDate + let city: String + let temp_lo: Int + let temp_hi: Int + let prcp: Double? + + static func == (lhs: WeatherClass, rhs: WeatherClass) -> Bool { + return lhs.date == rhs.date && + lhs.city == rhs.city && + lhs.temp_lo == rhs.temp_lo && + lhs.temp_hi == rhs.temp_hi && + lhs.prcp == rhs.prcp + } + } + + func testBasicOperation() { + + let weatherExpectedResults = [ + WeatherStruct(date: PostgresDate("1994-11-27")!, city: "San Francisco", temp_lo: 46, temp_hi: 50, prcp: 0.25), + WeatherStruct(date: PostgresDate("1994-11-29")!, city: "Hayward", temp_lo: 37, temp_hi: 54, prcp: nil), + WeatherStruct(date: PostgresDate("1994-11-29")!, city: "San Francisco", temp_lo: 43, temp_hi: 57, prcp: 0) + ] + + struct StringAndOptionalString: Decodable, Equatable { + let string: String + let optionalString: String? + } + + /// decodeByColumnName: basic scenario + try XCTAssertEqual( + decodeByColumnName( + sql: "SELECT date, city, temp_lo, temp_hi, prcp FROM weather ORDER BY date, city;", + type: WeatherStruct.self), + weatherExpectedResults) + + /// decodeByColumnName: fails if retrieveColumnMetadata is false + try XCTAssertThrowsError( + decodeByColumnName( + sql: "SELECT date, city, temp_lo, temp_hi, prcp FROM weather ORDER BY date, city;", + type: WeatherStruct.self, + retrieveColumnMetadata: false)) + { error in + guard case PostgresError.columnMetadataNotAvailable = error else { + return XCTFail(String(describing: error)) + } + } + + /// decodeByColumnName: column order doesn't matter + try XCTAssertEqual( + decodeByColumnName( + sql: "SELECT city, temp_lo, temp_hi, prcp, date FROM weather ORDER BY date, city;", + type: WeatherStruct.self), + weatherExpectedResults) + + /// decodeByColumnName: extra columns are ignored + try XCTAssertEqual( + decodeByColumnName( + sql: "SELECT 'hello' x1, date, city, 314 x2, temp_lo, temp_hi, prcp FROM weather ORDER BY date, city;", + type: WeatherStruct.self), + weatherExpectedResults) + + /// decodeByColumnName: another basic scenario + try XCTAssertEqual( + decodeByColumnName( + sql: "SELECT 's1' string, 's2' optionalString;", + type: StringAndOptionalString.self), + [StringAndOptionalString(string: "s1", optionalString: "s2")]) + + /// decodeByColumnName: duplicate column names (last one wins) + try XCTAssertEqual( + decodeByColumnName( + sql: "SELECT 's1' string, 's2' optionalString, 's3' string;", + type: StringAndOptionalString.self), + [StringAndOptionalString(string: "s3", optionalString: "s2")]) + + /// decodeByColumnName: column names are case-insensitive + try XCTAssertEqual( + decodeByColumnName( + sql: "SELECT 's1' \"STRING\", 's2' \"optionalSTRING\";", + type: StringAndOptionalString.self), + [StringAndOptionalString(string: "s1", optionalString: "s2")]) + + /// decodeByColumnName: a missing column for an optional property is allowed + try XCTAssertEqual( + decodeByColumnName( + sql: "SELECT 's1' string;", + type: StringAndOptionalString.self), + [StringAndOptionalString(string: "s1", optionalString: nil)]) + + /// decodeByColumnName: fails if missing a column for a non-optional property + try XCTAssertThrowsError( + decodeByColumnName( + sql: "SELECT 's2' optionalString;", + type: StringAndOptionalString.self)) + { error in + guard case DecodingError.keyNotFound = error else { + return XCTFail(String(describing: error)) + } + } + + /// decodeByColumnName: fails if a non-optional property is NULL + try XCTAssertThrowsError( + decodeByColumnName( + sql: "SELECT NULL string;", + type: StringAndOptionalString.self)) + { error in + guard case DecodingError.valueNotFound = error else { + return XCTFail(String(describing: error)) + } + } + + /// decodeByColumnIndex: basic scenario + try XCTAssertEqual( + decodeByColumnIndex( + sql: "SELECT date, city, temp_lo, temp_hi, prcp FROM weather ORDER BY date, city;", + type: WeatherStruct.self), + weatherExpectedResults) + + /// decodeByColumnIndex: column order does matter + try XCTAssertThrowsError( + decodeByColumnIndex( + sql: "SELECT city, temp_lo, temp_hi, prcp, date FROM weather ORDER BY date, city;", + type: WeatherStruct.self)) + { error in + guard case DecodingError.typeMismatch = error else { + return XCTFail(String(describing: error)) + } + } + + /// decodeByColumnIndex: extra columns are ignored (if at the end) + try XCTAssertEqual( + decodeByColumnIndex( + sql: "SELECT date, city, temp_lo, temp_hi, prcp, 'hello' x1 FROM weather ORDER BY date, city;", + type: WeatherStruct.self), + weatherExpectedResults) + + /// decodeByColumnIndex: another basic scenario + try XCTAssertEqual( + decodeByColumnIndex( + sql: "SELECT 's1' string, 's2' optionalString;", + type: StringAndOptionalString.self), + [StringAndOptionalString(string: "s1", optionalString: "s2")]) + + /// decodeByColumnIndex: column names are ignored + try XCTAssertEqual( + decodeByColumnIndex( + sql: "SELECT 's1' optionalString, 's2' string;", + type: StringAndOptionalString.self), + [StringAndOptionalString(string: "s1", optionalString: "s2")]) + + /// decodeByColumnIndex: column names are ignored + try XCTAssertEqual( + decodeByColumnIndex( + sql: "SELECT 's1' foo, 's2' foo;", + type: StringAndOptionalString.self), + [StringAndOptionalString(string: "s1", optionalString: "s2")]) + + /// decodeByColumnIndex: a missing column for an optional property is allowed + try XCTAssertEqual( + decodeByColumnIndex( + sql: "SELECT 's1' string;", + type: StringAndOptionalString.self), + [StringAndOptionalString(string: "s1", optionalString: nil)]) + + /// decodeByColumnIndex: fails if missing a column for a non-optional property + try XCTAssertThrowsError( + decodeByColumnIndex( + sql: "SELECT;", + type: StringAndOptionalString.self)) + { error in + guard case DecodingError.keyNotFound = error else { + return XCTFail(String(describing: error)) + } + } + + /// decodeByColumnIndex: fails if a non-optional property is NULL + try XCTAssertThrowsError( + decodeByColumnIndex( + sql: "SELECT NULL string;", + type: StringAndOptionalString.self)) + { error in + guard case DecodingError.valueNotFound = error else { + return XCTFail(String(describing: error)) + } + } + } + + func testDecodableClass() { + + let weatherExpectedResults = [ + WeatherClass(date: PostgresDate("1994-11-27")!, city: "San Francisco", temp_lo: 46, temp_hi: 50, prcp: 0.25), + WeatherClass(date: PostgresDate("1994-11-29")!, city: "Hayward", temp_lo: 37, temp_hi: 54, prcp: nil), + WeatherClass(date: PostgresDate("1994-11-29")!, city: "San Francisco", temp_lo: 43, temp_hi: 57, prcp: 0) + ] + + /// decodeByColumnName: basic scenario + try XCTAssertEqual( + decodeByColumnName( + sql: "SELECT date, city, temp_lo, temp_hi, prcp FROM weather ORDER BY date, city;", + type: WeatherClass.self), + weatherExpectedResults) + + /// decodeByColumnIndex: basic scenario + try XCTAssertEqual( + decodeByColumnIndex( + sql: "SELECT date, city, temp_lo, temp_hi, prcp FROM weather ORDER BY date, city;", + type: WeatherClass.self), + weatherExpectedResults) + } + + func testStandardLibraryTypes() { + + struct StandardLibraryTypes: Decodable, Equatable { + let bool: Bool + let string: String + let double: Double + let float: Float + let int: Int + let int8: Int8 + let int16: Int16 + let int32: Int32 + let int64: Int64 + let uint: UInt + let uint8: UInt8 + let uint16: UInt16 + let uint32: UInt32 + let uint64: UInt64 + } + + try XCTAssertEqual( + decodeByColumnIndex( + sql: + """ + SELECT + true, 'hello', 3.14, -3.14, + -9223372036854775808, -128, -32768, -2147483648, -9223372036854775808, + 18446744073709551615, 255, 65535, 4294967295, 18446744073709551615; + """, + type: StandardLibraryTypes.self), + [StandardLibraryTypes( + bool: true, string: "hello", double: 3.14, float: -3.14, + int: Int.min, int8: Int8.min, int16: Int16.min, int32: Int32.min, int64: Int64.min, + uint: UInt.max, uint8: UInt8.max, uint16: UInt16.max, uint32: UInt32.max, uint64: UInt64.max)]) + + struct Int8AndDouble: Decodable, Equatable { + let int8: Int8? + let double: Double? + } + + try XCTAssertEqual( + decodeByColumnIndex( + sql: "SELECT '123', '3.14';", + type: Int8AndDouble.self), + [Int8AndDouble(int8: 123, double: 3.14)]) + + try XCTAssertThrowsError( + decodeByColumnIndex( + sql: "SELECT 123.4, NULL;", + type: Int8AndDouble.self)) + { error in + guard case DecodingError.typeMismatch = error else { + return XCTFail(String(describing: error)) + } + } + + try XCTAssertThrowsError( + decodeByColumnIndex( + sql: "SELECT NULL, '3point14';", + type: Int8AndDouble.self)) + { error in + guard case DecodingError.typeMismatch = error else { + return XCTFail(String(describing: error)) + } + } + } + + func testPostgresClientKitTypes() { + + struct PostgresClientKitTypes: Decodable, Equatable { + let postgresByteA: PostgresByteA + let postgresTimestampWithTimeZone: PostgresTimestampWithTimeZone + let postgresTimestamp: PostgresTimestamp + let postgresDate: PostgresDate + let postgresTime: PostgresTime + let postgresTimeWithTimeZone: PostgresTimeWithTimeZone + } + + try XCTAssertEqual( + decodeByColumnIndex( + sql: + """ + SELECT + CAST('\\xdeadbeef' as BYTEA), + CAST('2019-03-14 16:25:19.365+00:00' as TIMESTAMP WITH TIME ZONE), + CAST('2019-03-14 16:25:19.365' as TIMESTAMP), + CAST('2019-03-14' as DATE), + CAST('16:25:19.365' as TIME), + CAST('16:25:19.365+00:00' as TIME WITH TIME ZONE); + """, + type: PostgresClientKitTypes.self), + [PostgresClientKitTypes( + postgresByteA: PostgresByteA("\\xDEADBEEF")!, + postgresTimestampWithTimeZone: PostgresTimestampWithTimeZone("2019-03-14 16:25:19.365+00:00")!, + postgresTimestamp: PostgresTimestamp("2019-03-14 16:25:19.365")!, + postgresDate: PostgresDate("2019-03-14")!, + postgresTime: PostgresTime("16:25:19.365")!, + postgresTimeWithTimeZone: PostgresTimeWithTimeZone("16:25:19.365+00:00")!)]) + } + + func testFoundationDate() { + + struct FoundationDate: Decodable, Equatable { + let dateFromTimestampWithTimeZone: Date + let dateFromTimestamp: Date + let dateFromDate: Date + let dateFromTime: Date + let dateFromTimeWithTimeZone: Date + } + + let utcTimeZone = TimeZone(secondsFromGMT: 0)! + + try XCTAssertEqual( + decodeByColumnIndex( + sql: + """ + SELECT + '2019-03-14 16:25:19.365+00:00', + '2019-03-14 16:25:19.365', + '2019-03-14', + '16:25:19.365', + '16:25:19.365+00:00'; + """, + type: FoundationDate.self), + [FoundationDate( + dateFromTimestampWithTimeZone: PostgresTimestampWithTimeZone("2019-03-14 16:25:19.365+00:00")!.date, + dateFromTimestamp: PostgresTimestamp("2019-03-14 16:25:19.365")!.date(in: utcTimeZone), + dateFromDate: PostgresDate("2019-03-14")!.date(in: utcTimeZone), + dateFromTime: PostgresTime("16:25:19.365")!.date(in: utcTimeZone), + dateFromTimeWithTimeZone: PostgresTimeWithTimeZone("16:25:19.365+00:00")!.date)]) + + let pstTimeZone = TimeZone(secondsFromGMT: -8 * 60 * 60)! + + try XCTAssertEqual( + decodeByColumnIndex( + sql: + """ + SELECT + '2019-03-14 16:25:19.365-08:00', + '2019-03-14 16:25:19.365', + '2019-03-14', + '16:25:19.365', + '16:25:19.365-08:00'; + """, + type: FoundationDate.self, + defaultTimeZone: pstTimeZone), + [FoundationDate( + dateFromTimestampWithTimeZone: PostgresTimestampWithTimeZone("2019-03-14 16:25:19.365-08:00")!.date, + dateFromTimestamp: PostgresTimestamp("2019-03-14 16:25:19.365")!.date(in: pstTimeZone), + dateFromDate: PostgresDate("2019-03-14")!.date(in: pstTimeZone), + dateFromTime: PostgresTime("16:25:19.365")!.date(in: pstTimeZone), + dateFromTimeWithTimeZone: PostgresTimeWithTimeZone("16:25:19.365-08:00")!.date)]) + } + + func testPerformance() { + do { + // INSERT 1000 days of random weather records for San Jose. + let connection = try Connection(configuration: terryConnectionConfiguration()) + try connection.beginTransaction() + let text = "INSERT INTO weather (date, city, temp_lo, temp_hi, prcp) VALUES ($1, $2, $3, $4, $5)" + let statement = try connection.prepareStatement(text: text) + var weatherHistory = [WeatherStruct]() + + for i in 0..<1_000 { + + let tempLo = Int.random(in: 20...70) + let tempHi = Int.random(in: tempLo...100) + + let prcp: Double? = { + let r = Double.random(in: 0..<1) + if r < 0.1 { return nil } + if r < 0.8 { return 0.0 } + return Double(Int.random(in: 1...20)) / 10.0 + }() + + let date: PostgresDate = { + let pgd = PostgresDate(year: 2000, month: 1, day: 1)! + var d = pgd.date(in: utcTimeZone) + d = enUsPosixUtcCalendar.date(byAdding: .day, value: i, to: d)! + return d.postgresDate(in: utcTimeZone) + }() + + let weather = WeatherStruct( + date: date, city: "San Jose", temp_lo: tempLo, temp_hi: tempHi, prcp: prcp) + + weatherHistory.append(weather) + + let cursor = try statement.execute(parameterValues: + [ weather.date, weather.city, weather.temp_lo, weather.temp_hi, weather.prcp ]) + + XCTAssertEqual(cursor.rowCount, 1) + } + + try connection.commitTransaction() + + // SELECT the weather records and decode by name + var selectedWeatherHistory = [WeatherStruct]() + try time("SELECT \(weatherHistory.count) rows and decode by name") { + selectedWeatherHistory = try decodeByColumnName( + sql: "SELECT date, city, temp_lo, temp_hi, prcp FROM weather WHERE city = 'San Jose' ORDER BY date;", + type: WeatherStruct.self) + } + XCTAssertEqual(selectedWeatherHistory, weatherHistory) + + // SELECT the weather records and decode by index + try time("SELECT \(weatherHistory.count) rows and decode by index") { + selectedWeatherHistory = try decodeByColumnIndex( + sql: "SELECT date, city, temp_lo, temp_hi, prcp FROM weather WHERE city = 'San Jose' ORDER BY date;", + type: WeatherStruct.self) + } + XCTAssertEqual(selectedWeatherHistory, weatherHistory) + } catch { + XCTFail(String(describing: error)) + } + } + + + // + // MARK: Helper functions + // + + func decodeByColumnName(sql: String, + type: T.Type, + retrieveColumnMetadata: Bool = true, + defaultTimeZone: TimeZone? = nil) throws -> [T] where T: Decodable { + let connection = try Connection(configuration: terryConnectionConfiguration()) + let statement = try connection.prepareStatement(text: sql) + let cursor = try statement.execute(retrieveColumnMetadata: retrieveColumnMetadata) + var results = [T]() + + for row in cursor { + results += [ try row.get().decodeByColumnName(T.self, defaultTimeZone: defaultTimeZone) ] + } + + return results + } + + func decodeByColumnIndex(sql: String, + type: T.Type, + defaultTimeZone: TimeZone? = nil) throws -> [T] where T: Decodable { + + let connection = try Connection(configuration: terryConnectionConfiguration()) + let statement = try connection.prepareStatement(text: sql) + let cursor = try statement.execute(retrieveColumnMetadata: false) + var results = [T]() + + for row in cursor { + results += [ try row.get().decodeByColumnIndex(T.self, defaultTimeZone: defaultTimeZone) ] + } + + return results + } + + func time(_ name: String, operation: () throws -> Void) throws { + let start = Date() + try operation() + let elapsed = Date().timeIntervalSince(start) * 1000 + Postgres.logger.info("\(name): elapsed time \(elapsed) ms") + } +} + +// EOF diff --git a/Tests/PostgresClientKitTests/XCTestManifests.swift b/Tests/PostgresClientKitTests/XCTestManifests.swift deleted file mode 100644 index 0d885e2..0000000 --- a/Tests/PostgresClientKitTests/XCTestManifests.swift +++ /dev/null @@ -1,260 +0,0 @@ -#if !canImport(ObjectiveC) -import XCTest - -extension ConnectionConfigurationTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__ConnectionConfigurationTest = [ - ("test", test), - ] -} - -extension ConnectionDelegateTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__ConnectionDelegateTest = [ - ("test", test), - ] -} - -extension ConnectionPoolConfigurationTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__ConnectionPoolConfigurationTest = [ - ("test", test), - ] -} - -extension ConnectionPoolMetricsTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__ConnectionPoolMetricsTest = [ - ("test", test), - ] -} - -extension ConnectionPoolStressTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__ConnectionPoolStressTest = [ - ("test", test), - ] -} - -extension ConnectionPoolTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__ConnectionPoolTest = [ - ("test", test), - ] -} - -extension ConnectionTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__ConnectionTest = [ - ("testApplicationName", testApplicationName), - ("testConnectionLifecycle", testConnectionLifecycle), - ("testCreateConnection", testCreateConnection), - ("testErrorRecovery", testErrorRecovery), - ("testTransactions", testTransactions), - ] -} - -extension CryptoTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__CryptoTest = [ - ("testHMACSHA256", testHMACSHA256), - ("testMD5", testMD5), - ("testPBKDF2HMACSHA256", testPBKDF2HMACSHA256), - ("testSHA256", testSHA256), - ] -} - -extension CursorTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__CursorTest = [ - ("testCursorLifecycle", testCursorLifecycle), - ] -} - -extension DataTypeTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__DataTypeTest = [ - ("test", test), - ] -} - -extension ISO8601Test { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__ISO8601Test = [ - ("testDateComponentsFromDate", testDateComponentsFromDate), - ("testFormatDate", testFormatDate), - ("testFormatTime", testFormatTime), - ("testFormatTimestamp", testFormatTimestamp), - ("testFormatTimestampWithTimeZone", testFormatTimestampWithTimeZone), - ("testFormatTimeWithTimeZone", testFormatTimeWithTimeZone), - ("testParseDate", testParseDate), - ("testParseTime", testParseTime), - ("testParseTimestamp", testParseTimestamp), - ("testParseTimestampWithTimeZone", testParseTimestampWithTimeZone), - ("testParseTimesWithTimeZone", testParseTimesWithTimeZone), - ("testTimeZoneHasFixedOffset", testTimeZoneHasFixedOffset), - ] -} - -extension LoggingTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__LoggingTest = [ - ("test", test), - ] -} - -extension PostgresByteATest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__PostgresByteATest = [ - ("test", test), - ] -} - -extension PostgresDateTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__PostgresDateTest = [ - ("test", test), - ] -} - -extension PostgresTimeTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__PostgresTimeTest = [ - ("test", test), - ] -} - -extension PostgresTimeWithTimeZoneTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__PostgresTimeWithTimeZoneTest = [ - ("test", test), - ] -} - -extension PostgresTimestampTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__PostgresTimestampTest = [ - ("test", test), - ] -} - -extension PostgresTimestampWithTimeZoneTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__PostgresTimestampWithTimeZoneTest = [ - ("test", test), - ] -} - -extension PostgresValueTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__PostgresValueTest = [ - ("test", test), - ] -} - -extension SASLPrepTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__SASLPrepTest = [ - ("test", test), - ] -} - -extension SCRAMSHA256AuthenticatorTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__SCRAMSHA256AuthenticatorTest = [ - ("test", test), - ] -} - -extension SQLStatementTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__SQLStatementTest = [ - ("testCRUD", testCRUD), - ("testResultMetadata", testResultMetadata), - ("testSQLCursor", testSQLCursor), - ] -} - -extension StatementTest { - // DO NOT MODIFY: This is autogenerated, use: - // `swift test --generate-linuxmain` - // to regenerate. - static let __allTests__StatementTest = [ - ("testExecuteStatement", testExecuteStatement), - ("testPrepareStatement", testPrepareStatement), - ("testStatementLifecycle", testStatementLifecycle), - ] -} - -public func __allTests() -> [XCTestCaseEntry] { - return [ - testCase(ConnectionConfigurationTest.__allTests__ConnectionConfigurationTest), - testCase(ConnectionDelegateTest.__allTests__ConnectionDelegateTest), - testCase(ConnectionPoolConfigurationTest.__allTests__ConnectionPoolConfigurationTest), - testCase(ConnectionPoolMetricsTest.__allTests__ConnectionPoolMetricsTest), - testCase(ConnectionPoolStressTest.__allTests__ConnectionPoolStressTest), - testCase(ConnectionPoolTest.__allTests__ConnectionPoolTest), - testCase(ConnectionTest.__allTests__ConnectionTest), - testCase(CryptoTest.__allTests__CryptoTest), - testCase(CursorTest.__allTests__CursorTest), - testCase(DataTypeTest.__allTests__DataTypeTest), - testCase(ISO8601Test.__allTests__ISO8601Test), - testCase(LoggingTest.__allTests__LoggingTest), - testCase(PostgresByteATest.__allTests__PostgresByteATest), - testCase(PostgresDateTest.__allTests__PostgresDateTest), - testCase(PostgresTimeTest.__allTests__PostgresTimeTest), - testCase(PostgresTimeWithTimeZoneTest.__allTests__PostgresTimeWithTimeZoneTest), - testCase(PostgresTimestampTest.__allTests__PostgresTimestampTest), - testCase(PostgresTimestampWithTimeZoneTest.__allTests__PostgresTimestampWithTimeZoneTest), - testCase(PostgresValueTest.__allTests__PostgresValueTest), - testCase(SASLPrepTest.__allTests__SASLPrepTest), - testCase(SCRAMSHA256AuthenticatorTest.__allTests__SCRAMSHA256AuthenticatorTest), - testCase(SQLStatementTest.__allTests__SQLStatementTest), - testCase(StatementTest.__allTests__StatementTest), - ] -} -#endif