diff --git a/README.md b/README.md index 8d98799..acc725a 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,12 @@ Doobie queries generated using higher-kinded data. 1. add in your sbt: ```scala -libraryDependencies += "io.scalaland" %% "ocdquery-core" % "0.3.0" +libraryDependencies += "io.scalaland" %% "ocdquery-core" % "0.4.0" ``` +(and maybe some optica library like [Quicklens](https://github.com/softwaremill/quicklens) +or [Monocle](https://github.com/julien-truffaut/Monocle)) -2. create kigner-kinded data representation: +2. create higner-kinded data representation: ```scala import java.time.LocalDate @@ -32,35 +34,25 @@ final case class TicketF[F[_], C[_]]( 3. create a repository: ```scala -import doobie._ -import doobie.implicits._ +import cats.Id +import com.softwaremill.quicklens._ +import doobie.h2.implicits._ import io.scalaland.ocdquery._ // only have to do it once! -object TicketRepo extends Repo( - RepoMeta.forEntity("tickets", - TicketF[ColumnNameF, ColumnNameF]( - id = "id", - name = "name", - surname = "surname", - from = "from_", - to = "to", - date = "date" - )) -) - -// but you can make your life even easier with a bit of default magic -// and lenses (they don't mess up type inference like .copy does) - -// (lenses, not provided, pick and add them on your own!) -// (both monocle with macro syntax and quicklenses are nice!) -import com.softwaremill.quicklens._ - -object TicketRepo extends Repo( - RepoMeta.forEntity("tickets", - DefaultColumnNames.forEntity[TicketF].modify(_.from).setTo("from_") - ) -) +val TicketRepo: Repo.EntityRepo[TicketF] = { + // I have no idea why shapeless cannot find this Generic on its own :/ + // if you figure it out, please PR!!! + implicit val ticketRead: doobie.Read[Repo.ForEntity[TicketF]#Entity] = + QuasiAuto.read(shapeless.Generic[TicketF[Id, Id]]) + + Repo.forEntity[TicketF]( + "tickets".tableName, + // I suggest using quicklens or monocle's extension methods + // as they are more reliable than .copy + DefaultColumnNames.forEntity[TicketF].modify(_.from).setTo("from_".columnName) + ) +} ``` 4. generate queries @@ -68,72 +60,35 @@ object TicketRepo extends Repo( ```scala // build these in you services with type safety! -TicketRepo.update( - select = TicketF[Selectable, Selectable]( - id = Skipped, - name: = Fixed("John"), - surname: = Fixed("Smith"), - from: = Skipped, - to: = Skipped, - date: = Skipped - ), - update = TicketF[Selectable, Selectable]( - id = Skipped, - name: = Skipped, - surname: = Skipped, - from: = Skipped, - to: = Skipped, - date: = Fixed(LocalDate.now) - ) +TicketRepo.insert( + // no need to pass "empty" fields like "id = Unit"! + Create.fromTuple(("John", "Smith", "London", "Paris", LocalDate.now)) ).run -TicketRepo.fetch( - select = TicketF[Selectable, Selectable]( - id = Skipped, - name: = Skipped, - surname: = Skipped, - from: = Fixed("London"), - to: = Skipped, - date: = Skipped - ), - sort = Some("name" -> Sort.Ascending), - limit = Some(5) -).to[List] - -// or (with defaults and lenses) - -// (lenses, not provided, pick and add them on your own!) -import com.softwaremill.quicklens._ +import io.scalaland.ocdquery.sql._ // common filter syntax like `=`, `<>` -TicketRepo.update( - select = TicketRepo.emptySelect - .modify(_.name).setTo("John") - .modify(_.surname).setTo("Smith"), - update = TicketRepo.emptySelect - .modify(_.data).setTo(LocalDate.now) +TicketRepo.update.withFilter { columns => + (columns.name `=` "John") and (columns.surname `=` "Smith") +}( + TicketRepo.emptyUpdate.modify(_.data).setTo(LocalDate.now) ).run -TicketRepo.fetch( - select = TicketRepo.emptySelect - .modify(_.from).setTo("London"), - sort = Some("name" -> Sort.Ascending), - limit = Some(5) -).to[List] +TicketRepo.fetch.withSort(_.name, Sort.Ascending).withLimit(5) { + _.from `=` "London" +}.to[List] + +TicketRepo.delete(_.id `=` deletedId).run ``` 5. perform even joins returning tuples of entities: ```scala -import TicketRepo.meta.Names - val joiner = TicketRepo - .join(TicketRepo, (TicketRepo.col(_.id), TicketRepo.col(_.id))) - .join(TicketRepo, (_._2.id, TicketRepo.col(_.id))) - -joiner.fetch((filter1, filter2, filter2), - sort = Some(((_: (Names, Names, Names))._1.name) -> Sort.Ascending), - offset = None, - limit = Some(5)).to[List] // ConnectionIO[(Entity, Entity, Entity)] + .join(TicketRepo).on(_._1.id, _._2.id) // after .join() we have a tuple! + .join(TicketRepo).on(_._2.id, _._3.id) // and now triple! + .fetch.withSort(_._1.name, Sort.Ascending).withLimit(5) { columns => + columns._1.name `=` "John" + }.to[List] // ConnectionIO[(Entity, Entity, Entity)] ``` ## How does it exactly works? @@ -269,9 +224,9 @@ And we can do this! The idea is called higher-kinded data and looks like this: import java.time.LocalDate import java.util.UUID -type Id[A] = A -type UnitF[A] = Unit -type ColumnNameF[A] = String +type Id[A] = A // turns +type UnitF[A] = Unit // make fields not available at creation "disappear" +case class ColumnName[A](name: String) // preserves name of column in DB and its type // F is for normal columns which should be available in some what for all lifecycle // C is for these that should be empty during creation and available from then on @@ -286,8 +241,8 @@ final case class TicketF[F[_], C[_]]( type TicketCreate = TicketF[Id, UnitF] // C[_] fields are Units, the rest as of type inside of F[_] type Ticket = TicketF[Id, Id] // all fields are of the type inside of F[_]/C[_] -type TicketFilter = TicketF[Option, Option] // all fields are of Option of inside of F[_]/C[_] -type TicketColumns = TicketF[ColumnNameF, ColumnNameF] // all fields are Strings +type TicketUpdate = TicketF[Option, Option] // all fields are of Option of inside of F[_]/C[_] +type TicketColumns = TicketF[ColumnName, ColumnName] // all fields are column names ``` Higher-kinded data is data with higher-kinded types as type parameters. @@ -299,7 +254,7 @@ basic CRUD queries for it. During implementation some decisions had to be made: - * instead of `Option` we use our own `Selectable` type which could be `Fixed(to)` or `Skipped ` + * instead of `Option` we use our own `Updatable` type which could be `UpdatedTo(to)` or `Skip` to avoid issues during derivation that would occur if you had e.g. `O[Option[String]]` as one of field types, * derivation metadata is stored inside `RepoMeta[EntityF]` instance - you can reuse @@ -311,66 +266,18 @@ During implementation some decisions had to be made: val ticketRepoMeta = RepoMeta.forEntity("tickets", - TicketF[ColumnNameF, ColumnNameF]( - id = "id", - name = "name", - surname = "surname", - from = "from_", - to = "to", - date = "date" - )) - ``` - * if however default way suit your taste feel free to use `Repo` implementation! - ```scala - object TicketRepo extends Repo(ticketRepoMeta) - - val ticketCreate = TicketF[Id, UnitF]( - id = (), - name = "John", - surname = "Smith", - from = "New York", - to = "London", - date = LocalDate.now() - ) - - val byName = TicketF[Selectable, Selectable]( - id = Skipped, - name = Fixed(ticketCreate.name), - surname = Fixed(ticketCreate.surname), - from = Skipped, - to = Skipped, - date = Skipped - ) - - val update = TicketF[Selectable, Selectable]( - id = Skipped, - name = Fixed("Jane"), - surname = Skipped, - from = Fixed("London"), - to = Fixed("New York"), - date = Skipped - ) + DefaultColumnNames.forEntity[TicketF].modify(_.from).setTo("from_".columnName)) - for { - // that's how you can insert data - _ <- TicketRepo.insert(ticketCreate).run - // that's how you can fetch existing data - ticket <- TicketRepo.fetch(byName).unique - // that's how you can update existing data - _ <- TicketRepo.update(byName, update).run - updatedTicket <- TicketRepo.fetch(fetch).unique - // this is how you can delete existing data - _ <- TicketRepo.delete(byName).run - } yield () + val ticketRepo = new EntityRepo(ticketRepoMeta) ``` * to avoid writing `EntityF[Id, Id]`, `EntityF[Selectable, Selectable]` and `EntityF[Id, UnitF]` manually, some type aliases were introduced: ```scala import io.scalaland.ocdquery._ - type TicketCreate = TicketRepo.meta.Create - type Ticket = TicketRepo.meta.Entity - type TicketSelect = TicketRepo.meta.Select + type TicketCreate = Repo.ForEntity[TicketF]#EntityCreate + type Ticket = Repo.ForEntity[TicketF]#Entity + type TicketUpdate = Repo.ForEntity[TicketF]#EntityUpdate ``` ## Limitations @@ -382,4 +289,7 @@ During implementation some decisions had to be made: define default values like e.g. `None`/`Skipped` for optional fields. So use them internally, as entities to work with your database and separate them from entities exposed in your API/published language. You can use [chimney](https://github.com/scalalandio/chimney) - for turning public instances to and from internal instances. + for turning public instances to and from internal instances, +* types sometimes confuse compiler, so while it can derive something like `shapeless.Generic[TicketF[Id, Id]]`, + it has issues finding `Generic.Aux`, so doobie sometimes get's confused - `QuasiAuto` let you provide + the right values explicitly, so that the derivation is not blocked by such silly issue. diff --git a/modules/core/src/main/scala/io/scalaland/ocdquery/DefaultColumnNames.scala b/modules/core/src/main/scala/io/scalaland/ocdquery/DefaultColumnNames.scala index 2d5d9fb..17e0231 100644 --- a/modules/core/src/main/scala/io/scalaland/ocdquery/DefaultColumnNames.scala +++ b/modules/core/src/main/scala/io/scalaland/ocdquery/DefaultColumnNames.scala @@ -1,11 +1,17 @@ package io.scalaland.ocdquery import magnolia._ -import scala.language.experimental.macros -trait DefaultColumnNames[A] { +import scala.annotation.implicitNotFound +import scala.language.experimental.macros - def get(): A +@implicitNotFound( + "Couldn't find/derive DefaultColumnNames[${Names}]\n" + + " - make sure that all fields are wrapped in obligatory or selectable F[_], " + + "so that ${Names} is made of ColumnNames only" +) +trait DefaultColumnNames[Names] { + def get(): Names } object DefaultColumnNames { @@ -22,7 +28,7 @@ object DefaultColumnNames { def combine[T](caseClass: CaseClass[Typeclass, T]): Typeclass[T] = () => caseClass.construct { param => - ColumnName(param.label) + ColumnName(param.label) // should run it only for classes with only ColumnNames anyway } def dispatch[T](sealedTrait: SealedTrait[Typeclass, T]): Typeclass[T] = ??? diff --git a/modules/core/src/main/scala/io/scalaland/ocdquery/Updatable.scala b/modules/core/src/main/scala/io/scalaland/ocdquery/Updatable.scala index 73e6f29..54d1a36 100644 --- a/modules/core/src/main/scala/io/scalaland/ocdquery/Updatable.scala +++ b/modules/core/src/main/scala/io/scalaland/ocdquery/Updatable.scala @@ -1,5 +1,9 @@ package io.scalaland.ocdquery +/* + * Virtually Option, but as a new type, to avoid confusion around + * Option[Option[A]] if you were trying to update optional field. + */ sealed trait Updatable[+A] extends Product with Serializable { def toOption: Option[A] = this match { case UpdateTo(a) => Some(a) diff --git a/modules/core/src/main/scala/io/scalaland/ocdquery/internal/AllColumns.scala b/modules/core/src/main/scala/io/scalaland/ocdquery/internal/AllColumns.scala index ce245b6..d719e81 100644 --- a/modules/core/src/main/scala/io/scalaland/ocdquery/internal/AllColumns.scala +++ b/modules/core/src/main/scala/io/scalaland/ocdquery/internal/AllColumns.scala @@ -7,7 +7,7 @@ import scala.annotation.implicitNotFound @implicitNotFound( "Couldn't find/derive AllColumns[${Names}]\n" + " - make sure that all fields are wrapped in obligatory or selectable F[_], " + - "so that ${Names} is made of Strings only" + "so that ${Names} is made of ColumnNames only" ) trait AllColumns[Names] { def getList(names: Names): List[ColumnName[Any]] diff --git a/modules/core/src/main/scala/io/scalaland/ocdquery/internal/ColumnNameByField.scala b/modules/core/src/main/scala/io/scalaland/ocdquery/internal/ColumnNameByField.scala index 1c98248..dcdf147 100644 --- a/modules/core/src/main/scala/io/scalaland/ocdquery/internal/ColumnNameByField.scala +++ b/modules/core/src/main/scala/io/scalaland/ocdquery/internal/ColumnNameByField.scala @@ -6,7 +6,11 @@ import magnolia._ import scala.annotation.implicitNotFound import scala.language.experimental.macros -@implicitNotFound("") +@implicitNotFound( + "Couldn't find/derive ColumnNameByField[${Names}]\n" + + " - make sure that all fields are wrapped in obligatory or selectable F[_], " + + "so that ${Names} is made of ColumnNames only" +) trait ColumnNameByField[Names] { def apply(names: Names): List[(String, ColumnName[Any])] } diff --git a/modules/core/src/main/scala/io/scalaland/ocdquery/internal/ColumnNameFragmentList.scala b/modules/core/src/main/scala/io/scalaland/ocdquery/internal/ColumnNameFragmentList.scala index 5c985ea..b8b78f8 100644 --- a/modules/core/src/main/scala/io/scalaland/ocdquery/internal/ColumnNameFragmentList.scala +++ b/modules/core/src/main/scala/io/scalaland/ocdquery/internal/ColumnNameFragmentList.scala @@ -3,8 +3,14 @@ package io.scalaland.ocdquery.internal import doobie.util.fragment.Fragment import io.scalaland.ocdquery.ColumnName +import scala.annotation.implicitNotFound import scala.collection.immutable.ListMap +@implicitNotFound( + "Couldn't find/derive ColumnNameFragmentList[${Values}, ${Names}]\n" + + " - make sure that all fields are wrapped in obligatory or selectable F[_], " + + "so that ${Values} is correctly substituted with ColumnName and $Names with ColumnName" +) trait ColumnNameFragmentList[Values, Names] { def apply(values: Values, names: Names): List[(ColumnName[Any], Fragment)] } diff --git a/modules/core/src/main/scala/io/scalaland/ocdquery/internal/FragmentByField.scala b/modules/core/src/main/scala/io/scalaland/ocdquery/internal/FragmentByField.scala index 7c6576f..2304502 100644 --- a/modules/core/src/main/scala/io/scalaland/ocdquery/internal/FragmentByField.scala +++ b/modules/core/src/main/scala/io/scalaland/ocdquery/internal/FragmentByField.scala @@ -4,10 +4,17 @@ import doobie._ import doobie.implicits._ import io.scalaland.ocdquery.{ Skip, Updatable, UpdateTo } import magnolia.{ CaseClass, Magnolia, SealedTrait } + +import scala.annotation.implicitNotFound import scala.language.experimental.macros -trait FragmentByField[T] { - def apply(t: T): List[(String, Fragment)] +@implicitNotFound( + "Couldn't find/derive FragmentByField[${Values}]\n" + + " - make sure that all fields are wrapped in obligatory or selectable F[_], " + + "so that ${Values} is correctly substituted with Id/UnitF/ColumnName" +) +trait FragmentByField[Values] { + def apply(t: Values): List[(String, Fragment)] } object FragmentByField extends FragmentByFieldLowLevelImplicit { diff --git a/modules/core/src/main/scala/io/scalaland/ocdquery/internal/PrefixColumns.scala b/modules/core/src/main/scala/io/scalaland/ocdquery/internal/PrefixColumns.scala index 0d86ffb..70bbe22 100644 --- a/modules/core/src/main/scala/io/scalaland/ocdquery/internal/PrefixColumns.scala +++ b/modules/core/src/main/scala/io/scalaland/ocdquery/internal/PrefixColumns.scala @@ -2,11 +2,18 @@ package io.scalaland.ocdquery.internal import io.scalaland.ocdquery.ColumnName import magnolia._ + +import scala.annotation.implicitNotFound import scala.language.experimental.macros -trait PrefixColumns[C] { +@implicitNotFound( + "Couldn't find/derive PrefixColumns[${Names}]\n" + + " - make sure that all fields are wrapped in obligatory or selectable F[_], " + + "so that ${Names} is correctly substituted with ColumnName" +) +trait PrefixColumns[Names] { - def prepend(columns: C, prefix: String): C + def prepend(columns: Names, prefix: String): Names } object PrefixColumns { diff --git a/modules/core/src/main/scala/io/scalaland/ocdquery/internal/SkipUnit.scala b/modules/core/src/main/scala/io/scalaland/ocdquery/internal/SkipUnit.scala index 9b26fe6..08b9f14 100644 --- a/modules/core/src/main/scala/io/scalaland/ocdquery/internal/SkipUnit.scala +++ b/modules/core/src/main/scala/io/scalaland/ocdquery/internal/SkipUnit.scala @@ -3,9 +3,13 @@ package io.scalaland.ocdquery.internal import io.scalaland.ocdquery.missingshapeless.Untupler import shapeless._ -trait SkipUnit[C] { +/* + * Used for figuring out the tuple version of a Create object but without Unit field, + * and then transforming that tuple into Create. + */ +trait SkipUnit[Create] { type SU - def from(skipped: SU): C + def from(skipped: SU): Create } object SkipUnit extends SkipUnitLowLevelImplicit { diff --git a/modules/core/src/main/scala/io/scalaland/ocdquery/missingshapeless/TupleAppender.scala b/modules/core/src/main/scala/io/scalaland/ocdquery/missingshapeless/TupleAppender.scala index 0166fdf..eb7af11 100644 --- a/modules/core/src/main/scala/io/scalaland/ocdquery/missingshapeless/TupleAppender.scala +++ b/modules/core/src/main/scala/io/scalaland/ocdquery/missingshapeless/TupleAppender.scala @@ -2,6 +2,10 @@ package io.scalaland.ocdquery.missingshapeless import shapeless._ +/* + * Appends B to tuple A, so that you can build a tuple incrementally + * and don't end up with a nested tuple monstrocity + */ trait TupleAppender[A, B] { type Out def append(a: A, b: B): Out @@ -16,6 +20,8 @@ object TupleAppender extends TupleAppenderLowPriorityImplicit { // scalastyle:off + // unrolled, because it only handles 22 cases, so why use slow shapeless? + implicit def appender2[A, B, Add]: TupleAppender.Aux[(A, B), Add, (A, B, Add)] = new TupleAppender[(A, B), Add] { type Out = (A, B, Add) diff --git a/modules/core/src/main/scala/io/scalaland/ocdquery/missingshapeless/Untupler.scala b/modules/core/src/main/scala/io/scalaland/ocdquery/missingshapeless/Untupler.scala index 04c9cbd..a2031fd 100644 --- a/modules/core/src/main/scala/io/scalaland/ocdquery/missingshapeless/Untupler.scala +++ b/modules/core/src/main/scala/io/scalaland/ocdquery/missingshapeless/Untupler.scala @@ -2,6 +2,9 @@ package io.scalaland.ocdquery.missingshapeless import shapeless._ +/** + * The opposite of shapeless.ops.hlist.Tupler that I was sorely missing. + */ trait Untupler[L <: HList] { type In def apply(in: In): L diff --git a/modules/core/src/test/scala/example/package.scala b/modules/core/src/test/scala/example/package.scala index 0c44008..51a7e56 100644 --- a/modules/core/src/test/scala/example/package.scala +++ b/modules/core/src/test/scala/example/package.scala @@ -1,7 +1,7 @@ import cats.Id import com.softwaremill.quicklens._ import doobie.h2.implicits._ -import io.scalaland.ocdquery.{ AsNameOps, DefaultColumnNames, QuasiAuto, Repo } +import io.scalaland.ocdquery._ package object example { @@ -11,9 +11,8 @@ package object example { QuasiAuto.read(shapeless.Generic[TicketF[Id, Id]]) Repo.forEntity[TicketF]( - "tickets".tableName, { - DefaultColumnNames.forEntity[TicketF].modify(_.from).setTo("from_".columnName) - } + "tickets".tableName, + DefaultColumnNames.forEntity[TicketF].modify(_.from).setTo("from_".columnName) ) } } diff --git a/version.sbt b/version.sbt index 52191cd..c014403 100644 --- a/version.sbt +++ b/version.sbt @@ -1,3 +1,3 @@ import com.typesafe.sbt.SbtGit.git.baseVersion -baseVersion := "0.3" +baseVersion := "0.4"