vapor / fluent-postgres-driver

🐘 PostgreSQL driver for Fluent.
MIT License
149 stars 53 forks source link

[Beta] Encode & Decode enums when possible #12

Closed bensyverson closed 6 years ago

bensyverson commented 6 years ago

Summary

It might just be temporarily broken, but I haven't been able to get Int or String enums to encode & decode to PostgreSQL.

Details

It should be possible to do the following:

final class User: Codable, Content {
  enum Authorization : Int, Codable {
    case unauthorized = 0, member = 1, moderator = 2, admin = 3, owner = 4
  }
  var authorization: Authorization
}

However, this results in the following error:

[ ERROR ] PostgreSQL.PostgreSQLError.convertible: Unsupported encodable type: Authorization (PostgreSQLRowEncoder.swift:65)
[ DEBUG ] Suggested fixes for PostgreSQL.PostgreSQLError.convertible: Conform Authorization to PostgreSQLDataCustomConvertible (EngineServer.swift:198)

Despite the error, the table is being created properly:

miiine=# \d+ users;
                                                   Table "public.users"
    Column     |  Type   | Collation | Nullable |             Default              | Storage  | Stats target | Description 
---------------+---------+-----------+----------+----------------------------------+----------+--------------+-------------
 id            | bigint  |           | not null | generated by default as identity | plain    |              | 
 name          | text    |           | not null |                                  | extended |              | 
 username      | text    |           | not null |                                  | extended |              | 
 password      | text    |           | not null |                                  | extended |              | 
 authorization | integer |           | not null |                                  | plain    |              | 
Indexes:
    "users_pkey" PRIMARY KEY, btree (id)

Workarounds

I can manually serialize the value, but it's gross:

final class User: Codable, Content {
  enum Authorization : Int, Codable {
    case unauthorized = 0, member = 1, moderator = 2, admin = 3, owner = 4
  }
  var authorizationLevel : Int = Authorization.unauthorized.rawValue
  var authorization: Authorization {
    get {
      guard let auth = Authorization(rawValue: authorizationLevel) else {
        return .unauthorized
      }
      return auth
    }
    set (newValue) {
      self.authorizationLevel = newValue.rawValue
    }
  }
}

Similarly, I can manually conform to PostgreSQLDataCustomConvertible, but this is unwieldy and fragile:

enum Authorization : Int, Codable, PostgreSQLDataCustomConvertible {
  static var postgreSQLDataType: PostgreSQLDataType = RawValue.postgreSQLDataType

  static var postgreSQLDataArrayType: PostgreSQLDataType = RawValue.postgreSQLDataArrayType

  static func convertFromPostgreSQLData(_ data: PostgreSQLData) throws -> User.Authorization {
    let intData = try RawValue.convertFromPostgreSQLData(data)
    guard let auth = User.Authorization(rawValue: intData) else {
      throw Abort(.badRequest, reason: "Invalid auth level")
    }
    return auth
  }

  func convertToPostgreSQLData() throws -> PostgreSQLData {
    return try self.rawValue.convertToPostgreSQLData()
  }

  case unauthorized, member, moderator, admin, owner
}
bensyverson commented 6 years ago

Okay, a bit more detail. With the following extension, I can more easily conform Int enums to PostgreSQLDataCustomConvertible:

extension RawRepresentable where RawValue == Int {
  static var postgreSQLDataType: PostgreSQLDataType {
    return Self.postgreSQLDataType
  }

  static var postgreSQLDataArrayType: PostgreSQLDataType {
    return Self.postgreSQLDataArrayType
  }

  static func convertFromPostgreSQLData(_ data: PostgreSQLData) throws -> Self {
    let intData = try RawValue.convertFromPostgreSQLData(data)
    guard let auth = Self(rawValue: intData) else {
      throw Abort(.badRequest, reason: "Invalid enum value")
    }
    return auth
  }

  func convertToPostgreSQLData() throws -> PostgreSQLData {
    return try self.rawValue.convertToPostgreSQLData()
  }
}

Now I can do the following:

enum Status : Int, Codable {
  case uninitialized, created, claimed, expired
}

extension Status : PostgreSQLDataCustomConvertible { }

…and the enum can be used within a Model as expected. Personally, I think it's acceptable for the user to manually extend the enum to PostgreSQLDataCustomConvertible by using the line: extension Status : PostgreSQLDataCustomConvertible { }

This follows the pattern set by other extensions such as: extension User: PostgreSQLModel etc.

bensyverson commented 6 years ago

It turns out String enums are a bit more gnarly. I start with a global RawRepresentable extension which is identical to the Int version except for the where clause:

extension RawRepresentable where RawValue == String {
  static var postgreSQLDataType: PostgreSQLDataType {
    return Self.postgreSQLDataType
  }

