Skip to content

Instantly share code, notes, and snippets.

@loicknuchel
Last active November 11, 2020 14:50
Show Gist options
  • Save loicknuchel/2297d612b58b399395bdd08d3c6dd217 to your computer and use it in GitHub Desktop.
Save loicknuchel/2297d612b58b399395bdd08d3c6dd217 to your computer and use it in GitHub Desktop.
SQL DSL: use shapeless to require correct Put instances
import cats.effect.{ContextShift, IO}
import doobie.syntax.connectionio._
import doobie.syntax.string._
import doobie.util.Put
import doobie.util.fragment.Fragment
import doobie.util.fragment.Fragment.const0
import doobie.util.transactor.Transactor
import org.scalatest.BeforeAndAfterEach
import org.scalatest.funspec.AnyFunSpec
import org.scalatest.matchers.should.Matchers
import scala.concurrent.ExecutionContext
/**
* I'm creating a jOOQ inspired DSL on top of Doobie. Ideally, I would prefer to decouple it from Doobie but it's not a requirement.
*
* I have some trouble with the INSERT INTO method that require `Put` instances of parameters but it should not require Put instances of Option[A], just A
* An idea can be to use shapeless to require specific types (based on list of fields) instead of any types with a Put instance.
* This will improve type safety as well. But I'm not very fluent in shapeless, any help is welcome ;)
*
* More generally, if you have some tips managing optionallity in generics it would be nice, I'm having some hard time with it ^^
*
* If you want to look at the lib code: https://github.com/loicknuchel/SafeQL/blob/master/src/main/scala/fr/loicknuchel/safeql/Query.scala#L50
* And where it's used: https://github.com/gospeak-io/gospeak/blob/master/infra/src/main/scala/gospeak/infra/services/storage/sql/CfpRepoSql.scala#L67
*/
class DemoSpec extends AnyFunSpec with Matchers with BeforeAndAfterEach {
private implicit val cs: ContextShift[IO] = IO.contextShift(ExecutionContext.global)
protected val xa: doobie.Transactor[IO] = Transactor.fromDriverManager[IO](
driver = "org.h2.Driver",
url = "jdbc:h2:mem:my_test;MODE=PostgreSQL;DATABASE_TO_UPPER=false;DB_CLOSE_DELAY=-1",
user = "",
pass = "")
override def beforeEach(): Unit = sql"CREATE TABLE users (id INT NOT NULL, name VARCHAR(50))".update.run.transact(xa).unsafeRunSync()
override def afterEach(): Unit = sql"DROP TABLE users".update.run.transact(xa).unsafeRunSync()
/*
* Basic SQL DSL
*/
case class Field[A](name: String, typ: String)
case class Table(name: String, fields: List[Field[_]]) {
def create: Fragment = const0(s"CREATE TABLE $name (${fields.map(f => f.name + " " + f.typ).mkString(", ")})")
def drop: Fragment = const0(s"DROP TABLE $name")
// FIXME: B is Option[String], but Doobie do not build Put[Option[A]] it expects a Put[A] instead and manage the Option in the quasiquotes
def insert[A: Put, B: Put](a: A, b: B): Fragment = const0(s"INSERT INTO $name (${fields.map(_.name).mkString(", ")}) VALUES ") ++ fr0"($a, $b)"
}
val users: Table = Table("users", List(
Field[Int]("id", "INT NOT NULL"),
Field[Option[String]]("name", "VARCHAR(50)")))
it("should perform operations using the DSL") {
implicit def optPut[A: Put]: Put[Option[A]] = implicitly[Put[A]].contramap[Option[A]](_.get) // FIXME bad workaround that fail on None :(
users.insert(1, Option("Toto")).update.run.transact(xa).unsafeRunSync() shouldBe 1
users.insert(2, Option.empty[String]).update.run.transact(xa).unsafeRunSync() shouldBe 1 // fails with None.get (from the optPut implicit)
}
/*
* Testing examples
*/
it("should insert a user") {
def insertUser(id: Int, name: Option[String]): doobie.Update0 = sql"INSERT INTO users (id, name) VALUES ($id, $name)".update
insertUser(1, Option("Toto")).run.transact(xa).unsafeRunSync() shouldBe 1
insertUser(2, None).run.transact(xa).unsafeRunSync() shouldBe 1
}
it("should insert a user 2") {
def insertUser[A: Put, B: Put](id: A, name: Option[B]): doobie.Update0 = sql"INSERT INTO users (id, name) VALUES ($id, $name)".update
insertUser(1, Option("Toto")).run.transact(xa).unsafeRunSync() shouldBe 1
insertUser(2, Option.empty[String]).run.transact(xa).unsafeRunSync() shouldBe 1
}
it("should insert a user 3") {
def insertUser[A: Put, B: Put](id: A, name: B): doobie.Update0 = sql"INSERT INTO users (id, name) VALUES ($id, $name)".update
implicit def optPut[A: Put]: Put[Option[A]] = implicitly[Put[A]].contramap[Option[A]](_.get) // FIXME bad workaround that fail on None :(
insertUser(1, Option("Toto")).run.transact(xa).unsafeRunSync() shouldBe 1
insertUser(2, Option.empty[String]).run.transact(xa).unsafeRunSync() shouldBe 1 // fails with None.get (from the optPut implicit)
}
}
@loicknuchel
Copy link
Author

