Custom Encoders/Decoders

In case you need to apply a transformation during the extraction process, don’t have a 1-to-1 mapping of case class names to cassandra table names, or are trying to use a type not included in the ScalaCass library, you can just define a custom encoder and decoder for any type. We will define a UniqueId class as an example for how you might customize it. Let’s say this class will only accept ids less than 15 characters long.

abstract case class UniqueId(id: String)
object UniqueId {
  def apply(untestedId: String): UniqueId = 
    if (untestedId.length > 15) throw new IllegalArgumentException("id must be less than 15 characters long")
    else new UniqueId(untestedId) {}
}

You can provide a custom type in 3 ways:

Map over an existing type

This is the easier way to create a custom type, since it is based on an existing decoder/encoder. You first retrieve an existing encoder/decoder via the CassFormatEncoder/CassFormatDecoder’s apply method.

scala> import com.weather.scalacass.{CassFormatDecoder, CassFormatEncoder}
import com.weather.scalacass.{CassFormatDecoder, CassFormatEncoder}

scala> implicit val uniqueIdDecoder: CassFormatDecoder[UniqueId] = CassFormatDecoder[String].map[UniqueId](UniqueId.apply)
uniqueIdDecoder: com.weather.scalacass.CassFormatDecoder[UniqueId] = com.weather.scalacass.CassFormatDecoder$$anon$11@4fc500e9

scala> implicit val uniqueIdEncoder: CassFormatEncoder[UniqueId] = CassFormatEncoder[String].map[UniqueId](uniqueId => uniqueId.id)
uniqueIdEncoder: com.weather.scalacass.CassFormatEncoder[UniqueId] = com.weather.scalacass.CassFormatEncoder$$anon$1@76f041d2

With these implicits in scope, you can now use the UniqueId type directly when encoding a Row.

First, insert a row:

scala> case class Insertable(s: UniqueId, i: Int, l: Long)
defined class Insertable

scala> val insertStatement = sSession.insert("mytable", Insertable(UniqueId("a_unique_id"), 1234, 5678L))
insertStatement: com.weather.scalacass.scsession.SCInsertStatement = SCInsertStatement(INSERT INTO mykeyspace.mytable (s, i, l) VALUES (a_unique_id, 1234, 5678))

scala> insertStatement.execute()
res4: com.weather.scalacass.Result[com.datastax.driver.core.ResultSet] = Right(ResultSet[ exhausted: true, Columns[]])

Then, select that row:

scala> case class Query(s: UniqueId)
defined class Query

scala> val selectStatement = sSession.selectOneStar("mytable", Query(UniqueId("a_unique_id")))
selectStatement: com.weather.scalacass.scsession.SCSelectOneStatement = SCSelectOneStatement(SELECT * FROM mykeyspace.mytable WHERE s=a_unique_id LIMIT 1)

scala> val res = selectStatement.execute()
res: com.weather.scalacass.Result[Option[com.datastax.driver.core.Row]] = Right(Some(Row[a_unique_id, 1234, 5678]))

Then, extract using UniqueId:

scala> res.map(_.map(_.as[UniqueId]("s")))
res5: scala.util.Either[Throwable,Option[UniqueId]] = Right(Some(UniqueId(a_unique_id)))

Of course, UniqueId might throw an exception, which may not be the behavior you want, so you can optionally use flatMap for operations that might fail, which uses a Result[T] type, which is just an alias to Either[Throwable, T]:

abstract case class SafeUniqueId(id: String)
object SafeUniqueId {
  def apply(untestedId: String): Result[SafeUniqueId] =
    if (untestedId.length > 15) Left(new IllegalArgumentException("id must be less than 15 characters long"))
    else Right(new SafeUniqueId(untestedId) {})
}

And with this definition, let’s redefine the encoder/decoder:

scala> implicit val safeUniqueIdDecoder: CassFormatDecoder[SafeUniqueId] = CassFormatDecoder[String].flatMap[SafeUniqueId](SafeUniqueId.apply)
safeUniqueIdDecoder: com.weather.scalacass.CassFormatDecoder[SafeUniqueId] = com.weather.scalacass.CassFormatDecoder$$anon$12@13282fd1

scala> implicit val safeUniqueIdEncoder: CassFormatEncoder[SafeUniqueId] = CassFormatEncoder[String].map[SafeUniqueId](safeId => safeId.id)
safeUniqueIdEncoder: com.weather.scalacass.CassFormatEncoder[SafeUniqueId] = com.weather.scalacass.CassFormatEncoder$$anon$1@7df6ca86

So, let’s go through the same steps this time, except inject an id that is too long for extraction

scala> case class UnsafeInsertable(s: String, i: Int, l: Long)
defined class UnsafeInsertable

scala> val unsafeInsertStatement = sSession.insert("mytable", UnsafeInsertable("this_id_is_definitely_too_long_to_be_safe", 1234, 5678L))
unsafeInsertStatement: com.weather.scalacass.scsession.SCInsertStatement = SCInsertStatement(INSERT INTO mykeyspace.mytable (s, i, l) VALUES (this_id_is_definitely_too_long_to_be_safe, 1234, 5678))

scala> unsafeInsertStatement.execute()
res6: com.weather.scalacass.Result[com.datastax.driver.core.ResultSet] = Right(ResultSet[ exhausted: true, Columns[]])

And then select that row:

scala> case class UnsafeQuery(s: String)
defined class UnsafeQuery

scala> val unsafeSelectStatement = sSession.selectOneStar("mytable", UnsafeQuery("this_id_is_definitely_too_long_to_be_safe"))
unsafeSelectStatement: com.weather.scalacass.scsession.SCSelectOneStatement = SCSelectOneStatement(SELECT * FROM mykeyspace.mytable WHERE s=this_id_is_definitely_too_long_to_be_safe LIMIT 1)

scala> val unsafeRes = unsafeSelectStatement.execute()
unsafeRes: com.weather.scalacass.Result[Option[com.datastax.driver.core.Row]] = Right(Some(Row[this_id_is_definitely_too_long_to_be_safe, 1234, 5678]))

And finally, try to extract it:

scala> unsafeRes.map(_.map(_.attemptAs[SafeUniqueId]("s")))
res7: scala.util.Either[Throwable,Option[com.weather.scalacass.Result[SafeUniqueId]]] = Right(Some(Left(java.lang.IllegalArgumentException: id must be less than 15 characters long)))

Using forProduct$arity for case classes

When parsing the entire row into a case class, sometimes it may not be possible to encapsulate specialized logic using the basic encoders. In these cases, forProduct$arity can be used as a way to have complete control over how values are extracted out of/inserted into a row. They can also be used when names in a Cassandra row do not match the names in the case class. Since it only applies on operations to an entire row, the functions are available on CCCassFormatEncoder and CCCassFormatDecoder. The functions take a format of forProduct1/forProduct2/…forProduct22, and you choose the one that matches the number of arguments you wish to extract/insert into a row.

scala> object Wrapper {
     |   case class SpecialInsert(s: String, i: Int, specialLong: Long)
     |   object SpecialInsert {
     |     implicit val ccDecoder: CCCassFormatDecoder[SpecialInsert] =
     |       CCCassFormatDecoder.forProduct3("s", "i", "special_long")((s: String, i: Int, specialLong: Long) => SpecialInsert(s, i, specialLong))
     |     implicit val ccEncoder: CCCassFormatEncoder[SpecialInsert] =
     |       CCCassFormatEncoder.forProduct3("s", "i", "special_long")((sc: SpecialInsert) => (sc.s, sc.i, sc.specialLong))
     |   }
     | }
defined object Wrapper

scala> import Wrapper._ // Wrapper is necessary for this interpreter, and should be excluded in your code
import Wrapper._

And now the SpecialInsert is ready to be used:

