Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Distinct values for undefined and null #177

Open
wants to merge 17 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ important reference and collection types. As long as your code uses nothing more
* String, Symbol
* BigInt, BigDecimal
* Option, Either, Tuple1 - Tuple7
* Tription
* List, Array
* immutable.{Map, Iterable, Seq, IndexedSeq, LinearSeq, Set, Vector}
* collection.{Iterable, Seq, IndexedSeq, LinearSeq, Set}
Expand All @@ -104,6 +105,29 @@ important reference and collection types. As long as your code uses nothing more
In most cases however you'll also want to convert types not covered by the `DefaultJsonProtocol`. In these cases you
need to provide `JsonFormat[T]`s for your custom types. This is not hard at all.

### Triptions

`Tription`s are "triple options": values that can either exist, be null, or be undefined.

JavaScript (and JSON), unlike Java/Scala, allow `undefined` values, which are distinct from `null` values.
For example, a PATCH request may have a payload like this:
```json
{ "id":"234565434567898789098765",
"field1": "new value",
"field3": null }
```
which would tell the server to update field1 to "new value", set field3 to null, and leave field2
unchanged. With a standard scala `Option`, it is impossible to tell whether the values of field2 and field3
in the original payload were `null` or undefined since any missing values translate to `None`.

The `Tription` solves that problem by defining `Value` for present values, `Null` for values explicitly marked
null, and `Undefined` for values which are missing.

`Tription`s can be used just like `Option`s:
```scala
case class RequestObject( id: String, field1: Tription[String], field2: Tription[Int],
field3: Tription[String], field4: Tription[SubResource] )
```

### Providing JsonFormats for Case Classes

