Contraband, an alternative to case class
Here are a few questions I’ve been thinking about:
- How should I express data or API?
- How should the data be represented in Java or Scala?
- How do I convert the data into wire formats such as JSON?
- How do I evolve the data without breaking binary compatibility?
limitation of case class
The sealed trait and case class is the idiomatic way to represent datatypes in Scala, but it’s impossible to add fields in binary compatible way. Take for example a simple case class Greeting
, and see how it would expand into a class and a companion object:
package com.example
class Greeting(name: String) {
override def equals(o: Any): Boolean = ???
override def hashCode: Int = ???
override def toString: String = ???
def copy(name: String = name): Greeting = ???
}
object Greeting {
def apply(name: String): Greeting = ???
def unapply(v: Greeting): Option[String] = ???
}
Next, add a new field x
:
package com.example
class Greeting(name: String, x: Int) {
override def equals(o: Any): Boolean = ???
override def hashCode: Int = ???
override def toString: String = ???
def copy(name: String = name, x: Int = x): Greeting = ???
}
object Greeting {
def apply(name: String): Greeting = ???
def unapply(v: Greeting): Option[(String, Int)] = ???
}
As you can see, both copy
method and unapply
method breaks the binary compatibility.
To workaround this, some of the sbt code handrolls pseudo case class such as UpdateOptions.
Contraband
GraphQL is a query language for JSON API, developed by Facebook. I’ve made a dialect of GraphQL’s schema language, and called it Contraband. There’s an sbt plugin that can generate pseudo-case class targeting either Java or Scala. This was previously called sbt-datatype, which Martin Duhem and I worked on last year.
In Contraband, the Greeting
example would look like this:
package com.example
@target(Scala)
type Greeting {
name: String
}
This would generate:
// DO NOT EDIT MANUALLY
package com.example
final class Greeting private (
val name: Option[String]) extends Serializable {
override def equals(o: Any): Boolean = o match {
case x: Greeting => (this.name == x.name)
case _ => false
}
override def hashCode: Int = {
37 * (17 + name.##)
}
override def toString: String = {
"Greeting(" + name + ")"
}
protected[this] def copy(name: Option[String] = name): Greeting = {
new Greeting(name)
}
def withName(name: Option[String]): Greeting = {
copy(name = name)
}
def withName(name: String): Greeting = {
copy(name = Option(name))
}
}
object Greeting {
def apply(name: Option[String]): Greeting = new Greeting(name)
def apply(name: String): Greeting = new Greeting(Option(name))
}
Instead of copy
, you would use withName("foo")
. Also note that GraphQL/Contraband’s String
would map to Scala’s Option[String]
. This is also similar in Protocol Buffer v3 where a singular field means zero-or-one.
evolving the data
Let’s see how we can evolve this data. Here’s how we add a new field x
.
package com.example
@target(Scala)
type Greeting {
name: String @since("0.0.0")
x: Int @since("0.1.0")
}
In Contraband, we can denote each field with a version name using @since
.
This would generate:
// DO NOT EDIT MANUALLY
package com.example
final class Greeting private (
val name: Option[String],
val x: Option[Int]) extends Serializable {
private def this(name: Option[String]) = this(name, None)
....
def withX(x: Option[Int]): Greeting = {
copy(x = x)
}
def withX(x: Int): Greeting = {
copy(x = Option(x))
}
}
object Greeting {
def apply(name: Option[String]): Greeting = new Greeting(name, None)
def apply(name: String): Greeting = new Greeting(Option(name), None)
def apply(name: Option[String], x: Option[Int]): Greeting = new Greeting(name, x)
def apply(name: String, x: Int): Greeting = new Greeting(Option(name), Option(x))
}
I’ve omitted equals
, hashCode
, toString
, and withName
from above.
The point here is that overloads of apply
is generated as of version 0.0.0 and 0.1.0.
JSON codec generation
Adding JsonCodecPlugin
to the subproject will generate sjson-new JSON codes for the Contraband types.
lazy val root = (project in file("."))
.enablePlugins(ContrabandPlugin, JsonCodecPlugin)
.settings(
scalaVersion := "2.12.1",
libraryDependencies += "com.eed3si9n" %% "sjson-new-scalajson" % "0.7.1"
)
sjson-new is a codec toolkit that lets you define a code that supports Sray JSON’s AST, SLIP-28 Scala JSON, and MessagePack as the backend.
Here are a few more things to specify in the schema:
package com.example
@target(Scala)
@codecPackage("com.example.codec")
@codecTypeField("type")
@fullCodec("CustomJsonProtocol")
type Greeting {
name: String @since("0.0.0")
x: Int @since("0.1.0")
}
This will generate GreetingFormat
trait that can be used as backend-independent JSON codec. Here’s a REPL session that demonstrates Greeting
-to-JSON roundtrip.
scala> import sjsonnew.support.scalajson.unsafe.{ Converter, CompactPrinter, Parser }
import sjsonnew.support.scalajson.unsafe.{Converter, CompactPrinter, Parser}
scala> import com.example.codec.CustomJsonProtocol._
import com.example.codec.CustomJsonProtocol._
scala> import com.example.Greeting
import com.example.Greeting
scala> val g = Greeting("Bob")
g: com.example.Greeting = Greeting(Some(Bob), None)
scala> val j = Converter.toJsonUnsafe(g)
j: scala.json.ast.unsafe.JValue = JObject([Lscala.json.ast.unsafe.JField;@25667024)
scala> val s = CompactPrinter(j)
s: String = {"name":"Bob"}
scala> val x = Parser.parseUnsafe(s)
x: scala.json.ast.unsafe.JValue = JObject([Lscala.json.ast.unsafe.JField;@372115ef)
scala> val h = Converter.fromJsonUnsafe[Greeting](x)
h: com.example.Greeting = Greeting(Some(Bob), None)
scala> assert(g == h)
For now the target language is Java and Scala only, but given that Contraband is a dialect of GraphQL, it might be able to reuse some of the tooling to cross over to other languages as well if there are interests.
More details about Contraband are available in Contraband docs.