  static var postgreSQLDataArrayType: PostgreSQLDataType {
    return Self.postgreSQLDataArrayType
  }

  static func convertFromPostgreSQLData(_ data: PostgreSQLData) throws -> Self {
    let aRawValue = try RawValue.convertFromPostgreSQLData(data)
    guard let enumValue = Self(rawValue: aRawValue) else {
      throw Abort(.badRequest, reason: "Invalid enum value")
    }
    return enumValue
  }

  func convertToPostgreSQLData() throws -> PostgreSQLData {
    return try self.rawValue.convertToPostgreSQLData()
  }
}

Now I can define String enums like this:

enum Status : String, Codable {
  case uninitialized, created, claimed, expired
}

extension Status : KeyStringDecodable, PostgreSQLDataCustomConvertible, PostgreSQLColumnStaticRepresentable {
  static var postgreSQLColumn: PostgreSQLColumn = RawValue.postgreSQLColumn
  static var keyStringTrue: Status = .created
  static var keyStringFalse: Status = .uninitialized
}

Interestingly, I have to define postgreSQLColumn in the enum extension. If I try to conform to it in a protocol extension, the compiler can't figure out which method to use, because some other protocol (maybe PostgreSQLDataCustomConvertible?) already defines postgreSQLColumn.

In any case, it feels pretty clunky. Especially the KeyStringDecodable conformance, which forces me to specify a "truthiness" for the enum…

tanner0101 commented 6 years ago

I would try:

extension RawRepresentable where RawValue: PostgreSQLDataCustomConvertible {
    ...
}

Then at least it would be a simple matter of just adding:

extension MyEnum: PostgreSQLDataCustomConvertible { }

If that works, we can definitely add the generic RawRepresentable extension to the vapor/postgresql package.

extension RawRepresentable where RawValue: PostgreSQLColumnStaticRepresentable {
    ...
}

This ^ as well, which we could add to the vapor/fluent-postgresql package to make column representation just another simple conformance.

Regarding the KeyStringDecodable, unfortunately there's no way around that as Swift doesn't offer us any methods for discovering the available cases on an enum at runtime (cc @gwynne). There are certainly ways to hack this using knowledge of the ABI, but we'd really like to avoid that.

IIRC, there are some swift evolution proposals up for solutions to this enum case discovery problem.

With the above extensions added by vapor/fluent-postgres and vapor/postgresql, we could at least simplify things to look like:

enum Status : String, Codable {
  case uninitialized, created, claimed, expired
}

extension Status: KeyStringDecodable, PostgreSQLType  {
  static var keyStringTrue: Status = .created
  static var keyStringFalse: Status = .uninitialized
}

(See https://github.com/vapor/fluent-postgresql/blob/beta/Sources/FluentPostgreSQL/PostgreSQLType.swift#L4 btw)

bensyverson commented 6 years ago

@tanner0101 Oh, cool, that worked! Using the following extension:

extension RawRepresentable where RawValue: PostgreSQLDataCustomConvertible {
  static var postgreSQLDataType: PostgreSQLDataType {
    return Self.postgreSQLDataType
  }

  static var postgreSQLDataArrayType: PostgreSQLDataType {
    return Self.postgreSQLDataArrayType
  }

  static func convertFromPostgreSQLData(_ data: PostgreSQLData) throws -> Self {
    let aRawValue = try RawValue.convertFromPostgreSQLData(data)
    guard let enumValue = Self(rawValue: aRawValue) else {
      throw Abort(.badRequest, reason: "Invalid enum value")
    }
    return enumValue
  }

  func convertToPostgreSQLData() throws -> PostgreSQLData {
    return try self.rawValue.convertToPostgreSQLData()
  }
}

I can now do this:

enum Status : String, Codable {
  case uninitialized, created, claimed, expired
}

extension Status : KeyStringDecodable, PostgreSQLDataCustomConvertible, PostgreSQLColumnStaticRepresentable {
  static var postgreSQLColumn: PostgreSQLColumn = RawValue.postgreSQLColumn
  static var keyStringTrue: Status = .created
  static var keyStringFalse: Status = .uninitialized
}

Thanks! Should I wrap the extension up in a PR?

tanner0101 commented 6 years ago

@bensyverson, yeah that would be great! The PostgreSQLDataCustomConvertible extension should go in vapor/postgresql.

tanner0101 commented 6 years ago

Merged in vapor/postgresql, thanks!

bensyverson commented 6 years ago

@tanner0101 What needs to happen in vapor/fluent-postgresql inside the closure you mentioned? Is it just the following?

extension RawRepresentable where RawValue: PostgreSQLColumnStaticRepresentable {
      static var postgreSQLColumn: PostgreSQLColumn = RawValue.postgreSQLColumn
}

If so, I can send a PR through on this repo so we can get that tighter syntax you outlined!