Skip to content

Commit

Permalink
Initial implementation of kebs-opaque. (#196)
Browse files Browse the repository at this point in the history
* Initial implementation of kebs-opaque.

* change paths, disable scala 2 build

Co-authored-by: Paweł Kiersznowski <pkiersznowski@iterato.rs>
  • Loading branch information
luksow and pk044 committed Feb 16, 2022
1 parent 0ded8d9 commit d2e6568
Show file tree
Hide file tree
Showing 4 changed files with 200 additions and 16 deletions.
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,62 @@ There are some conventions that are assumed during generation.

Also, `CaseClass1Rep` is generated for each tag meaning you will get a lot of `kebs` machinery for free eg. spray formats etc.

### Opaque types

As an alternative to tagged types, Scala 3 provides [opaque types](https://docs.scala-lang.org/scala3/reference/other-new-features/opaques.html).
The principles of opaque types are similar to tagged type. The basic usage of opaque types requires the
same amount of boilerplate as tagged types - e.g. you have to write smart constructors, validations and unwrapping
mechanisms all by hand. `kebs-opaque` is meant to help with that by generating a handful of methods and providing a
`CaseClass1Rep` for an easy typclass derivation.

```scala
import pl.iterators.kebs.opaque._

object MyDomain {
opaque type ISBN = String
object ISBN extends Opaque[ISBN, String]
}
```

That's the basic usage. Inside the companion object you will get methods like `from`, `apply`, `unsafe` and extension
method `unwrap` plus an instance of `CaseClass1Rep[ISBN, String]`. A more complete example below.

```scala
import pl.iterators.kebs.macros.CaseClass1Rep
import pl.iterators.kebs.opaque._

object MyDomain {
opaque type ISBN = String
object ISBN extends Opaque[ISBN, String] {
override protected def validate(unwrapped: String): Either[String, ISBN] = {
val trimmed = unwrapped.trim
val allDigits = trimmed.forall(_.isDigit)
if (allDigits && trimmed.length == 9) Right("0" + trimmed) // converting old style ISBN to a new one
else if (allDigits && trimmed.length == 10) Right(trimmed)
else Left(s"Invalid ISBN: $trimmed")
}
}
}

import MyDomain._
ISBN.from("1234567890") // Right(ISBN("1234567890"))
ISBN.from(" 123456789 ") // Right(ISBN("023456789"))
ISBN.from("foo") // Left("Invalid ISBN: foo")

val isbn = ISBN("1234567890") // ISBN("1234567890")
isbn.unwrap // "1234567890"
ISBN("foo") // throws IllegalArgumentException("Invalid ISBN: foo")

ISBN.unsafe("boom") // don't do that, unless you really need to!

trait Showable[A] {
def show(a: A): String
}
given Showable[String] = (a: String) => a
given[S, A](using showable: Showable[S], cc1Rep: CaseClass1Rep[A, S]): Showable[A] = (a: A) => showable.show(cc1Rep.unapply(a))
implicitly[Showable[ISBN]].show(ISBN("1234567890")) // "1234567890"
```

### JsonSchema support

**Still at experimental stage.**
Expand Down
48 changes: 32 additions & 16 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -74,31 +74,31 @@ lazy val noPublishSettings =
}
)

