Search code examples
scalascala-catscats-effectdoobie

Cannot construct a Read instance for type User. Type misunderstanding with Doobie in Scala


I am trying to return a User record from a database using doobie, http4s, and cats. I have been stymied by the type system, which is providing the following error based on the code below:

router:

val httpRoutes = HttpRoutes.of[IO] {
    case GET -> Root / "second" / id =>
      val intId : Integer = Integer.parseInt(id)
      //if i make thie ConnectionIO[Option[Unit]] it compiles, but returns a cats Free object
      val userOption: ConnectionIO[Option[User]] = UserModel.findById(intId, transactor.transactor)
      Ok(s"userOption is instance of: ${userOption.getClass} object: ${userOption.toString}")
  }.orNotFound

model:

case class User(
                 id: Read[Integer],
                 username: Read[String],
                 email: Read[String],
                 passwordHash: Read[String], //PasswordHash[SCrypt],
                 isActive: Read[Boolean],
                 dob: Read[Date]
               ) {
//  def verifyPassword(password: String) : VerificationStatus = SCrypt.checkpw[cats.Id](password, passwordHash)
}

object UserModel {
  def findById[User: Read](id: Integer, transactor: Transactor[ConnectionIO]): ConnectionIO[Option[User]] = findBy(fr"id = ${id.toString}", transactor)

  private def findBy[User: Read](by: Fragment, transactor: Transactor[ConnectionIO]): ConnectionIO[Option[User]] = {
    (sql"SELECT id, username, email, password_hash, is_active, dob FROM public.user WHERE " ++ by)
      .query[User]
      .option
      .transact(transactor)
  }
}

Error:

Error:(35, 70) Cannot find or construct a Read instance for type:
  core.model.User
This can happen for a few reasons, but the most common case is that a data
member somewhere within this type doesn't have a Get instance in scope. Here are
some debugging hints:
- For Option types, ensure that a Read instance is in scope for the non-Option
  version.
- For types you expect to map to a single column ensure that a Get instance is
  in scope.
- For case classes, HLists, and shapeless records ensure that each element
  has a Read instance in scope.
- Lather, rinse, repeat, recursively until you find the problematic bit.
You can check that an instance exists for Read in the REPL or in your code:
  scala> Read[Foo]
and similarly with Get:
  scala> Get[Foo]
And find the missing instance and construct it as needed. Refer to Chapter 12
of the book of doobie for more information.
      val userOption: ConnectionIO[Option[User]] = UserModel.findById(intId, transactor.transactor)

If I change the line to a ConnectionIO[Option[User] to ConnectionIO[Option[Unit]] it compiles and runs but returns a Free(...) object from the cats library which I have not been able to figure out how to parse, and I don't see why I shouldn't be able to return my case class!

also See the type declarations on the findBy and findById methods. Before I added those there was a compile error that said it found a User, but required a Read[User]. I attempted applying the same type declaration to the invocation of findById in the router, but it gave the same error provided above.

Thank you for your help in advance, and please be patient with my ignorance. I've never encountered a type system so much smarter than me!


Solution

  • There's a lot to unpack here...

    1. You don't need to wrap fields in User in Read.
    2. Parameterizing the functions with User is not necessary, since you know what type you are getting back.
    3. Most of the time if you manually handle Read instances, you're doing something wrong. Building a Read instance is only useful for when the data you're reading doesn't directly map to your type.
    4. Transactor is meant to be a conversion from ConnectionIO (some action over a JDBC connection) to some other monad (e.g. IO) by summoning a connection, performing the action in a transaction, and disposing of said action. Transactor[ConnectionIO] doesn't really make much sense with this, and can probably lead to deadlocks (since you will eventually try to summon a connection while you are holding onto one). Just write your DB logic in ConnectionIO, and transact the whole thing afterwards.
    5. Integer is not used in Scala code other than to interop with Java, and Doobie doesn't have Get/Put instances for it.
    6. In your routes you take ConnectionIO[Option[User]], and do .toString. This doesn't do what you want it to - it just turns the action you've built into a useless string, without actually evaluating it. To actually get an Option[User] you would need to evaluate your action.

    Putting all of that together, we end up with a piece of code like this:

    import java.util.Date
    import cats.effect.IO
    import doobie.{ConnectionIO, Fragment, Transactor}
    import doobie.implicits._
    import org.http4s.HttpRoutes
    import org.http4s.dsl.io._
    import org.http4s.syntax.kleisli._
    
    def httpRoutes(transactor: Transactor[IO]) = HttpRoutes.of[IO] {
      case GET -> Root / "second" / IntVar(intId) =>
        UserModel.findById(intId)
          .transact(transactor)
          .flatMap { userOption =>
            Ok(s"userOption is instance of: ${userOption.getClass} object: ${userOption.toString}")
          }
    }.orNotFound
    
    final case class User(
      id: Int,
      username: String,
      email: String,
      passwordHash: String,
      isActive: Boolean,
      dob: Date
    )
    
    object UserModel {
      def findById(id: Int): ConnectionIO[Option[User]] = findBy(fr"id = ${id.toString}")
    
      private def findBy(by: Fragment): ConnectionIO[Option[User]] =
        (sql"SELECT id, username, email, password_hash, is_active, dob FROM public.user WHERE " ++ by)
          .query[User]
          .option
    }
    

    userOption here is Option[User].