diff --git a/.sbtopts b/.sbtopts new file mode 100644 index 0000000..c1def87 --- /dev/null +++ b/.sbtopts @@ -0,0 +1 @@ +-J-XX:MaxMetaspaceSize=1024M diff --git a/build.sbt b/build.sbt index eead493..b8ecc44 100644 --- a/build.sbt +++ b/build.sbt @@ -1,14 +1,9 @@ import Dependencies._ -import bintry.Version name := "play-json-ops-root" ThisBuild / organization := "com.rallyhealth" ThisBuild / organizationName := "Rally Health" -val Scala_2_11 = "2.11.12" -val Scala_2_12 = "2.12.6" -val Scala_2_13 = "2.13.1" - ThisBuild / gitVersioningSnapshotLowerBound := "3.0.0" ThisBuild / bintrayOrganization := Some("rallyhealth") @@ -102,10 +97,15 @@ def playJsonOpsCommon(scalacVersion: String, includePlayVersion: String): Projec lazy val `play25-json-ops-common-211` = playJsonOpsCommon(Scala_2_11, Play_2_5) lazy val `play26-json-ops-common-211` = playJsonOpsCommon(Scala_2_11, Play_2_6) lazy val `play26-json-ops-common-212` = playJsonOpsCommon(Scala_2_12, Play_2_6) +lazy val `play27-json-ops-common-211` = playJsonOpsCommon(Scala_2_11, Play_2_7) +lazy val `play27-json-ops-common-212` = playJsonOpsCommon(Scala_2_12, Play_2_7) def playJsonOps(scalacVersion: String, includePlayVersion: String): Project = { val id = s"play${playSuffix(includePlayVersion)}-json-ops" - val projectPath = id + val projectPath = scalacVersion match { + case Scala_2_13 => s"play${playSuffix(includePlayVersion)}-json-ops-scala213" + case _ => id + } val scalaCheckVersion = scalaCheckVersionForPlay(includePlayVersion) commonProject(id, projectPath, scalacVersion) .settings( @@ -136,6 +136,12 @@ def playJsonOps(scalacVersion: String, includePlayVersion: String): Project = { case (Scala_2_12, Play_2_6) => Seq( `play26-json-ops-common-212` ) + case (Scala_2_11, Play_2_7) => Seq( + `play27-json-ops-common-211` + ) + case (Scala_2_12, Play_2_7) => Seq( + `play27-json-ops-common-212` + ) case _ => Seq() }).map(_ % Compile): _*) } @@ -156,23 +162,20 @@ def playJsonTests(scalacVersion: String, includePlayVersion: String, includeScal case ScalaCheck_1_14 => "-sc14" } val id = s"play${playSuffix(includePlayVersion)}-json-tests$scalaCheckSuffix" - val projectPath = (includePlayVersion, scalacVersion) match { + val projectPath = (includePlayVersion, includeScalaCheckVersion) match { // Scala 2.13 and ScalaTest 3.1 has some source code incompatibilities that require separate source directories - case (_, Scala_2_13) | (Play_2_7, _) => id + case (Play_2_7, ScalaCheck_1_14) => "play27-json-tests-sc14" case _ => "play-json-tests-common" } commonProject(id, projectPath, scalacVersion).settings( - // set the source code directories to the shared project root - sourceDirectory := file(s"$projectPath/src").getAbsoluteFile, - Compile / sourceDirectory := file(s"$projectPath/src/main").getAbsoluteFile, - Test / sourceDirectory := file(s"$projectPath/src/test").getAbsoluteFile, + Test / scalacOptions -= "-deprecation", libraryDependencies ++= Seq( scalaCheckOps(includeScalaCheckVersion), scalaTest(includeScalaCheckVersion) ) ++ { // Test-only dependencies - includePlayVersion match { - case Play_2_7 => Seq( + includeScalaCheckVersion match { + case ScalaCheck_1_14 => Seq( scalaTestPlusScalaCheck(includeScalaCheckVersion) ) case _ => Seq() @@ -205,6 +208,6 @@ lazy val `play25-json-tests-sc12-211` = playJsonTests(Scala_2_11, Play_2_5, Scal lazy val `play25-json-tests-sc13-211` = playJsonTests(Scala_2_11, Play_2_5, ScalaCheck_1_13) lazy val `play26-json-tests-sc13-211` = playJsonTests(Scala_2_11, Play_2_6, ScalaCheck_1_13) lazy val `play26-json-tests-sc13-212` = playJsonTests(Scala_2_12, Play_2_6, ScalaCheck_1_13) -lazy val `play27-json-tests-sc14-211` = playJsonTests(Scala_2_11, Play_2_7, ScalaCheck_1_14) -lazy val `play27-json-tests-sc14-212` = playJsonTests(Scala_2_12, Play_2_7, ScalaCheck_1_14) +lazy val `play27-json-tests-sc13-211` = playJsonTests(Scala_2_11, Play_2_7, ScalaCheck_1_14) +lazy val `play27-json-tests-sc13-212` = playJsonTests(Scala_2_12, Play_2_7, ScalaCheck_1_14) lazy val `play27-json-tests-sc14-213` = playJsonTests(Scala_2_13, Play_2_7, ScalaCheck_1_14) diff --git a/play-json-ops-common/src/main/scala/play/api/libs/json/ops/PlayJsonMacros.scala b/play-json-ops-common/src/main/scala/play/api/libs/json/ops/PlayJsonMacros.scala new file mode 100644 index 0000000..8ae6531 --- /dev/null +++ b/play-json-ops-common/src/main/scala/play/api/libs/json/ops/PlayJsonMacros.scala @@ -0,0 +1,343 @@ +package play.api.libs.json.ops + +import play.api.libs.json.{Json, Reads} + +import scala.language.reflectiveCalls +import scala.reflect.macros.whitebox +import language.experimental.macros +import scala.collection.generic + +/** + * Provides macros similar to those in [[Json]] but with modified functionality to better handle our common json + * parsing use cases, for example missing fields (instead of empty arrays). + */ +object PlayJsonMacros extends TolerantContainerFormats { + + /** + * Same as [[Json.reads]] but when reading containers such as [[List]], [[Seq]], [[Set]], etc., if the field + * is missing will return an empty container instead of failing to validate the model. + * + * NOTE: this doesn't seem to work with Array, but does work with [[scala.collection.mutable.ArraySeq]] and + * other collections which extend [[Traversable]] + * + * @see [[TolerantContainerPath.readNullableContainer]] + * @tparam A The model you're generating a [[Reads]] for + * @return a [[Reads]] that will deserialize [[A]], with empty containers for fields that are missing from the json. + */ + def nullableReads[A]: Reads[A] = macro nullableReadsImpl[A] + + /** + * copied from [[play.api.libs.json.JsMacroImpl.readsImpl]] implementation and modified to use the readNullableContainer helper + * @see https://github.com/playframework/playframework/blob/2.3.x/framework/src/play-json/src/main/scala/play/api/libs/json/JsMacroImpl.scala#L11 + */ + def nullableReadsImpl[A: c.WeakTypeTag](c: whitebox.Context): c.Expr[Reads[A]] = { + import c.universe.Flag._ + import c.universe._ + + val companioned = weakTypeOf[A].typeSymbol + val companionSymbol = companioned.companion + val companionType = companionSymbol.typeSignature + + val libsPkg = Select(Select(Ident(TermName("play")), TermName("api")), TermName("libs")) + val jsonPkg = Select(libsPkg, TermName("json")) + val functionalSyntaxPkg = Select(Select(libsPkg, TermName("functional")), TermName("syntax")) + val utilPkg = Select(jsonPkg, TermName("util")) + + val jsPathSelect = Select(jsonPkg, TermName("JsPath")) + val readsSelect = Select(jsonPkg, TermName("Reads")) + val lazyHelperSelect = Select(utilPkg, TypeName("LazyHelper")) + + val importFunctionalSyntax = Import(functionalSyntaxPkg, List(ImportSelector(termNames.WILDCARD, -1, null, -1))) + + companionType.decl(TermName("unapply")) match { + case NoSymbol => c.abort(c.enclosingPosition, "No unapply function found") + case s => + val unapply = s.asMethod + val unapplyReturnTypes = unapply.returnType match { + case TypeRef(_, _, Nil) => + c.abort(c.enclosingPosition, s"Apply of ${companionSymbol} has no parameters. Are you using an empty case class?") + case TypeRef(_, _, args) => + args.head match { + case t @ TypeRef(_, _, Nil) => Some(List(t)) + case t @ TypeRef(_, _, args) => + if (t <:< typeOf[Option[_]]) Some(List(t)) + else if (t <:< typeOf[Seq[_]]) Some(List(t)) + else if (t <:< typeOf[Set[_]]) Some(List(t)) + else if (t <:< typeOf[Map[_, _]]) Some(List(t)) + else if (t <:< typeOf[Product]) Some(args) + case _ => None + } + case _ => None + } + + companionType.decl(TermName("apply")) match { + case NoSymbol => c.abort(c.enclosingPosition, "No apply function found") + case s => + // searches apply method corresponding to unapply + val applies = s.asMethod.alternatives + val apply = applies.collectFirst { + case apply: MethodSymbol + if apply.paramLists.headOption.map(_.map(_.asTerm.typeSignature)) == unapplyReturnTypes => apply + } + apply match { + case Some(apply) => + val params = apply.paramLists.head //verify there is a single parameter group + + val inferedImplicits = params.map(_.typeSignature).map { implType => + + // innerType is only used if we're working with a container and using readNullableContainer below + val (isRecursive, tpe, innerType) = implType match { + case TypeRef(_, _, args) => + // Option[_] needs special treatment because we need to use XXXOpt + if (implType.typeConstructor <:< typeOf[Option[_]].typeConstructor) + (args.exists(_.typeSymbol == companioned), args.head, args.head) + else if (implType.typeConstructor <:< typeOf[Traversable[_]].typeConstructor) + (args.exists(_.typeSymbol == companioned), implType, args.head) + else + (args.exists(_.typeSymbol == companioned), implType, implType) + case _ => + (false, implType, implType) + } + + // builds reads implicit from expected type + val neededReadsImplicitType = appliedType(weakTypeOf[Reads[_]].typeConstructor, tpe :: Nil) + // infers implicit + val neededReadsImplicit = c.inferImplicitValue(neededReadsImplicitType) + + // builds canBuildFrom implicit type if type is Traversable + val neededCanBuildFromImplicit = if (tpe != innerType) { + val neededCanBuildFromImplicitType = + appliedType(weakTypeOf[generic.CanBuildFrom[_, _, _]].typeConstructor, List(tpe, innerType, tpe)) + c.inferImplicitValue(neededCanBuildFromImplicitType) + } else + neededReadsImplicit + + (implType, neededReadsImplicit, neededCanBuildFromImplicit, isRecursive, tpe, innerType) + } + + // if any implicit is missing, abort + // else goes on + inferedImplicits.collect { + case (t, readsImplicit, _, rec, _, _) if readsImplicit == EmptyTree && !rec => t + } match { + case List() => + val namedImplicits = params.map(_.name).zip(inferedImplicits) + + val helperMember = Select(This(typeNames.EMPTY), TermName("lazyStuff")) + + var hasRec = false + + // combines all reads into CanBuildX + val canBuild = namedImplicits.map { + case (name, (t, readsImplicit, cbfImplicit, rec, tpe, innerType)) => + // inception of (__ \ name).read(readsImplicit) + val jspathTree = Apply( + Select(jsPathSelect, TermName(scala.reflect.NameTransformer.encode("\\"))), + List(Literal(Constant(name.decodedName.toString))) + ) + + if (!rec) { + val readTree = + if (t.typeConstructor <:< typeOf[Option[_]].typeConstructor) + Apply( + Select(jspathTree, TermName("readNullable")), + List(readsImplicit) + ) + // If Traversable, then apply readNullableContainer helper instead + else if (t.typeConstructor <:< typeOf[Traversable[_]].typeConstructor) { + val justContainer = tpe.typeConstructor + val app = Apply( + c.Expr[Any](q"${Select(jspathTree, TermName("readNullableContainer"))}[$justContainer,$innerType]").tree, + List(readsImplicit, cbfImplicit) + ) + app + } else Apply( + Select(jspathTree, TermName("read")), + List(readsImplicit) + ) + + readTree + } else { + hasRec = true + val readTree = + if (t.typeConstructor <:< typeOf[Option[_]].typeConstructor) + Apply( + Select(jspathTree, TermName("readNullable")), + List( + Apply( + Select(Apply(jsPathSelect, List()), TermName("lazyRead")), + List(helperMember) + ) + ) + ) + // If Traversable, then apply readNullableContainer helper instead + else if (t.typeConstructor <:< typeOf[Traversable[_]].typeConstructor) + Apply( + Select(jspathTree, TermName("readNullableContainer")), + if (tpe.typeConstructor <:< typeOf[List[_]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("list")), + List(helperMember) + ) + ) + else if (tpe.typeConstructor <:< typeOf[Set[_]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("set")), + List(helperMember) + ) + ) + else if (tpe.typeConstructor <:< typeOf[Seq[_]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("seq")), + List(helperMember) + ) + ) + else if (tpe.typeConstructor <:< typeOf[Map[_, _]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("map")), + List(helperMember) + ) + ) + else List(helperMember) + ) + + else { + Apply( + Select(jspathTree, TermName("lazyRead")), + if (tpe.typeConstructor <:< typeOf[List[_]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("list")), + List(helperMember) + ) + ) + else if (tpe.typeConstructor <:< typeOf[Set[_]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("set")), + List(helperMember) + ) + ) + else if (tpe.typeConstructor <:< typeOf[Seq[_]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("seq")), + List(helperMember) + ) + ) + else if (tpe.typeConstructor <:< typeOf[Map[_, _]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("map")), + List(helperMember) + ) + ) + else List(helperMember) + ) + } + + readTree + } + }.reduceLeft { (acc, r) => + Apply( + Select(acc, TermName("and")), + List(r) + ) + } + + // builds the final Reads using apply method + val applyMethod = + Function( + params.foldLeft(List[ValDef]())((l, e) => + l :+ ValDef(Modifiers(PARAM), TermName(e.name.encodedName.toString), TypeTree(), EmptyTree) + ), + Apply( + Select(Ident(companionSymbol.name), TermName("apply")), + params.foldLeft(List[Tree]())((l, e) => + l :+ Ident(TermName(e.name.encodedName.toString)) + ) + ) + ) + + // if case class has one single field, needs to use inmap instead of canbuild.apply + val finalTree = if (params.length > 1) { + Apply( + Select(canBuild, TermName("apply")), + List(applyMethod) + ) + } else { + Apply( + Select(canBuild, TermName("map")), + List(applyMethod) + ) + } + + if (!hasRec) { + c.Expr[Reads[A]]( + q"""{ + $importFunctionalSyntax + $finalTree + }""" + ) + } else { + val defineClassWithLazyStuff = ClassDef( + Modifiers(Flag.FINAL), + TypeName("$anon"), + List(), + Template( + List( + AppliedTypeTree( + lazyHelperSelect, + List( + Ident(weakTypeOf[Reads[A]].typeSymbol), + Ident(weakTypeOf[A].typeSymbol) + ) + ) + ), + noSelfType, + List( + DefDef( + Modifiers(), + termNames.CONSTRUCTOR, + List(), + List(List()), + TypeTree(), + Apply( + Select(Super(This(typeNames.EMPTY), typeNames.EMPTY), termNames.CONSTRUCTOR), + List() + ) + ), + ValDef( + Modifiers(Flag.OVERRIDE | Flag.LAZY), + TermName("lazyStuff"), + AppliedTypeTree(Ident(weakTypeOf[Reads[A]].typeSymbol), List(TypeTree(weakTypeOf[A]))), + finalTree + ) + ) + ) + ) + val constructInstance = Apply(Select(New(Ident(TypeName("$anon"))), termNames.CONSTRUCTOR), List()) + val lazyStuff = TermName("lazyStuff") + + c.Expr[Reads[A]]( + q"""{ + $importFunctionalSyntax + $defineClassWithLazyStuff + $constructInstance + }.$lazyStuff""" + ) + } + case l => c.abort(c.enclosingPosition, s"No implicit Reads for ${l.mkString(", ")} available.") + } + + case None => c.abort(c.enclosingPosition, "No apply function found matching unapply return types") + } + + } + } + } +} diff --git a/play-json-ops-common/src/main/scala/play/api/libs/json/ops/TolerantContainerFormats.scala b/play-json-ops-common/src/main/scala/play/api/libs/json/ops/TolerantContainerFormats.scala new file mode 100644 index 0000000..ce0b62f --- /dev/null +++ b/play-json-ops-common/src/main/scala/play/api/libs/json/ops/TolerantContainerFormats.scala @@ -0,0 +1,24 @@ +package play.api.libs.json.ops + +import play.api.libs.json.{JsPath, Reads} + +import scala.collection.generic +import scala.language.{higherKinds, implicitConversions} + +/** + * Provides helpers which will create empty containers if a json field is missing + * + * @see [[play.api.libs.json.ops.TolerantContainerPath.readNullableContainer]] + */ +trait TolerantContainerFormats { + implicit final def getTolerantContainerPath(jsPath: JsPath): TolerantContainerPath = new TolerantContainerPath(jsPath) +} + +class TolerantContainerPath(val jsPath: JsPath) extends AnyVal { + + /** + * Defines a [[Reads]] that will read all container fields such that `null=[]` (or an empty [[Set]] / [[Map]]) + */ + def readNullableContainer[C[_], A](implicit r: Reads[C[A]], bf: generic.CanBuildFrom[C[_], A, C[A]]): Reads[C[A]] = + Reads.nullable[C[A]](jsPath)(r).map(_.getOrElse(bf().result())) +} diff --git a/play-json-tests-common/src/test/scala/play/api/libs/json/ops/PlayJsonMacrosSpec.scala b/play-json-tests-common/src/test/scala/play/api/libs/json/ops/PlayJsonMacrosSpec.scala new file mode 100644 index 0000000..6639a03 --- /dev/null +++ b/play-json-tests-common/src/test/scala/play/api/libs/json/ops/PlayJsonMacrosSpec.scala @@ -0,0 +1,144 @@ +package play.api.libs.json.ops + +import org.scalatest.{FunSpec, Matchers} +import play.api.libs.json.{Json, Reads} + +import scala.collection.mutable + +class PlayJsonMacrosSpec extends FunSpec with Matchers { + + case class TestA(strings: List[String]) + object TestA extends TolerantContainerFormats { + implicit val TestAReads: Reads[TestA] = PlayJsonMacros.nullableReads[TestA] + } + + case class TestB(strings: Seq[String]) + object TestB extends TolerantContainerFormats { + implicit val TestBReads: Reads[TestB] = PlayJsonMacros.nullableReads[TestB] + } + + case class TestC(testA: Set[TestA], testB: mutable.ArraySeq[TestB]) + object TestC extends TolerantContainerFormats { + implicit val TestCReads: Reads[TestC] = PlayJsonMacros.nullableReads[TestC] + } + + describe("PlayJsonMacrosSpec") { + it("should deserialize a TestA when TestA is populated") { + val jsonStr = + """ + |{"strings":["string1", "string2"]} + """.stripMargin + val json = Json.parse(jsonStr) + val testA = json.as[TestA] + + assert(testA == TestA(List("string1", "string2"))) + } + + it("should deserialize a TestA when TestA is empty") { + val jsonStr = + """ + |{"strings":[]} + """.stripMargin + val json = Json.parse(jsonStr) + val testA = json.as[TestA] + + assert(testA == TestA(List.empty[String])) + } + + it("should deserialize a TestA when TestA has field missing") { + val jsonStr = + """ + |{} + """.stripMargin + val json = Json.parse(jsonStr) + val testA = json.as[TestA] + + assert(testA == TestA(List.empty[String])) + } + + it("should deserialize a TestB when TestB is populated") { + val jsonStr = + """ + |{"strings":["string1", "string2"]} + """.stripMargin + val json = Json.parse(jsonStr) + val testB = json.as[TestB] + + assert(testB == TestB(List("string1", "string2"))) + } + + it("should deserialize a TestB when TestB is empty") { + val jsonStr = + """ + |{"strings":[]} + """.stripMargin + val json = Json.parse(jsonStr) + val testB = json.as[TestB] + + assert(testB == TestB(List.empty[String])) + } + + it("should deserialize a TestB when TestB has field missing") { + val jsonStr = + """ + |{} + """.stripMargin + val json = Json.parse(jsonStr) + val testB = json.as[TestB] + + assert(testB == TestB(List.empty[String])) + } + + it("should deserialize a TestC when TestC is populated") { + val jsonStr = + """ + |{ + |"testA":[{"strings":["stringZ", "stringX"]}], + |"testB":[{"strings":["stringA", "stringB"]}] + |} + """.stripMargin + val json = Json.parse(jsonStr) + val testC = json.as[TestC] + + assert(testC == TestC(Set(TestA(List("stringZ", "stringX"))), mutable.ArraySeq(TestB(List("stringA", "stringB"))))) + } + + it("should deserialize a TestC when parts of testC are empty") { + val jsonStr = + """ + |{ + |"testA":[{}], + |"testB":[] + |} + """.stripMargin + val json = Json.parse(jsonStr) + val testC = json.as[TestC] + + assert(testC == TestC(Set(TestA(List.empty[String])), mutable.ArraySeq.empty[TestB])) + } + + it("should deserialize a TestC when parts of testC are missing") { + val jsonStr = + """ + |{ + |"testB":[] + |} + """.stripMargin + val json = Json.parse(jsonStr) + val testC = json.as[TestC] + + assert(testC == TestC(Set.empty[TestA], mutable.ArraySeq.empty[TestB])) + } + + it("should deserialize a TestC when all of testC is missing") { + val jsonStr = + """ + |{} + """.stripMargin + val json = Json.parse(jsonStr) + val testC = json.as[TestC] + + assert(testC == TestC(Set.empty[TestA], mutable.ArraySeq.empty[TestB])) + } + } +} diff --git a/play-json-tests-common/src/test/scala/play/api/libs/json/ops/package.scala b/play-json-tests-common/src/test/scala/play/api/libs/json/ops/package.scala index 63d788d..81689aa 100644 --- a/play-json-tests-common/src/test/scala/play/api/libs/json/ops/package.scala +++ b/play-json-tests-common/src/test/scala/play/api/libs/json/ops/package.scala @@ -1,5 +1,3 @@ package play.api.libs.json -import play.api.libs.json.ops.JsonImplicits - package object ops extends JsonImplicits diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/AbstractJsonOps.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/AbstractJsonOps.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/AbstractJsonOps.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/AbstractJsonOps.scala diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/DurationFormat.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/DurationFormat.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/DurationFormat.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/DurationFormat.scala diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/Exceptions.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/Exceptions.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/Exceptions.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/Exceptions.scala diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/FormatKey.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/FormatKey.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/FormatKey.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/FormatKey.scala diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/FormatOps.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/FormatOps.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/FormatOps.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/FormatOps.scala diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/ImplicitEmptyIterableReads.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/ImplicitEmptyIterableReads.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/ImplicitEmptyIterableReads.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/ImplicitEmptyIterableReads.scala diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/ImplicitTupleFormats.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/ImplicitTupleFormats.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/ImplicitTupleFormats.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/ImplicitTupleFormats.scala diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/JsResults.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/JsResults.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/JsResults.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/JsResults.scala diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/JsValueOps.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/JsValueOps.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/JsValueOps.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/JsValueOps.scala diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/JsonImplicits.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/JsonImplicits.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/JsonImplicits.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/JsonImplicits.scala diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/JsonTransform.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/JsonTransform.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/JsonTransform.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/JsonTransform.scala diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/OFormatOps.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/OFormatOps.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/OFormatOps.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/OFormatOps.scala diff --git a/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/PlayJsonMacros.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/PlayJsonMacros.scala new file mode 100644 index 0000000..c431a01 --- /dev/null +++ b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/PlayJsonMacros.scala @@ -0,0 +1,345 @@ +package play.api.libs.json.ops + +import play.api.libs.json.{Json, Reads} + +import scala.collection.Factory +import scala.language.experimental.macros +import scala.language.reflectiveCalls +import scala.reflect.macros.whitebox + +/** + * Provides macros similar to those in [[Json]] but with modified functionality to better handle our common json + * parsing use cases, for example missing fields (instead of empty arrays). + */ +object PlayJsonMacros extends TolerantContainerFormats { + + /** + * Same as [[Json.reads]] but when reading containers such as [[List]], [[Seq]], [[Set]], etc., if the field + * is missing will return an empty container instead of failing to validate the model. + * + * NOTE: this doesn't seem to work with Array, but does work with [[scala.collection.mutable.ArraySeq]] and + * other collections which extend [[Traversable]] + * + * @see [[TolerantContainerPath.readNullableContainer]] + * @tparam A The model you're generating a [[Reads]] for + * @return a [[Reads]] that will deserialize [[A]], with empty containers for fields that are missing from the json. + */ + def nullableReads[A]: Reads[A] = macro nullableReadsImpl[A] + + /** + * copied from [[play.api.libs.json.JsMacroImpl.readsImpl]] implementation and modified to use the readNullableContainer helper + * @see https://github.com/playframework/playframework/blob/2.3.x/framework/src/play-json/src/main/scala/play/api/libs/json/JsMacroImpl.scala#L11 + */ + def nullableReadsImpl[A: c.WeakTypeTag](c: whitebox.Context): c.Expr[Reads[A]] = { + import c.universe.Flag._ + import c.universe._ + + val companioned = weakTypeOf[A].typeSymbol + val companionSymbol = companioned.companion + val companionType = companionSymbol.typeSignature + + val libsPkg = Select(Select(Ident(TermName("play")), TermName("api")), TermName("libs")) + val jsonPkg = Select(libsPkg, TermName("json")) + val functionalSyntaxPkg = Select(Select(libsPkg, TermName("functional")), TermName("syntax")) + val utilPkg = Select(jsonPkg, TermName("util")) + + val jsPathSelect = Select(jsonPkg, TermName("JsPath")) + val readsSelect = Select(jsonPkg, TermName("Reads")) + val lazyHelperSelect = Select(utilPkg, TypeName("LazyHelper")) + + val importFunctionalSyntax = Import(functionalSyntaxPkg, List(ImportSelector(termNames.WILDCARD, -1, null, -1))) + + companionType.decl(TermName("unapply")) match { + case NoSymbol => c.abort(c.enclosingPosition, "No unapply function found") + case s => + val unapply = s.asMethod + val unapplyReturnTypes = unapply.returnType match { + case TypeRef(_, _, Nil) => + c.abort(c.enclosingPosition, s"Apply of ${companionSymbol} has no parameters. Are you using an empty case class?") + case TypeRef(_, _, args) => + args.head match { + case t @ TypeRef(_, _, Nil) => Some(List(t)) + case t @ TypeRef(_, _, args) => + if (t <:< typeOf[Option[_]]) Some(List(t)) + else if (t <:< typeOf[Seq[_]]) Some(List(t)) + else if (t <:< typeOf[Set[_]]) Some(List(t)) + else if (t <:< typeOf[Map[_, _]]) Some(List(t)) + else if (t <:< typeOf[Product]) Some(args) + case _ => None + } + case _ => None + } + + companionType.decl(TermName("apply")) match { + case NoSymbol => c.abort(c.enclosingPosition, "No apply function found") + case s => + // searches apply method corresponding to unapply + val applies = s.asMethod.alternatives + val apply = applies.collectFirst { + case apply: MethodSymbol + if apply.paramLists.headOption.map(_.map(_.asTerm.typeSignature)) == unapplyReturnTypes => apply + } + apply match { + case Some(apply) => + val params = apply.paramLists.head // verify there is a single parameter group + + val inferedImplicits = params.map(_.typeSignature).map { implType => + + // innerType is only used if we're working with a container and using readNullableContainer below + val (isRecursive, tpe, innerType) = implType match { + case TypeRef(_, _, args) => + // Option[_] needs special treatment because we need to use XXXOpt + if (implType.typeConstructor <:< typeOf[Option[_]].typeConstructor) + (args.exists(_.typeSymbol == companioned), args.head, args.head) + else if (implType.typeConstructor <:< typeOf[Iterable[_]].typeConstructor) + (args.exists(_.typeSymbol == companioned), implType, args.head) + else + (args.exists(_.typeSymbol == companioned), implType, implType) + case _ => + (false, implType, implType) + } + + // builds reads implicit from expected type + val neededReadsImplicitType = appliedType(weakTypeOf[Reads[_]].typeConstructor, tpe :: Nil) + // infers implicit + val neededReadsImplicit = c.inferImplicitValue(neededReadsImplicitType) + + // summons implicit Factory if type is Iterable + val neededFactoryImplicit = if (tpe != innerType) { + val neededFactoryImplicitType = + appliedType(weakTypeOf[Factory[_, _]].typeConstructor, List(innerType, tpe)) + c.inferImplicitValue(neededFactoryImplicitType) + } else + neededReadsImplicit + + (implType, neededReadsImplicit, neededFactoryImplicit, isRecursive, tpe, innerType) + } + + // if any implicit is missing, abort + // else goes on + inferedImplicits.collect { + case (t, readsImplicit, _, rec, _, _) if readsImplicit == EmptyTree && !rec => t + } match { + case List() => + val namedImplicits = params.map(_.name).zip(inferedImplicits) + + val helperMember = Select(This(typeNames.EMPTY), TermName("lazyStuff")) + + var hasRec = false + + // combines all reads into CanBuildX + val canBuild = namedImplicits.map { + case (name, (t, readsImplicit, bfImplicit, rec, tpe, innerType)) => + // inception of (__ \ name).read(readsImplicit) + val jspathTree = Apply( + Select(jsPathSelect, TermName(scala.reflect.NameTransformer.encode("\\"))), + List(Literal(Constant(name.decodedName.toString))) + ) + + if (!rec) { + val readTree = + if (t.typeConstructor <:< typeOf[Option[_]].typeConstructor) + Apply( + Select(jspathTree, TermName("readNullable")), + List(readsImplicit) + ) + // If Traversable, then apply readNullableContainer helper instead + else if (t.typeConstructor <:< typeOf[Iterable[_]].typeConstructor) { + val justContainer = tpe.typeConstructor + val app = Apply( + c.Expr[Any]( + q"${Select(jspathTree, TermName("readNullableContainer"))}[$justContainer,$innerType]" + ).tree, + List(readsImplicit, bfImplicit) + ) + app + } else Apply( + Select(jspathTree, TermName("read")), + List(readsImplicit) + ) + + readTree + } else { + hasRec = true + val readTree = + if (t.typeConstructor <:< typeOf[Option[_]].typeConstructor) + Apply( + Select(jspathTree, TermName("readNullable")), + List( + Apply( + Select(Apply(jsPathSelect, List()), TermName("lazyRead")), + List(helperMember) + ) + ) + ) + // If Traversable, then apply readNullableContainer helper instead + else if (t.typeConstructor <:< typeOf[Iterable[_]].typeConstructor) + Apply( + Select(jspathTree, TermName("readNullableContainer")), + if (tpe.typeConstructor <:< typeOf[List[_]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("list")), + List(helperMember) + ) + ) + else if (tpe.typeConstructor <:< typeOf[Set[_]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("set")), + List(helperMember) + ) + ) + else if (tpe.typeConstructor <:< typeOf[Seq[_]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("seq")), + List(helperMember) + ) + ) + else if (tpe.typeConstructor <:< typeOf[Map[_, _]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("map")), + List(helperMember) + ) + ) + else List(helperMember) + ) + + else { + Apply( + Select(jspathTree, TermName("lazyRead")), + if (tpe.typeConstructor <:< typeOf[List[_]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("list")), + List(helperMember) + ) + ) + else if (tpe.typeConstructor <:< typeOf[Set[_]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("set")), + List(helperMember) + ) + ) + else if (tpe.typeConstructor <:< typeOf[Seq[_]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("seq")), + List(helperMember) + ) + ) + else if (tpe.typeConstructor <:< typeOf[Map[_, _]].typeConstructor) + List( + Apply( + Select(readsSelect, TermName("map")), + List(helperMember) + ) + ) + else List(helperMember) + ) + } + + readTree + } + }.reduceLeft { (acc, r) => + Apply( + Select(acc, TermName("and")), + List(r) + ) + } + + // builds the final Reads using apply method + val applyMethod = + Function( + params.foldLeft(List[ValDef]())((l, e) => + l :+ ValDef(Modifiers(PARAM), TermName(e.name.encodedName.toString), TypeTree(), EmptyTree) + ), + Apply( + Select(Ident(companionSymbol.name), TermName("apply")), + params.foldLeft(List[Tree]())((l, e) => + l :+ Ident(TermName(e.name.encodedName.toString)) + ) + ) + ) + + // if case class has one single field, needs to use inmap instead of canbuild.apply + val finalTree = if (params.length > 1) { + Apply( + Select(canBuild, TermName("apply")), + List(applyMethod) + ) + } else { + Apply( + Select(canBuild, TermName("map")), + List(applyMethod) + ) + } + + if (!hasRec) { + c.Expr[Reads[A]]( + q"""{ + $importFunctionalSyntax + $finalTree + }""" + ) + } else { + val defineClassWithLazyStuff = ClassDef( + Modifiers(Flag.FINAL), + TypeName("$anon"), + List(), + Template( + List( + AppliedTypeTree( + lazyHelperSelect, + List( + Ident(weakTypeOf[Reads[A]].typeSymbol), + Ident(weakTypeOf[A].typeSymbol) + ) + ) + ), + noSelfType, + List( + DefDef( + Modifiers(), + termNames.CONSTRUCTOR, + List(), + List(List()), + TypeTree(), + Apply( + Select(Super(This(typeNames.EMPTY), typeNames.EMPTY), termNames.CONSTRUCTOR), + List() + ) + ), + ValDef( + Modifiers(Flag.OVERRIDE | Flag.LAZY), + TermName("lazyStuff"), + AppliedTypeTree(Ident(weakTypeOf[Reads[A]].typeSymbol), List(TypeTree(weakTypeOf[A]))), + finalTree + ) + ) + ) + ) + val constructInstance = Apply(Select(New(Ident(TypeName("$anon"))), termNames.CONSTRUCTOR), List()) + val lazyStuff = TermName("lazyStuff") + + c.Expr[Reads[A]]( + q"""{ + $importFunctionalSyntax + $defineClassWithLazyStuff + $constructInstance + }.$lazyStuff""" + ) + } + case l => c.abort(c.enclosingPosition, s"No implicit Reads for ${l.mkString(", ")} available.") + } + + case None => c.abort(c.enclosingPosition, "No apply function found matching unapply return types") + } + + } + } + } +} diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/ReadsKey.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/ReadsKey.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/ReadsKey.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/ReadsKey.scala diff --git a/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/TolerantContainerFormats.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/TolerantContainerFormats.scala new file mode 100644 index 0000000..e859d6e --- /dev/null +++ b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/TolerantContainerFormats.scala @@ -0,0 +1,25 @@ +package play.api.libs.json.ops + +import play.api.libs.json.{JsPath, Reads} + +import scala.collection.{BuildFrom, Factory} +import scala.language.{higherKinds, implicitConversions} + +/** + * Provides helpers which will create empty containers if a json field is missing + * + * @see [[play.api.libs.json.ops.TolerantContainerPath.readNullableContainer]] + */ +trait TolerantContainerFormats { + implicit final def getTolerantContainerPath(jsPath: JsPath): TolerantContainerPath = new TolerantContainerPath(jsPath) +} + +class TolerantContainerPath(val jsPath: JsPath) extends AnyVal { + + /** + * Defines a [[Reads]] that will read all container fields such that `null=[]` (or an empty [[Set]] / [[Map]]) + */ + def readNullableContainer[C[_], A](implicit r: Reads[C[A]], f: Factory[A, C[A]]): Reads[C[A]] = { + Reads.nullable[C[A]](jsPath)(r).map(_.getOrElse(f.newBuilder.result())) + } +} diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/WritesKey.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/WritesKey.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/WritesKey.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/WritesKey.scala diff --git a/play27-json-ops/src/main/scala/play/api/libs/json/ops/package.scala b/play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/package.scala similarity index 100% rename from play27-json-ops/src/main/scala/play/api/libs/json/ops/package.scala rename to play27-json-ops-scala213/src/main/scala/play/api/libs/json/ops/package.scala diff --git a/play27-json-ops/src/main/scala/scala/concurrent/duration/ops/DurationOps.scala b/play27-json-ops-scala213/src/main/scala/scala/concurrent/duration/ops/DurationOps.scala similarity index 100% rename from play27-json-ops/src/main/scala/scala/concurrent/duration/ops/DurationOps.scala rename to play27-json-ops-scala213/src/main/scala/scala/concurrent/duration/ops/DurationOps.scala diff --git a/play27-json-ops/src/main/scala/scala/concurrent/duration/ops/package.scala b/play27-json-ops-scala213/src/main/scala/scala/concurrent/duration/ops/package.scala similarity index 100% rename from play27-json-ops/src/main/scala/scala/concurrent/duration/ops/package.scala rename to play27-json-ops-scala213/src/main/scala/scala/concurrent/duration/ops/package.scala diff --git a/play27-json-ops/src/test/scala/play/api/libs/json/ops/JsonImplicitsSpec.scala b/play27-json-ops-scala213/src/test/scala/play/api/libs/json/ops/JsonImplicitsSpec.scala similarity index 100% rename from play27-json-ops/src/test/scala/play/api/libs/json/ops/JsonImplicitsSpec.scala rename to play27-json-ops-scala213/src/test/scala/play/api/libs/json/ops/JsonImplicitsSpec.scala diff --git a/play27-json-ops/src/test/scala/play/api/libs/json/ops/package.scala b/play27-json-ops-scala213/src/test/scala/play/api/libs/json/ops/package.scala similarity index 100% rename from play27-json-ops/src/test/scala/play/api/libs/json/ops/package.scala rename to play27-json-ops-scala213/src/test/scala/play/api/libs/json/ops/package.scala diff --git a/play27-json-tests-sc14/src/test/scala/play/api/libs/json/ops/PlayJsonMacrosSpec.scala b/play27-json-tests-sc14/src/test/scala/play/api/libs/json/ops/PlayJsonMacrosSpec.scala new file mode 100644 index 0000000..5504fe8 --- /dev/null +++ b/play27-json-tests-sc14/src/test/scala/play/api/libs/json/ops/PlayJsonMacrosSpec.scala @@ -0,0 +1,145 @@ +package play.api.libs.json.ops + +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers +import play.api.libs.json.{Json, Reads} + +import scala.collection.mutable + +class PlayJsonMacrosSpec extends AnyFunSpec with Matchers { + + case class TestA(strings: List[String]) + object TestA extends TolerantContainerFormats { + implicit val TestAReads: Reads[TestA] = PlayJsonMacros.nullableReads[TestA] + } + + case class TestB(strings: Seq[String]) + object TestB extends TolerantContainerFormats { + implicit val TestBReads: Reads[TestB] = PlayJsonMacros.nullableReads[TestB] + } + + case class TestC(testA: Set[TestA], testB: mutable.ArraySeq[TestB]) + object TestC extends TolerantContainerFormats { + implicit val TestCReads: Reads[TestC] = PlayJsonMacros.nullableReads[TestC] + } + + describe("PlayJsonMacrosSpec") { + it("should deserialize a TestA when TestA is populated") { + val jsonStr = + """ + |{"strings":["string1", "string2"]} + """.stripMargin + val json = Json.parse(jsonStr) + val testA = json.as[TestA] + + assert(testA == TestA(List("string1", "string2"))) + } + + it("should deserialize a TestA when TestA is empty") { + val jsonStr = + """ + |{"strings":[]} + """.stripMargin + val json = Json.parse(jsonStr) + val testA = json.as[TestA] + + assert(testA == TestA(List.empty[String])) + } + + it("should deserialize a TestA when TestA has field missing") { + val jsonStr = + """ + |{} + """.stripMargin + val json = Json.parse(jsonStr) + val testA = json.as[TestA] + + assert(testA == TestA(List.empty[String])) + } + + it("should deserialize a TestB when TestB is populated") { + val jsonStr = + """ + |{"strings":["string1", "string2"]} + """.stripMargin + val json = Json.parse(jsonStr) + val testB = json.as[TestB] + + assert(testB == TestB(List("string1", "string2"))) + } + + it("should deserialize a TestB when TestB is empty") { + val jsonStr = + """ + |{"strings":[]} + """.stripMargin + val json = Json.parse(jsonStr) + val testB = json.as[TestB] + + assert(testB == TestB(List.empty[String])) + } + + it("should deserialize a TestB when TestB has field missing") { + val jsonStr = + """ + |{} + """.stripMargin + val json = Json.parse(jsonStr) + val testB = json.as[TestB] + + assert(testB == TestB(List.empty[String])) + } + + it("should deserialize a TestC when TestC is populated") { + val jsonStr = + """ + |{ + |"testA":[{"strings":["stringZ", "stringX"]}], + |"testB":[{"strings":["stringA", "stringB"]}] + |} + """.stripMargin + val json = Json.parse(jsonStr) + val testC = json.as[TestC] + + assert(testC == TestC(Set(TestA(List("stringZ", "stringX"))), mutable.ArraySeq(TestB(List("stringA", "stringB"))))) + } + + it("should deserialize a TestC when parts of testC are empty") { + val jsonStr = + """ + |{ + |"testA":[{}], + |"testB":[] + |} + """.stripMargin + val json = Json.parse(jsonStr) + val testC = json.as[TestC] + + assert(testC == TestC(Set(TestA(List.empty[String])), mutable.ArraySeq.empty[TestB])) + } + + it("should deserialize a TestC when parts of testC are missing") { + val jsonStr = + """ + |{ + |"testB":[] + |} + """.stripMargin + val json = Json.parse(jsonStr) + val testC = json.as[TestC] + + assert(testC == TestC(Set.empty[TestA], mutable.ArraySeq.empty[TestB])) + } + + it("should deserialize a TestC when all of testC is missing") { + val jsonStr = + """ + |{} + """.stripMargin + val json = Json.parse(jsonStr) + val testC = json.as[TestC] + + assert(testC == TestC(Set.empty[TestA], mutable.ArraySeq.empty[TestB])) + } + } +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index 1fb47bb..0219dd8 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -2,13 +2,17 @@ import sbt._ object Dependencies { - val Play_2_5 = "2.5.18" - val Play_2_6 = "2.6.19" - val Play_2_7 = "2.7.4" + final val Play_2_5 = "2.5.18" + final val Play_2_6 = "2.6.19" + final val Play_2_7 = "2.7.4" - val ScalaCheck_1_12 = "1.12.5" - val ScalaCheck_1_13 = "1.13.4" - val ScalaCheck_1_14 = "1.14.3" + final val Scala_2_11 = "2.11.12" + final val Scala_2_12 = "2.12.6" + final val Scala_2_13 = "2.13.1" + + final val ScalaCheck_1_12 = "1.12.5" + final val ScalaCheck_1_13 = "1.13.4" + final val ScalaCheck_1_14 = "1.14.3" private val Play_2_6_JsonVersion = "2.6.10" private val ScalaCheckOpsVersion = "2.2.1"