Expand Down Expand Up @@ -159,10 +183,10 @@ object MyJsonProtocol extends DefaultJsonProtocol {
#### NullOptions

The `NullOptions` trait supplies an alternative rendering mode for optional case class members. Normally optional
members that are undefined (`None`) are not rendered at all. By mixing in this trait into your custom JsonProtocol you
can enforce the rendering of undefined members as `null`.
(Note that this only affect JSON writing, spray-json will always read missing optional members as well as `null`
optional members as `None`.)
members that are undefined (`None`/`Undefined`) are not rendered at all. By mixing in this trait into your custom
JsonProtocol you can enforce the rendering of undefined members as `null`.
(Note that this only affect JSON writing, spray-json will always read missing `Option` members as well as `null`
`Option` members as `None` and missing `Tription` members as `Undefined`.)


### Providing JsonFormats for other Types
Expand Down
9 changes: 5 additions & 4 deletions src/main/scala/spray/json/CompactPrinter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ trait CompactPrinter extends JsonPrinter {

protected def printObject(members: Map[String, JsValue], sb: StringBuilder) {
sb.append('{')
printSeq(members, sb.append(',')) { m =>
printString(m._1, sb)
sb.append(':')
print(m._2, sb)
val definedMembers = members filter { case (_, v) => v != JsUndefined }
printSeq(definedMembers, sb.append(',')) { m =>
printString( m._1, sb )
sb.append( ':' )
print( m._2, sb )
}
sb.append('}')
}
Expand Down
8 changes: 6 additions & 2 deletions src/main/scala/spray/json/JsValue.scala
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ case class JsArray(elements: Vector[JsValue]) extends JsValue {
}
object JsArray {
val empty = JsArray(Vector.empty)
def apply(elements: JsValue*) = new JsArray(elements.toVector)
def apply(elements: JsValue*) = if( elements contains JsUndefined ) throw new IllegalStateException( "JSON arrays cannot contain undefined values" )
else new JsArray(elements.toVector)
@deprecated("Use JsArray(Vector[JsValue]) instead", "1.3.0")
def apply(elements: List[JsValue]) = new JsArray(elements.toVector)
}
Expand Down Expand Up @@ -120,5 +121,8 @@ case object JsFalse extends JsBoolean {

/**
* The representation for JSON null.
*/
*/
case object JsNull extends JsValue

/** The representation for JSON missing value. **/
case object JsUndefined extends JsValue
1 change: 1 addition & 0 deletions src/main/scala/spray/json/JsonPrinter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ trait JsonPrinter extends (JsValue => String) {
case JsFalse => sb.append("false")
case JsNumber(x) => sb.append(x)
case JsString(x) => printString(x, sb)
case JsUndefined => throw new IllegalStateException( "Cannot display JsUndefined" )
case _ => throw new IllegalStateException
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/main/scala/spray/json/PrettyPrinter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ trait PrettyPrinter extends JsonPrinter {
protected def organiseMembers(members: Map[String, JsValue]): Seq[(String, JsValue)] = members.toSeq

protected def printObject(members: Map[String, JsValue], sb: StringBuilder, indent: Int) {
sb.append("{\n")
printSeq(organiseMembers(members), sb.append(",\n")) { m =>
sb.append("{\n")
val definedMembers = members filter { case (_, v) => v != JsUndefined }
printSeq(organiseMembers(definedMembers), sb.append(",\n")) { m =>
printIndent(sb, indent + Indent)
printString(m._1, sb)
sb.append(": ")
Expand Down
5 changes: 5 additions & 0 deletions src/main/scala/spray/json/ProductFormats.scala
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ trait ProductFormats extends ProductFormatsInstances {
val value = p.productElement(ix).asInstanceOf[T]
writer match {
case _: OptionFormat[_] if (value == None) => rest
case _: TriptionFormat[_] if (value == Undefined) => rest
case _ => (fieldName, writer.write(value)) :: rest
}
}
Expand All @@ -53,6 +54,10 @@ trait ProductFormats extends ProductFormatsInstances {
(reader.isInstanceOf[OptionFormat[_]] &
!x.fields.contains(fieldName)) =>
None.asInstanceOf[T]
case x: JsObject if
(reader.isInstanceOf[TriptionFormat[_]] &
!x.fields.contains(fieldName)) =>
Undefined.asInstanceOf[T]
case x: JsObject =>
try reader.read(x.fields(fieldName))
catch {
Expand Down
17 changes: 17 additions & 0 deletions src/main/scala/spray/json/StandardFormats.scala
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,23 @@ trait StandardFormats {
def readSome(value: JsValue) = Some(value.convertTo[T])
}

implicit def triptionFormat[T :JF]: JF[Tription[T]] = new TriptionFormat[T]

class TriptionFormat[T :JF] extends JF[Tription[T]] {
def write(tription: Tription[T]) = tription match {
case Value(x) => x.toJson
case Null => JsNull
case Undefined => JsUndefined
}
def read(value: JsValue) = value match {
case JsUndefined => Undefined
case JsNull => Null
case x => Value(x.convertTo[T])
}
// allows reading the JSON as a Value (useful in container formats)
def readSome(value: JsValue) = Value(value.convertTo[T])
}

implicit def eitherFormat[A :JF, B :JF] = new JF[Either[A, B]] {
def write(either: Either[A, B]) = either match {
case Right(a) => a.toJson
Expand Down
67 changes: 67 additions & 0 deletions src/main/scala/spray/json/Tription.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package spray.json

/**
* A Triple-Option
*
* JavaScript (and JSON), unlike Java/Scala, allow undefined values, which are distinct from null values.
* For example, a PUT request may have a payload like this:
* <code>
* { "id":"234565434567898789098765",
* "field1": 7,
* "field3: null }
* </code>
* which would tell the server to update field1 to 7, set field3 to null, and leave field2 alone.
* With a standard scala `Option`, it is impossible to tell whether field2 and field3 were
* null or undefined since any missing values translate to `None`.
*
* The Tription solves that problem by defining `Value` for present values, `Null` for values
* explicitly set to null, and `Undefined` for values which are not there at all.
*
* Created by bathalh on 2/19/16.
*/
sealed abstract class Tription[+T] extends Product
{
def isDefined: Boolean
def isNull: Boolean
def hasValue = isDefined && !isNull
def get: T

final def getOrElse[N >: T](default: => N): N =
if( !hasValue ) default else this.get

final def map[N]( f: T => N ): Tription[N] =
if( !isDefined ) Undefined
else if( isNull ) Null
else Value( f( get ) )

final def flatMap[N](f: T => Tription[N]): Tription[N] =
if( !isDefined ) Undefined
else if( isNull ) Null
else f( get )

/** return `Null` (not `Undefined`) if filter criteria don't match **/
final def filter(p: T => Boolean): Tription[T] =
if (!hasValue || p(this.get)) this else Null

final def foreach[U](f: T => U): Unit =
if( hasValue ) f( this.get )
}

case class Value[+T](x: T) extends Tription[T] {
override def isDefined: Boolean = true
override def isNull: Boolean = false
override def get: T = x
}

case object Null extends Tription[Nothing] {
override def isDefined: Boolean = true
override def isNull: Boolean = true
override def get = throw new NoSuchElementException("Null.get")
}

case object Undefined extends Tription[Nothing] {
override def isDefined: Boolean = false
override def isNull: Boolean = false
override def get = throw new NoSuchElementException("Undefined.get")
}

16 changes: 16 additions & 0 deletions src/test/scala/spray/json/CompactPrinterSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ class CompactPrinterSpec extends Specification {
"print JsNull to 'null'" in {
CompactPrinter(JsNull) mustEqual "null"
}
"throw exception when printing JsUndefined" in {
try {
CompactPrinter(JsUndefined) mustEqual "undefined"
} catch {
case ise: IllegalStateException =>
ise.getMessage mustEqual "Cannot display JsUndefined"
}
}
"print JsTrue to 'true'" in {
CompactPrinter(JsTrue) mustEqual "true"
}
Expand Down Expand Up @@ -64,6 +72,14 @@ class CompactPrinterSpec extends Specification {
CompactPrinter(JsObject("key" -> JsNumber(42), "key2" -> JsString("value")))
mustEqual """{"key":42,"key2":"value"}"""
)
"properly print a simple JsObject with undefined values" in (
CompactPrinter(JsObject("key" -> JsNumber(42), "key2" -> JsString("value"), "key3" -> JsUndefined))
mustEqual """{"key":42,"key2":"value"}"""
)
"properly print a simple JsObject with only undefined values" in (
CompactPrinter(JsObject("key" -> JsUndefined, "key2" -> JsUndefined))
mustEqual "{}"
)
"properly print a simple JsArray" in (
CompactPrinter(JsArray(JsNull, JsNumber(1.23), JsObject("key" -> JsBoolean(true))))
mustEqual """[null,1.23,{"key":true}]"""
Expand Down
43 changes: 34 additions & 9 deletions src/test/scala/spray/json/PrettyPrinterSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,50 @@ import org.specs2.mutable._
class PrettyPrinterSpec extends Specification {

"The PrettyPrinter" should {
val JsObject(fields) = JsonParser {
"""{
| "Boolean no": false,
| "Boolean yes":true,
| "Unic\u00f8de" : "Long string with newline\nescape",
| "key with \"quotes\"" : "string",
| "key with spaces": null,
| "number": -1.2323424E-5,
| "simpleKey" : "some value",
| "sub object" : {
| "sub key": 26.5,
| "a": "b",
| "array": [1, 2, { "yes":1, "no":0 }, ["a", "b", null], false]
| },
| "zero": 0
|}""".stripMargin
}

"print a more complicated JsObject nicely aligned" in {
val JsObject(fields) = JsonParser {
PrettyPrinter(JsObject(ListMap(fields.toSeq.sortBy(_._1):_*))) mustEqual {
"""{
| "Boolean no": false,
| "Boolean yes":true,
| "Unic\u00f8de" : "Long string with newline\nescape",
| "key with \"quotes\"" : "string",
| "Boolean yes": true,
| "Unic\u00f8de": "Long string with newline\nescape",
| "key with \"quotes\"": "string",
| "key with spaces": null,
| "number": -1.2323424E-5,
| "simpleKey" : "some value",
| "sub object" : {
| "number": -0.000012323424,
| "simpleKey": "some value",
| "sub object": {
| "sub key": 26.5,
| "a": "b",
| "array": [1, 2, { "yes":1, "no":0 }, ["a", "b", null], false]
| "array": [1, 2, {
| "yes": 1,
| "no": 0
| }, ["a", "b", null], false]
| },
| "zero": 0
|}""".stripMargin
}
PrettyPrinter(JsObject(ListMap(fields.toSeq.sortBy(_._1):_*))) mustEqual {
}

"ignore undefined fields" in {
val fieldsWithUndefined = fields + ("notthere" -> JsUndefined)
PrettyPrinter(JsObject(ListMap(fieldsWithUndefined.toSeq.sortBy(_._1):_*))) mustEqual {
"""{
| "Boolean no": false,
| "Boolean yes": true,
Expand Down
30 changes: 28 additions & 2 deletions src/test/scala/spray/json/ProductFormatsSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class ProductFormatsSpec extends Specification {
case class Test2(a: Int, b: Option[Double])
case class Test3[A, B](as: List[A], bs: List[B])
case class Test4(t2: Test2)
case class TestTription(a: Int, b: Tription[Double])
case class TestTransient(a: Int, b: Option[Double]) {
@transient var c = false
}
Expand All @@ -37,6 +38,7 @@ class ProductFormatsSpec extends Specification {
implicit val test2Format = jsonFormat2(Test2)
implicit def test3Format[A: JsonFormat, B: JsonFormat] = jsonFormat2(Test3.apply[A, B])
implicit def test4Format = jsonFormat1(Test4)
implicit val test5Format = jsonFormat2(TestTription)
implicit def testTransientFormat = jsonFormat2(TestTransient)
implicit def testStaticFormat = jsonFormat2(TestStatic)
implicit def testMangledFormat = jsonFormat5(TestMangled)
Expand All @@ -58,12 +60,33 @@ class ProductFormatsSpec extends Specification {
JsObject("b" -> JsNumber(4.2)).convertTo[Test2] must
throwA(new DeserializationException("Object is missing required member 'a'"))
)
"not require the presence of optional fields for deserialization" in {
"not require the presence of Option fields for deserialization" in {
JsObject("a" -> JsNumber(42)).convertTo[Test2] mustEqual Test2(42, None)
}
"not require the presence of Tription fields for deserialization" in {
JsObject("a" -> JsNumber(42)).convertTo[TestTription] mustEqual TestTription(42, Undefined)
}
"deserialize null to Tription `Null`" in {
JsObject("a" -> JsNumber(42), "b" -> JsNull).convertTo[TestTription] mustEqual TestTription(42, Null)
}
"deserialize undefined to Tription `Undefined`" in {
JsObject("a" -> JsNumber(42), "b" -> JsUndefined).convertTo[TestTription] mustEqual TestTription(42, Undefined)
}
"deserialize valued to Tription the value" in {
JsObject("a" -> JsNumber(42), "b" -> JsNumber(4.2D)).convertTo[TestTription] mustEqual TestTription(42, Value(4.2D))
}
"not render `None` members during serialization" in {
Test2(42, None).toJson mustEqual JsObject("a" -> JsNumber(42))
}
"render `Null` members during serialization" in {
TestTription(42, Null).toJson mustEqual JsObject("a" -> JsNumber(42), "b" -> JsNull)
}
"render `Value` members during serialization" in {
TestTription(42, Value(4.2D)).toJson mustEqual JsObject("a" -> JsNumber(42), "b" -> JsNumber(4.2D))
}
"not render `Undefined` members during serialization" in {
TestTription(42, Undefined).toJson mustEqual JsObject("a" -> JsNumber(42))
}
"ignore additional members during deserialization" in {
JsObject("a" -> JsNumber(42), "b" -> JsNumber(4.2), "c" -> JsString('no)).convertTo[Test2] mustEqual obj
}
Expand All @@ -86,10 +109,13 @@ class ProductFormatsSpec extends Specification {
}

"A JsonProtocol mixing in NullOptions" should {
import TestProtocol2._
"render `None` members to `null`" in {
import TestProtocol2._
Test2(42, None).toJson mustEqual JsObject("a" -> JsNumber(42), "b" -> JsNull)
}
"render `Undefined` members to `undefined`" in {
TestTription(42, Undefined).toJson mustEqual JsObject("a" -> JsNumber(42), "b" -> JsUndefined)
}
}

"A JsonFormat for a generic case class and created with `jsonFormat`" should {
Expand Down
Loading