calypso
A BSON library based on org.bson
. Encoder and Decoder type classes with instances for common types.
Usage
To use calypso in an existing sbt project, add the following dependencies to your build.sbt:
libraryDependencies += "ru.m2" %% "calypso-core" % "<version>"
The most current release can be found in the maven badge at the top of this readme.
Supported types
Scala
Unit
Boolean
Int
Long
Double
String
Array[Byte]
(A, B)
List[A]
Set[A]
SortedSet[A]
Map[K, V]
SortedMap[K, V]
Option[A]
Either[A, B]
java
java.time.Instant
java.util.UUID
cats
cats.data.NonEmptyList
Product type (case class)
It is possible to construct codecs for product types (case classes) using forProductN
constructors if you have codecs for each of its elements.
import org.bson.BsonValue
import ru.m2.calypso.syntax.*
import ru.m2.calypso.{Decoder, Encoder}
case class Record(id: Int, name: String)
object Record:
given Encoder[Record] = Encoder.forProduct2("id", "name")(r => (r.id, r.name))
given Decoder[Record] = Decoder.forProduct2("id", "name")(Record.apply)
val bson: BsonValue = Record(1, "John").asBson // {"id": 1, "name": "John"}
val record: Either[String, Record] = bson.as[Record] // Right(Record(1,John))
Coproduct type (sealed trait hierarchy)
Coproduct is also known as ADT, sum, or tagged union. Not as ergonomic as product type, but it is possible to create codecs for coproduct types using forCoproductN
constructors.
import org.bson.BsonValue
import ru.m2.calypso.syntax.*
import ru.m2.calypso.{Decoder, Encoder}
enum AorB:
case A(i: Int)
case B(s: String)
object AorB:
import AorB.*
given Encoder[A] = Encoder.forProduct1("i")(_.i)
given Encoder[B] = Encoder.forProduct1("s")(_.s)
given Encoder[AorB] = Encoder.forCoproduct {
case a: A => "A" -> a.asBson
case b: B => "B" -> b.asBson
}
given Decoder[A] = Decoder.forProduct1("i")(A.apply)
given Decoder[B] = Decoder.forProduct1("s")(B.apply)
given Decoder[AorB] = Decoder.forCoproduct2[AorB, A, B]("A", "B")
object Example:
import AorB.*
val aBson: BsonValue = (A(42): AorB).asBson // {"tag": "A", "value": {"i": 42}}
val bBson: BsonValue = (B("hello"): AorB).asBson // {"tag": "B", "value": {"s": "hello"}}
val a: Either[String, AorB] = aBson.as[AorB] // Right(A(42))
val b: Either[String, AorB] = bBson.as[AorB] // Right(B(hello))
Derive codecs
Use existing codecs to derive new ones.
import org.bson.BsonValue
import ru.m2.calypso.syntax.*
import ru.m2.calypso.{Decoder, Encoder}
import java.time.{Instant, LocalDateTime, ZoneOffset}
given Encoder[LocalDateTime] = Encoder[Instant].contramap(_.toInstant(ZoneOffset.UTC))
given Decoder[LocalDateTime] = Decoder[Instant].map(LocalDateTime.ofInstant(_, ZoneOffset.UTC))
val bson: BsonValue = LocalDateTime.of(1970, 1, 1, 0, 0, 0).asBson // BsonDateTime{value=0}
val time: Either[String, LocalDateTime] = bson.as[LocalDateTime] // Right(1970-01-01T00:00)
Refined
Codecs for refined types are derived, so if you have Encoder[A]
, then you have Encoder[A Refined P]
(where P
is a predicate) for free. The same for decoders, so having Decoder[A]
in implicit scope automatically gives you Decoder[A Refined P]
.
To use calypso-refined in an existing sbt project, add the following dependencies to your build.sbt:
libraryDependencies += "ru.m2" %% "calypso-refined" % "<version>"
import eu.timepit.refined.types.string.NonEmptyString
import ru.m2.calypso.refined.*
import ru.m2.calypso.syntax.*
NonEmptyString("Text").asBson // BsonString{value='Text'}
Why?
Passion for going with MongoDB Java Reactive Streams in a type-safe manner.
-
MongoDB Scala Driver
are wrappers aroundorg.bson
without advantages. -
ReactiveMongo
is not compatible with MongoDB Java Reactive Streams, as well as it does not offer reasonable API to encode/decode case classes. -
MongoLess
,shapeless-reactivemongo
,Pure BSON
andmedeia
are based on shapeless, so they are refactoring blind and not a safe way to express persistence schema. -
circe-bson
use JSON subset of BSON which is a no go for binary data.
Design
opaque type Encoder[A] = A => org.bson.BsonValue
opaque type Decoder[A] = org.bson.BsonValue => Either[String, A]
opaque type KeyEncoder[A] = A => String
opaque type KeyDecoder[A] = String => Either[String, A]
This type classes allows to map Scala types to BSON and back. Key codecs are essential to preserving Map keys. Library is heavily inspired by circe and argonaut.
- Map keys are encoded as strings
- Tuple (A, B) is encoded as object {"_1": A, "_2": B}
- String Refined Uuid is encoded as native binary BsonUuid
- Instant is encoded as DateTime (epoch millis)
On optional values: object keys with null values and non-existing object keys are semantically equal.
Law testing
Calypso type classes come with laws. Encoder and Decoder instances should hold CodecLaws. Calypso uses discipline to define type class laws and the ScalaCheck tests based on them.
First, you will need to specify dependencies on calypso-testing
in your build.sbt
file.
libraryDependencies ++= List(
"ru.m2" %% "calypso-testing" % "<version>" % Test,
"org.typelevel" %% "discipline-munit" % "1.0.9" % Test
)
We’ll begin by creating an Eq
instance for UserId data type, as laws will need to compare values.
import cats.Eq
import ru.m2.calypso.{Decoder, Encoder}
opaque type UserId = Long
object UserId:
def apply(value: Long): UserId = value
given Encoder[UserId] = Encoder.given_Encoder_Long
given Decoder[UserId] = Decoder.given_Decoder_Long
given Eq[UserId] = Eq.fromUniversalEquals
ScalaCheck requires Arbitrary instances for data types being tested. The following example is for MUnit.
import munit.DisciplineSuite
import org.scalacheck.{Arbitrary, Gen}
import ru.m2.calypso.testing.CodecTests
class CodecSuite extends DisciplineSuite:
checkAll("Codec[UserId]", CodecTests[UserId].codec)
given Arbitrary[UserId] = Arbitrary(Gen.long.map(UserId.apply))
Now when we run test in sbt console, ScalaCheck will test if the Codec laws hold for our UserId type. You should see something like this:
ru.m2.example.CodecSuite:
+ Codec[UserId]: codec.roundTrip 0.04s
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
You are gorgeous — the data type upholds the Codec laws!