diff --git a/README.markdown b/README.markdown index 30ca8ed5..6258912c 100644 --- a/README.markdown +++ b/README.markdown @@ -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} @@ -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 @@ -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 diff --git a/src/main/scala/spray/json/CompactPrinter.scala b/src/main/scala/spray/json/CompactPrinter.scala index a51583d3..06b3f7ff 100644 --- a/src/main/scala/spray/json/CompactPrinter.scala +++ b/src/main/scala/spray/json/CompactPrinter.scala @@ -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('}') } diff --git a/src/main/scala/spray/json/JsValue.scala b/src/main/scala/spray/json/JsValue.scala index 08a673b4..1de810e4 100644 --- a/src/main/scala/spray/json/JsValue.scala +++ b/src/main/scala/spray/json/JsValue.scala @@ -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) } @@ -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 diff --git a/src/main/scala/spray/json/JsonPrinter.scala b/src/main/scala/spray/json/JsonPrinter.scala index 258fc5aa..8c62cf97 100644 --- a/src/main/scala/spray/json/JsonPrinter.scala +++ b/src/main/scala/spray/json/JsonPrinter.scala @@ -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 } } diff --git a/src/main/scala/spray/json/PrettyPrinter.scala b/src/main/scala/spray/json/PrettyPrinter.scala index 6af54433..7568df3c 100644 --- a/src/main/scala/spray/json/PrettyPrinter.scala +++ b/src/main/scala/spray/json/PrettyPrinter.scala @@ -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(": ") diff --git a/src/main/scala/spray/json/ProductFormats.scala b/src/main/scala/spray/json/ProductFormats.scala index 7d6c63e2..8d7aaac4 100644 --- a/src/main/scala/spray/json/ProductFormats.scala +++ b/src/main/scala/spray/json/ProductFormats.scala @@ -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 } } @@ -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 { diff --git a/src/main/scala/spray/json/StandardFormats.scala b/src/main/scala/spray/json/StandardFormats.scala index e59de646..c2edf34a 100644 --- a/src/main/scala/spray/json/StandardFormats.scala +++ b/src/main/scala/spray/json/StandardFormats.scala @@ -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 diff --git a/src/main/scala/spray/json/Tription.scala b/src/main/scala/spray/json/Tription.scala new file mode 100644 index 00000000..905963c5 --- /dev/null +++ b/src/main/scala/spray/json/Tription.scala @@ -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: + * + * { "id":"234565434567898789098765", + * "field1": 7, + * "field3: null } + * + * 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") +} + diff --git a/src/test/scala/spray/json/CompactPrinterSpec.scala b/src/test/scala/spray/json/CompactPrinterSpec.scala index 6a9560b7..b804d2ef 100644 --- a/src/test/scala/spray/json/CompactPrinterSpec.scala +++ b/src/test/scala/spray/json/CompactPrinterSpec.scala @@ -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" } @@ -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}]""" diff --git a/src/test/scala/spray/json/PrettyPrinterSpec.scala b/src/test/scala/spray/json/PrettyPrinterSpec.scala index 6354ef0b..737607d4 100644 --- a/src/test/scala/spray/json/PrettyPrinterSpec.scala +++ b/src/test/scala/spray/json/PrettyPrinterSpec.scala @@ -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, diff --git a/src/test/scala/spray/json/ProductFormatsSpec.scala b/src/test/scala/spray/json/ProductFormatsSpec.scala index 30582a8f..be3a13e3 100644 --- a/src/test/scala/spray/json/ProductFormatsSpec.scala +++ b/src/test/scala/spray/json/ProductFormatsSpec.scala @@ -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 } @@ -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) @@ -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 } @@ -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 { diff --git a/src/test/scala/spray/json/StandardFormatsSpec.scala b/src/test/scala/spray/json/StandardFormatsSpec.scala index 833f06a7..7a81e20b 100644 --- a/src/test/scala/spray/json/StandardFormatsSpec.scala +++ b/src/test/scala/spray/json/StandardFormatsSpec.scala @@ -17,10 +17,12 @@ package spray.json import org.specs2.mutable._ -import scala.Right +import scala.util.Random._ class StandardFormatsSpec extends Specification with DefaultJsonProtocol { + def nextString = new String( (alphanumeric take (nextInt( 16 ) + 1)).toArray ) + "The optionFormat" should { "convert None to JsNull" in { None.asInstanceOf[Option[Int]].toJson mustEqual JsNull @@ -36,6 +38,29 @@ class StandardFormatsSpec extends Specification with DefaultJsonProtocol { } } + "The triptionFormat" should { + "convert Undefined to JsUndefined" in { + Undefined.asInstanceOf[Tription[Int]].toJson mustEqual JsUndefined + } + "convert JsUndefined to Undefined" in { + JsUndefined.convertTo[Tription[Int]] mustEqual Undefined + } + "convert Null to JsNull" in { + Null.asInstanceOf[Tription[Int]].toJson mustEqual JsNull + } + "convert JsNull to Null" in { + JsNull.convertTo[Tription[Int]] mustEqual Null + } + "convert Value(x) to JsString(x)" in { + val x = nextString + Value(x).asInstanceOf[Tription[String]].toJson mustEqual JsString(x) + } + "convert JsString(x) to Value(x)" in { + val x = nextString + JsString(x).convertTo[Tription[String]] mustEqual Value(x) + } + } + "The eitherFormat" should { val a: Either[Int, String] = Left(42) val b: Either[Int, String] = Right("Hello") diff --git a/src/test/scala/spray/json/TriptionSpec.scala b/src/test/scala/spray/json/TriptionSpec.scala new file mode 100644 index 00000000..8218665a --- /dev/null +++ b/src/test/scala/spray/json/TriptionSpec.scala @@ -0,0 +1,117 @@ +package spray.json + +import java.util.NoSuchElementException + +import org.specs2.mutable._ + +import scala.util.Random._ + +/** + * Created by bathalh on 2/22/16. + */ +class TriptionSpec extends Specification +{ + def nextString = new String( (alphanumeric take (nextInt( 16 ) + 1)).toArray ) + + "Basic monadic and helper function should work work:" should + { + "Undefined.isDefined is false; Null and Value are true" in { + Undefined.isDefined mustEqual false + Null.isDefined mustEqual true + Value(nextString).isDefined mustEqual true + } + "Null.isNull is true; Null and Value are false" in { + Undefined.isNull mustEqual false + Null.isNull mustEqual true + Value(nextString).isNull mustEqual false + } + "Value.hasValue is true; Null and Undefined are false" in { + Undefined.hasValue mustEqual false + Null.hasValue mustEqual false + Value(nextString).hasValue mustEqual true + } + "Value.get retrieves the value" in { + val x = nextString + Value(x).get mustEqual x + } + "Null.get throws NoSuchElementException" in { + try { + Null.get + "" mustEqual "Expected NoSuchElementException" + } catch { + case nsee: NoSuchElementException => nsee.getMessage mustEqual "Null.get" + } + } + "Undefined.get throws NoSuchElementException" in { + try { + Undefined.get + "" mustEqual "Expected NoSuchElementException" + } catch { + case nsee: NoSuchElementException => nsee.getMessage mustEqual "Undefined.get" + } + } + "getOrElse gets the value if it exists; otherwise executes the else" in { + val value = nextString + val alt = nextString + Undefined.getOrElse( alt ) mustEqual alt + Null.getOrElse( alt ) mustEqual alt + Value(value).getOrElse( alt ) mustEqual value + } + "map translates a value to another value or returns original Tription if value is not there" in { + val value = nextString + val append = nextString + def mapFunction( s: String ) = s + append + + Undefined.map( mapFunction ) mustEqual Undefined + Null.map( mapFunction ) mustEqual Null + Value(value).map( mapFunction ) mustEqual Value(value + append) + } + "flatMap translates a value to another value or returns original Tription if value is not there" in { + val value = nextString + val append = nextString + def mapFunction( s: String ) = Value(s + append) + + Undefined.flatMap( mapFunction ) mustEqual Undefined + Null.flatMap( mapFunction ) mustEqual Null + Value(value).flatMap( mapFunction ) mustEqual Value(value + append) + } + "filter returns itself for Null and Undefined" in { + Undefined filter { _ => false } mustEqual Undefined + Null filter { _ => false } mustEqual Null + Undefined filter { _ => true } mustEqual Undefined + Null filter { _ => true } mustEqual Null + } + "filter returns Null if value does not match criteria" in { + Value(nextString) filter { _ => false } mustEqual Null + } + "filter returns itself if value matches criteria" in { + val value = nextString + Value(value) filter { _ => true } mustEqual Value(value) + } + "filter returns itself for Null and Undefined" in { + Undefined filter { _ => false } mustEqual Undefined + Null filter { _ => false } mustEqual Null + Undefined filter { _ => true } mustEqual Undefined + Null filter { _ => true } mustEqual Null + } + "filter returns Null if value does not match criteria" in { + Value(nextString) filter { _ => false } mustEqual Null + } + "filter returns itself if value matches criteria" in { + val value = nextString + Value(value) filter { _ => true } mustEqual Value(value) + } + "foreach executes for value in a Value and does nothing otherwise" in { + val sb = new StringBuilder + def foreachFunction( x: Any ) = sb.append( x.toString ) + + Undefined foreach foreachFunction + sb.toString mustEqual "" + Undefined foreach foreachFunction + sb.toString mustEqual "" + val v = nextString + Value(v) foreach foreachFunction + sb.toString mustEqual v + } + } +}