One solution given by @ScalaWilliam: use Write instead of Put, so Doobie is able to manage Option :D

import cats.effect.{ContextShift, IO}
import doobie.syntax.connectionio._
import doobie.syntax.string._
import doobie.util.Write
import doobie.util.fragment.Fragment
import doobie.util.fragment.Fragment.const0
import doobie.util.transactor.Transactor
import org.scalatest.BeforeAndAfterEach
import org.scalatest.funspec.AnyFunSpec
import org.scalatest.matchers.should.Matchers

import scala.concurrent.ExecutionContext

/**
 * SOLUTION: Use Write instead of Put, so Doobie is able to manage Option :D
 */

/**
 * I'm creating a jOOQ inspired DSL on top of Doobie. Ideally, I would prefer to decouple it from Doobie but it's not a requirement.
 *
 * I have some trouble with the INSERT INTO method that require `Put` instances of parameters but it should not require Put instances of Option[A], just A
 * An idea can be to use shapeless to require specific types (based on list of fields) instead of any types with a Put instance.
 * This will improve type safety as well. But I'm not very fluent in shapeless, any help is welcome ;)
 *
 * More generally, if you have some tips managing optionallity in generics it would be nice, I'm having some hard time with it ^^
 *
 * If you want to look at the lib code: https://github.com/loicknuchel/SafeQL/blob/master/src/main/scala/fr/loicknuchel/safeql/Query.scala#L50
 * And where it's used: https://github.com/gospeak-io/gospeak/blob/master/infra/src/main/scala/gospeak/infra/services/storage/sql/CfpRepoSql.scala#L67
 */
class DemoSpec extends AnyFunSpec with Matchers with BeforeAndAfterEach {
  private implicit val cs: ContextShift[IO] = IO.contextShift(ExecutionContext.global)
  protected val xa: doobie.Transactor[IO] = Transactor.fromDriverManager[IO](
    driver = "org.h2.Driver",
    url = "jdbc:h2:mem:my_test;MODE=PostgreSQL;DATABASE_TO_UPPER=false;DB_CLOSE_DELAY=-1",
    user = "",
    pass = "")

  override def beforeEach(): Unit = {
    sql"CREATE TABLE users (id INT NOT NULL, name VARCHAR(50))".update.run.transact(xa).unsafeRunSync()
    ()
  }

  override def afterEach(): Unit = {
    sql"DROP TABLE users".update.run.transact(xa).unsafeRunSync()
    ()
  }

  /*
   * Basic SQL DSL
   */

  case class Field[A](name: String, typ: String)

  case class Table(name: String, fields: List[Field[_]]) {
    def create: Fragment = const0(s"CREATE TABLE $name (${fields.map(f => f.name + " " + f.typ).mkString(", ")})")

    def drop: Fragment = const0(s"DROP TABLE $name")

    def insert[A, B](a: A, b: B)(implicit A: Write[A], B: Write[B]): Fragment =
      const0(s"INSERT INTO $name (${fields.map(_.name).mkString(", ")}) VALUES ") ++ fr0"(${A.toFragment(a)}, ${B.toFragment(b)})"
  }

  val users: Table = Table("users", List(
    Field[Int]("id", "INT NOT NULL"),
    Field[Option[String]]("name", "VARCHAR(50)")))

  it("should perform operations using the DSL") {
    users.insert(1, Option("Toto")).update.run.transact(xa).unsafeRunSync() shouldBe 1
    users.insert(2, Option.empty[String]).update.run.transact(xa).unsafeRunSync() shouldBe 1 // fails with None.get (from the optPut implicit)
  }

  /*
   * Testing examples
   */

  it("should insert a user") {
    def insertUser(id: Int, name: Option[String]): doobie.Update0 = sql"INSERT INTO users (id, name) VALUES ($id, $name)".update

    insertUser(1, Option("Toto")).run.transact(xa).unsafeRunSync() shouldBe 1
    insertUser(2, None).run.transact(xa).unsafeRunSync() shouldBe 1
  }

  /* it("should insert a user 2") {
    def insertUser[A, B](id: A, name: Option[B])(implicit A: Write[A], B: Write[B]): doobie.Update0 =
      sql"INSERT INTO users (id, name) VALUES (${A.toFragment(id)}, ${B.toFragment(name)})".update

    insertUser(1, Option("Toto")).run.transact(xa).unsafeRunSync() shouldBe 1
    insertUser(2, Option.empty[String]).run.transact(xa).unsafeRunSync() shouldBe 1
  } */

  it("should insert a user 3") {
    def insertUser[A, B](id: A, name: B)(implicit A: Write[A], B: Write[B]): doobie.Update0 =
      sql"INSERT INTO users (id, name) VALUES (${A.toFragment(id)}, ${B.toFragment(name)})".update

    insertUser(1, Option("Toto")).run.transact(xa).unsafeRunSync() shouldBe 1
    insertUser(2, Option.empty[String]).run.transact(xa).unsafeRunSync() shouldBe 1
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment