m2-oss / calypso   0.6.0

MIT License GitHub

BSON library for Scala

Scala versions: 3.x 2.13

calypso

build Maven Central

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 around org.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 and medeia 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!