diff --git a/build.sbt b/build.sbt index 9f02a1cc..ecdf6436 100644 --- a/build.sbt +++ b/build.sbt @@ -10,15 +10,18 @@ lazy val versions = new { val catsCore = "2.1.1" val catsEffect = "2.1.4" val catsRetry = "1.1.1" + val log4cats = "1.1.1" val zio = "1.0.0-RC21-2" val zioInteropCats = "2.1.3.0-RC16" val circe = "0.13.0" + val newtype = "0.4.4" val refined = "0.9.15" val squants = "1.6.0" val enumeratum = "1.6.1" val fs2 = "2.4.2" val http4s = "0.21.6" val doobie = "0.9.0" + val flyway = "6.5.0" val caliban = "0.9.0" val magnolia = "0.16.0" val droste = "0.8.0" @@ -39,6 +42,8 @@ lazy val dependencies = new { "org.typelevel" %% "cats-core" % versions.catsCore, "org.typelevel" %% "cats-effect" % versions.catsEffect, "com.github.cb372" %% "cats-retry" % versions.catsRetry, + "io.chrisdavenport" %% "log4cats-core" % versions.log4cats, + "io.chrisdavenport" %% "log4cats-slf4j" % versions.log4cats, "dev.zio" %% "zio" % versions.zio, "dev.zio" %% "zio-streams" % versions.zio, "dev.zio" %% "zio-interop-cats" % versions.zioInteropCats, @@ -46,6 +51,7 @@ lazy val dependencies = new { "io.circe" %% "circe-generic" % versions.circe, "io.circe" %% "circe-parser" % versions.circe, "io.circe" %% "circe-refined" % versions.circe, + "io.estatico" %% "newtype" % versions.newtype, "eu.timepit" %% "refined" % versions.refined, "org.typelevel" %% "squants" % versions.squants, "com.beachape" %% "enumeratum" % versions.enumeratum, @@ -58,7 +64,9 @@ lazy val dependencies = new { "org.http4s" %% "http4s-blaze-client" % versions.http4s, "org.http4s" %% "http4s-prometheus-metrics" % versions.http4s, "org.tpolecat" %% "doobie-core" % versions.doobie, + "org.tpolecat" %% "doobie-refined" % versions.doobie, "org.tpolecat" %% "doobie-h2" % versions.doobie, + "org.flywaydb" % "flyway-core" % versions.flyway, "com.github.ghostdogpr" %% "caliban" % versions.caliban, "com.github.ghostdogpr" %% "caliban-http4s" % versions.caliban, "com.github.ghostdogpr" %% "caliban-cats" % versions.caliban, @@ -83,6 +91,8 @@ lazy val commonSettings = Seq( // https://tpolecat.github.io/2017/04/25/scalac-flags.html // https://github.com/DavidGregory084/sbt-tpolecat // https://nathankleyn.com/2019/05/13/recommended-scalac-flags-for-2-13 + // http://eed3si9n.com/stricter-scala-with-xlint-xfatal-warnings-and-scalafix + // https://alexn.org/blog/2020/05/26/scala-fatal-warnings.html scalacOptions ++= Seq( "-encoding", "UTF-8", // source files are in UTF-8 @@ -93,6 +103,7 @@ lazy val commonSettings = Seq( "-language:implicitConversions", // allow definition of implicit functions called views "-Xlint", // enable handy linter warnings "-Wconf:any:error", // configurable warnings see https://github.com/scala/scala/pull/8373 + "-Ymacro-annotations", // required by newtype "-Xsource:2.13" ), addCompilerPlugin("org.typelevel" %% "kind-projector" % versions.kindProjector cross CrossVersion.full) diff --git a/docs/fp-ecosystem.md b/docs/fp-ecosystem.md index 5daa9f67..77ccb4aa 100644 --- a/docs/fp-ecosystem.md +++ b/docs/fp-ecosystem.md @@ -76,6 +76,13 @@ sbt "test:testOnly *fs2*" * [doobie](https://tpolecat.github.io/doobie) (Documentation) * [Pure Functional Database Programming with Fixpoint Types](https://www.youtube.com/watch?v=7xSfLPD6tiQ) by Rob Norris (video) +### Examples + +```bash +# run +sbt "ecosystem/runMain com.github.niqdev.doobie.ExampleH2" +``` + ## shapeless ### Resources diff --git a/docs/graphql-error.txt b/docs/graphql-error.txt new file mode 100644 index 00000000..8bc712e2 --- /dev/null +++ b/docs/graphql-error.txt @@ -0,0 +1,81 @@ +# Since Node2 is also a root node, it needs to be of higher-kinded i.e. Node2[F[_]] + +--- +# how to reproduce the error + +final case class Queries[F[_]]( + example: Int => F[Example[F]], + node2: Node2[F] +) +object Queries { + private[this] def resolver[F[_]: Effect](services: Services[F]): Queries[F] = + Queries( + example = a => Effect[F].pure(Example[F](NodeId(Base64String.unsafeFrom("aGVsbG8K")), b => Effect[F].pure(b))), + node2 = Example[F](NodeId(Base64String.unsafeFrom("aGVsbG8K")), b => Effect[F].pure(b)) + ) + } +} + +# if Node2[F[_]] is not higher-kinded too it cause the error below ONLY because it's also a root node +@GQLInterface +sealed trait Node2 { + def id: NodeId +} + +case class Example[F[_]](id: NodeId, f: Int => F[Int]) extends Node2 + +--- +# error (not sure why it's not always printed) +[error] /PATH/REDACTED/scala-fp/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/schema.scala:64:14: illegal cyclic reference involving type F +[error] case class Example[F[_]](id: NodeId, f: Int => F[Int]) extends Node2 + +--- +# error + +[info] welcome to sbt 1.3.13 (AdoptOpenJDK Java 1.8.0_242) +[info] loading settings for project scala-fp-build from plugins.sbt ... +[info] loading project definition from /PATH/REDACTED/scala-fp/project +[info] loading settings for project root from build.sbt ... +[info] set current project to scala-fp (in build file:/PATH/REDACTED/scala-fp/) +[success] Total time: 0 s, completed 16-Jul-2020 19:40:18 +[info] Compiling 4 Scala sources to /PATH/REDACTED/scala-fp/modules/common/target/scala-2.13/classes ... +[warn] There may be incompatibilities among your library dependencies; run 'evicted' to see detailed eviction warnings. +[warn] There may be incompatibilities among your library dependencies; run 'evicted' to see detailed eviction warnings. +[info] Compiling 26 Scala sources to /PATH/REDACTED/scala-fp/modules/ecosystem/target/scala-2.13/classes ... +[error] +[error] no-symbol does not have a type constructor (this may indicate scalac cannot find fundamental classes) +[error] while compiling: /PATH/REDACTED/scala-fp/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/queries.scala +[error] during phase: typer +[error] library version: version 2.13.3 +[error] compiler version: version 2.13.3 +[error] reconstructed args: -bootclasspath REDACTED +[error] +[error] last tree to typer: Ident(Base64String) +[error] tree position: line 48 of /PATH/REDACTED/scala-fp/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/schema.scala +[error] symbol: +[error] symbol definition: (a NoSymbol) +[error] symbol package: +[error] symbol owners: +[error] call site: object Cursor in object schema in package pagination +[error] +[error] == Source file context for tree position == +[error] +[error] 45 +[error] 46 @newtype case class Offset(value: NonNegLong) +[error] 47 @newtype case class NodeId(value: Base64String) +[error] 48 @newtype case class Cursor(value: Base64String) +[error] 49 object Cursor { +[error] 50 final val prefix = "cursor:v1:" +[error] 51 } +[info] Compiling 21 Scala sources to /PATH/REDACTED/scala-fp/modules/fp/target/scala-2.13/classes ... +[info] Compiling 1 Scala source to /PATH/REDACTED/scala-fp/modules/common/target/scala-2.13/test-classes ... +[error] /PATH/REDACTED/scala-fp/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/queries.scala:43:22: Cannot find a Schema for type com.github.niqdev.caliban.pagination.queries.Queries[F]. +[error] +[error] Caliban derives a Schema automatically for basic Scala types, case classes and sealed traits, but +[error] you need to manually provide an implicit Schema for other types that could be nested in com.github.niqdev.caliban.pagination.queries.Queries[F]. +[error] If you use a custom type as an argument, you also need to provide an implicit ArgBuilder for that type. +[error] See https://ghostdogpr.github.io/caliban/docs/schema.html for more information. +[error] +[error] Error occurred in an application involving default arguments. +[error] GraphQL.graphQL(RootResolver(resolver[F](services))) +[error] \ No newline at end of file diff --git a/modules/ecosystem/src/main/resources/db/migration/V1__create_example_schema.sql b/modules/ecosystem/src/main/resources/db/migration/V1__create_example_schema.sql new file mode 100644 index 00000000..de5b9fc0 --- /dev/null +++ b/modules/ecosystem/src/main/resources/db/migration/V1__create_example_schema.sql @@ -0,0 +1 @@ +CREATE SCHEMA IF NOT EXISTS example; diff --git a/modules/ecosystem/src/main/resources/db/migration/V2__create_user_table.sql b/modules/ecosystem/src/main/resources/db/migration/V2__create_user_table.sql new file mode 100644 index 00000000..ede2a6d7 --- /dev/null +++ b/modules/ecosystem/src/main/resources/db/migration/V2__create_user_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS example.user ( + id UUID NOT NULL DEFAULT RANDOM_UUID(), + name VARCHAR(250) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(), + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(), + CONSTRAINT user_pkey PRIMARY KEY (id) +); + +-- index on TEXT not supported by H2 +CREATE INDEX IF NOT EXISTS user_name_idx ON example.user (name); diff --git a/modules/ecosystem/src/main/resources/db/migration/V3__insert_users.sql b/modules/ecosystem/src/main/resources/db/migration/V3__insert_users.sql new file mode 100644 index 00000000..1f577b0e --- /dev/null +++ b/modules/ecosystem/src/main/resources/db/migration/V3__insert_users.sql @@ -0,0 +1,2 @@ +INSERT INTO example.user (id, name) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'typelevel'); +INSERT INTO example.user (id, name) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio'); diff --git a/modules/ecosystem/src/main/resources/db/migration/V4__create_repository_table.sql b/modules/ecosystem/src/main/resources/db/migration/V4__create_repository_table.sql new file mode 100644 index 00000000..00f21b3c --- /dev/null +++ b/modules/ecosystem/src/main/resources/db/migration/V4__create_repository_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS example.repository ( + id UUID NOT NULL DEFAULT RANDOM_UUID(), + user_id UUID NOT NULL, + name VARCHAR(250) NOT NULL, + url VARCHAR(250) NOT NULL, + is_fork BOOLEAN NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(), + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP(), + CONSTRAINT repository_pkey PRIMARY KEY (id) +); + +CREATE INDEX IF NOT EXISTS repository_user_id_idx ON example.repository (user_id); +CREATE INDEX IF NOT EXISTS repository_name_idx ON example.repository (name); + +ALTER TABLE example.repository ADD FOREIGN KEY (user_id) REFERENCES example.user (id); diff --git a/modules/ecosystem/src/main/resources/db/migration/V5__insert_repositories.sql b/modules/ecosystem/src/main/resources/db/migration/V5__insert_repositories.sql new file mode 100644 index 00000000..fa9f72e7 --- /dev/null +++ b/modules/ecosystem/src/main/resources/db/migration/V5__insert_repositories.sql @@ -0,0 +1,43 @@ +-- typelevel +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'cats', 'https://github.com/typelevel/cats', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'cats-effect', 'https://github.com/typelevel/cats-effect', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'cats-mtl', 'https://github.com/typelevel/cats-mtl', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'scalacheck', 'https://github.com/typelevel/scalacheck', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'kind-projector', 'https://github.com/typelevel/kind-projector', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'algebra', 'https://github.com/typelevel/algebra', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'discipline', 'https://github.com/typelevel/discipline', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'squants', 'https://github.com/typelevel/squants', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'simulacrum', 'https://github.com/typelevel/simulacrum', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'kittens', 'https://github.com/typelevel/kittens', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'jawn', 'https://github.com/typelevel/jawn', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'shapeless', 'https://github.com/milessabin/shapeless', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'refined', 'https://github.com/fthomas/refined', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'scala-steward', 'https://github.com/fthomas/scala-steward', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'scodec', 'https://github.com/scodec/scodec', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'ciris', 'https://github.com/vlovgr/ciris', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'doobie', 'https://github.com/tpolecat/doobie', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'fs2', 'https://github.com/functional-streams-for-scala/fs2', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'monix', 'https://github.com/monix/monix', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'Monocle', 'https://github.com/optics-dev/Monocle', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('e1fba794-54f5-4231-a448-f464aac512e5', 'scala', 'https://github.com/typelevel/scala', TRUE); + +-- zio +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio', 'https://github.com/zio/zio', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-config', 'https://github.com/zio/zio-config', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-keeper', 'https://github.com/zio/zio-keeper', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-nio', 'https://github.com/zio/zio-nio', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-redis', 'https://github.com/zio/zio-redis', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-sqs', 'https://github.com/zio/zio-sqs', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-s3', 'https://github.com/zio/zio-s3', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-logging', 'https://github.com/zio/zio-logging', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-kafka', 'https://github.com/zio/zio-kafka', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-query', 'https://github.com/zio/zio-query', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-actors', 'https://github.com/zio/zio-actors', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-lambda', 'https://github.com/zio/zio-lambda', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-ftp', 'https://github.com/zio/zio-ftp', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-web', 'https://github.com/zio/zio-web', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-sql', 'https://github.com/zio/zio-sql', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-metrics', 'https://github.com/zio/zio-metrics', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-codec', 'https://github.com/zio/zio-codec', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-cli', 'https://github.com/zio/zio-cli', FALSE); +INSERT INTO example.repository (user_id, name, url, is_fork) VALUES ('f0fbe131-3f65-4145-b373-5bbfc1c9a1ae', 'zio-concurrent', 'https://github.com/zio/zio-concurrent', FALSE); diff --git a/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/CalibanCatsHttp4sApp.scala b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/CalibanCatsHttp4sApp.scala index c3b2246e..a258f68b 100644 --- a/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/CalibanCatsHttp4sApp.scala +++ b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/CalibanCatsHttp4sApp.scala @@ -2,7 +2,13 @@ package com.github.niqdev.caliban import caliban.Http4sAdapter import caliban.interop.cats.implicits.CatsEffectGraphQL -import cats.effect.{ ConcurrentEffect, ExitCode, IO, IOApp, Resource, Timer } +import cats.effect.{ ConcurrentEffect, ContextShift, ExitCode, IO, IOApp, Resource, Timer } +import com.github.niqdev.caliban.pagination.queries.Queries +import com.github.niqdev.caliban.pagination.repositories.Repositories +import com.github.niqdev.caliban.pagination.services.Services +import com.github.niqdev.doobie.Database +import io.chrisdavenport.log4cats.Logger +import io.chrisdavenport.log4cats.slf4j.Slf4jLogger import org.http4s.server.Router import org.http4s.server.blaze.BlazeServerBuilder import org.http4s.syntax.kleisli.http4sKleisliResponseSyntaxOptionT @@ -10,18 +16,29 @@ import zio.Runtime import scala.concurrent.ExecutionContext +// sbt -jvm-debug 5005 "ecosystem/runMain com.github.niqdev.caliban.CalibanCatsHttp4sApp" object CalibanCatsHttp4sApp extends IOApp { - implicit val runtime: Runtime[Any] = Runtime.default + private[this] implicit val runtime: Runtime[Any] = Runtime.default override def run(args: List[String]): IO[ExitCode] = - server[IO] - .use(_ => IO.never) - .as(ExitCode.Success) + Slf4jLogger + .create[IO] + .flatMap(implicit logger => + server[IO] + .use(_ => IO.never) + .as(ExitCode.Success) + ) - def server[F[_]: ConcurrentEffect: Timer]: Resource[F, Unit] = + private[caliban] def server[F[_]: ConcurrentEffect: ContextShift: Timer: Logger]: Resource[F, Unit] = for { - interpreter <- Resource.liftF(ExampleApi.api.interpreterAsync) + _ <- Resource.liftF(Logger[F].info("Start server...")) + xa <- Database.initInMemory[F] + repositories <- Repositories.make[F](xa) + services <- Services.make[F](repositories) + api = ExampleApi.api |+| Queries.api[F](services) + _ <- Resource.liftF(Logger[F].info(s"GraphQL Schema:\n${api.render}")) + interpreter <- Resource.liftF(api.interpreterAsync) httpApp = Router( "/api/graphql" -> Http4sAdapter.makeHttpServiceF(interpreter) ).orNotFound diff --git a/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/codecs.scala b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/codecs.scala new file mode 100644 index 00000000..c25ec2e5 --- /dev/null +++ b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/codecs.scala @@ -0,0 +1,132 @@ +package com.github.niqdev.caliban +package pagination + +import java.util.UUID + +import cats.syntax.either._ +import com.github.niqdev.caliban.pagination.models._ +import com.github.niqdev.caliban.pagination.repositories._ +import com.github.niqdev.caliban.pagination.schema._ +import com.github.niqdev.caliban.pagination.schema.arguments._ +import eu.timepit.refined.types.numeric.PosLong + +// TODO SchemaDecoderOps + move instances in sealed traits +object codecs { + + /** + * + */ + trait SchemaEncoder[A, B] { + def from(model: A): B + } + + object SchemaEncoder { + def apply[A, B](implicit ev: SchemaEncoder[A, B]): SchemaEncoder[A, B] = ev + + implicit lazy val cursorSchemaEncoder: SchemaEncoder[RowNumber, Cursor] = + rowNumber => + Cursor(Base64String.unsafeFrom(utils.toBase64(s"${Cursor.prefix}${rowNumber.value.value}"))) + + implicit lazy val userNodeIdSchemaEncoder: SchemaEncoder[UserId, NodeId] = + model => NodeId(Base64String.unsafeFrom(utils.toBase64(s"${UserNode.idPrefix}${model.value.toString}"))) + + implicit def userNodeSchemaEncoder[F[_]]( + implicit + uniSchemaEncoder: SchemaEncoder[UserId, NodeId] + ): SchemaEncoder[(User, ForwardPaginationArg => F[RepositoryConnection[F]]), UserNode[F]] = { + case (user, getRepositoryConnectionF) => + UserNode( + id = uniSchemaEncoder.from(user.id), + name = user.name.value, + createdAt = user.createdAt, + updatedAt = user.updatedAt, + repositories = getRepositoryConnectionF + ) + } + + implicit lazy val repositoryNodeIdSchemaEncoder: SchemaEncoder[RepositoryId, NodeId] = + model => + NodeId(Base64String.unsafeFrom(utils.toBase64(s"${RepositoryNode.idPrefix}${model.value.toString}"))) + + implicit def repositoryNodeSchemaEncoder[F[_]]( + implicit rniSchemaEncoder: SchemaEncoder[RepositoryId, NodeId] + ): SchemaEncoder[Repository, RepositoryNode[F]] = + model => + RepositoryNode( + id = rniSchemaEncoder.from(model.id), + name = model.name.value, + url = model.url.value, + isFork = model.isFork, + createdAt = model.createdAt, + updatedAt = model.updatedAt + ) + + implicit def repositoryEdgeSchemaEncoder[F[_]]( + implicit + cSchemaEncoder: SchemaEncoder[RowNumber, Cursor], + //rniSchemaEncoder: SchemaEncoder[RepositoryId, NodeId], + rnSchemaEncoder: SchemaEncoder[Repository, RepositoryNode[F]] + ): SchemaEncoder[(Repository, RowNumber), RepositoryEdge[F]] = { + case (model, rowNumber) => + RepositoryEdge( + cursor = cSchemaEncoder.from(rowNumber), + node = rnSchemaEncoder.from(model) + ) + } + } + + final class SchemaEncoderOps[A](private val model: A) extends AnyVal { + def encodeFrom[B](implicit schemaEncoder: SchemaEncoder[A, B]): B = + schemaEncoder.from(model) + } + + /** + * + */ + trait SchemaDecoder[A, B] { + def to(schema: A): Either[Throwable, B] + } + + object SchemaDecoder { + def apply[A, B](implicit ev: SchemaDecoder[A, B]): SchemaDecoder[A, B] = ev + + private[this] def base64SchemaDecoder(prefix: String): SchemaDecoder[Base64String, String] = + schema => { + val base64String = utils.fromBase64(schema.value) + val errorMessage = s"invalid prefix: expected to start with [$prefix] but found [$base64String]" + Either + .cond( + base64String.startsWith(prefix), + utils.removePrefix(base64String, prefix), + new IllegalArgumentException(errorMessage) + ) + } + + private[this] def uuidSchemaDecoder(prefix: String): SchemaDecoder[NodeId, UUID] = + schema => + base64SchemaDecoder(prefix) + .to(schema.value) + .flatMap(uuidString => Either.catchNonFatal(UUID.fromString(uuidString))) + + implicit lazy val userIdSchemaDecoder: SchemaDecoder[NodeId, UserId] = + schema => uuidSchemaDecoder(UserNode.idPrefix).to(schema).map(UserId.apply) + + implicit lazy val repositoryIdSchemaDecoder: SchemaDecoder[NodeId, RepositoryId] = + schema => uuidSchemaDecoder(RepositoryNode.idPrefix).to(schema).map(RepositoryId.apply) + + implicit lazy val cursorSchemaDecoder: SchemaDecoder[Cursor, RowNumber] = + schema => + base64SchemaDecoder(Cursor.prefix) + .to(schema.value) + .flatMap(cursorString => Either.catchNonFatal(PosLong.unsafeFrom(cursorString.toLong))) + .map(RowNumber.apply) + + implicit lazy val offsetSchemaDecoder: SchemaDecoder[Offset, Limit] = + schema => Limit(schema.value).asRight[Throwable] + + implicit def optionSchemaDecoder[I, O]( + implicit schemaDecoder: SchemaDecoder[I, O] + ): SchemaDecoder[Option[I], Option[O]] = + maybeSchema => maybeSchema.fold(Option.empty[O])(i => schemaDecoder.to(i).toOption).asRight[Throwable] + } +} diff --git a/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/models.scala b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/models.scala new file mode 100644 index 00000000..f05daf25 --- /dev/null +++ b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/models.scala @@ -0,0 +1,38 @@ +package com.github.niqdev.caliban +package pagination + +import java.time.Instant +import java.util.UUID + +import eu.timepit.refined.api.Refined +import eu.timepit.refined.string.Url +import eu.timepit.refined.types.string.NonEmptyString +import io.estatico.newtype.macros.newtype + +object models { + + @newtype case class UserId(value: UUID) + @newtype case class UserName(value: NonEmptyString) + + final case class User( + id: UserId, + name: UserName, + createdAt: Instant, + updatedAt: Instant + ) + + @newtype case class RepositoryId(value: UUID) + @newtype case class RepositoryName(value: NonEmptyString) + @newtype case class RepositoryUrl(value: String Refined Url) + + final case class Repository( + id: RepositoryId, + userId: UserId, + name: RepositoryName, + url: RepositoryUrl, + isFork: Boolean, + createdAt: Instant, + updatedAt: Instant + ) + +} diff --git a/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/package.scala b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/package.scala new file mode 100644 index 00000000..a2a61ba1 --- /dev/null +++ b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/package.scala @@ -0,0 +1,9 @@ +package com.github.niqdev.caliban + +import com.github.niqdev.caliban.pagination.codecs.SchemaEncoderOps + +package object pagination { + + final implicit def schemaEncoderSyntax[A](model: A): SchemaEncoderOps[A] = + new SchemaEncoderOps[A](model) +} diff --git a/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/queries.scala b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/queries.scala new file mode 100644 index 00000000..4e705d1f --- /dev/null +++ b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/queries.scala @@ -0,0 +1,140 @@ +package com.github.niqdev.caliban +package pagination + +import caliban.{ GraphQL, RootResolver } +import cats.effect.Effect +import com.github.niqdev.caliban.pagination.schema._ +import com.github.niqdev.caliban.pagination.schema.arguments._ +import com.github.niqdev.caliban.pagination.services._ + +// https://developer.github.com/v4/explorer +// https://relay.dev/graphql/connections.htm +// https://graphql.org/learn/pagination +// https://graphql.org/learn/global-object-identification +// https://relay.dev/docs/en/graphql-server-specification +// https://medium.com/javascript-in-plain-english/graphql-pagination-using-edges-vs-nodes-in-connections-f2ddb8edffa0 +// https://slack.engineering/evolving-api-pagination-at-slack-1c1f644f8e12 +object queries { + + /** + * Root Nodes + */ + final case class Queries[F[_]]( + node: NodeArg => F[Option[Node[F]]], + user: UserArg => F[Option[UserNode[F]]], + repository: RepositoryArg => F[Option[RepositoryNode[F]]], + repositories: ForwardPaginationArg => F[RepositoryConnection[F]] + ) + object Queries { + private[this] def resolver[F[_]: Effect](services: Services[F]): Queries[F] = + Queries( + node = nodeArg => services.nodeService.findNode(nodeArg.id), + user = userArg => services.userService.findByName(userArg.name), + repository = repositoryArg => services.repositoryService.findByName(repositoryArg.name), + repositories = services.repositoryService.connection(None) + ) + + // TODO log errors: mapError or Wrapper + def api[F[_]: Effect](services: Services[F]): GraphQL[Any] = + GraphQL.graphQL(RootResolver(resolver[F](services))) + } +} + +/* + +query findRepositoryByName { + repository(name: "zio") { + id + name + url + isFork + createdAt + updatedAt + } +} + +query getNodeById { + node(id: "opaqueCursor") { + id + ... on UserNodeF { + id + name + createdAt + updatedAt + } + ... on RepositoryNodeF { + name + url + isFork + createdAt + updatedAt + } + } +} + +query getSimpleUser { + user(name: "typelevel") { + id + name + repositories(first: 10, after: "opaqueCursor") { + edges { + cursor + node { + id + name + } + } + pageInfo { + hasNextPage + } + } + } +} + +query getRepositories { + repositories(first: 2, after: "opaqueCursor") { + nodes { + id + name + } + } +} + +query getUser { + user(name: "typelevel") { + id + name + createdAt + updatedAt + repositories(first: 10) { + edges { + cursor + node { + id + name + url + isFork + createdAt + updatedAt + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + totalCount + nodes { + id + name + url + isFork + createdAt + updatedAt + } + } + } +} + + */ diff --git a/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/repositories.scala b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/repositories.scala new file mode 100644 index 00000000..963684bf --- /dev/null +++ b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/repositories.scala @@ -0,0 +1,171 @@ +package com.github.niqdev.caliban.pagination + +import cats.effect.{ Resource, Sync } +import com.github.niqdev.caliban.pagination.models._ +import doobie.syntax.all._ +import doobie.util.fragment.Fragment +import doobie.util.meta.Meta +import doobie.util.transactor.Transactor +import eu.timepit.refined.types.numeric.{ NonNegInt, NonNegLong, PosLong } +import eu.timepit.refined.types.string.NonEmptyString +import io.estatico.newtype.Coercible +import io.estatico.newtype.macros.newtype + +@scala.annotation.nowarn +object repositories { + + import doobie.implicits.legacy.instant.JavaTimeInstantMeta + import doobie.refined.implicits.refinedMeta + import doobie.h2.implicits.UuidType + + // enable default logging + private[this] implicit val logHandler = + doobie.util.log.LogHandler.jdkLogHandler + + // newtype meta + private[this] implicit def coercibleMeta[R, N]( + implicit ev: Coercible[Meta[R], Meta[N]], + R: Meta[R] + ): Meta[N] = ev(R) + + @newtype case class RowNumber(value: PosLong) + @newtype case class Limit(value: NonNegInt) + + /** + * + */ + sealed abstract class UserRepo[F[_]: Sync](xa: Transactor[F]) { + + def findById(id: UserId): F[Option[User]] = + UserRepo.queries.findById(id).query[User].option.transact(xa) + + def findByName(name: NonEmptyString): F[Option[User]] = + UserRepo.queries.findByName(name).query[User].option.transact(xa) + + def count: F[NonNegLong] = + UserRepo.queries.count.query[NonNegLong].unique.transact(xa) + } + object UserRepo { + def apply[F[_]: Sync](xa: Transactor[F]): UserRepo[F] = + new UserRepo[F](xa) {} + + private[pagination] object queries { + private[this] val schemaName = "example" + private[this] val tableName = "user" + private[this] val tableFrom = Fragment.const(s" FROM $schemaName.$tableName ") + + private[this] lazy val findAll: Fragment = + fr"SELECT id, name, created_at, updated_at" ++ tableFrom + + lazy val findById: UserId => Fragment = + id => findAll ++ fr"WHERE id = $id" + + lazy val findByName: NonEmptyString => Fragment = + name => findAll ++ fr"WHERE name = $name" + + lazy val count: Fragment = + fr"SELECT COUNT(*)" ++ tableFrom + } + } + + /** + * + */ + sealed abstract class RepositoryRepo[F[_]: Sync](xa: Transactor[F]) { + + def find(limit: Limit, nextRowNumber: Option[RowNumber]): F[List[(Repository, RowNumber)]] = + RepositoryRepo.queries + .find(limit, nextRowNumber) + .query[(Repository, RowNumber)] + .to[List] + .transact(xa) + + def findByUserId(limit: Limit, nextRowNumber: Option[RowNumber])( + userId: UserId + ): F[List[(Repository, RowNumber)]] = + RepositoryRepo.queries + .findByUserId(limit, nextRowNumber)(userId) + .query[(Repository, RowNumber)] + .to[List] + .transact(xa) + + def findById(id: RepositoryId): F[Option[Repository]] = + RepositoryRepo.queries.findById(id).query[Repository].option.transact(xa) + + def findByName(name: NonEmptyString): F[Option[Repository]] = + RepositoryRepo.queries.findByName(name).query[Repository].option.transact(xa) + + def count: F[NonNegLong] = + RepositoryRepo.queries.count.query[NonNegLong].unique.transact(xa) + + def countByUserId(userId: UserId): F[NonNegLong] = + RepositoryRepo.queries.countByUserId(userId).query[NonNegLong].unique.transact(xa) + } + object RepositoryRepo { + def apply[F[_]: Sync](xa: Transactor[F]): RepositoryRepo[F] = + new RepositoryRepo[F](xa) {} + + // TODO orderBy: default updated_at + private[pagination] object queries { + private[this] val schemaName = "example" + private[this] val tableName = "repository" + private[this] val tableFrom = Fragment.const(s" FROM $schemaName.$tableName ") + private[this] val columns = Fragment.const(s"id, user_id, name, url, is_fork, created_at, updated_at") + + private[this] def findAll( + extraColumns: Option[Fragment] = None, + where: Option[Fragment] = None + ): Fragment = + fr"SELECT " ++ columns ++ extraColumns.getOrElse(fr"") ++ tableFrom ++ where.getOrElse(fr"") ++ fr" ORDER BY updated_at" + + private[this] def find( + limit: Limit, + nextRowNumber: Option[RowNumber], + where: Option[Fragment] + ): Fragment = { + val rowNumberColumn = Fragment.const(s", ROW_NUMBER() OVER (ORDER BY updated_at) AS row_number") + val findLimit: Fragment = findAll(Some(rowNumberColumn), where) ++ fr" LIMIT $limit" + val findLimitAfterRowNumber: RowNumber => Fragment = rowNumber => + fr"SELECT * FROM (" ++ findAll(Some(rowNumberColumn), where) ++ fr") t WHERE t.row_number > $rowNumber" ++ fr" LIMIT $limit" + + nextRowNumber.fold(findLimit)(findLimitAfterRowNumber) + } + + def find(limit: Limit, nextRowNumber: Option[RowNumber]): Fragment = + find(limit, nextRowNumber, None) + + def findByUserId(limit: Limit, nextRowNumber: Option[RowNumber]): UserId => Fragment = + userId => find(limit, nextRowNumber, Some(fr"WHERE user_id = $userId")) + + lazy val findById: RepositoryId => Fragment = + id => findAll(where = Some(fr"WHERE id = $id")) + + lazy val findByName: NonEmptyString => Fragment = + name => findAll(where = Some(fr"WHERE name = $name")) + + lazy val count: Fragment = + fr"SELECT COUNT(*)" ++ tableFrom + + lazy val countByUserId: UserId => Fragment = + userId => fr"SELECT COUNT(*)" ++ tableFrom ++ fr"WHERE user_id = $userId" + } + } + + /** + * + */ + sealed trait Repositories[F[_]] { + def userRepo: UserRepo[F] + def repositoryRepo: RepositoryRepo[F] + } + object Repositories { + private[this] def apply[F[_]: Sync](xa: Transactor[F]): Repositories[F] = + new Repositories[F] { + val userRepo: UserRepo[F] = UserRepo[F](xa) + val repositoryRepo: RepositoryRepo[F] = RepositoryRepo[F](xa) + } + + def make[F[_]: Sync](xa: Transactor[F]): Resource[F, Repositories[F]] = + Resource.liftF(Sync[F].delay(apply[F](xa))) + } +} diff --git a/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/schema.scala b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/schema.scala new file mode 100644 index 00000000..a6e0dbcf --- /dev/null +++ b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/schema.scala @@ -0,0 +1,178 @@ +package com.github.niqdev.caliban +package pagination + +import java.time.Instant + +import caliban.CalibanError +import caliban.Value.{ IntValue, StringValue } +import caliban.interop.cats.CatsInterop +import caliban.schema.Annotations.GQLInterface +import caliban.schema.{ ArgBuilder, Schema } +import cats.effect.Effect +import cats.syntax.either._ +import com.github.niqdev.caliban.pagination.schema._ +import com.github.niqdev.caliban.pagination.schema.arguments._ +import eu.timepit.refined.W +import eu.timepit.refined.api.{ Refined, RefinedTypeOps } +import eu.timepit.refined.string.{ MatchesRegex, Url } +import eu.timepit.refined.types.numeric.{ NonNegInt, NonNegLong } +import eu.timepit.refined.types.string.NonEmptyString +import io.estatico.newtype.macros.newtype + +object schema extends CommonSchemaInstances with CommonArgInstances { + + // TODO RepositoriesArg(first*, after, orderBy: {direction*, field*}) * is mandatory + // TODO ForwardPaginationArg change to trait + object arguments { + final case class NodeArg(id: NodeId) + final case class UserArg(name: NonEmptyString) + final case class RepositoryArg(name: NonEmptyString) + // "after" is the cursor of the last edge in the previous page + final case class ForwardPaginationArg( + first: Offset, + after: Option[Cursor] + ) + // "before" is the cursor of the first edge in the next page + final case class BackwardPaginationArg( + last: Offset, + before: Option[Cursor] + ) + } + + // https://stackoverflow.com/questions/475074/regex-to-parse-or-validate-base64-data + final val Base64Regex = W( + """^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$""" + ) + final type Base64String = String Refined MatchesRegex[Base64Regex.T] + final object Base64String extends RefinedTypeOps[Base64String, String] + + // TODO replace Offset with first/last + @newtype case class Offset(value: NonNegInt) + @newtype case class NodeId(value: Base64String) + @newtype case class Cursor(value: Base64String) + object Cursor { + final val prefix = "cursor:v1:" + } + + // Node is also a root node and it must be of higher-kinded i.e. Node[F[_]] + // for more details see docs/graphql-error.txt + @GQLInterface + sealed trait Node[F[_]] { + def id: NodeId + } + + // TODO rename UserNodeF > User + final case class UserNode[F[_]]( + id: NodeId, + name: NonEmptyString, + createdAt: Instant, + updatedAt: Instant, + //repository: Repository, + repositories: ForwardPaginationArg => F[RepositoryConnection[F]] + ) extends Node[F] + object UserNode { + final val idPrefix = "user:v1:" + } + + // TODO add issue|issues + final case class RepositoryNode[F[_]]( + id: NodeId, + name: NonEmptyString, + url: String Refined Url, + isFork: Boolean, + createdAt: Instant, + updatedAt: Instant + ) extends Node[F] + object RepositoryNode { + val idPrefix = "repository:v1:" + } + + final case class RepositoryConnection[F[_]]( + edges: List[RepositoryEdge[F]], + nodes: List[RepositoryNode[F]], + pageInfo: PageInfo, + totalCount: NonNegLong + ) + + final case class RepositoryEdge[F[_]]( + cursor: Cursor, + node: RepositoryNode[F] + ) + + /* + * If the client is paginating with first/after (Forward Pagination): + * - hasNextPage is true if further edges exist, otherwise false + * - hasPreviousPage may be true if edges prior to after exist, if it can do so efficiently, otherwise may return false + * + * If the client is paginating with last/before (Backward Pagination): + * - hasNextPage may be true if edges further from before exist, if it can do so efficiently, otherwise may return false + * - hasPreviousPage is true if prior edges exist, otherwise false + * + * startCursor and endCursor must be the cursors corresponding to the first and last nodes in edges, respectively + */ + final case class PageInfo( + hasNextPage: Boolean, + hasPreviousPage: Boolean, + startCursor: Cursor, + endCursor: Cursor + ) +} + +protected[caliban] sealed trait CommonSchemaInstances { + + // see caliban.interop.cats.implicits.effectSchema + implicit def effectSchema[F[_]: Effect, R, A](implicit ev: Schema[R, A]): Schema[R, F[A]] = + CatsInterop.schema + + implicit val instantSchema: Schema[Any, Instant] = + Schema.longSchema.contramap(_.getEpochSecond) + + implicit val nonEmptyStringSchema: Schema[Any, NonEmptyString] = + Schema.stringSchema.contramap(_.value) + + implicit val base64StringSchema: Schema[Any, Base64String] = + Schema.stringSchema.contramap(_.value) + + implicit val nodeIdSchema: Schema[Any, NodeId] = + base64StringSchema.contramap(_.value) + + implicit val cursorSchema: Schema[Any, Cursor] = + base64StringSchema.contramap(_.value) + + implicit val urlSchema: Schema[Any, Url] = + Schema.stringSchema.contramap(_.toString) + + implicit val offsetSchema: Schema[Any, Offset] = + Schema.intSchema.contramap(_.value.value) +} + +protected[caliban] sealed trait CommonArgInstances { + + implicit val nonEmptyStringArgBuilder: ArgBuilder[NonEmptyString] = { + case StringValue(value) => + NonEmptyString.from(value).leftMap(CalibanError.ExecutionError(_)) + case other => + Left(CalibanError.ExecutionError(s"Can't build a NonEmptyString from input $other")) + } + + implicit val base64StringArgBuilder: ArgBuilder[Base64String] = { + case StringValue(value) => + Base64String.from(value).leftMap(CalibanError.ExecutionError(_)) + case other => + Left(CalibanError.ExecutionError(s"Can't build a Base64String from input $other")) + } + + implicit val nodeIdArgBuilder: ArgBuilder[NodeId] = + base64StringArgBuilder.map(NodeId.apply) + + implicit val cursorArgBuilder: ArgBuilder[Cursor] = + base64StringArgBuilder.map(Cursor.apply) + + implicit val offsetArgBuilder: ArgBuilder[Offset] = { + case value: IntValue => + NonNegInt.from(value.toInt).map(Offset.apply).leftMap(CalibanError.ExecutionError(_)) + case other => + Left(CalibanError.ExecutionError(s"Can't build a NonNegInt from input $other")) + } + +} diff --git a/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/services.scala b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/services.scala new file mode 100644 index 00000000..2866378f --- /dev/null +++ b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/services.scala @@ -0,0 +1,158 @@ +package com.github.niqdev.caliban +package pagination + +import cats.effect.{ Resource, Sync } +import cats.instances.list._ +import cats.instances.option._ +import cats.syntax.applicativeError._ +import cats.syntax.flatMap._ +import cats.syntax.foldable._ +import cats.syntax.functor._ +import cats.syntax.nested._ +import com.github.niqdev.caliban.pagination.codecs._ +import com.github.niqdev.caliban.pagination.models._ +import com.github.niqdev.caliban.pagination.repositories._ +import com.github.niqdev.caliban.pagination.schema._ +import com.github.niqdev.caliban.pagination.schema.arguments.ForwardPaginationArg +import eu.timepit.refined.types.string.NonEmptyString + +object services { + + /** + * + */ + sealed abstract class UserService[F[_]]( + userRepo: UserRepo[F], + repositoryService: RepositoryService[F] + )( + implicit F: Sync[F] + ) { + + protected[pagination] val toNode: User => UserNode[F] = + user => (user, repositoryService.connection(Some(user.id))).encodeFrom[UserNode[F]] + + def findNode(id: NodeId): F[Option[UserNode[F]]] = + F.fromEither(SchemaDecoder[NodeId, UserId].to(id)) + .flatMap(userRepo.findById) + .nested + .map(toNode) + .value + + def findByName(name: NonEmptyString): F[Option[UserNode[F]]] = + userRepo.findByName(name).nested.map(toNode).value + + } + object UserService { + def apply[F[_]: Sync]( + userRepo: UserRepo[F], + repositoryService: RepositoryService[F] + ): UserService[F] = + new UserService[F](userRepo, repositoryService) {} + } + + /** + * + */ + sealed abstract class RepositoryService[F[_]]( + repositoryRepo: RepositoryRepo[F] + )( + implicit F: Sync[F] + ) { + + protected[pagination] val toNode: Repository => RepositoryNode[F] = + _.encodeFrom[RepositoryNode[F]] + + protected[pagination] val toEdge: Repository => RowNumber => RepositoryEdge[F] = + repository => rowNumber => (repository -> rowNumber).encodeFrom[RepositoryEdge[F]] + + def findNode(id: NodeId): F[Option[RepositoryNode[F]]] = + F.fromEither(SchemaDecoder[NodeId, RepositoryId].to(id)) + .flatMap(repositoryRepo.findById) + .nested + .map(toNode) + .value + + def findByName(name: NonEmptyString): F[Option[RepositoryNode[F]]] = + repositoryRepo.findByName(name).nested.map(toNode).value + + // TODO + def connection(maybeUserId: Option[UserId]): ForwardPaginationArg => F[RepositoryConnection[F]] = + paginationArg => + for { + limit <- F.fromEither(SchemaDecoder[Offset, Limit].to(paginationArg.first)) + nextRowNumber <- F.fromEither( + SchemaDecoder[Option[Cursor], Option[RowNumber]].to(paginationArg.after) + ) + repositories <- maybeUserId.fold(repositoryRepo.find(limit, nextRowNumber))( + repositoryRepo.findByUserId(limit, nextRowNumber) + ) + edges <- F.pure(repositories).nested.map(repository => toEdge(repository._1)(repository._2)).value + nodes <- F.pure(repositories).nested.map(repository => toNode(repository._1)).value + pageInfo <- F.pure { + PageInfo( + true, // TODO + false, + repositories.head._2.encodeFrom[Cursor], + repositories.last._2.encodeFrom[Cursor] + ) + } + totalCount <- repositoryRepo.count + } yield RepositoryConnection(edges, nodes, pageInfo, totalCount) + + } + object RepositoryService { + def apply[F[_]: Sync](repositoryRepo: RepositoryRepo[F]): RepositoryService[F] = + new RepositoryService[F](repositoryRepo) {} + } + + /** + * + */ + abstract class NodeService[F[_]: Sync]( + userService: UserService[F], + repositoryService: RepositoryService[F] + ) { + + // TODO create specific error + log WARN + private[this] def recoverInvalidNode[T <: Node[F]]: PartialFunction[Throwable, Option[T]] = { + case _: IllegalArgumentException => None + } + + def findNode(id: NodeId): F[Option[Node[F]]] = + for { + userNode <- userService.findNode(id).recover(recoverInvalidNode[UserNode[F]]) + repositoryNode <- repositoryService.findNode(id).recover(recoverInvalidNode[RepositoryNode[F]]) + } yield List(userNode, repositoryNode).collectFirstSomeM(List(_)).head + + } + object NodeService { + def apply[F[_]: Sync](repositories: Repositories[F]): NodeService[F] = { + val repositoryService = RepositoryService[F](repositories.repositoryRepo) + new NodeService[F]( + UserService[F](repositories.userRepo, repositoryService), + repositoryService + ) {} + } + } + + /** + * + */ + sealed trait Services[F[_]] { + def nodeService: NodeService[F] + def userService: UserService[F] + def repositoryService: RepositoryService[F] + } + object Services { + private[this] def apply[F[_]: Sync](repos: Repositories[F]) = + new Services[F] { + val nodeService: NodeService[F] = NodeService[F](repos) + val repositoryService: RepositoryService[F] = RepositoryService[F](repos.repositoryRepo) + val userService: UserService[F] = UserService[F](repos.userRepo, repositoryService) + } + + def make[F[_]: Sync](repos: Repositories[F]) = + Resource.liftF(Sync[F].delay(apply[F](repos))) + } + +} diff --git a/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/utils.scala b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/utils.scala new file mode 100644 index 00000000..52ed6a4d --- /dev/null +++ b/modules/ecosystem/src/main/scala/com/github/niqdev/caliban/pagination/utils.scala @@ -0,0 +1,18 @@ +package com.github.niqdev.caliban +package pagination + +import java.nio.charset.StandardCharsets +import java.util.Base64 + +object utils { + + val toBase64: String => String = + value => Base64.getEncoder.encodeToString(value.getBytes(StandardCharsets.UTF_8)) + + val fromBase64: String => String = + value => new String(Base64.getDecoder.decode(value), StandardCharsets.UTF_8) + + def removePrefix(value: String, prefixes: String*): String = + prefixes.foldLeft(value)((v, prefix) => v.replace(prefix, "")) + +} diff --git a/modules/ecosystem/src/main/scala/com/github/niqdev/doobie/Database.scala b/modules/ecosystem/src/main/scala/com/github/niqdev/doobie/Database.scala new file mode 100644 index 00000000..f775162d --- /dev/null +++ b/modules/ecosystem/src/main/scala/com/github/niqdev/doobie/Database.scala @@ -0,0 +1,54 @@ +package com.github.niqdev.doobie + +import cats.effect.{ Async, Blocker, ContextShift, Resource, Sync } +import doobie.h2.H2Transactor +import doobie.util.ExecutionContexts +import io.chrisdavenport.log4cats.Logger +import org.flywaydb.core.Flyway + +object Database { + + final case class Config( + connectionUrl: String, + username: String, + password: String, + schema: String + ) + + private[this] def migration[F[_]](config: Config)(implicit F: Sync[F]): F[Int] = + F.delay( + Flyway + .configure() + .schemas(config.schema) + .defaultSchema(config.schema) + .dataSource(config.connectionUrl, config.username, config.password) + .load() + .migrate() + ) + + private[this] def transactor[F[_]: Async: ContextShift](config: Config): Resource[F, H2Transactor[F]] = + for { + ec <- ExecutionContexts.fixedThreadPool[F](32) + blockingEC <- Blocker[F] + xa <- H2Transactor.newH2Transactor[F]( + url = config.connectionUrl, + user = config.username, + pass = config.password, + connectEC = ec, + blocker = blockingEC + ) + } yield xa + + // http://h2database.com/html/main.html + def initInMemory[F[_]: Async: ContextShift: Logger]: Resource[F, H2Transactor[F]] = { + val config = Config("jdbc:h2:mem:example_db;DB_CLOSE_DELAY=-1", "sa", "", "example") + + for { + _ <- Resource.liftF(Logger[F].info(s"Init in-memory database...")) + _ <- Resource.liftF(Logger[F].info(s"config: $config")) + version <- Resource.liftF(migration[F](config)) + _ <- Resource.liftF(Logger[F].info(s"migration version: $version")) + xa <- transactor[F](config) + } yield xa + } +} diff --git a/modules/ecosystem/src/main/scala/com/github/niqdev/doobie/ExampleEmbedded.scala b/modules/ecosystem/src/main/scala/com/github/niqdev/doobie/ExampleEmbedded.scala deleted file mode 100644 index 60881e4f..00000000 --- a/modules/ecosystem/src/main/scala/com/github/niqdev/doobie/ExampleEmbedded.scala +++ /dev/null @@ -1,36 +0,0 @@ -package com.github.niqdev.doobie - -import cats.effect._ -import doobie._ -import doobie.h2._ -import doobie.implicits._ - -// TODO https://tpolecat.github.io/doobie/book -object ExampleEmbedded extends IOApp { - - // Resource yielding a transactor configured with a bounded connect EC and an unbounded - // transaction EC. Everything will be closed and shut down cleanly after use. - val transactor: Resource[IO, H2Transactor[IO]] = - for { - ce <- ExecutionContexts.fixedThreadPool[IO](32) // our connect EC - be <- Blocker[IO] // our blocking EC - xa <- H2Transactor.newH2Transactor[IO]( - "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", // connect URL - "sa", // username - "", // password - ce, // await connection here - be // execute JDBC operations here - ) - } yield xa - - def run(args: List[String]): IO[ExitCode] = - transactor.use { xa => - // Construct and run your server here! - for { - n <- sql"select 42".query[Int].unique.transact(xa) - _ <- IO(println(n)) - } yield ExitCode.Success - - } - -} diff --git a/modules/ecosystem/src/main/scala/com/github/niqdev/doobie/ExampleH2.scala b/modules/ecosystem/src/main/scala/com/github/niqdev/doobie/ExampleH2.scala new file mode 100644 index 00000000..4e032d7d --- /dev/null +++ b/modules/ecosystem/src/main/scala/com/github/niqdev/doobie/ExampleH2.scala @@ -0,0 +1,56 @@ +package com.github.niqdev.doobie + +import java.net.URL +import java.time.Instant +import java.util.UUID + +import cats.effect._ +import doobie.syntax.all._ +import doobie.util.meta.Meta +import doobie.util.transactor.Transactor +import eu.timepit.refined.types.numeric.PosLong +import eu.timepit.refined.types.string.NonEmptyString +import io.chrisdavenport.log4cats.Logger +import io.chrisdavenport.log4cats.slf4j.Slf4jLogger + +// https://tpolecat.github.io/doobie/book +object ExampleH2 extends IOApp { + + import doobie.implicits.legacy.instant.JavaTimeInstantMeta + import doobie.refined.implicits.refinedMeta + + implicit val logHandler = doobie.util.log.LogHandler.jdkLogHandler + implicit val idMeta = Meta.Advanced.other[UUID]("id") + implicit val urlMeta = Meta.StringMeta.timap(new URL(_))(_.toString) + + private[this] def countUsers[F[_]](xa: Transactor[F])( + implicit ev: Bracket[F, Throwable] + ): F[Int] = + sql"select count(*) from example.user" + .query[Int] + .unique + .transact(xa) + + @scala.annotation.nowarn + private[this] def findRepositories[F[_]](xa: Transactor[F])( + implicit ev: Bracket[F, Throwable] + ): F[List[(PosLong, UUID, UUID, NonEmptyString, URL, Boolean, Instant, Instant)]] = + sql"select ROWNUM(), id, user_id, name, url, is_fork, created_at, updated_at from example.repository" + .query[(PosLong, UUID, UUID, NonEmptyString, URL, Boolean, Instant, Instant)] + .to[List] + .transact(xa) + + private[this] def h2Example[F[_]: Async: ContextShift: Logger]: Resource[F, Unit] = { + for { + xa <- Database.initInMemory[F] + userCount <- Resource.liftF(countUsers[F](xa)) + _ <- Resource.liftF(Logger[F].info(s"countUsers: $userCount")) + repositories <- Resource.liftF(findRepositories[F](xa)) + _ <- Resource.liftF(Logger[F].info(s"findRepositories: $repositories")) + } yield () + } + + def run(args: List[String]): IO[ExitCode] = + Slf4jLogger.create[IO].flatMap(implicit logger => h2Example[IO].use(_ => IO.pure(ExitCode.Success))) + +} diff --git a/modules/ecosystem/src/test/scala/com/github/niqdev/cats/FoldableSpec.scala b/modules/ecosystem/src/test/scala/com/github/niqdev/cats/FoldableSpec.scala new file mode 100644 index 00000000..96e4b4af --- /dev/null +++ b/modules/ecosystem/src/test/scala/com/github/niqdev/cats/FoldableSpec.scala @@ -0,0 +1,19 @@ +package com.github.niqdev.cats + +import cats.instances.list.catsStdInstancesForList +import cats.syntax.foldable.catsSyntaxFoldOps +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpecLike + +final class FoldableSpec extends AnyWordSpecLike with Matchers { + + "Foldable" should { + + "verify collectFirstSomeM" in { + List.empty[Option[Int]].collectFirstSomeM(List(_)) shouldBe List(None) + List[Option[Int]](None, None).collectFirstSomeM(List(_)) shouldBe List(None) + List[Option[Int]](Some(1)).collectFirstSomeM(List(_)) shouldBe List(Some(1)) + List(None, None, Some(1), Some(2), None).collectFirstSomeM(List(_)) shouldBe List(Some(1)) + } + } +}