scala> val specialInsertStatement = sSession.insert("specialtable", SpecialInsert("asdf", 1234, 5678L))
specialInsertStatement: com.weather.scalacass.scsession.SCInsertStatement = SCInsertStatement(INSERT INTO mykeyspace.specialtable (s, i, special_long) VALUES (asdf, 1234, 5678))

scala> specialInsertStatement.execute()
res8: com.weather.scalacass.Result[com.datastax.driver.core.ResultSet] = Left(com.datastax.driver.core.exceptions.InvalidQueryException: unconfigured table specialtable)

Renaming is not the only usage of forProduct$arity, nor is it strictly required to create one for a case class.

Create a new encoder/decoder from scratch

You might use a new encoder/decoder from scratch if you’ve added a user type to Cassandra itself, and want to use the library to read from it. However, let’s continue with the UniqueId example, as above.

For decoder

  • type From is the Java type that is extracted from Cassandra directly, from which you will convert to a Scala type
  • val typeToken is the special class instance for that type
    • TypeToken is used over classOf because it can correctly encode type parameters to Maps, Lists, and Sets
    • CassFormatDecoder provides 3 helper functions for these types: CassFormatDecoder.mapOf, .listOf, and .setOf
  • def f2t defines the transformation from the Java type to the Scala type
  • def extract defines the way to extract the Java type from the Cassandra Row
  • def tupleExtract is the same as extract, but for tuples
scala> import com.google.common.reflect.TypeToken, com.datastax.driver.core.{Row, TupleValue}
import com.google.common.reflect.TypeToken
import com.datastax.driver.core.{Row, TupleValue}

scala> implicit val safeUniqueIdDecoder: CassFormatDecoder[SafeUniqueId] = new CassFormatDecoder[SafeUniqueId] {
     |   type From = String
     |   val typeToken = TypeToken.of(classOf[String])
     |   def f2t(f: String): Result[SafeUniqueId] = SafeUniqueId(f)
     |   def extract(r: Row, name: String): From = r.getString(name)
     |   def tupleExtract(tup: TupleValue, pos: Int): From = tup.getString(pos)
     | }
safeUniqueIdDecoder: com.weather.scalacass.CassFormatDecoder[SafeUniqueId] = $anon$1@3170139c

For encoder

  • type From is the Scala type which you are encoding from
  • val cassDataType is the Cassandra type which you are converting to
  • def encode is the way that you encode that conversion, meaning the Scala -> Java conversion
scala> import com.datastax.driver.core.DataType
import com.datastax.driver.core.DataType

scala> implicit val safeUniqueIdEncoder: CassFormatEncoder[SafeUniqueId] = new CassFormatEncoder[SafeUniqueId] {
     |   type From = String
     |   val cassDataType: DataType = DataType.varchar()
     |   def encode(f: SafeUniqueId): Result[String] = Right(f.id)
     | }
safeUniqueIdEncoder: com.weather.scalacass.CassFormatEncoder[SafeUniqueId] = $anon$1@2fc318ea

And as before,

scala> case class UnsafeQuery(s: String)
defined class UnsafeQuery

scala> val unsafeSelectStatement = sSession.selectOneStar("mytable", UnsafeQuery("this_id_is_definitely_too_long_to_be_safe"))
unsafeSelectStatement: com.weather.scalacass.scsession.SCSelectOneStatement = SCSelectOneStatement(SELECT * FROM mykeyspace.mytable WHERE s=this_id_is_definitely_too_long_to_be_safe LIMIT 1)

scala> val unsafeRes = unsafeSelectStatement.execute()
unsafeRes: com.weather.scalacass.Result[Option[com.datastax.driver.core.Row]] = Right(Some(Row[this_id_is_definitely_too_long_to_be_safe, 1234, 5678]))

scala> unsafeRes.map(_.map(_.attemptAs[SafeUniqueId]("s")))
res11: scala.util.Either[Throwable,Option[com.weather.scalacass.Result[SafeUniqueId]]] = Right(Some(Left(java.lang.IllegalArgumentException: id must be less than 15 characters long)))