lazy val disableScala3 = Def.settings(
def disableScala(v: String) = Def.settings(
libraryDependencies := {
if (scalaBinaryVersion.value == "3") {
if (scalaBinaryVersion.value == v) {
Nil
} else {
libraryDependencies.value
}
},
Seq(Compile, Test).map { x =>
(x / sources) := {
if (scalaBinaryVersion.value == "3") {
if (scalaBinaryVersion.value == v) {
Nil
} else {
(x / sources).value
}
}
},
Test / test := {
if (scalaBinaryVersion.value == "3") {
if (scalaBinaryVersion.value == v) {
()
} else {
(Test / test).value
}
},
publish / skip := (scalaBinaryVersion.value == "3")
publish / skip := (scalaBinaryVersion.value == v)
)

def optional(dependency: ModuleID) = dependency % "provided"
Expand Down Expand Up @@ -230,6 +230,8 @@ lazy val taggedSettings = commonSettings ++ Seq(
libraryDependencies += optionalCirce
)

lazy val opaqueSettings = commonSettings

lazy val examplesSettings = commonSettings ++ Seq(
libraryDependencies += slickPg.cross(CrossVersion.for3Use2_13),
libraryDependencies += circeParser,
Expand Down Expand Up @@ -268,7 +270,7 @@ lazy val slickSupport = project
.dependsOn(macroUtils, instances)
.settings(slickSettings: _*)
.settings(publishSettings: _*)
.settings(disableScala3)
.settings(disableScala("3"))
.settings(
name := "slick",
description := "Library to eliminate the boilerplate code that comes with the use of Slick",
Expand All @@ -281,7 +283,7 @@ lazy val sprayJsonMacros = project
.dependsOn(macroUtils)
.settings(sprayJsonMacroSettings: _*)
.settings(publishSettings: _*)
.settings(disableScala3)
.settings(disableScala("3"))
.settings(
name := "spray-json-macros",
description := "Automatic generation of Spray json formats for case-classes - macros",
Expand All @@ -294,7 +296,7 @@ lazy val sprayJsonSupport = project
.dependsOn(sprayJsonMacros, instances)
.settings(sprayJsonSettings: _*)
.settings(publishSettings: _*)
.settings(disableScala3)
.settings(disableScala("3"))
.settings(
name := "spray-json",
description := "Automatic generation of Spray json formats for case-classes",
Expand All @@ -307,7 +309,7 @@ lazy val playJsonSupport = project
.dependsOn(macroUtils)
.settings(playJsonSettings: _*)
.settings(publishSettings: _*)
.settings(disableScala3)
.settings(disableScala("3"))
.settings(
name := "play-json",
description := "Automatic generation of Play json formats for case-classes",
Expand All @@ -321,7 +323,7 @@ lazy val circeSupport = project
.settings(circeSettings: _*)
.settings(crossBuildSettings: _*)
.settings(publishSettings: _*)
.settings(disableScala3)
.settings(disableScala("3"))
.settings(
name := "circe",
description := "Automatic generation of circe formats for case-classes",
Expand All @@ -333,7 +335,7 @@ lazy val akkaHttpSupport = project
.dependsOn(macroUtils, instances, tagged, taggedMeta % "test -> test")
.settings(akkaHttpSettings: _*)
.settings(publishSettings: _*)
.settings(disableScala3)
.settings(disableScala("3"))
.settings(
name := "akka-http",
description := "Automatic generation of akka-http deserializers for 1-element case classes",
Expand All @@ -346,7 +348,7 @@ lazy val jsonschemaSupport = project
.dependsOn(macroUtils)
.settings(jsonschemaSettings: _*)
.settings(publishSettings: _*)
.settings(disableScala3)
.settings(disableScala("3"))
.settings(
name := "jsonschema",
description := "Automatic generation of JSON Schemas for case classes",
Expand All @@ -359,7 +361,7 @@ lazy val scalacheckSupport = project
.dependsOn(macroUtils)
.settings(scalacheckSettings: _*)
.settings(publishSettings: _*)
.settings(disableScala3)
.settings(disableScala("3"))
.settings(
name := "scalacheck",
description := "Automatic generation of scalacheck generators for case classes",
Expand All @@ -372,14 +374,27 @@ lazy val tagged = project
.dependsOn(macroUtils)
.settings(taggedSettings: _*)
.settings(publishSettings: _*)
.settings(disableScala3)
.settings(disableScala("3"))
.settings(
name := "tagged",
description := "Representation of tagged types",
moduleName := "kebs-tagged",
crossScalaVersions := supportedScalaVersions
)

lazy val opaque = project
.in(file("opaque"))
.dependsOn(macroUtils)
.settings(opaqueSettings: _*)
.settings(publishSettings: _*)
.settings(disableScala("2"))
.settings(
name := "opaque",
description := "Representation of opaque types",
moduleName := "kebs-opaque",
crossScalaVersions := Seq(scala_3)
)

lazy val taggedMeta = project
.in(file("tagged-meta"))
.dependsOn(
Expand All @@ -392,7 +407,7 @@ lazy val taggedMeta = project
)
.settings(taggedMetaSettings: _*)
.settings(publishSettings: _*)
.settings(disableScala3)
.settings(disableScala("3"))
.settings(
name := "tagged-meta",
description := "Representation of tagged types - code generation based on scala-meta",
Expand All @@ -405,7 +420,7 @@ lazy val examples = project
.dependsOn(slickSupport, sprayJsonSupport, playJsonSupport, akkaHttpSupport, taggedMeta, circeSupport, instances)
.settings(examplesSettings: _*)
.settings(noPublishSettings: _*)
.settings(disableScala3)
.settings(disableScala("3"))
.settings(
name := "examples",
moduleName := "kebs-examples"
Expand Down Expand Up @@ -440,6 +455,7 @@ lazy val kebs = project
.in(file("."))
.aggregate(
tagged,
opaque,
macroUtils,
slickSupport,
sprayJsonMacros,
Expand Down
44 changes: 44 additions & 0 deletions opaque/src/main/scala-3/pl/iterators/kebs/opaque/Opaque.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package pl.iterators.kebs.opaque

import pl.iterators.kebs.macros.CaseClass1Rep

trait Opaque[OpaqueType, Unwrapped](using ev: OpaqueType =:= Unwrapped) {
/**
* Validates and transforms (ex. sanitizes) unwrapped value.
* @param unwrapped value to be validated and transformed
* @return Left(reason) if validation fails; Right(opaqueType) if validation succeeds
*/
protected def validate(unwrapped: Unwrapped): Either[String, OpaqueType] = Right(ev.flip.apply(unwrapped))

/**
* Validates and transforms (ex. sanitizes) unwrapped value. By default, there is no validation or transformation.
* @param unwrapped value to be validated and transformed
* @return Left(reason) if validation fails; Right(opaqueType) if validation succeeds
*/
def from(unwrapped: Unwrapped): Either[String, OpaqueType] = validate(unwrapped)

/**
* Creates an instance of OpaqueType from unwrapped value.
* @param unwrapped value to be validated, transformed and wrapped in OpaqueType
* @throws IllegalArgumentException with reason, if validation fails
* @return OpaqueType wrapping validated & transformed unwrapped value
*/
def apply(unwrapped: Unwrapped): OpaqueType = validate(unwrapped).fold(l => throw new IllegalArgumentException(l), identity)

/**
* Creates an instance of OpaqueType from unwrapped value in an unsafe manner - without validation or transformation.
* @param unwrapped value to be wrapped in OpaqueType
* @return OpaqueType wrapping unwrapped value
*/
def unsafe(unwrapped: Unwrapped): OpaqueType = ev.flip.apply(unwrapped)

extension (w: OpaqueType) {
/**
* Unwraps value wrapped in OpaqueType.
* @return unwrapped value
*/
def unwrap: Unwrapped = ev.apply(w)
}

given cc1Rep: CaseClass1Rep[OpaqueType, Unwrapped] = CaseClass1Rep(apply, _.unwrap)
}
68 changes: 68 additions & 0 deletions opaque/src/test/scala-3/OpaqueTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import pl.iterators.kebs.opaque.Opaque
import pl.iterators.kebs.macros.CaseClass1Rep

object OpaqueTestDomain {
opaque type TestWrappedInt = Int
object TestWrappedInt extends Opaque[TestWrappedInt, Int]

opaque type ValidatedTestWrappedString = String
object ValidatedTestWrappedString extends Opaque[ValidatedTestWrappedString, String] {
override def validate(value: String): Either[String, ValidatedTestWrappedString] =
if (value.isEmpty) Left("Empty string") else Right(value.trim)
}

extension (s: ValidatedTestWrappedString) {
def myMap(f: Char => Char): ValidatedTestWrappedString = s.map(f)
}
}

object OpaqueTestTypeclass {
trait Showable[A] {
def show(a: A): String
}

given Showable[Int] = (a: Int) => a.toString
given[S, A](using showable: Showable[S], cc1Rep: CaseClass1Rep[A, S]): Showable[A] = (a: A) => showable.show(cc1Rep.unapply(a))
}

class OpaqueTest extends AnyFunSuite with Matchers {
import OpaqueTestDomain._
test("Equality") {
TestWrappedInt(42) shouldEqual TestWrappedInt(42)
TestWrappedInt(42) shouldNot equal (TestWrappedInt(1337))
"""TestWrappedString("foo") == "foo"""" shouldNot compile
"""implicitly[=:=[TestWrappedString, String]]""" shouldNot compile
}

test("Basic ops") {
TestWrappedInt(42).unwrap shouldEqual 42
TestWrappedInt.from(42) should equal (Right(TestWrappedInt(42)))
}

test("Validation & sanitization") {
an[IllegalArgumentException] should be thrownBy ValidatedTestWrappedString("")
ValidatedTestWrappedString.unsafe("").unwrap should equal ("")
ValidatedTestWrappedString(" foo ").unwrap should equal ("foo")
ValidatedTestWrappedString.from("") should equal (Left("Empty string"))
ValidatedTestWrappedString.from(" foo ") should equal (Right(ValidatedTestWrappedString("foo")))
}

test("Extension") {
ValidatedTestWrappedString("foo").myMap(_.toUpper) shouldEqual ValidatedTestWrappedString("FOO")
}

test("Basic derivation") {
"implicitly[CaseClass1Rep[ValidatedTestWrappedString, String]]" should compile
implicitly[CaseClass1Rep[ValidatedTestWrappedString, String]].apply("foo") shouldEqual ValidatedTestWrappedString("foo")
implicitly[CaseClass1Rep[ValidatedTestWrappedString, String]].unapply(ValidatedTestWrappedString("foo")) shouldEqual "foo"
an[IllegalArgumentException] should be thrownBy implicitly[CaseClass1Rep[ValidatedTestWrappedString, String]].apply("")
}

test("Typeclass derivation") {
import OpaqueTestTypeclass._
"implicitly[Showable[TestWrappedInt]]" should compile
implicitly[Showable[TestWrappedInt]].show(TestWrappedInt(42)) shouldEqual "42"
}
}

0 comments on commit d2e6568

Please sign in to comment.