diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 840cccd5..d101af06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - scala: [2.12.20, 2.13.16, 3.3.5] + scala: [2.13.16, 3.3.5] java: [temurin@21] runs-on: ${{ matrix.os }} steps: @@ -50,16 +50,11 @@ jobs: - name: Check that workflows are up to date run: sbt '++ ${{ matrix.scala }}' githubWorkflowCheck - - name: Build Scala 2 project - if: matrix.scala != '3.3.5' + - name: Build project run: sbt '++ ${{ matrix.scala }}' test - - name: Build Scala 3 project - if: matrix.scala == '3.3.5' - run: sbt '++ ${{ matrix.scala }}' sphere-util/test sphere-json-core/test sphere-mongo-core/test - - name: Compress target directories - run: tar cf targets.tar benchmarks/target mongo/target json/json-core/target mongo/mongo-core/target json/target util/target json/json-derivation/target mongo/mongo-derivation-magnolia/target target mongo/mongo-derivation/target project/target + run: tar cf targets.tar benchmarks/target mongo/target json/json-core/target mongo/mongo-core/target json/target util/target json/json-derivation/target target mongo/mongo-derivation/target project/target - name: Upload target directories uses: actions/upload-artifact@v4 @@ -94,16 +89,6 @@ jobs: - name: Setup sbt uses: sbt/setup-sbt@v1 - - name: Download target directories (2.12.20) - uses: actions/download-artifact@v4 - with: - name: target-${{ matrix.os }}-2.12.20-${{ matrix.java }} - - - name: Inflate target directories (2.12.20) - run: | - tar xf targets.tar - rm targets.tar - - name: Download target directories (2.13.16) uses: actions/download-artifact@v4 with: diff --git a/.scalafmt.conf b/.scalafmt.conf index 719c6e47..980351ec 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,6 +1,7 @@ version = 3.8.3 -runner.dialect = scala213 +runner.dialect = scala3 +runner.dialectOverride.allowSignificantIndentation = false maxColumn = 100 diff --git a/benchmarks/src/main/scala/json/DateTimeFromJSONBenchmark.scala b/benchmarks/src/main/scala-2/json/DateTimeFromJSONBenchmark.scala similarity index 100% rename from benchmarks/src/main/scala/json/DateTimeFromJSONBenchmark.scala rename to benchmarks/src/main/scala-2/json/DateTimeFromJSONBenchmark.scala diff --git a/benchmarks/src/main/scala/json/EnumFromJSONBenchmark.scala b/benchmarks/src/main/scala-2/json/EnumFromJSONBenchmark.scala similarity index 100% rename from benchmarks/src/main/scala/json/EnumFromJSONBenchmark.scala rename to benchmarks/src/main/scala-2/json/EnumFromJSONBenchmark.scala diff --git a/benchmarks/src/main/scala/json/FromJsonBenchmark.scala b/benchmarks/src/main/scala-2/json/FromJsonBenchmark.scala similarity index 100% rename from benchmarks/src/main/scala/json/FromJsonBenchmark.scala rename to benchmarks/src/main/scala-2/json/FromJsonBenchmark.scala diff --git a/benchmarks/src/main/scala/json/FromMongoBenchmark.scala b/benchmarks/src/main/scala-2/json/FromMongoBenchmark.scala similarity index 100% rename from benchmarks/src/main/scala/json/FromMongoBenchmark.scala rename to benchmarks/src/main/scala-2/json/FromMongoBenchmark.scala diff --git a/benchmarks/src/main/scala/json/JsonBenchmark.scala b/benchmarks/src/main/scala-2/json/JsonBenchmark.scala similarity index 100% rename from benchmarks/src/main/scala/json/JsonBenchmark.scala rename to benchmarks/src/main/scala-2/json/JsonBenchmark.scala diff --git a/benchmarks/src/main/scala/json/ParseJsonBenchmark.scala b/benchmarks/src/main/scala-2/json/ParseJsonBenchmark.scala similarity index 100% rename from benchmarks/src/main/scala/json/ParseJsonBenchmark.scala rename to benchmarks/src/main/scala-2/json/ParseJsonBenchmark.scala diff --git a/benchmarks/src/main/scala/json/ToJsonBenchmark.scala b/benchmarks/src/main/scala-2/json/ToJsonBenchmark.scala similarity index 100% rename from benchmarks/src/main/scala/json/ToJsonBenchmark.scala rename to benchmarks/src/main/scala-2/json/ToJsonBenchmark.scala diff --git a/benchmarks/src/main/scala/json/ToMongoValueBenchmark.scala b/benchmarks/src/main/scala-2/json/ToMongoValueBenchmark.scala similarity index 100% rename from benchmarks/src/main/scala/json/ToMongoValueBenchmark.scala rename to benchmarks/src/main/scala-2/json/ToMongoValueBenchmark.scala diff --git a/build.sbt b/build.sbt index 4ac7383f..1d6d9128 100644 --- a/build.sbt +++ b/build.sbt @@ -1,11 +1,10 @@ import pl.project13.scala.sbt.JmhPlugin -lazy val scala212 = "2.12.20" lazy val scala213 = "2.13.16" lazy val scala3 = "3.3.5" // sbt-github-actions needs configuration in `ThisBuild` -ThisBuild / crossScalaVersions := Seq(scala212, scala213, scala3) +ThisBuild / crossScalaVersions := Seq(scala213, scala3) ThisBuild / scalaVersion := scala213 ThisBuild / githubWorkflowPublishTargetBranches := List() ThisBuild / githubWorkflowJavaVersions := List(JavaSpec.temurin("21")) @@ -14,21 +13,6 @@ ThisBuild / githubWorkflowBuildPreamble ++= List( ) ThisBuild / githubWorkflowBuildMatrixFailFast := Some(false) -// workaround for CI because `sbt ++3.3.4 test` used by sbt-github-actions -// still tries to compile the Scala 2 only projects leading to weird issues -// note that `sbt +test` is working fine to run cross-compiled tests locally -ThisBuild / githubWorkflowBuild := Seq( - WorkflowStep.Sbt( - commands = List("test"), - name = Some("Build Scala 2 project"), - cond = Some(s"matrix.scala != '$scala3'")), - WorkflowStep.Sbt( - commands = List("sphere-util/test", "sphere-json-core/test", "sphere-mongo-core/test"), - name = Some("Build Scala 3 project"), - cond = Some(s"matrix.scala == '$scala3'") - ) -) - // Release inThisBuild( @@ -65,7 +49,7 @@ lazy val standardSettings = Defaults.coreDefaultSettings ++ Seq( javacOptions ++= Seq("-deprecation", "-Xlint:unchecked"), // targets Java 8 bytecode (scalac & javac) scalacOptions ++= { - if (scalaVersion.value.startsWith("2.12") || scalaVersion.value.startsWith("3")) Seq.empty + if (scalaVersion.value.startsWith("3")) Seq("-noindent") else Seq("-target", "8") }, ThisBuild / javacOptions ++= Seq("-source", "8", "-target", "8"), @@ -93,27 +77,32 @@ lazy val `sphere-libs` = project `sphere-mongo`, `sphere-mongo-core`, `sphere-mongo-derivation`, - `sphere-mongo-derivation-magnolia`, `benchmarks` ) lazy val `sphere-util` = project .in(file("./util")) .settings(standardSettings: _*) - .settings(crossScalaVersions := Seq(scala212, scala213, scala3)) .settings(homepage := Some(url("https://github.com/commercetools/sphere-scala-libs/README.md"))) lazy val `sphere-json-core` = project .in(file("./json/json-core")) .settings(standardSettings: _*) - .settings(crossScalaVersions := Seq(scala212, scala213, scala3)) + .dependsOn(`sphere-util`) + +lazy val `sphere-mongo-core` = project + .in(file("./mongo/mongo-core")) + .settings(standardSettings: _*) .dependsOn(`sphere-util`) lazy val `sphere-json-derivation` = project .in(file("./json/json-derivation")) .settings(standardSettings: _*) .settings(Fmpp.settings: _*) - .settings(crossScalaVersions := Seq(scala212, scala213)) + .settings( + inConfig(Compile)( + sourceGenerators ++= (if (scalaVersion.value.startsWith("2")) Seq(Fmpp.fmpp.taskValue) + else Seq()))) .dependsOn(`sphere-json-core`) lazy val `sphere-json` = project @@ -121,26 +110,16 @@ lazy val `sphere-json` = project .settings(standardSettings: _*) .settings(homepage := Some( url("https://github.com/commercetools/sphere-scala-libs/blob/master/json/README.md"))) - .settings(crossScalaVersions := Seq(scala212, scala213)) .dependsOn(`sphere-json-core`, `sphere-json-derivation`) -lazy val `sphere-mongo-core` = project - .in(file("./mongo/mongo-core")) - .settings(standardSettings: _*) - .settings(crossScalaVersions := Seq(scala212, scala213, scala3)) - .dependsOn(`sphere-util`) - lazy val `sphere-mongo-derivation` = project .in(file("./mongo/mongo-derivation")) .settings(standardSettings: _*) .settings(Fmpp.settings: _*) - .settings(crossScalaVersions := Seq(scala212, scala213)) - .dependsOn(`sphere-mongo-core`) - -lazy val `sphere-mongo-derivation-magnolia` = project - .in(file("./mongo/mongo-derivation-magnolia")) - .settings(standardSettings: _*) - .settings(crossScalaVersions := Seq(scala212, scala213)) + .settings( + inConfig(Compile)( + sourceGenerators ++= (if (scalaVersion.value.startsWith("2")) Seq(Fmpp.fmpp.taskValue) + else Seq()))) .dependsOn(`sphere-mongo-core`) lazy val `sphere-mongo` = project @@ -148,7 +127,6 @@ lazy val `sphere-mongo` = project .settings(standardSettings: _*) .settings(homepage := Some( url("https://github.com/commercetools/sphere-scala-libs/blob/master/mongo/README.md"))) - .settings(crossScalaVersions := Seq(scala212, scala213)) .dependsOn(`sphere-mongo-core`, `sphere-mongo-derivation`) // benchmarks @@ -156,6 +134,5 @@ lazy val `sphere-mongo` = project lazy val benchmarks = project .settings(standardSettings: _*) .settings(publishArtifact := false, publish := {}) - .settings(crossScalaVersions := Seq(scala212, scala213)) .enablePlugins(JmhPlugin) .dependsOn(`sphere-util`, `sphere-json`, `sphere-mongo`) diff --git a/json/json-core/src/main/scala-2/io/sphere/json/FromJSON.scala b/json/json-core/src/main/scala-2/io/sphere/json/FromJSON.scala new file mode 100644 index 00000000..ac54ca1a --- /dev/null +++ b/json/json-core/src/main/scala-2/io/sphere/json/FromJSON.scala @@ -0,0 +1,23 @@ +package io.sphere.json + +import org.json4s.JsonAST._ + +import scala.annotation.implicitNotFound + +/** Type class for types that can be read from JSON. */ +@implicitNotFound("Could not find an instance of FromJSON for ${A}") +trait FromJSON[@specialized A] extends Serializable { + def read(jval: JValue): JValidation[A] + final protected def fail(msg: String) = jsonParseError(msg) + + /** needed JSON fields - ignored if empty */ + val fields: Set[String] = FromJSON.emptyFieldsSet +} + +object FromJSON extends FromJSONInstances with FromJSONCatsInstances { + + private[FromJSON] val emptyFieldsSet: Set[String] = Set.empty + + @inline def apply[A](implicit instance: FromJSON[A]): FromJSON[A] = instance + +} diff --git a/json/json-core/src/main/scala/io/sphere/json/JSON.scala b/json/json-core/src/main/scala-2/io/sphere/json/JSON.scala similarity index 94% rename from json/json-core/src/main/scala/io/sphere/json/JSON.scala rename to json/json-core/src/main/scala-2/io/sphere/json/JSON.scala index a9820039..306194a1 100644 --- a/json/json-core/src/main/scala/io/sphere/json/JSON.scala +++ b/json/json-core/src/main/scala-2/io/sphere/json/JSON.scala @@ -12,7 +12,7 @@ trait JSON[A] extends FromJSON[A] with ToJSON[A] { def subTypeNames: List[String] = Nil } -object JSON extends JSONInstances with JSONLowPriorityImplicits { +object JSON extends JSONCatsInstances with JSONLowPriorityImplicits { @inline def apply[A](implicit instance: JSON[A]): JSON[A] = instance } diff --git a/json/json-core/src/main/scala-2/io/sphere/json/ToJSON.scala b/json/json-core/src/main/scala-2/io/sphere/json/ToJSON.scala new file mode 100644 index 00000000..bd02e1b7 --- /dev/null +++ b/json/json-core/src/main/scala-2/io/sphere/json/ToJSON.scala @@ -0,0 +1,25 @@ +package io.sphere.json + +import scala.annotation.implicitNotFound +import java.time +import org.json4s.JsonAST.JValue + +/** Type class for types that can be written to JSON. */ +@implicitNotFound("Could not find an instance of ToJSON for ${A}") +trait ToJSON[@specialized A] extends Serializable { + def write(value: A): JValue +} + +class JSONWriteException(msg: String) extends JSONException(msg) + +object ToJSON extends ToJSONInstances with ToJSONCatsInstances { + + @inline def apply[A](implicit instance: ToJSON[A]): ToJSON[A] = instance + + /** construct an instance from a function + */ + def instance[T](toJson: T => JValue): ToJSON[T] = new ToJSON[T] { + override def write(value: T): JValue = toJson(value) + } + +} diff --git a/json/json-core/src/main/scala-3/io/sphere/json/FromJSON.scala b/json/json-core/src/main/scala-3/io/sphere/json/FromJSON.scala new file mode 100644 index 00000000..2029ed11 --- /dev/null +++ b/json/json-core/src/main/scala-3/io/sphere/json/FromJSON.scala @@ -0,0 +1,36 @@ +package io.sphere.json + +import io.sphere.json.JValidation +import io.sphere.json.generic.JSONTypeSwitch.FromFormatters +import org.json4s.JsonAST.JValue + +/** Type class for types that can be read from JSON. */ +trait FromJSON[A] extends Serializable { + def read(jval: JValue): JValidation[A] + final protected def fail(msg: String) = jsonParseError(msg) + + /** needed JSON fields - ignored if empty */ + val fields: Set[String] = FromJSON.emptyFieldsSet + + // This is automatically filled for traits + val fromFormatters: FromFormatters = null + def getSerializedNames: Vector[String] = + if (fromFormatters == null) Vector.empty + else fromFormatters.serializedNames +} + +object FromJSON extends FromJSONInstances with FromJSONCatsInstances with generic.DeriveFromJSON { + val emptyFieldsSet: Set[String] = Set.empty + + inline def apply[A](using instance: FromJSON[A]): FromJSON[A] = instance + + def instance[A]( + readFn: JValue => JValidation[A], + fromFs: FromFormatters, + fieldSet: Set[String] = emptyFieldsSet): FromJSON[A] = new { + + override def read(jval: JValue): JValidation[A] = readFn(jval) + override val fields: Set[String] = fieldSet + override val fromFormatters: FromFormatters = fromFs + } +} diff --git a/json/json-core/src/main/scala-3/io/sphere/json/JSON.scala b/json/json-core/src/main/scala-3/io/sphere/json/JSON.scala new file mode 100644 index 00000000..d1c4f9a2 --- /dev/null +++ b/json/json-core/src/main/scala-3/io/sphere/json/JSON.scala @@ -0,0 +1,50 @@ +package io.sphere.json + +import cats.implicits.* +import io.sphere.json.generic.JSONTypeSwitch.{FromFormatters, ToFormatters, fromJsonTypeSwitch} +import org.json4s.JsonAST.JValue + +trait JSON[A] extends FromJSON[A] with ToJSON[A] { + // This field is only used in case we derive a trait, for classes/objects it remains empty + // It uses the JSON names not the Scala names (if there's @JSONTypeHint renaming a class the renamed name is used here) + def subTypeNames: Vector[String] = Vector.empty +} + +object JSON extends JSONCatsInstances { + inline def apply[A: JSON]: JSON[A] = summon[JSON[A]] + inline given derived[A](using fromJSON: FromJSON[A], toJSON: ToJSON[A]): JSON[A] = + instance( + readFn = fromJSON.read, + writeFn = toJSON.write, + fromFs = fromJSON.fromFormatters, + toFs = toJSON.toFormatters, + fieldSet = fromJSON.fields, + subTypeNameList = fromJSON.getSerializedNames + ) + + def instance[A]( + readFn: JValue => JValidation[A], + writeFn: A => JValue, + fromFs: FromFormatters, + toFs: ToFormatters, + subTypeNameList: Vector[String] = Vector.empty, + fieldSet: Set[String] = FromJSON.emptyFieldsSet): JSON[A] = + new JSON[A] { + override def read(jval: JValue): JValidation[A] = readFn(jval) + override def write(value: A): JValue = writeFn(value) + override val fields: Set[String] = fieldSet + override def subTypeNames: Vector[String] = subTypeNameList + override val fromFormatters: FromFormatters = fromFs + override val toFormatters: ToFormatters = toFs + } +} + +class JSONException(msg: String) extends RuntimeException(msg) + +sealed abstract class JSONError +case class JSONFieldError(path: List[String], message: String) extends JSONError { + override def toString = path.mkString(" -> ") + ": " + message +} +case class JSONParseError(message: String) extends JSONError { + override def toString = message +} diff --git a/json/json-core/src/main/scala-3/io/sphere/json/ToJSON.scala b/json/json-core/src/main/scala-3/io/sphere/json/ToJSON.scala new file mode 100644 index 00000000..d9e12d05 --- /dev/null +++ b/json/json-core/src/main/scala-3/io/sphere/json/ToJSON.scala @@ -0,0 +1,31 @@ +package io.sphere.json + +import io.sphere.json.generic.JSONTypeSwitch.ToFormatters +import org.json4s.JsonAST.JValue + +/** Type class for types that can be written to JSON. */ +trait ToJSON[A] extends Serializable { + def write(value: A): JValue + + // Filled automatically for traits + // I decided to not use option, because it's not an internal type anyway and + // on traits it's always filled + // on case classes it's always null + // So there's not a lot of reasons to check for it runtime in most cases. + val toFormatters: ToFormatters = null +} + +class JSONWriteException(msg: String) extends JSONException(msg) + +object ToJSON extends ToJSONInstances with ToJSONCatsInstances with generic.DeriveToJSON { + inline def apply[A](implicit instance: ToJSON[A]): ToJSON[A] = instance + inline def apply[A: JSON]: ToJSON[A] = summon[ToJSON[A]] + + /** construct an instance from a function + */ + def instance[T](toJson: T => JValue, toFs: ToFormatters = null): ToJSON[T] = new ToJSON[T] { + override def write(value: T): JValue = toJson(value) + + override val toFormatters: ToFormatters = toFs + } +} diff --git a/json/json-core/src/main/scala-3/io/sphere/json/generic/Annotations.scala b/json/json-core/src/main/scala-3/io/sphere/json/generic/Annotations.scala new file mode 100644 index 00000000..7d3ace8d --- /dev/null +++ b/json/json-core/src/main/scala-3/io/sphere/json/generic/Annotations.scala @@ -0,0 +1,11 @@ +package io.sphere.json.generic + +import scala.annotation.StaticAnnotation + +sealed trait JSONAnnotation extends StaticAnnotation + +case class JSONEmbedded() extends JSONAnnotation +case class JSONIgnore() extends JSONAnnotation +case class JSONKey(value: String) extends JSONAnnotation +case class JSONTypeHintField(value: String) extends JSONAnnotation +case class JSONTypeHint(value: String) extends JSONAnnotation diff --git a/json/json-core/src/main/scala-3/io/sphere/json/generic/DeriveFromJSON.scala b/json/json-core/src/main/scala-3/io/sphere/json/generic/DeriveFromJSON.scala new file mode 100644 index 00000000..994aafc0 --- /dev/null +++ b/json/json-core/src/main/scala-3/io/sphere/json/generic/DeriveFromJSON.scala @@ -0,0 +1,67 @@ +package io.sphere.json.generic + +import cats.data.Validated +import cats.syntax.traverse.* +import io.sphere.json.* +import io.sphere.util.{Field, TypeMetaData} +import org.json4s.JsonAST.* + +import scala.deriving.Mirror + +trait DeriveFromJSON { + inline given derived[A](using Mirror.Of[A]): FromJSON[A] = Derivation.derived[A] + + protected object Derivation { + + import scala.compiletime.{erasedValue, summonInline} + + inline def derived[A](using m: Mirror.Of[A]): FromJSON[A] = + inline m match { + case s: Mirror.SumOf[A] => fromJsonTypeSwitch[A, s.MirroredElemTypes] + case p: Mirror.ProductOf[A] => deriveCaseClass(p) + } + + inline private def deriveCaseClass[A](mirrorOfProduct: Mirror.ProductOf[A]): FromJSON[A] = { + val caseClassMetaData: TypeMetaData = AnnotationReader.readTypeMetaData[A] + val fromJsons: Vector[FromJSON[Any]] = summonFromJsons[mirrorOfProduct.MirroredElemTypes] + val fieldsAndJsons: Vector[(Field, FromJSON[Any])] = caseClassMetaData.fields.zip(fromJsons) + + val fieldNames: Vector[String] = fieldsAndJsons.flatMap { (field, fromJson) => + if (field.embedded) fromJson.fields.toVector :+ field.scalaName + else Vector(field.scalaName) + } + + def readField(field: Field, fromJson: FromJSON[Any], jObject: JObject): JValidation[Any] = + if (field.embedded) fromJson.read(jObject) + else io.sphere.json.field(field.serializedName, field.defaultArgument)(jObject)(fromJson) + + FromJSON.instance( + readFn = { + case jObject: JObject => + for { + fieldsAsAList <- + fieldsAndJsons.traverse((field, fromJson) => readField(field, fromJson, jObject)) + + fieldsAsTuple = Tuple.fromArray(fieldsAsAList.toArray) + } yield mirrorOfProduct.fromTuple( + fieldsAsTuple.asInstanceOf[mirrorOfProduct.MirroredElemTypes]) + + case x => + Validated.invalidNel(JSONParseError(s"JSON object expected. $x")) + }, + fieldSet = fieldNames.toSet, + fromFs = null + ) + } + + inline private def summonFromJsons[T <: Tuple]: Vector[FromJSON[Any]] = + inline erasedValue[T] match { + case _: EmptyTuple => Vector.empty + case _: (t *: ts) => + summonInline[FromJSON[t]] + .asInstanceOf[FromJSON[Any]] +: summonFromJsons[ts] + } + + } + +} diff --git a/json/json-core/src/main/scala-3/io/sphere/json/generic/DeriveSingleton.scala b/json/json-core/src/main/scala-3/io/sphere/json/generic/DeriveSingleton.scala new file mode 100644 index 00000000..9f932e54 --- /dev/null +++ b/json/json-core/src/main/scala-3/io/sphere/json/generic/DeriveSingleton.scala @@ -0,0 +1,93 @@ +package io.sphere.json.generic + +import cats.data.Validated +import io.sphere.json.{JSON, JSONParseError, JValidation} +import io.sphere.util.TraitMetaData +import org.json4s.{JNull, JString, JValue} + +import scala.deriving.Mirror + +inline def deriveSingletonJSON[A](using Mirror.Of[A]): DeriveSingleton[A] = DeriveSingleton.derived + +// This is required so we don't summon normal JSON instances in summonFormatters (maybe there's a better way to work around this) +trait DeriveSingleton[A] extends JSON[A] + +object DeriveSingleton { + def instance[A]( + readFn: JValue => JValidation[A], + writeFn: A => JValue, + fieldSet: Set[String] = Set.empty): DeriveSingleton[A] = new { + + override def read(jval: JValue): JValidation[A] = readFn(jval) + override def write(value: A): JValue = writeFn(value) + override val fields: Set[String] = fieldSet + } + inline given derived[A](using Mirror.Of[A]): DeriveSingleton[A] = Derivation.derived[A] + + private object Derivation { + + import scala.compiletime.{constValue, constValueTuple, erasedValue, summonInline} + + inline def derived[A](using m: Mirror.Of[A]): DeriveSingleton[A] = + inline m match { + case s: Mirror.SumOf[A] => deriveTrait(s) + case p: Mirror.ProductOf[A] => deriveObject(p) + } + + inline private def deriveTrait[A](mirrorOfSum: Mirror.SumOf[A]): DeriveSingleton[A] = { + val traitMetaData: TraitMetaData = AnnotationReader.readTraitMetaData[A] + + val typeHintMap = traitMetaData.serializedNamesOfSubTypes + + val reverseTypeHintMap: Map[String, String] = typeHintMap.map((on, n) => (n, on)) + val jsons: Seq[DeriveSingleton[Any]] = summonFormatters[mirrorOfSum.MirroredElemTypes] + + val names: Seq[String] = + constValueTuple[mirrorOfSum.MirroredElemLabels].productIterator.toVector + .asInstanceOf[Vector[String]] + + val jsonsByNames: Map[String, DeriveSingleton[Any]] = names.zip(jsons).toMap + + DeriveSingleton.instance( + readFn = { + case JString(typeName) => + val scalaTypeName = reverseTypeHintMap.getOrElse(typeName, typeName) + jsonsByNames.get(scalaTypeName) match { + case Some(json) => + val dummyValue = JNull + json.read(dummyValue).map(_.asInstanceOf[A]) + case None => + Validated.invalidNel(JSONParseError(s"'$typeName' is not a valid value")) + } + + case x => + Validated.invalidNel(JSONParseError(s"JSON string expected. Got >>> $x")) + }, + writeFn = { value => + val scalaTypeName = value.asInstanceOf[Product].productPrefix + val serializedTypeName = typeHintMap.getOrElse(scalaTypeName, scalaTypeName) + JString(serializedTypeName) + } + ) + } + + inline private def deriveObject[A](mirrorOfProduct: Mirror.ProductOf[A]): DeriveSingleton[A] = + DeriveSingleton.instance( + writeFn = { _ => ??? }, // This is already taken care of in `deriveTrait` + readFn = { _ => + // Just create the object instance, no need to do anything else + val obj = + mirrorOfProduct.fromTuple(EmptyTuple.asInstanceOf[mirrorOfProduct.MirroredElemTypes]) + Validated.Valid(obj) + } + ) + + inline private def summonFormatters[T <: Tuple]: Vector[DeriveSingleton[Any]] = + inline erasedValue[T] match { + case _: EmptyTuple => Vector.empty + case _: (t *: ts) => + summonInline[DeriveSingleton[t]] + .asInstanceOf[DeriveSingleton[Any]] +: summonFormatters[ts] + } + } +} diff --git a/json/json-core/src/main/scala-3/io/sphere/json/generic/DeriveToJSON.scala b/json/json-core/src/main/scala-3/io/sphere/json/generic/DeriveToJSON.scala new file mode 100644 index 00000000..703e91e1 --- /dev/null +++ b/json/json-core/src/main/scala-3/io/sphere/json/generic/DeriveToJSON.scala @@ -0,0 +1,55 @@ +package io.sphere.json.generic + +import io.sphere.json.ToJSON +import io.sphere.util.{Field, TypeMetaData} +import org.json4s.JsonAST.* + +import scala.deriving.Mirror + +trait DeriveToJSON { + + inline given derived[A](using Mirror.Of[A]): ToJSON[A] = Derivation.derived[A] + + protected object Derivation { + + import scala.compiletime.{erasedValue, summonInline} + + inline def derived[A](using m: Mirror.Of[A]): ToJSON[A] = + inline m match { + case s: Mirror.SumOf[A] => toJsonTypeSwitch[A, s.MirroredElemTypes] + case p: Mirror.ProductOf[A] => deriveCaseClass(p) + } + + inline private def deriveCaseClass[A](mirrorOfProduct: Mirror.ProductOf[A]): ToJSON[A] = { + val caseClassMetaData: TypeMetaData = AnnotationReader.readTypeMetaData[A] + val toJsons: Vector[ToJSON[Any]] = summonToJson[mirrorOfProduct.MirroredElemTypes] + + ToJSON.instance { value => + val caseClassFields = value.asInstanceOf[Product].productIterator + toJsons.iterator + .zip(caseClassFields) + .zip(caseClassMetaData.fields) + .foldLeft[JValue](JObject()) { case (jObject, ((toJson, fieldValue), field)) => + addField(jObject.asInstanceOf[JObject], field, toJson.write(fieldValue)) + } + } + } + + inline private def summonToJson[T <: Tuple]: Vector[ToJSON[Any]] = + inline erasedValue[T] match { + case _: EmptyTuple => Vector.empty + case _: (t *: ts) => + summonInline[ToJSON[t]] + .asInstanceOf[ToJSON[Any]] +: summonToJson[ts] + } + } + + private def addField(jObject: JObject, field: Field, jValue: JValue): JValue = + jValue match { + case o: JObject => + if (field.embedded) JObject(jObject.obj ++ o.obj) + else JObject(jObject.obj :+ (field.serializedName -> o)) + case other => JObject(jObject.obj :+ (field.serializedName -> other)) + } + +} diff --git a/json/json-core/src/main/scala-3/io/sphere/json/generic/EnumerationInstances.scala b/json/json-core/src/main/scala-3/io/sphere/json/generic/EnumerationInstances.scala new file mode 100644 index 00000000..83d9729b --- /dev/null +++ b/json/json-core/src/main/scala-3/io/sphere/json/generic/EnumerationInstances.scala @@ -0,0 +1,49 @@ +package io.sphere.json.generic + +import cats.syntax.validated.* +import io.sphere.json.* +import org.json4s.JsonAST.* + +import scala.collection.mutable + +object EnumerationInstances { + def toJsonEnum(e: Enumeration): ToJSON[e.Value] = new ToJSON[e.Value] { + def write(a: e.Value): JValue = JString(a.toString) + } + + /** Creates a FromJSON instance for an Enumeration type that encodes the `toString` + * representations of the enumeration values. + */ + def fromJsonEnum(e: Enumeration): FromJSON[e.Value] = { + // We're using an AnyRefMap for performance, it's not for mutability. + val validRepresentations = mutable.AnyRefMap( + e.values.iterator.map(value => value.toString -> value.validNel[JSONError]).toSeq: _* + ) + + val allowedValues = e.values.mkString("'", "','", "'") + + new FromJSON[e.Value] { + override def read(json: JValue): JValidation[e.Value] = + json match { + case JString(string) => + validRepresentations.getOrElse( + string, + jsonParseError( + "Invalid enum value: '%s'. Expected one of: %s".format(string, allowedValues))) + case _ => jsonParseError("JSON String expected.") + } + } + } + + // This can be used instead of deriveJSON + def jsonEnum(e: Enumeration): JSON[e.Value] = { + val toJson = toJsonEnum(e) + val fromJson = fromJsonEnum(e) + + new JSON[e.Value] { + override def read(jval: JValue): JValidation[e.Value] = fromJson.read(jval) + + override def write(value: e.Value): JValue = toJson.write(value) + } + } +} diff --git a/json/json-core/src/main/scala-3/io/sphere/json/generic/JSONTypeSwitch.scala b/json/json-core/src/main/scala-3/io/sphere/json/generic/JSONTypeSwitch.scala new file mode 100644 index 00000000..562fed2f --- /dev/null +++ b/json/json-core/src/main/scala-3/io/sphere/json/generic/JSONTypeSwitch.scala @@ -0,0 +1,168 @@ +package io.sphere.json.generic + +import cats.data.Validated +import io.sphere.json.{FromJSON, JSON, JSONParseError, ToJSON} +import org.json4s.DefaultJsonFormats.given +import org.json4s.{JObject, JString, jvalue2monadic, jvalue2readerSyntax} + +import scala.compiletime.{constValue, constValueTuple} +import scala.reflect.ClassTag + +object JSONTypeSwitch { + import scala.compiletime.{erasedValue, summonInline} + + inline def deriveToFormatters[SuperType, SubTypes <: Tuple]: ToFormatters = { + val traitMetaData = AnnotationReader.readTraitMetaData[SuperType] + summonToFormatters[SubTypes]() + .reduce(ToFormatters.merge(traitMetaData.typeDiscriminator)) + } + + inline def deriveFromFormatters[SuperType, SubTypes <: Tuple]: FromFormatters = { + val traitMetaData = AnnotationReader.readTraitMetaData[SuperType] + summonFromFormatters[SubTypes]() + .reduce(FromFormatters.merge(traitMetaData.typeDiscriminator)) + } + + inline def toJsonTypeSwitch[SuperType](formatters: ToFormatters): ToJSON[SuperType] = + ToJSON.instance( + toJson = { scalaValue => + val clazz = scalaValue.getClass + val serializedTypeName = formatters.serializedNamesByClass(clazz) + val jsonObj = formatters.formatterByClass(clazz).write(scalaValue) match { + case JObject(obj) => obj + case json => + throw new Exception(s"This code only handles objects as of now, but got: $json") + } + val typeDiscriminator = formatters.typeDiscriminator -> JString(serializedTypeName) + JObject(typeDiscriminator :: jsonObj) + }, + toFs = formatters + ) + + inline def fromJsonTypeSwitch[SuperType](formatters: FromFormatters): FromJSON[SuperType] = + FromJSON.instance( + readFn = { + case jObject: JObject => + val serializedTypeName = (jObject \ formatters.typeDiscriminator).as[String] + formatters + .formatterBySerializedName(serializedTypeName) + .read(jObject) + .map(_.asInstanceOf[SuperType]) + case x => + Validated.invalidNel(JSONParseError(s"JSON object expected. Got: '$x'")) + }, + fromFs = formatters + ) + + inline def jsonTypeSwitch[SuperType, SubTypes <: Tuple]: JSON[SuperType] = { + val fromFormatters = deriveFromFormatters[SuperType, SubTypes] + val fromJson = fromJsonTypeSwitch[SuperType](fromFormatters) + val toFormatters = deriveToFormatters[SuperType, SubTypes] + val toJson = toJsonTypeSwitch[SuperType](toFormatters) + + JSON.instance( + writeFn = toJson.write, + readFn = fromJson.read, + subTypeNameList = fromFormatters.serializedNames, + fromFs = fromJson.fromFormatters, + toFs = toJson.toFormatters + ) + } + + inline private def summonFromFormatters[T <: Tuple]( + acc: Vector[FromFormatters] = Vector.empty): Vector[FromFormatters] = + inline erasedValue[T] match { + case _: EmptyTuple => acc + case _: (t *: ts) => + val traitMetaData = AnnotationReader.readTraitMetaData[t] + val headFormatter = summonInline[FromJSON[t]].asInstanceOf[FromJSON[Any]] + val (formatterMap, names) = + if (traitMetaData.isTrait) + ( + headFormatter.fromFormatters.formatterBySerializedName, + headFormatter.fromFormatters.serializedNames) + else + ( + Map(traitMetaData.top.serializedName -> headFormatter), + Vector(traitMetaData.top.serializedName) + ) + + val f = FromFormatters( + serializedNames = names, + formatterBySerializedName = formatterMap, + typeDiscriminator = traitMetaData.typeDiscriminator + ) + summonFromFormatters[ts](acc :+ f) + } + + inline private def summonToFormatters[T <: Tuple]( + acc: Vector[ToFormatters] = Vector.empty): Vector[ToFormatters] = + inline erasedValue[T] match { + case _: EmptyTuple => acc + case _: (t *: ts) => + val traitMetaData = AnnotationReader.readTraitMetaData[t] + val formatterT = summonInline[ToJSON[t]].asInstanceOf[ToJSON[Any]] + + val (formatterMap, serializedTypeNames) = + if (traitMetaData.isTrait) + ( + formatterT.toFormatters.formatterByClass, + formatterT.toFormatters.serializedNamesByClass + ) + else { + val clazz = summonInline[ClassTag[t]].runtimeClass + ( + Map(clazz -> formatterT), + Map(clazz -> traitMetaData.top.serializedName) + ) + } + + val f = ToFormatters( + serializedNamesByClass = serializedTypeNames, + formatterByClass = formatterMap, + typeDiscriminator = traitMetaData.typeDiscriminator + ) + summonToFormatters[ts](acc :+ f) + } + + case class ToFormatters( + serializedNamesByClass: Map[Class[_], String], + formatterByClass: Map[Class[_], ToJSON[Any]], + typeDiscriminator: String + ) + object ToFormatters { + def merge( + typeDiscriminatorFromParent: String)(f1: ToFormatters, f2: ToFormatters): ToFormatters = { + require( + f1.typeDiscriminator == f2.typeDiscriminator && typeDiscriminatorFromParent == f2.typeDiscriminator, + "@JSONTypeHintField has to be the same on all traits") + ToFormatters( + serializedNamesByClass = f1.serializedNamesByClass ++ f2.serializedNamesByClass, + formatterByClass = f1.formatterByClass ++ f2.formatterByClass, + typeDiscriminator = typeDiscriminatorFromParent + ) + } + } + + case class FromFormatters( + serializedNames: Vector[String], + formatterBySerializedName: Map[String, FromJSON[Any]], + typeDiscriminator: String + ) + + object FromFormatters { + def merge(typeDiscriminatorFromParent: String)( + f1: FromFormatters, + f2: FromFormatters): FromFormatters = { + require( + f1.typeDiscriminator == f2.typeDiscriminator && typeDiscriminatorFromParent == f2.typeDiscriminator, + "@JSONTypeHintField has to be the same on all traits") + FromFormatters( + serializedNames = f1.serializedNames ++ f2.serializedNames, + formatterBySerializedName = f1.formatterBySerializedName ++ f2.formatterBySerializedName, + typeDiscriminator = typeDiscriminatorFromParent + ) + } + } + +} diff --git a/json/json-core/src/main/scala-3/io/sphere/json/generic/JsonAnnotationReader.scala b/json/json-core/src/main/scala-3/io/sphere/json/generic/JsonAnnotationReader.scala new file mode 100644 index 00000000..c955cbb6 --- /dev/null +++ b/json/json-core/src/main/scala-3/io/sphere/json/generic/JsonAnnotationReader.scala @@ -0,0 +1,46 @@ +package io.sphere.json.generic + +import io.sphere.util.{AnnotationReader, TraitMetaData, TypeMetaData} + +import scala.quoted.{Expr, Quotes, Type} + +object AnnotationReader { + inline def readTypeMetaData[T]: TypeMetaData = ${ readTypeMetaDataImpl[T] } + + inline def readTraitMetaData[T]: TraitMetaData = ${ readTraitMetaDataImpl[T] } + + private def readTypeMetaDataImpl[T: Type](using Quotes): Expr[TypeMetaData] = + JsonAnnotationReader().readTypeMetaData[T] + + private def readTraitMetaDataImpl[T: Type](using Quotes): Expr[TraitMetaData] = + JsonAnnotationReader().readTraitMetaData[T] +} + +class JsonAnnotationReader(using q: Quotes) { + import q.reflect.* + private def findAnnotation[JA <: JSONAnnotation: Type](tree: Tree): Option[Expr[Any]] = + Option.when(tree.isExpr)(tree.asExpr).filter(_.isExprOf[JA]) + + private def embeddedExists(tree: Tree): Boolean = + findAnnotation[JSONEmbedded](tree).isDefined + + private def ignoredExists(tree: Tree): Boolean = + findAnnotation[JSONIgnore](tree).isDefined + + private def findKey(tree: Tree): Option[Expr[String]] = + findAnnotation[JSONKey](tree).map(_.asExprOf[JSONKey]).map(a => '{ $a.value }) + + private def findTypeHint(tree: Tree): Option[Expr[String]] = + findAnnotation[JSONTypeHint](tree).map(_.asExprOf[JSONTypeHint]).map(a => '{ $a.value }) + + private def findTypeHintField(tree: Tree): Option[Expr[String]] = + findAnnotation[JSONTypeHintField](tree) + .map(_.asExprOf[JSONTypeHintField]) + .map(a => '{ $a.value }) + + private val annotationReader = + new AnnotationReader(embeddedExists, ignoredExists, findKey, findTypeHint, findTypeHintField) + + export annotationReader.readTypeMetaData + export annotationReader.readTraitMetaData +} diff --git a/json/json-core/src/main/scala-3/io/sphere/json/generic/generic.scala b/json/json-core/src/main/scala-3/io/sphere/json/generic/generic.scala new file mode 100644 index 00000000..b9ea67ef --- /dev/null +++ b/json/json-core/src/main/scala-3/io/sphere/json/generic/generic.scala @@ -0,0 +1,140 @@ +package io.sphere.json.generic + +import io.sphere.json.* +import io.sphere.json.generic.JSONTypeSwitch.{FromFormatters, ToFormatters} +import org.json4s.JsonAST.JValue + +import scala.deriving.Mirror + +inline def deriveJSON[A](using Mirror.Of[A]): JSON[A] = JSON.derived +inline def deriveToJSON[A](using Mirror.Of[A]): ToJSON[A] = ToJSON.derived +inline def deriveFromJSON[A](using Mirror.Of[A]): FromJSON[A] = FromJSON.derived + +/** Creates a ToJSON instance for an Enumeration type that encodes the `toString` representations of + * the enumeration values. + */ +inline def toJsonEnum(e: Enumeration): ToJSON[e.Value] = EnumerationInstances.toJsonEnum(e) + +/** Creates a FromJSON instance for an Enumeration type that encodes the `toString` representations + * of the enumeration values. + */ +inline def fromJsonEnum(e: Enumeration): FromJSON[e.Value] = EnumerationInstances.fromJsonEnum(e) + +// This can be used instead of deriveJSON +inline def jsonEnum(e: Enumeration): JSON[e.Value] = EnumerationInstances.jsonEnum(e) + +inline def jsonTypeSwitch[SuperType, SubTypes <: Tuple]: JSON[SuperType] = + JSONTypeSwitch.jsonTypeSwitch[SuperType, SubTypes] + +inline def toJsonTypeSwitch[SuperType, SubTypes <: Tuple]: ToJSON[SuperType] = { + val f = JSONTypeSwitch.deriveToFormatters[SuperType, SubTypes] + JSONTypeSwitch.toJsonTypeSwitch[SuperType](f) +} + +inline def fromJsonTypeSwitch[SuperType, SubTypes <: Tuple]: FromJSON[SuperType] = { + val f = JSONTypeSwitch.deriveFromFormatters[SuperType, SubTypes] + JSONTypeSwitch.fromJsonTypeSwitch[SuperType](f) +} + +// Compatibility with the scala-2 methods, that will be deprecated later +// fromJsonTypeSwitch is not used, so no old syntax support will be added for now +// Compatibility with Scala 2 syntax +case class TypeSelector(clazz: Class[?], typeValue: String, json: JSON[?]) + +// Compatibility with Scala 2 syntax +trait TypeSelectorContainer { + def typeSelectors: List[TypeSelector] +} + +// Compatibility with Scala 2 syntax +private def addTypeSelectorContainer[A](derviedJson: JSON[A])( + typeSelectors: List[TypeSelector]): JSON[A] with TypeSelectorContainer = { + val additionalJsons = typeSelectors.map(_.json) + val mergedFromFormatters = additionalJsons + .map(_.fromFormatters) + .fold(derviedJson.fromFormatters)( + FromFormatters.merge(derviedJson.fromFormatters.typeDiscriminator)) + val mergedToFormatters = additionalJsons + .map(_.toFormatters) + .fold(derviedJson.toFormatters)(ToFormatters.merge(derviedJson.toFormatters.typeDiscriminator)) + + // We create the write/read methods again with the all the formatters present, so they have the correct references + val toJson = JSONTypeSwitch.toJsonTypeSwitch[A](mergedToFormatters) + val fromJson = JSONTypeSwitch.fromJsonTypeSwitch[A](mergedFromFormatters) + + new JSON[A] with TypeSelectorContainer { + override def read(jval: JValue): JValidation[A] = fromJson.read(jval) + override def write(value: A): JValue = toJson.write(value) + override val fields: Set[String] = fromJson.fields + override def subTypeNames: Vector[String] = Vector.empty + override val fromFormatters: FromFormatters = mergedFromFormatters + override val toFormatters: ToFormatters = mergedToFormatters + + override def typeSelectors: List[TypeSelector] = + toFormatters.serializedNamesByClass + .map((cls, serializedName) => TypeSelector(cls, serializedName, this)) + .toList + } +} + +// jsonTypeSwitch is used up to 26 parameters, so I'll up to 28 +// format: off +inline def jsonTypeSwitch[SuperType, A1: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, Tuple1[A1]])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON, A16: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15, A16)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON, A16: JSON, A17: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15, A16, A17)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON, A16: JSON, A17: JSON, A18: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15, A16, A17, A18)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON, A16: JSON, A17: JSON, A18: JSON, A19: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15, A16, A17, A18, A19)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON, A16: JSON, A17: JSON, A18: JSON, A19: JSON, A20: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15, A16, A17, A18, A19, A20)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON, A16: JSON, A17: JSON, A18: JSON, A19: JSON, A20: JSON, A21: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15, A16, A17, A18, A19, A20, A21)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON, A16: JSON, A17: JSON, A18: JSON, A19: JSON, A20: JSON, A21: JSON, A22: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15, A16, A17, A18, A19, A20, A21, A22)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON, A16: JSON, A17: JSON, A18: JSON, A19: JSON, A20: JSON, A21: JSON, A22: JSON, A23: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15, A16, A17, A18, A19, A20, A21, A22, A23)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON, A16: JSON, A17: JSON, A18: JSON, A19: JSON, A20: JSON, A21: JSON, A22: JSON, A23: JSON, A24: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15, A16, A17, A18, A19, A20, A21, A22, A23, A24)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON, A16: JSON, A17: JSON, A18: JSON, A19: JSON, A20: JSON, A21: JSON, A22: JSON, A23: JSON, A24: JSON, A25: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15, A16, A17, A18, A19, A20, A21, A22, A23, A24, A25)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON, A16: JSON, A17: JSON, A18: JSON, A19: JSON, A20: JSON, A21: JSON, A22: JSON, A23: JSON, A24: JSON, A25: JSON, A26: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15, A16, A17, A18, A19, A20, A21, A22, A23, A24, A25, A26)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON, A16: JSON, A17: JSON, A18: JSON, A19: JSON, A20: JSON, A21: JSON, A22: JSON, A23: JSON, A24: JSON, A25: JSON, A26: JSON, A27: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15, A16, A17, A18, A19, A20, A21, A22, A23, A24, A25, A26, A27)])(typeSelectors) +inline def jsonTypeSwitch[SuperType, A1: JSON, A2: JSON, A3: JSON, A4: JSON, A5: JSON, A6: JSON, A7: JSON, A8: JSON, A9: JSON, A10: JSON, A11: JSON, A12: JSON, A13: JSON, A14: JSON, A15: JSON, A16: JSON, A17: JSON, A18: JSON, A19: JSON, A20: JSON, A21: JSON, A22: JSON, A23: JSON, A24: JSON, A25: JSON, A26: JSON, A27: JSON, A28: JSON](typeSelectors: List[TypeSelector]): JSON[SuperType] with TypeSelectorContainer = + addTypeSelectorContainer(jsonTypeSwitch[SuperType, (A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, A11, A12, A13, A14, A15, A16, A17, A18, A19, A20, A21, A22, A23, A24, A25, A26, A27, A28)])(typeSelectors) +// format: on + +// toJsonTypeSwitch is used up to 5 parameters, so I'll add up to 7 diff --git a/json/json-core/src/main/scala/io/sphere/json/FromJSON.scala b/json/json-core/src/main/scala/io/sphere/json/FromJSONInstances.scala similarity index 95% rename from json/json-core/src/main/scala/io/sphere/json/FromJSON.scala rename to json/json-core/src/main/scala/io/sphere/json/FromJSONInstances.scala index e1d4c37e..d9890c0a 100644 --- a/json/json-core/src/main/scala/io/sphere/json/FromJSON.scala +++ b/json/json-core/src/main/scala/io/sphere/json/FromJSONInstances.scala @@ -10,38 +10,29 @@ import cats.syntax.traverse._ import io.sphere.json.field import io.sphere.util.{BaseMoney, DateTimeFormats, HighPrecisionMoney, LangTag, Logging, Money} import org.json4s.JsonAST._ -import org.joda.time.format.ISODateTimeFormat -import scala.annotation.implicitNotFound import java.time +import java.util.{Currency, Locale, UUID} +import scala.collection.mutable.ListBuffer +import scala.util.control.NonFatal import org.joda.time.DateTime import org.joda.time.DateTimeZone import org.joda.time.YearMonth import org.joda.time.LocalTime import org.joda.time.LocalDate +import org.joda.time.format.ISODateTimeFormat -/** Type class for types that can be read from JSON. */ -@implicitNotFound("Could not find an instance of FromJSON for ${A}") -trait FromJSON[@specialized A] extends Serializable { - def read(jval: JValue): JValidation[A] - final protected def fail(msg: String) = jsonParseError(msg) - - /** needed JSON fields - ignored if empty */ - val fields: Set[String] = FromJSON.emptyFieldsSet -} - -object FromJSON extends FromJSONInstances with Logging { - - private[FromJSON] val emptyFieldsSet: Set[String] = Set.empty - - @inline def apply[A](implicit instance: FromJSON[A]): FromJSON[A] = instance - +object FromJSONInstances { private val validNone = Valid(None) private val validNil = Valid(Nil) private val validEmptyAnyVector: Valid[Vector[Any]] = Valid(Vector.empty) private def validList[A]: Valid[List[A]] = validNil private def validEmptyVector[A]: Valid[Vector[A]] = validEmptyAnyVector.asInstanceOf[Valid[Vector[A]]] +} + +trait FromJSONInstances extends Logging { + import FromJSONInstances._ implicit def optionMapReader[@specialized A](implicit c: FromJSON[A]): FromJSON[Option[Map[String, A]]] = @@ -415,4 +406,5 @@ object FromJSON extends FromJSONInstances with Logging { case _ => fail("JSON string expected.") } } + } diff --git a/json/json-core/src/main/scala/io/sphere/json/ToJSON.scala b/json/json-core/src/main/scala/io/sphere/json/ToJSONInstances.scala similarity index 91% rename from json/json-core/src/main/scala/io/sphere/json/ToJSON.scala rename to json/json-core/src/main/scala/io/sphere/json/ToJSONInstances.scala index b7c6f991..7b50e6e5 100644 --- a/json/json-core/src/main/scala/io/sphere/json/ToJSON.scala +++ b/json/json-core/src/main/scala/io/sphere/json/ToJSONInstances.scala @@ -3,6 +3,7 @@ package io.sphere.json import cats.data.NonEmptyList import java.util.{Currency, Locale, UUID} +import java.time import io.sphere.util.{BaseMoney, DateTimeFormats, HighPrecisionMoney, Money} import org.json4s.JsonAST._ import org.joda.time.DateTime @@ -12,29 +13,13 @@ import org.joda.time.LocalDate import org.joda.time.YearMonth import org.joda.time.format.ISODateTimeFormat -import scala.annotation.implicitNotFound -import java.time - -/** Type class for types that can be written to JSON. */ -@implicitNotFound("Could not find an instance of ToJSON for ${A}") -trait ToJSON[@specialized A] extends Serializable { - def write(value: A): JValue -} - -class JSONWriteException(msg: String) extends JSONException(msg) - -object ToJSON extends ToJSONInstances { - +object ToJSONInstances { private val emptyJArray = JArray(Nil) private val emptyJObject = JObject(Nil) +} - @inline def apply[A](implicit instance: ToJSON[A]): ToJSON[A] = instance - - /** construct an instance from a function - */ - def instance[T](toJson: T => JValue): ToJSON[T] = new ToJSON[T] { - override def write(value: T): JValue = toJson(value) - } +trait ToJSONInstances { + import ToJSONInstances._ implicit def optionWriter[@specialized A](implicit c: ToJSON[A]): ToJSON[Option[A]] = new ToJSON[Option[A]] { @@ -119,6 +104,7 @@ object ToJSON extends ToJSONInstances { } implicit val moneyWriter: ToJSON[Money] = new ToJSON[Money] { + import Money._ def write(m: Money): JValue = JObject( @@ -132,7 +118,9 @@ object ToJSON extends ToJSONInstances { implicit val highPrecisionMoneyWriter: ToJSON[HighPrecisionMoney] = new ToJSON[HighPrecisionMoney] { + import HighPrecisionMoney._ + def write(m: HighPrecisionMoney): JValue = JObject( JField(BaseMoney.TypeField, toJValue(m.`type`)) :: JField(CurrencyCodeField, toJValue(m.currency)) :: diff --git a/json/json-core/src/main/scala/io/sphere/json/catsinstances/package.scala b/json/json-core/src/main/scala/io/sphere/json/catsinstances/package.scala index 779fd45d..0d562785 100644 --- a/json/json-core/src/main/scala/io/sphere/json/catsinstances/package.scala +++ b/json/json-core/src/main/scala/io/sphere/json/catsinstances/package.scala @@ -5,17 +5,20 @@ import org.json4s.JValue /** Cats instances for [[JSON]], [[FromJSON]] and [[ToJSON]] */ -package object catsinstances extends JSONInstances with FromJSONInstances with ToJSONInstances +package object catsinstances + extends JSONCatsInstances + with FromJSONCatsInstances + with ToJSONCatsInstances -trait JSONInstances { +trait JSONCatsInstances { implicit val catsInvariantForJSON: Invariant[JSON] = new JSONInvariant } -trait FromJSONInstances { +trait FromJSONCatsInstances { implicit val catsFunctorForFromJSON: Functor[FromJSON] = new FromJSONFunctor } -trait ToJSONInstances { +trait ToJSONCatsInstances { implicit val catsContravariantForToJSON: Contravariant[ToJSON] = new ToJSONContravariant } diff --git a/json/json-core/src/test/scala/io/sphere/json/MoneyMarshallingSpec.scala b/json/json-core/src/test/scala/io/sphere/json/MoneyMarshallingSpec.scala index fae40bd6..048971d5 100644 --- a/json/json-core/src/test/scala/io/sphere/json/MoneyMarshallingSpec.scala +++ b/json/json-core/src/test/scala/io/sphere/json/MoneyMarshallingSpec.scala @@ -14,7 +14,7 @@ class MoneyMarshallingSpec extends AnyWordSpec with Matchers { val money = Money.EUR(34.56) val jsonAst = toJValue(money) val jsonAsString = compactJson(jsonAst) - val Valid(readAst) = parseJSON(jsonAsString) + val Valid(readAst) = parseJSON(jsonAsString): @unchecked jsonAst should equal(readAst) } @@ -52,9 +52,9 @@ class MoneyMarshallingSpec extends AnyWordSpec with Matchers { val money = HighPrecisionMoney.fromDecimalAmount(34.123456, 6, Currency.getInstance("EUR")) val jsonAst = toJValue(money) val jsonAsString = compactJson(jsonAst) - val Valid(readAst) = parseJSON(jsonAsString) - val Valid(decodedMoney) = fromJSON[HighPrecisionMoney](jsonAsString) - val Valid(decodedBaseMoney) = fromJSON[BaseMoney](jsonAsString) + val Valid(readAst) = parseJSON(jsonAsString): @unchecked + val Valid(decodedMoney) = fromJSON[HighPrecisionMoney](jsonAsString): @unchecked + val Valid(decodedBaseMoney) = fromJSON[BaseMoney](jsonAsString): @unchecked jsonAst should equal(readAst) decodedMoney should equal(money) @@ -85,9 +85,9 @@ class MoneyMarshallingSpec extends AnyWordSpec with Matchers { "centAmount": 1, "fractionDigits": 4 } - """) + """): @unchecked - val Valid(parsed) = fromJValue[BaseMoney](json) + val Valid(parsed) = fromJValue[BaseMoney](json): @unchecked toJValue(parsed) should be(json) } diff --git a/json/json-derivation/src/main/scala/io/sphere/json/ToJSONProduct.fmpp.scala b/json/json-derivation/src/main/scala-2/io/sphere/json/ToJSONProduct.fmpp.scala similarity index 100% rename from json/json-derivation/src/main/scala/io/sphere/json/ToJSONProduct.fmpp.scala rename to json/json-derivation/src/main/scala-2/io/sphere/json/ToJSONProduct.fmpp.scala diff --git a/json/json-derivation/src/main/scala/io/sphere/json/generic/JSONMacros.scala b/json/json-derivation/src/main/scala-2/io/sphere/json/generic/JSONMacros.scala similarity index 100% rename from json/json-derivation/src/main/scala/io/sphere/json/generic/JSONMacros.scala rename to json/json-derivation/src/main/scala-2/io/sphere/json/generic/JSONMacros.scala diff --git a/json/json-derivation/src/main/scala/io/sphere/json/generic/package.fmpp.scala b/json/json-derivation/src/main/scala-2/io/sphere/json/generic/package.fmpp.scala similarity index 100% rename from json/json-derivation/src/main/scala/io/sphere/json/generic/package.fmpp.scala rename to json/json-derivation/src/main/scala-2/io/sphere/json/generic/package.fmpp.scala diff --git a/json/json-derivation/src/test/scala/io/sphere/json/DeriveJSONCompatibilitySpec.scala b/json/json-derivation/src/test/scala-2/io/sphere/json/DeriveJSONCompatibilitySpec.scala similarity index 95% rename from json/json-derivation/src/test/scala/io/sphere/json/DeriveJSONCompatibilitySpec.scala rename to json/json-derivation/src/test/scala-2/io/sphere/json/DeriveJSONCompatibilitySpec.scala index 9284d7b0..38da7203 100644 --- a/json/json-derivation/src/test/scala/io/sphere/json/DeriveJSONCompatibilitySpec.scala +++ b/json/json-derivation/src/test/scala-2/io/sphere/json/DeriveJSONCompatibilitySpec.scala @@ -1,13 +1,12 @@ package io.sphere.json -import java.util.UUID +import io.sphere.json.generic.{deriveJSON, jsonProduct} import org.scalacheck.{Arbitrary, Gen} import org.scalatest.matchers.must.Matchers import org.scalatest.wordspec.AnyWordSpec import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks -import io.sphere.json.generic.deriveJSON -import io.sphere.json.generic.jsonProduct +import java.util.UUID class DeriveJSONCompatibilitySpec extends AnyWordSpec with Matchers with ScalaCheckPropertyChecks { diff --git a/json/json-derivation/src/test/scala/io/sphere/json/ForProductNSpec.scala b/json/json-derivation/src/test/scala-2/io/sphere/json/ForProductNSpec.scala similarity index 100% rename from json/json-derivation/src/test/scala/io/sphere/json/ForProductNSpec.scala rename to json/json-derivation/src/test/scala-2/io/sphere/json/ForProductNSpec.scala diff --git a/json/json-derivation/src/test/scala/io/sphere/json/JSONSpec.scala b/json/json-derivation/src/test/scala-2/io/sphere/json/JSONSpec.scala similarity index 86% rename from json/json-derivation/src/test/scala/io/sphere/json/JSONSpec.scala rename to json/json-derivation/src/test/scala-2/io/sphere/json/JSONSpec.scala index 7a55d96a..8c9e570a 100644 --- a/json/json-derivation/src/test/scala/io/sphere/json/JSONSpec.scala +++ b/json/json-derivation/src/test/scala-2/io/sphere/json/JSONSpec.scala @@ -12,6 +12,8 @@ import org.scalatest.matchers.must.Matchers import org.scalatest.funspec.AnyFunSpec object JSONSpec { + case class Test(a: String) + case class Project(nr: Int, name: String, version: Int = 1, milestones: List[Milestone] = Nil) case class Milestone(name: String, date: Option[DateTime] = None) @@ -24,7 +26,7 @@ object JSONSpec { case class GenericA[A](a: A) extends GenericBase[A] case class GenericB[A](a: A) extends GenericBase[A] - object Singleton + case object Singleton sealed abstract class SingletonEnum case object SingletonA extends SingletonEnum @@ -45,6 +47,23 @@ object JSONSpec { class JSONSpec extends AnyFunSpec with Matchers { import JSONSpec._ + describe("JSON.apply") { + it("must find possible JSON instance") { + implicit val testJson: JSON[Test] = new JSON[Test] { + override def read(jval: JValue): JValidation[Test] = ??? + override def write(value: Test): JValue = ??? + } + + JSON[Test] must be(testJson) + } + + it("must create instance from FromJSON and ToJSON") { + JSON[Int] + JSON[List[Double]] + JSON[Map[String, Int]] + } + } + describe("JSON") { it("must read/write a custom class using custom typeclass instances") { import JSONSpec.{Milestone, Project} @@ -56,7 +75,7 @@ class JSONSpec extends AnyFunSpec with Matchers { ) def read(j: JValue): ValidatedNel[JSONError, Milestone] = j match { case o: JObject => - (field[String]("name")(o), field[Option[DateTime]]("date")(o)).mapN(Milestone) + (field[String]("name")(o), field[Option[DateTime]]("date")(o)).mapN(Milestone.apply) case _ => fail("JSON object expected.") } } @@ -73,7 +92,7 @@ class JSONSpec extends AnyFunSpec with Matchers { field[Int]("nr")(o), field[String]("name")(o), field[Int]("version", Some(1))(o), - field[List[Milestone]]("milestones", Some(Nil))(o)).mapN(Project) + field[List[Milestone]]("milestones", Some(Nil))(o)).mapN(Project.apply) case _ => fail("JSON object expected.") } } @@ -90,7 +109,7 @@ class JSONSpec extends AnyFunSpec with Matchers { "milestones":[{"name":"Bravo", "date": "xxx"}] } """ - val Invalid(errs) = fromJSON[Project](wrongTypeJSON) + val Invalid(errs) = fromJSON[Project](wrongTypeJSON): @unchecked errs.toList must equal( List( JSONFieldError(List("nr"), "JSON Number in the range of an Int expected."), @@ -110,76 +129,77 @@ class JSONSpec extends AnyFunSpec with Matchers { it("must provide derived JSON instances for product types (case classes)") { import JSONSpec.{Milestone, Project} - implicit val milestoneJSON = deriveJSON[Milestone] - implicit val projectJSON = deriveJSON[Project] + implicit val milestoneJSON: JSON[Milestone] = deriveJSON[Milestone] + implicit val projectJSON: JSON[Project] = deriveJSON[Project] val proj = Project(42, "Linux", 7, Milestone("1.0") :: Milestone("2.0") :: Milestone("3.0") :: Nil) fromJSON[Project](toJSON(proj)) must equal(Valid(proj)) } it("must handle empty String") { - val Invalid(err) = fromJSON[Int]("") + val Invalid(err) = fromJSON[Int](""): @unchecked err.toList.head mustBe a[JSONParseError] } it("must provide user-friendly error by empty String") { - val Invalid(err) = fromJSON[Int]("") + val Invalid(err) = fromJSON[Int](""): @unchecked err.toList mustEqual List(JSONParseError("No content to map due to end-of-input")) } it("must handle incorrect json") { - val Invalid(err) = fromJSON[Int]("""{"key: "value"}""") + val Invalid(err) = fromJSON[Int]("""{"key: "value"}"""): @unchecked err.toList.head mustBe a[JSONParseError] } it("must provide user-friendly error by incorrect json") { - val Invalid(err) = fromJSON[Int]("""{"key: "value"}""") + val Invalid(err) = fromJSON[Int]("""{"key: "value"}"""): @unchecked err.toList mustEqual List(JSONParseError( "Unexpected character ('v' (code 118)): was expecting a colon to separate field name and value")) } it("must provide derived JSON instances for sum types") { - implicit val animalJSON = deriveJSON[Animal] - List(Bird("Peewee"), Dog("Hasso"), Cat("Felidae")).foreach { a: Animal => + implicit val animalJSON: JSON[Animal] = deriveJSON + List(Bird("Peewee"), Dog("Hasso"), Cat("Felidae")).foreach { (a: Animal) => fromJSON[Animal](toJSON(a)) must equal(Valid(a)) } } it("must provide derived instances for product types with concrete type parameters") { - implicit val aJSON = deriveJSON[GenericA[String]] + implicit val aJSON: JSON[GenericA[String]] = deriveJSON[GenericA[String]] val a = GenericA("hello") fromJSON[GenericA[String]](toJSON(a)) must equal(Valid(a)) } it("must provide derived instances for product types with generic type parameters") { - implicit def aJSON[A: FromJSON: ToJSON] = deriveJSON[GenericA[A]] + implicit def aJSON[A: FromJSON: ToJSON]: JSON[GenericA[A]] = deriveJSON[GenericA[A]] val a = GenericA("hello") fromJSON[GenericA[String]](toJSON(a)) must equal(Valid(a)) } it("must provide derived instances for singleton objects") { - implicit val singletonJSON = deriveJSON[Singleton.type] + implicit val singletonJSON: JSON[JSONSpec.Singleton.type] = + deriveJSON[JSONSpec.Singleton.type] + val json = s"""[${toJSON(Singleton)}]""" withClue(json) { fromJSON[Seq[Singleton.type]](json) must equal(Valid(Seq(Singleton))) } - implicit val singleEnumJSON = deriveJSON[SingletonEnum] - List(SingletonA, SingletonB, SingletonC).foreach { s: SingletonEnum => + implicit val singleEnumJSON: JSON[SingletonEnum] = deriveJSON[SingletonEnum] + List(SingletonA, SingletonB, SingletonC).foreach { (s: SingletonEnum) => fromJSON[SingletonEnum](toJSON(s)) must equal(Valid(s)) } } it("must provide derived instances for sum types with a mix of case class / object") { - implicit val mixedJSON = deriveJSON[Mixed] - - List(SingletonMixed, RecordMixed(1)).foreach { m: Mixed => + implicit val mixedJSON: JSON[Mixed] = deriveJSON + List(SingletonMixed, RecordMixed(1)).foreach { (m: Mixed) => fromJSON[Mixed](toJSON(m)) must equal(Valid(m)) } } it("must provide derived instances for scala.Enumeration") { - implicit val scalaEnumJSON: JSON[JSONSpec.ScalaEnum.Value] = jsonEnum(ScalaEnum) + implicit val scalaEnumJSON: JSON[ScalaEnum.Value] = jsonEnum(ScalaEnum) ScalaEnum.values.foreach { v => val json = s"""[${toJSON(v)}]""" withClue(json) { @@ -204,9 +224,7 @@ class JSONSpec extends AnyFunSpec with Matchers { fromJSON[TestSubjectBase](json) must equal(Valid(testSubject)) } } - } - } describe("ToJSON and FromJSON") { @@ -279,8 +297,8 @@ class JSONSpec extends AnyFunSpec with Matchers { } it("must provide derived instances for scala.Enumeration") { - implicit val toScalaEnumJSON = toJsonEnum(ScalaEnum) - implicit val fromScalaEnumJSON = fromJsonEnum(ScalaEnum) + implicit val toScalaEnumJSON: ToJSON[JSONSpec.ScalaEnum.Value] = toJsonEnum(ScalaEnum) + implicit val fromScalaEnumJSON: FromJSON[JSONSpec.ScalaEnum.Value] = fromJsonEnum(ScalaEnum) ScalaEnum.values.foreach { v => val json = s"""[${toJSON(v)}]""" withClue(json) { @@ -356,6 +374,7 @@ case class TestSubjectConcrete1(c1: String) extends TestSubjectCategoryA case class TestSubjectConcrete2(c2: String) extends TestSubjectCategoryA case class TestSubjectConcrete3(c3: String) extends TestSubjectCategoryB +@JSONTypeHint("foo2") case class TestSubjectConcrete4(c4: String) extends TestSubjectCategoryB object TestSubjectCategoryA { @@ -368,8 +387,8 @@ object TestSubjectCategoryB { object TestSubjectBase { val json: JSON[TestSubjectBase] = { - implicit val jsonA = TestSubjectCategoryA.json - implicit val jsonB = TestSubjectCategoryB.json + implicit val jsonA: JSON[TestSubjectCategoryA] = TestSubjectCategoryA.json + implicit val jsonB: JSON[TestSubjectCategoryB] = TestSubjectCategoryB.json jsonTypeSwitch[TestSubjectBase, TestSubjectCategoryA, TestSubjectCategoryB](Nil) } diff --git a/json/json-derivation/src/test/scala/io/sphere/json/TypesSwitchSpec.scala b/json/json-derivation/src/test/scala-2/io/sphere/json/TypesSwitchSpec.scala similarity index 100% rename from json/json-derivation/src/test/scala/io/sphere/json/TypesSwitchSpec.scala rename to json/json-derivation/src/test/scala-2/io/sphere/json/TypesSwitchSpec.scala diff --git a/json/json-derivation/src/test/scala/io/sphere/json/generic/SubTypeNameSpec.scala b/json/json-derivation/src/test/scala-2/io/sphere/json/generic/SubTypeNameSpec.scala similarity index 65% rename from json/json-derivation/src/test/scala/io/sphere/json/generic/SubTypeNameSpec.scala rename to json/json-derivation/src/test/scala-2/io/sphere/json/generic/SubTypeNameSpec.scala index 2a2912ac..b758e035 100644 --- a/json/json-derivation/src/test/scala/io/sphere/json/generic/SubTypeNameSpec.scala +++ b/json/json-derivation/src/test/scala-2/io/sphere/json/generic/SubTypeNameSpec.scala @@ -33,6 +33,16 @@ class SubTypeNameSpec extends AnyWordSpec with Matchers { format.asInstanceOf[TypeSelectorContainer].typeSelectors.map(_.typeValue) must be( subTypeNames) } + + "return only class names in nested trait hierarchies" in { + val format: JSON[SuperType2] = + jsonTypeSwitch[SuperType2, SubType1, SubType2](Nil) + + val names = + List("SubClass1A", "SubClass1A", "SubType1", "SubClass2A", "SubClass2A", "SubType2") + format.asInstanceOf[TypeSelectorContainer].typeSelectors.map(_.typeValue) must be(names) + format.subTypeNames must be(names) + } } } @@ -42,4 +52,16 @@ object SubTypeNameSpec { @JSONTypeHint("Obj2") case object ObjHidden extends SuperType case class Class1(int: Int) extends SuperType @JSONTypeHint("Class2") case class ClassHidden(int: Int) extends SuperType + + sealed trait SuperType2 + sealed trait SubType1 extends SuperType2 + object SubType1 { + case class SubClass1A(x: Int) extends SubType1 + implicit val json: JSON[SubType1] = deriveJSON + } + sealed trait SubType2 extends SuperType2 + object SubType2 { + case class SubClass2A(x: Int) extends SubType2 + implicit val json: JSON[SubType2] = deriveJSON + } } diff --git a/json/json-derivation/src/test/scala-3/io/sphere/json/generic/JSONSpec.scala b/json/json-derivation/src/test/scala-3/io/sphere/json/generic/JSONSpec.scala new file mode 100644 index 00000000..92eda8a4 --- /dev/null +++ b/json/json-derivation/src/test/scala-3/io/sphere/json/generic/JSONSpec.scala @@ -0,0 +1,366 @@ +package io.sphere.json.generic + +import cats.data.Validated.{Invalid, Valid} +import cats.data.ValidatedNel +import cats.syntax.apply.* +import org.json4s.JsonAST.* +import io.sphere.json.field +import io.sphere.json.generic.* +import io.sphere.json.* +import io.sphere.util.Money +import org.joda.time.DateTime +import org.scalatest.matchers.must.Matchers +import org.scalatest.funspec.AnyFunSpec +import org.json4s.DefaultJsonFormats.given + +object JSONSpec { + case class Test(a: String) + + case class Project(nr: Int, name: String, version: Int = 1, milestones: List[Milestone] = Nil) + case class Milestone(name: String, date: Option[DateTime] = None) + + sealed abstract class Animal + case class Dog(name: String) extends Animal + case class Cat(name: String) extends Animal + case class Bird(name: String) extends Animal + + sealed trait GenericBase[A] + case class GenericA[A](a: A) extends GenericBase[A] + case class GenericB[A](a: A) extends GenericBase[A] + + case object Singleton + + sealed abstract class SingletonEnum + case object SingletonA extends SingletonEnum + case object SingletonB extends SingletonEnum + case object SingletonC extends SingletonEnum + + sealed trait Mixed + case object SingletonMixed extends Mixed + case class RecordMixed(i: Int) extends Mixed + + object ScalaEnum extends Enumeration { + val One, Two, Three = Value + } + + // case class Node(value: Option[List[Node]]) // JSON instances for recursive data types cannot be derived +} + +class JSONSpec extends AnyFunSpec with Matchers { + import JSONSpec._ + + describe("JSON.apply") { + it("must find possible JSON instance") { + implicit val testJson: JSON[Test] = new JSON[Test] { + override def read(jval: JValue): JValidation[Test] = ??? + override def write(value: Test): JValue = ??? + } + + JSON[Test] must be(testJson) + } + + it("must create instance from FromJSON and ToJSON") { + JSON[Int] + JSON[List[Double]] + JSON[Map[String, Int]] + } + } + + describe("JSON") { + it("must read/write a custom class using custom typeclass instances") { + import JSONSpec.{Milestone, Project} + + implicit object MilestoneJSON extends JSON[Milestone] { + def write(m: Milestone): JValue = JObject( + JField("name", JString(m.name)) :: + JField("date", toJValue(m.date)) :: Nil + ) + def read(j: JValue): ValidatedNel[JSONError, Milestone] = j match { + case o: JObject => + (field[String]("name")(o), field[Option[DateTime]]("date")(o)).mapN(Milestone.apply) + case _ => fail("JSON object expected.") + } + } + implicit object ProjectJSON extends JSON[Project] { + def write(p: Project): JValue = JObject( + JField("nr", JInt(p.nr)) :: + JField("name", JString(p.name)) :: + JField("version", JInt(p.version)) :: + JField("milestones", toJValue(p.milestones)) :: Nil + ) + def read(jval: JValue): ValidatedNel[JSONError, Project] = jval match { + case o: JObject => + ( + field[Int]("nr")(o), + field[String]("name")(o), + field[Int]("version", Some(1))(o), + field[List[Milestone]]("milestones", Some(Nil))(o)).mapN(Project.apply) + case _ => fail("JSON object expected.") + } + } + + val proj = Project(42, "Linux") + fromJSON[Project](toJSON(proj)) must equal(Valid(proj)) + + // Now some invalid JSON to test the error accumulation + val wrongTypeJSON = """ + { + "nr":"1", + "name":23, + "version":1, + "milestones":[{"name":"Bravo", "date": "xxx"}] + } + """ + val Invalid(errs) = fromJSON[Project](wrongTypeJSON): @unchecked + errs.toList must equal( + List( + JSONFieldError(List("nr"), "JSON Number in the range of an Int expected."), + JSONFieldError(List("name"), "JSON String expected."), + JSONFieldError(List("milestones", "date"), "Failed to parse date/time: xxx") + )) + + // Now without a version value and without a milestones list. Defaults should apply. + val noVersionJSON = """{"nr":1,"name":"Linux"}""" + fromJSON[Project](noVersionJSON) must equal(Valid(Project(1, "Linux"))) + } + + it("must fail reading wrong currency code.") { + val wrongMoney = """{"currencyCode":"WRONG","centAmount":1000}""" + fromJSON[Money](wrongMoney).isInvalid must be(true) + } + + it("must provide derived JSON instances for product types (case classes)") { + import JSONSpec.{Milestone, Project} + implicit val milestoneJSON: JSON[Milestone] = deriveJSON[Milestone] + implicit val projectJSON: JSON[Project] = deriveJSON[Project] + val proj = + Project(42, "Linux", 7, Milestone("1.0") :: Milestone("2.0") :: Milestone("3.0") :: Nil) + fromJSON[Project](toJSON(proj)) must equal(Valid(proj)) + } + + it("must handle empty String") { + val Invalid(err) = fromJSON[Int](""): @unchecked + err.toList.head mustBe a[JSONParseError] + } + + it("must provide user-friendly error by empty String") { + val Invalid(err) = fromJSON[Int](""): @unchecked + err.toList mustEqual List(JSONParseError("No content to map due to end-of-input")) + } + + it("must handle incorrect json") { + val Invalid(err) = fromJSON[Int]("""{"key: "value"}"""): @unchecked + err.toList.head mustBe a[JSONParseError] + } + + it("must provide user-friendly error by incorrect json") { + val Invalid(err) = fromJSON[Int]("""{"key: "value"}"""): @unchecked + err.toList mustEqual List(JSONParseError( + "Unexpected character ('v' (code 118)): was expecting a colon to separate field name and value")) + } + + it("must provide derived JSON instances for sum types") { + implicit val animalJSON: JSON[Animal] = deriveJSON + List(Bird("Peewee"), Dog("Hasso"), Cat("Felidae")).foreach { (a: Animal) => + fromJSON[Animal](toJSON(a)) must equal(Valid(a)) + } + } + + it("must provide derived instances for product types with concrete type parameters") { + implicit val aJSON: JSON[GenericA[String]] = deriveJSON[GenericA[String]] + val a = GenericA("hello") + fromJSON[GenericA[String]](toJSON(a)) must equal(Valid(a)) + } + + it("must provide derived instances for product types with generic type parameters") { + implicit def aJSON[A: FromJSON: ToJSON]: JSON[GenericA[A]] = deriveJSON[GenericA[A]] + val a = GenericA("hello") + fromJSON[GenericA[String]](toJSON(a)) must equal(Valid(a)) + } + + it("must provide derived instances for singleton objects") { + implicit val singletonJSON: JSON[JSONSpec.Singleton.type] = + deriveJSON[JSONSpec.Singleton.type] + + val json = s"""[${toJSON(Singleton)}]""" + withClue(json) { + fromJSON[Seq[Singleton.type]](json) must equal(Valid(Seq(Singleton))) + } + + implicit val singleEnumJSON: JSON[SingletonEnum] = deriveJSON[SingletonEnum] + List(SingletonA, SingletonB, SingletonC).foreach { (s: SingletonEnum) => + fromJSON[SingletonEnum](toJSON(s)) must equal(Valid(s)) + } + } + + it("must provide derived instances for sum types with a mix of case class / object") { + implicit val mixedJSON: JSON[Mixed] = deriveJSON + List(SingletonMixed, RecordMixed(1)).foreach { (m: Mixed) => + fromJSON[Mixed](toJSON(m)) must equal(Valid(m)) + } + } + + it("must provide derived instances for scala.Enumeration") { + implicit val scalaEnumJSON: JSON[ScalaEnum.Value] = jsonEnum(ScalaEnum) + ScalaEnum.values.foreach { v => + val json = s"""[${toJSON(v)}]""" + withClue(json) { + fromJSON[Seq[ScalaEnum.Value]](json) must equal(Valid(Seq(v))) + } + } + } + + it("must handle subclasses correctly in `jsonTypeSwitch`") { + implicit val jsonImpl: JSON[TestSubjectBase] = TestSubjectBase.json + + val testSubjects = List[TestSubjectBase]( + TestSubjectConcrete1("testSubject1"), + TestSubjectConcrete2("testSubject2"), + TestSubjectConcrete3("testSubject3"), + TestSubjectConcrete4("testSubject4") + ) + + testSubjects.foreach { testSubject => + val json = toJSON(testSubject) + withClue(json) { + fromJSON[TestSubjectBase](json) must equal(Valid(testSubject)) + } + } + } + } + + describe("ToJSON and FromJSON") { + it("must provide derived JSON instances for sum types") { + // ToJSON + given ToJSON[Bird] = deriveToJSON[Bird] + given ToJSON[Dog] = deriveToJSON[Dog] + given ToJSON[Cat] = deriveToJSON[Cat] + given ToJSON[Animal] = toJsonTypeSwitch[Animal, (Bird, Dog, Cat)] + // FromJSON + given FromJSON[Bird] = deriveFromJSON[Bird] + given FromJSON[Dog] = deriveFromJSON[Dog] + given FromJSON[Cat] = deriveFromJSON[Cat] + given FromJSON[Animal] = fromJsonTypeSwitch[Animal, (Bird, Dog, Cat)] + + List(Bird("Peewee"), Dog("Hasso"), Cat("Felidae")).foreach { (a: Animal) => + fromJSON[Animal](toJSON(a)) must equal(Valid(a)) + } + } + + it("must provide derived instances for product types with concrete type parameters") { + given ToJSON[GenericA[String]] = deriveToJSON + given FromJSON[GenericA[String]] = deriveFromJSON + val a = GenericA("hello") + fromJSON[GenericA[String]](toJSON(a)) must equal(Valid(a)) + } + + it("must provide derived instances for sum types with a mix of case class / object") { + // ToJSON + given ToJSON[SingletonMixed.type] = deriveToJSON + given ToJSON[RecordMixed] = deriveToJSON + given ToJSON[Mixed] = toJsonTypeSwitch[Mixed, (SingletonMixed.type, RecordMixed)] + // FromJSON + given FromJSON[SingletonMixed.type] = deriveFromJSON + given FromJSON[RecordMixed] = deriveFromJSON + given FromJSON[Mixed] = fromJsonTypeSwitch[Mixed, (SingletonMixed.type, RecordMixed)] + + List(SingletonMixed, RecordMixed(1)).foreach { m => + fromJSON[Mixed](toJSON(m)) must equal(Valid(m)) + } + } + + it("must provide derived instances for scala.Enumeration") { + implicit val toScalaEnumJSON: ToJSON[JSONSpec.ScalaEnum.Value] = toJsonEnum(ScalaEnum) + implicit val fromScalaEnumJSON: FromJSON[JSONSpec.ScalaEnum.Value] = fromJsonEnum(ScalaEnum) + ScalaEnum.values.foreach { v => + val json = s"""[${toJSON(v)}]""" + withClue(json) { + fromJSON[Seq[ScalaEnum.Value]](json) must equal(Valid(Seq(v))) + } + } + } + + it("must handle subclasses correctly in `jsonTypeSwitch`") { + // ToJSON + given ToJSON[TestSubjectConcrete1] = deriveToJSON + given ToJSON[TestSubjectConcrete2] = deriveToJSON + given ToJSON[TestSubjectConcrete3] = deriveToJSON + given ToJSON[TestSubjectConcrete4] = deriveToJSON + given ToJSON[TestSubjectCategoryA] = + toJsonTypeSwitch[TestSubjectCategoryA, (TestSubjectConcrete1, TestSubjectConcrete2)] + given ToJSON[TestSubjectCategoryB] = + toJsonTypeSwitch[TestSubjectCategoryB, (TestSubjectConcrete3, TestSubjectConcrete4)] + given ToJSON[TestSubjectBase] = + toJsonTypeSwitch[TestSubjectBase, (TestSubjectCategoryA, TestSubjectCategoryB)] + + // FromJSON + given FromJSON[TestSubjectConcrete1] = deriveFromJSON + given FromJSON[TestSubjectConcrete2] = deriveFromJSON + given FromJSON[TestSubjectConcrete3] = deriveFromJSON + given FromJSON[TestSubjectConcrete4] = deriveFromJSON + given FromJSON[TestSubjectCategoryA] = + fromJsonTypeSwitch[TestSubjectCategoryA, (TestSubjectConcrete1, TestSubjectConcrete2)] + given FromJSON[TestSubjectCategoryB] = + fromJsonTypeSwitch[TestSubjectCategoryB, (TestSubjectConcrete3, TestSubjectConcrete4)] + given FromJSON[TestSubjectBase] = + fromJsonTypeSwitch[TestSubjectBase, (TestSubjectCategoryA, TestSubjectCategoryB)] + + val testSubjects = List[TestSubjectBase]( + TestSubjectConcrete1("testSubject1"), + TestSubjectConcrete2("testSubject2"), + TestSubjectConcrete3("testSubject3"), + TestSubjectConcrete4("testSubject4") + ) + + testSubjects.foreach { testSubject => + val json = toJSON(testSubject) + withClue(json) { + fromJSON[TestSubjectBase](json) must equal(Valid(testSubject)) + } + } + + } + + it("must provide derived JSON instances for product types (case classes)") { + import JSONSpec.{Milestone, Project} + given ToJSON[Milestone] = deriveToJSON[Milestone] + given ToJSON[Project] = deriveToJSON[Project] + given FromJSON[Milestone] = deriveFromJSON[Milestone] + given FromJSON[Project] = deriveFromJSON[Project] + + val proj = + Project(42, "Linux", 7, Milestone("1.0") :: Milestone("2.0") :: Milestone("3.0") :: Nil) + fromJSON[Project](toJSON(proj)) must equal(Valid(proj)) + } + } +} + +abstract class TestSubjectBase + +sealed abstract class TestSubjectCategoryA extends TestSubjectBase +sealed abstract class TestSubjectCategoryB extends TestSubjectBase + +@JSONTypeHint("foo") +case class TestSubjectConcrete1(c1: String) extends TestSubjectCategoryA +case class TestSubjectConcrete2(c2: String) extends TestSubjectCategoryA + +case class TestSubjectConcrete3(c3: String) extends TestSubjectCategoryB +@JSONTypeHint("foo2") +case class TestSubjectConcrete4(c4: String) extends TestSubjectCategoryB + +object TestSubjectCategoryA { + val json: JSON[TestSubjectCategoryA] = deriveJSON[TestSubjectCategoryA] +} + +object TestSubjectCategoryB { + val json: JSON[TestSubjectCategoryB] = deriveJSON[TestSubjectCategoryB] +} + +object TestSubjectBase { + val json: JSON[TestSubjectBase] = { + implicit val jsonA: JSON[TestSubjectCategoryA] = TestSubjectCategoryA.json + implicit val jsonB: JSON[TestSubjectCategoryB] = TestSubjectCategoryB.json + + jsonTypeSwitch[TestSubjectBase, (TestSubjectCategoryA, TestSubjectCategoryB)] + } +} diff --git a/json/json-derivation/src/test/scala-3/io/sphere/json/generic/JsonTypeSwitchSpec.scala b/json/json-derivation/src/test/scala-3/io/sphere/json/generic/JsonTypeSwitchSpec.scala new file mode 100644 index 00000000..8de9a390 --- /dev/null +++ b/json/json-derivation/src/test/scala-3/io/sphere/json/generic/JsonTypeSwitchSpec.scala @@ -0,0 +1,236 @@ +package io.sphere.json.generic + +import cats.data.Validated.Valid +import cats.implicits.toTraverseOps +import io.sphere.json.{JSON, JSONParseError, JValidation, parseJSON} +import io.sphere.json.generic.deriveJSON +import io.sphere.json.generic.jsonTypeSwitch +import org.json4s.JsonAST.JObject +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec +import org.json4s.* +import org.json4s.DefaultReaders.StringReader + +class JsonTypeSwitchSpec extends AnyWordSpec with Matchers { + import JsonTypeSwitchSpec.* + + "jsonTypeSwitch" must { + + { + given JSON[B] = deriveJSON[B] + "derive a subset of a sealed trait".withFormatters( + newSyntax = jsonTypeSwitch[A, (B, C)], + oldSyntax = jsonTypeSwitch[A, B, C](Nil) + ) { + val b = B(123) + val jsonB = JSON[A].write(b) + + val b2 = JSON[A].read(jsonB).getOrElse(null) + + b2 must be(b) + + val c = C(2345345) + val jsonC = JSON[A].write(c) + + val c2 = JSON[A].read(jsonC).getOrElse(null) + + c2 must be(c) + } + } + + "derive a subset of a sealed trait with a mongoKey".withFormatters( + newSyntax = jsonTypeSwitch[A, (B, D)], + oldSyntax = jsonTypeSwitch[A, B, D](Nil) + ) { + val d = D(123) + val json = JSON[A].write(d) + val d2 = JSON[A].read(json) + + (json \ "type").as[String] must be("D2") + d2 must be(Valid(d)) + } + + "combine different sum types tree".withFormatters( + newSyntax = jsonTypeSwitch[Message, (TypeA, TypeB)], + oldSyntax = jsonTypeSwitch[Message, TypeA, TypeB](Nil) + ) { + val m: Seq[Message] = List( + TypeA.ClassA1(23), + TypeA.ClassA2("world"), + TypeB.ClassB1(valid = false), + TypeB.ClassB2(Seq("a23", "c62"))) + + val jsons = m.map(JSON[Message].write) + jsons must be( + List( + JObject("number" -> JLong(23), "type" -> JString("ClassA1")), + JObject("name" -> JString("world"), "type" -> JString("ClassA2")), + JObject("valid" -> JBool(false), "type" -> JString("ClassB1")), + JObject( + "references" -> JArray(List(JString("a23"), JString("c62"))), + "type" -> JString("ClassB2")) + )) + + val messages = jsons.map(JSON[Message].read).map(_.toOption.get) + messages must be(m) + } + + { + given JSON[B] = new JSON[B] { + override def read(jval: JValue): JValidation[B] = jval match { + case JObject(List(_, "field" -> JString(s"Custom-B-${n}"))) => + Valid(B(n.toInt)) + case _ => ??? + } + + override def write(value: B): JValue = + JObject(List("field" -> JString(s"Custom-B-${value.int}"))) + } + + "handle custom implementations for subtypes".withFormatters( + newSyntax = jsonTypeSwitch[A, (B, D, C)], + oldSyntax = jsonTypeSwitch[A, B, D, C](Nil) + ) { + check[A](D(2345), """ {"type": "D2", "int": 2345 } """) + check[A](C(4), """ {"type": "C", "int": 4 } """) + check[A](B(34), """ {"type": "B", "field": "Custom-B-34" } """) + } + } + + "handle the PlatformFormattedNotification case" when { + // This means deriving a formatter for a supertrait that has 3 sub traits. + // 1 of them passed in as a type parameter and fully being derived + // the other 2 sub traits passed in as a value parameter, through their combined type selectors + + "using the /old/ syntax" in { + val formatSub2 = jsonTypeSwitch[SubTrait2, SubTrait2.O3.type, SubTrait2.O4.type](Nil) + val formatSub3 = jsonTypeSwitch[SubTrait3, SubTrait3.O5.type, SubTrait3.O6.type](Nil) + + val typeSelectors = formatSub2.typeSelectors ++ formatSub3.typeSelectors + val formatSuper: JSON[SuperTrait] = jsonTypeSwitch[SuperTrait, SubTrait1](typeSelectors) + + val objs = + List[SuperTrait]( + SubTrait1.O1, + SubTrait1.O2, + SubTrait2.O3, + SubTrait2.O4, + SubTrait3.O5, + SubTrait3.O6) + + val res = objs.map(formatSuper.write).map(formatSuper.read).sequence.getOrElse(null) + + res must be(objs) + + } + + "using the /new/ syntax" in { + type Trait234 = SubTrait2 *: (SubTrait3, SubTrait4) + + val formatSuper: JSON[SuperTrait] = jsonTypeSwitch[SuperTrait, SubTrait1 *: Trait234] + + val objs = + List[SuperTrait]( + SubTrait1.O1, + SubTrait1.O2, + SubTrait2.O3, + SubTrait2.O4, + SubTrait3.O5, + SubTrait3.O6, + SubTrait4.O7) + + val res = objs.map(formatSuper.write).map(formatSuper.read).sequence.getOrElse(null) + + res must be(objs) + + } + + } + + } + + def check[A](a: A, json: String)(using format: JSON[A]): Unit = { + val parsedJson = parseJSON(json).getOrElse(null) + val json2 = format.write(a) + json2 must be(parsedJson) + format.read(json2).getOrElse(null) must be(a) + } + + type FormatTest[A] = JSON[A] ?=> Any + extension (string: String) { + def withFormatters[A](newSyntax: JSON[A], oldSyntax: JSON[A])(f: FormatTest[A]): Unit = { + s"$string with newSyntax" in { + f(using newSyntax) + } + + s"$string with oldSyntax" in { + f(using oldSyntax) + } + } + } +} + +object JsonTypeSwitchSpec { + sealed trait A + case class B(int: Int) extends A + case class C(int: Int) extends A + @JSONTypeHint("D2") case class D(int: Int) extends A + + trait Message + + sealed trait TypeA extends Message + object TypeA { + case class ClassA1(number: Int) extends TypeA + case class ClassA2(name: String) extends TypeA + implicit val json: JSON[TypeA] = deriveJSON[TypeA] + } + + sealed trait TypeB extends Message + object TypeB { + case class ClassB1(valid: Boolean) extends TypeB + case class ClassB2(references: Seq[String]) extends TypeB + implicit val json: JSON[TypeB] = deriveJSON[TypeB] + } + + trait SuperTrait + + sealed trait SubTrait1 extends SuperTrait + + object SubTrait1 { + case object O1 extends SubTrait1 + + case object O2 extends SubTrait1 + + given JSON[SubTrait1] = deriveJSON + } + + sealed trait SubTrait2 extends SuperTrait + + object SubTrait2 { + case object O3 extends SubTrait2 + + case object O4 extends SubTrait2 + + given JSON[SubTrait2] = deriveJSON + } + + sealed trait SubTrait3 extends SuperTrait + + object SubTrait3 { + case object O5 extends SubTrait3 + + case object O6 extends SubTrait3 + + given JSON[SubTrait3] = deriveJSON + } + + sealed trait SubTrait4 extends SuperTrait + + object SubTrait4 { + case object O7 extends SubTrait4 + + case object O8 extends SubTrait4 + + given JSON[SubTrait4] = deriveJSON + } +} diff --git a/json/json-derivation/src/test/scala-3/io/sphere/json/generic/SubTypeNameSpec.scala b/json/json-derivation/src/test/scala-3/io/sphere/json/generic/SubTypeNameSpec.scala new file mode 100644 index 00000000..39a175e7 --- /dev/null +++ b/json/json-derivation/src/test/scala-3/io/sphere/json/generic/SubTypeNameSpec.scala @@ -0,0 +1,63 @@ +package io.sphere.json.generic + +import io.sphere.json.JSON +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec +import io.sphere.json.generic.deriveJSON + +class SubTypeNameSpec extends AnyWordSpec with Matchers { + import SubTypeNameSpec._ + + "JSON.subtypeNames" must { + + val subTypeNames = List("Obj1", "Obj2", "Class1", "Class2") + "return all subtypes of a trait when using deriveJSON" in { + val format: JSON[SuperType] = deriveJSON + + format.subTypeNames must be(subTypeNames) + + } + + "return all subtypes of a trait when using jsonTypeSwitch" in { + implicit val obj1F: JSON[Obj1.type] = deriveJSON + implicit val objHF: JSON[ObjHidden.type] = deriveJSON + implicit val class1F: JSON[Class1] = deriveJSON + implicit val classhF: JSON[ClassHidden] = deriveJSON + + val format: JSON[SuperType] = + jsonTypeSwitch[SuperType, (Obj1.type, ObjHidden.type, Class1, ClassHidden)] + + format.subTypeNames must be(subTypeNames) + + } + + "return only class names in nested trait hierarchies" in { + val format: JSON[SuperType2] = + jsonTypeSwitch[SuperType2, (SubType1, SubType2)] + + // Should only contain class names, no trait names + val names = List("SubClass1A", "SubClass2A") + format.subTypeNames must be(names) + } + } +} + +object SubTypeNameSpec { + sealed trait SuperType + case object Obj1 extends SuperType + @JSONTypeHint("Obj2") case object ObjHidden extends SuperType + case class Class1(int: Int) extends SuperType + @JSONTypeHint("Class2") case class ClassHidden(int: Int) extends SuperType + + sealed trait SuperType2 + sealed trait SubType1 extends SuperType2 + object SubType1 { + case class SubClass1A(x: Int) extends SubType1 + given JSON[SubType1] = deriveJSON + } + sealed trait SubType2 extends SuperType2 + object SubType2 { + case class SubClass2A(x: Int) extends SubType2 + given JSON[SubType2] = deriveJSON + } +} diff --git a/json/json-derivation/src/test/scala/io/sphere/json/DeriveSingletonJSONSpec.scala b/json/json-derivation/src/test/scala/io/sphere/json/DeriveSingletonJSONSpec.scala index 8f244864..da79c37a 100644 --- a/json/json-derivation/src/test/scala/io/sphere/json/DeriveSingletonJSONSpec.scala +++ b/json/json-derivation/src/test/scala/io/sphere/json/DeriveSingletonJSONSpec.scala @@ -1,5 +1,6 @@ package io.sphere.json +import cats.data.Validated import cats.data.Validated.Valid import io.sphere.json.generic._ import org.json4s.JValue @@ -35,7 +36,7 @@ class DeriveSingletonJSONSpec extends AnyWordSpec with Matchers { "write normal singleton values" in { val userJson = toJValue(UserWithPicture("foo-123", Medium, "http://exmple.com")) - val Valid(expectedJson) = parseJSON(""" + val expectedJson = parseValidJSON(""" { "userId": "foo-123", "pictureSize": "Medium", @@ -61,7 +62,7 @@ class DeriveSingletonJSONSpec extends AnyWordSpec with Matchers { "write custom singleton values" in { val userJson = toJValue(UserWithPicture("foo-123", Custom, "http://exmple.com")) - val Valid(expectedJson) = parseJSON(""" + val expectedJson = parseValidJSON(""" { "userId": "foo-123", "pictureSize": "bar", @@ -103,16 +104,30 @@ class DeriveSingletonJSONSpec extends AnyWordSpec with Matchers { val newJson = toJValue[UserWithPicture](user) Valid(newJson) must be(parseJSON(json)) - val Valid(newUser) = fromJValue[UserWithPicture](newJson) + val newUser = fromValidJValue[UserWithPicture](newJson) newUser must be(user) } } - private def filter(jvalue: JValue): JValue = + private def filter(jvalue: JValue): JValue = { + import org.json4s.jvalue2monadic jvalue.removeField { case (_, JNothing) => true case _ => false } + } + + def parseValidJSON(string: String): JValue = + parseJSON(string) match { + case Valid(a) => a + case Validated.Invalid(e) => throw new Exception(e.head.toString) + } + + def fromValidJValue[A](jval: JValue)(implicit json: FromJSON[A]): A = + json.read(jval) match { + case Valid(a) => a + case Validated.Invalid(e) => throw new Exception(e.head.toString) + } } sealed abstract class PictureSize(val weight: Int, val height: Int) diff --git a/json/json-derivation/src/test/scala/io/sphere/json/NullHandlingSpec.scala b/json/json-derivation/src/test/scala/io/sphere/json/NullHandlingSpec.scala index 3a11d3bb..2a4d2e7d 100644 --- a/json/json-derivation/src/test/scala/io/sphere/json/NullHandlingSpec.scala +++ b/json/json-derivation/src/test/scala/io/sphere/json/NullHandlingSpec.scala @@ -7,13 +7,13 @@ import org.scalatest.wordspec.AnyWordSpec class NullHandlingSpec extends AnyWordSpec with Matchers { "JSON deserialization" must { - "should accept undefined fields and use default values for them" in { + "accept undefined fields and use default values for them" in { val jeans = getFromJSON[Jeans]("{}") jeans must be(Jeans(None, None, Set.empty, "secret")) } - "should accept null values and use default values for them" in { + "accept null values and use default values for them" in { val jeans = getFromJSON[Jeans](""" { "leftPocket": null, @@ -26,7 +26,7 @@ class NullHandlingSpec extends AnyWordSpec with Matchers { jeans must be(Jeans(None, None, Set.empty, "secret")) } - "should accept JNothing values and use default values for them" in { + "accept JNothing values and use default values for them" in { val jeans = getFromJValue[Jeans]( JObject( "leftPocket" -> JNothing, @@ -37,7 +37,7 @@ class NullHandlingSpec extends AnyWordSpec with Matchers { jeans must be(Jeans(None, None, Set.empty, "secret")) } - "should accept not-null values and use them" in { + "accept not-null values and use them" in { val jeans = getFromJSON[Jeans](""" { "leftPocket": "Axe", diff --git a/json/json-derivation/src/test/scala/io/sphere/json/generic/DefaultValuesSpec.scala b/json/json-derivation/src/test/scala/io/sphere/json/generic/DefaultValuesSpec.scala index 116ba388..ecf4b0b6 100644 --- a/json/json-derivation/src/test/scala/io/sphere/json/generic/DefaultValuesSpec.scala +++ b/json/json-derivation/src/test/scala/io/sphere/json/generic/DefaultValuesSpec.scala @@ -1,7 +1,6 @@ -package io.sphere.mongo.generic +package io.sphere.json.generic import io.sphere.json._ -import io.sphere.json.generic._ import org.scalatest.matchers.must.Matchers import org.scalatest.wordspec.AnyWordSpec @@ -10,12 +9,17 @@ class DefaultValuesSpec extends AnyWordSpec with Matchers { "deriving JSON" must { "handle default values" in { - val json = "{}" + val json = "{ }" val test = getFromJSON[Test](json) test.value1 must be("hello") test.value2 must be(None) - test.value3 must be(None) - test.value4 must be(Some("hi")) + test.value3 must be(Some("hi")) + } + "handle Option with no explicit default values" in { + val json = "{ }" + val test2 = getFromJSON[Test2](json) + test2.value1 must be("hello") + test2.value2 must be(None) } } } @@ -23,11 +27,17 @@ class DefaultValuesSpec extends AnyWordSpec with Matchers { object DefaultValuesSpec { case class Test( value1: String = "hello", - value2: Option[String], - value3: Option[String] = None, - value4: Option[String] = Some("hi") + value2: Option[String] = None, + value3: Option[String] = Some("hi") ) object Test { - implicit val mongo: JSON[Test] = deriveJSON + implicit val json: JSON[Test] = deriveJSON[Test] + } + case class Test2( + value1: String = "hello", + value2: Option[String] + ) + object Test2 { + implicit val json: JSON[Test2] = deriveJSON[Test2] } } diff --git a/json/json-derivation/src/test/scala/io/sphere/json/generic/JSONKeySpec.scala b/json/json-derivation/src/test/scala/io/sphere/json/generic/JSONKeySpec.scala index 66bb9c5e..f9630d80 100644 --- a/json/json-derivation/src/test/scala/io/sphere/json/generic/JSONKeySpec.scala +++ b/json/json-derivation/src/test/scala/io/sphere/json/generic/JSONKeySpec.scala @@ -1,7 +1,8 @@ -package io.sphere.mongo.generic +package io.sphere.json.generic +import org.json4s.MonadicJValue.jvalueToMonadic +import org.json4s.jvalue2readerSyntax import io.sphere.json._ -import io.sphere.json.generic._ import org.json4s.DefaultReaders._ import org.scalatest.matchers.must.Matchers import org.scalatest.wordspec.AnyWordSpec @@ -31,7 +32,7 @@ object JSONKeySpec { @JSONKey("new_sub_value_2") value2: String ) object SubTest { - implicit val mongo: JSON[SubTest] = deriveJSON + implicit val json: JSON[SubTest] = deriveJSON } case class Test( @@ -40,6 +41,6 @@ object JSONKeySpec { @JSONEmbedded subTest: SubTest ) object Test { - implicit val mongo: JSON[Test] = deriveJSON + implicit val json: JSON[Test] = deriveJSON } } diff --git a/json/json-derivation/src/test/scala/io/sphere/json/generic/JsonTypeHintFieldSpec.scala b/json/json-derivation/src/test/scala/io/sphere/json/generic/JsonTypeHintFieldSpec.scala index 62d7af94..6fabbcd6 100644 --- a/json/json-derivation/src/test/scala/io/sphere/json/generic/JsonTypeHintFieldSpec.scala +++ b/json/json-derivation/src/test/scala/io/sphere/json/generic/JsonTypeHintFieldSpec.scala @@ -3,10 +3,11 @@ package io.sphere.json.generic import cats.data.Validated.Valid import io.sphere.json._ import org.json4s._ +import org.scalatest.Inside import org.scalatest.matchers.must.Matchers import org.scalatest.wordspec.AnyWordSpec -class JsonTypeHintFieldSpec extends AnyWordSpec with Matchers { +class JsonTypeHintFieldSpec extends AnyWordSpec with Matchers with Inside { import JsonTypeHintFieldSpec._ "JSONTypeHintField" must { @@ -20,9 +21,13 @@ class JsonTypeHintFieldSpec extends AnyWordSpec with Matchers { val json = toJValue[UserWithPicture](user) json must be(expected) + + inside(fromJValue[UserWithPicture](json)) { case Valid(parsedUser) => + parsedUser must be(user) + } } - "allow to set another field to distinguish between types (fromMongo)" in { + "allow to set another field to distinguish between types (fromJSON)" in { val json = """ { @@ -32,7 +37,7 @@ class JsonTypeHintFieldSpec extends AnyWordSpec with Matchers { } """ - val Valid(user) = fromJSON[UserWithPicture](json) + val user = fromJSON[UserWithPicture](json).getOrElse(null) user must be(UserWithPicture("foo-123", Medium, "http://example.com")) } diff --git a/mongo/mongo-core/src/main/scala/io/sphere/mongo/format/MongoFormat.scala b/mongo/mongo-core/src/main/scala-2/io/sphere/mongo/format/MongoFormat.scala similarity index 91% rename from mongo/mongo-core/src/main/scala/io/sphere/mongo/format/MongoFormat.scala rename to mongo/mongo-core/src/main/scala-2/io/sphere/mongo/format/MongoFormat.scala index 7179cd2b..0a726c9a 100644 --- a/mongo/mongo-core/src/main/scala/io/sphere/mongo/format/MongoFormat.scala +++ b/mongo/mongo-core/src/main/scala-2/io/sphere/mongo/format/MongoFormat.scala @@ -13,12 +13,12 @@ trait MongoFormat[@specialized A] extends Serializable { def default: Option[A] = None /** needed JSON fields - ignored if empty */ - val fields: Set[String] = MongoFormat.emptyFieldsSet + val fields: Set[String] = MongoFormat.emptyFields } object MongoFormat extends MongoFormatInstances { - private[MongoFormat] val emptyFieldsSet: Set[String] = Set.empty + private[MongoFormat] val emptyFields: Set[String] = Set.empty @inline def apply[A](implicit instance: MongoFormat[A]): MongoFormat[A] = instance diff --git a/mongo/mongo-core/src/main/scala-3/io/sphere/mongo/format/MongoFormat.scala b/mongo/mongo-core/src/main/scala-3/io/sphere/mongo/format/MongoFormat.scala new file mode 100644 index 00000000..2d01c13c --- /dev/null +++ b/mongo/mongo-core/src/main/scala-3/io/sphere/mongo/format/MongoFormat.scala @@ -0,0 +1,140 @@ +package io.sphere.mongo.format + +import com.mongodb.BasicDBObject +import io.sphere.mongo.generic.{MongoAnnotationReader, mongoTypeSwitch} +import io.sphere.util.Field +import org.bson.BSONObject +import org.bson.types.ObjectId + +import java.util.UUID +import java.util.regex.Pattern +import scala.deriving.Mirror +import scala.util.Try + +type SimpleMongoType = UUID | String | ObjectId | Short | Int | Long | Float | Double | Boolean | + Pattern + +trait MongoFormat[A] extends Serializable { + def toMongoValue(a: A): Any + def fromMongoValue(mongoType: Any): A + + // /** needed JSON fields - ignored if empty */ + val fields: Set[String] = MongoFormat.emptyFields + + def default: Option[A] = None +} + +/** Some extra information for traits and abstract classes so we can handle nested hierarchies + * easier + */ +trait TraitMongoFormat[A] extends MongoFormat[A] { + // This approach is somewhat slow, the reason I chose to implement it like this is because: + // 1. this approach supports different type discriminators for different traits + // 2. no need for classtag + def attemptWrite(a: A): Try[Any] = Try(toMongoValue(a)) + + def attemptRead(bson: BSONObject): Try[A] = Try(fromMongoValue(bson)) +} + +object TraitMongoFormat { + def instance[A](fromMongo: Any => A, toMongo: A => Any): TraitMongoFormat[A] = new { + override def toMongoValue(a: A): Any = toMongo(a) + override def fromMongoValue(mongoType: Any): A = fromMongo(mongoType) + } +} + +object MongoFormat { + private val emptyFields: Set[String] = Set.empty + + inline def apply[A: MongoFormat]: MongoFormat[A] = summon + inline given derived[A](using Mirror.Of[A]): MongoFormat[A] = Derivation.derived + + def instance[A]( + fromMongo: Any => A, + toMongo: A => Any, + fieldSet: Set[String] = emptyFields): MongoFormat[A] = new { + + override def toMongoValue(a: A): Any = toMongo(a) + override def fromMongoValue(mongoType: Any): A = fromMongo(mongoType) + override val fields: Set[String] = fieldSet + } + + private object Derivation { + import scala.compiletime.{constValue, constValueTuple, erasedValue, error, summonInline} + + inline def derived[A](using m: Mirror.Of[A]): MongoFormat[A] = + inline m match { + case s: Mirror.SumOf[A] => mongoTypeSwitch[A, s.MirroredElemTypes] + case p: Mirror.ProductOf[A] => deriveCaseClass(p) + } + + inline private def deriveCaseClass[A](mirrorOfProduct: Mirror.ProductOf[A]): MongoFormat[A] = { + val caseClassMetaData = MongoAnnotationReader.readTypeMetaData[A] + val formatters = summonFormatters[mirrorOfProduct.MirroredElemTypes] + val fieldsAndFormatters = caseClassMetaData.fields.zip(formatters) + + val fields: Set[String] = fieldsAndFormatters.toSet.flatMap((field, formatter) => + if (field.embedded) formatter.fields + field.scalaName + else Set(field.scalaName)) + + instance( + toMongo = { a => + val bson = new BasicDBObject() + val values = a.asInstanceOf[Product].productIterator + formatters.zip(values).zip(caseClassMetaData.fields).foreach { + case ((format, value), field) => + addField(bson, field, format.toMongoValue(value)) + } + bson + }, + fromMongo = { + case bson: BasicDBObject => + val valuesOfClass = fieldsAndFormatters.map(readField(bson)) + val tuple = Tuple.fromArray(valuesOfClass.toArray) + mirrorOfProduct.fromTuple(tuple.asInstanceOf[mirrorOfProduct.MirroredElemTypes]) + + case x => throw new Exception(s"BasicDBObject is expected for a class, instead got: $x") + }, + fieldSet = fields + ) + } + + inline private def summonFormatters[T <: Tuple]: Vector[MongoFormat[Any]] = + inline erasedValue[T] match { + case _: EmptyTuple => Vector.empty + case _: (t *: ts) => + summonInline[MongoFormat[t]] + .asInstanceOf[MongoFormat[Any]] +: summonFormatters[ts] + } + + } + + private def addField(bson: BasicDBObject, field: Field, mongoType: Any): Unit = + if (!field.ignored) + mongoType match { + case s: SimpleMongoType => bson.put(field.serializedName, s) + case innerBson: BasicDBObject => + if (field.embedded) innerBson.entrySet().forEach(p => bson.put(p.getKey, p.getValue)) + else bson.put(field.serializedName, innerBson) + case MongoNothing => + } + + private def readField(bson: BSONObject)(field: Field, format: MongoFormat[Any]): Any = { + def defaultValue = field.defaultArgument.orElse(format.default) + if (field.ignored) + defaultValue.getOrElse { + throw new Exception( + s"Ignored Mongo field '${field.serializedName}' must have a default value.") + } + else if (field.embedded) format.fromMongoValue(bson) + else { + val value = bson.get(field.serializedName) + if (value ne null) format.fromMongoValue(value.asInstanceOf[Any]) + else + defaultValue.getOrElse { + throw new Exception( + s"Missing required field '${field.serializedName}' on deserialization.") + } + } + } +} diff --git a/mongo/mongo-core/src/main/scala-3/io/sphere/mongo/generic/Annotations.scala b/mongo/mongo-core/src/main/scala-3/io/sphere/mongo/generic/Annotations.scala new file mode 100644 index 00000000..17d3bd6f --- /dev/null +++ b/mongo/mongo-core/src/main/scala-3/io/sphere/mongo/generic/Annotations.scala @@ -0,0 +1,11 @@ +package io.sphere.mongo.generic + +import scala.annotation.StaticAnnotation + +sealed trait MongoAnnotation extends StaticAnnotation + +case class MongoEmbedded() extends MongoAnnotation +case class MongoIgnore() extends MongoAnnotation +case class MongoKey(value: String) extends MongoAnnotation +case class MongoTypeHintField(value: String) extends MongoAnnotation +case class MongoTypeHint(value: String) extends MongoAnnotation diff --git a/mongo/mongo-core/src/main/scala-3/io/sphere/mongo/generic/MongoAnnotationReader.scala b/mongo/mongo-core/src/main/scala-3/io/sphere/mongo/generic/MongoAnnotationReader.scala new file mode 100644 index 00000000..d9171842 --- /dev/null +++ b/mongo/mongo-core/src/main/scala-3/io/sphere/mongo/generic/MongoAnnotationReader.scala @@ -0,0 +1,48 @@ +package io.sphere.mongo.generic + +import io.sphere.util.{AnnotationReader, TraitMetaData, TypeMetaData} + +import scala.quoted.{Expr, Quotes, Type, Varargs} + +object MongoAnnotationReader { + + inline def readTraitMetaData[T]: TraitMetaData = ${ readTraitMetaDataImpl[T] } + + inline def readTypeMetaData[T]: TypeMetaData = ${ readTypeMetaDataImpl[T] } + + private def readTypeMetaDataImpl[T: Type](using Quotes): Expr[TypeMetaData] = + MongoAnnotationReader().readTypeMetaData[T] + + private def readTraitMetaDataImpl[T: Type](using Quotes): Expr[TraitMetaData] = + MongoAnnotationReader().readTraitMetaData[T] +} + +class MongoAnnotationReader(using q: Quotes) { + import q.reflect.* + + private def findAnnotation[MA <: MongoAnnotation: Type](tree: Tree): Option[Expr[Any]] = + Option.when(tree.isExpr)(tree.asExpr).filter(_.isExprOf[MA]) + + private def embeddedExists(tree: Tree): Boolean = + findAnnotation[MongoEmbedded](tree).isDefined + + private def ignoredExists(tree: Tree): Boolean = + findAnnotation[MongoIgnore](tree).isDefined + + private def findKey(tree: Tree): Option[Expr[String]] = + findAnnotation[MongoKey](tree).map(_.asExprOf[MongoKey]).map(a => '{ $a.value }) + + private def findTypeHint(tree: Tree): Option[Expr[String]] = + findAnnotation[MongoTypeHint](tree).map(_.asExprOf[MongoTypeHint]).map(a => '{ $a.value }) + + private def findTypeHintField(tree: Tree): Option[Expr[String]] = + findAnnotation[MongoTypeHintField](tree) + .map(_.asExprOf[MongoTypeHintField]) + .map(a => '{ $a.value }) + + private val annotationReader = + new AnnotationReader(embeddedExists, ignoredExists, findKey, findTypeHint, findTypeHintField) + export annotationReader.readTraitMetaData + export annotationReader.readTypeMetaData + +} diff --git a/mongo/mongo-core/src/main/scala-3/io/sphere/mongo/generic/generic.scala b/mongo/mongo-core/src/main/scala-3/io/sphere/mongo/generic/generic.scala new file mode 100644 index 00000000..c32d752e --- /dev/null +++ b/mongo/mongo-core/src/main/scala-3/io/sphere/mongo/generic/generic.scala @@ -0,0 +1,82 @@ +package io.sphere.mongo.generic + +import com.mongodb.BasicDBObject +import io.sphere.mongo.format.{MongoFormat, TraitMongoFormat} +import io.sphere.util.TypeMetaData +import org.bson.BSONObject + +import scala.deriving.Mirror +import scala.compiletime.{constValueTuple, erasedValue, error, summonInline} + +inline def deriveMongoFormat[A](using Mirror.Of[A]): MongoFormat[A] = MongoFormat.derived + +def mongoEnum(e: Enumeration): MongoFormat[e.Value] = new MongoFormat[e.Value] { + def toMongoValue(a: e.Value): Any = a.toString + + def fromMongoValue(any: Any): e.Value = e.withName(any.asInstanceOf[String]) +} + +inline def mongoTypeSwitch[SuperType, SubTypeTuple <: Tuple]: MongoFormat[SuperType] = { + val traitMetaData = MongoAnnotationReader.readTraitMetaData[SuperType] + val typeHintMap = traitMetaData.serializedNamesOfSubTypes + val reverseTypeHintMap = typeHintMap.map((on, n) => (n, on)) + val formatters = summonFormatters[SubTypeTuple]() + val subTypeNames = summonMetaData[SubTypeTuple]() + + val pairedFormatterWithSubtypeName = subTypeNames.map(_.scalaName).zip(formatters) + val (caseClassFormatterList, traitFormatters) = pairedFormatterWithSubtypeName.partitionMap { + case kv @ (name, formatter) => + formatter match { + case traitFormatter: TraitMongoFormat[_] => Right(traitFormatter) + case _ => Left(kv) + } + } + val caseClassFormatters = caseClassFormatterList.toMap + + TraitMongoFormat.instance( + toMongo = { a => + traitFormatters.view.map(_.attemptWrite(a)).find(_.isSuccess).map(_.get) match { + case Some(bson) => bson + case None => + val scalaTypeName = a.asInstanceOf[Product].productPrefix + val serializedTypeName = typeHintMap.getOrElse(scalaTypeName, scalaTypeName) + val bson = + caseClassFormatters(scalaTypeName).toMongoValue(a).asInstanceOf[BasicDBObject] + bson.put(traitMetaData.typeDiscriminator, serializedTypeName) + bson + } + }, + fromMongo = { + case bson: BasicDBObject => + traitFormatters.view.map(_.attemptRead(bson)).find(_.isSuccess).map(_.get) match { + case Some(a) => a.asInstanceOf[SuperType] + case None => + val serializedTypeName = bson.get(traitMetaData.typeDiscriminator).asInstanceOf[String] + val scalaTypeName = reverseTypeHintMap.getOrElse(serializedTypeName, serializedTypeName) + caseClassFormatters(scalaTypeName).fromMongoValue(bson).asInstanceOf[SuperType] + } + case x => + throw new Exception(s"BsonObject is expected for a Trait subtype, instead got $x") + } + ) +} + +private def findTypeValue(dbo: BSONObject, typeField: String): Option[String] = + Option(dbo.get(typeField)).map(_.toString) + +inline private def summonMetaData[T <: Tuple]( + acc: Vector[TypeMetaData] = Vector.empty): Vector[TypeMetaData] = + inline erasedValue[T] match { + case _: EmptyTuple => acc + case _: (t *: ts) => + summonMetaData[ts](acc :+ MongoAnnotationReader.readTypeMetaData[t]) + } + +inline private def summonFormatters[T <: Tuple]( + acc: Vector[MongoFormat[Any]] = Vector.empty): Vector[MongoFormat[Any]] = + inline erasedValue[T] match { + case _: EmptyTuple => acc + case _: (t *: ts) => + val headFormatter = summonInline[MongoFormat[t]].asInstanceOf[MongoFormat[Any]] + summonFormatters[ts](acc :+ headFormatter) + } diff --git a/mongo/mongo-core/src/main/scala/io/sphere/mongo/format/DefaultMongoFormats.scala b/mongo/mongo-core/src/main/scala/io/sphere/mongo/format/DefaultMongoFormats.scala index 199343fb..06ce679a 100644 --- a/mongo/mongo-core/src/main/scala/io/sphere/mongo/format/DefaultMongoFormats.scala +++ b/mongo/mongo-core/src/main/scala/io/sphere/mongo/format/DefaultMongoFormats.scala @@ -1,12 +1,11 @@ package io.sphere.mongo.format -import java.util.{Currency, Locale, UUID} -import java.util.regex.Pattern - import io.sphere.util.{BaseMoney, HighPrecisionMoney, LangTag, Money} -import org.bson.{BSONObject, BasicBSONObject} import org.bson.types.{BasicBSONList, ObjectId} +import org.bson.{BSONObject, BasicBSONObject} +import java.util.regex.Pattern +import java.util.{Currency, Locale, UUID} import scala.collection.immutable.VectorBuilder import scala.collection.mutable.ListBuffer @@ -50,7 +49,7 @@ trait DefaultMongoFormats { implicit def optionFormat[@specialized A](implicit f: MongoFormat[A]): MongoFormat[Option[A]] = new MongoFormat[Option[A]] { - import scala.collection.JavaConverters._ + import scala.jdk.CollectionConverters._ override def toMongoValue(a: Option[A]) = a match { case Some(aa) => f.toMongoValue(aa) case None => MongoNothing @@ -78,7 +77,7 @@ trait DefaultMongoFormats { implicit def vecFormat[@specialized A](implicit f: MongoFormat[A]): MongoFormat[Vector[A]] = new MongoFormat[Vector[A]] { - import scala.collection.JavaConverters._ + import scala.jdk.CollectionConverters._ override def toMongoValue(a: Vector[A]): BasicBSONList = { val m = new BasicBSONList() if (a.nonEmpty) m.addAll(a.map(f.toMongoValue(_).asInstanceOf[AnyRef]).asJavaCollection) @@ -103,7 +102,7 @@ trait DefaultMongoFormats { implicit def listFormat[@specialized A](implicit f: MongoFormat[A]): MongoFormat[List[A]] = new MongoFormat[List[A]] { - import scala.collection.JavaConverters._ + import scala.jdk.CollectionConverters._ override def toMongoValue(a: List[A]): BasicBSONList = { val m = new BasicBSONList() if (a.nonEmpty) m.addAll(a.map(f.toMongoValue(_).asInstanceOf[AnyRef]).asJavaCollection) @@ -128,7 +127,7 @@ trait DefaultMongoFormats { implicit def setFormat[@specialized A](implicit f: MongoFormat[A]): MongoFormat[Set[A]] = new MongoFormat[Set[A]] { - import scala.collection.JavaConverters._ + import scala.jdk.CollectionConverters._ override def toMongoValue(a: Set[A]): BasicBSONList = { val m = new BasicBSONList() if (a.nonEmpty) m.addAll(a.map(f.toMongoValue(_).asInstanceOf[AnyRef]).asJavaCollection) diff --git a/mongo/mongo-core/src/test/scala/io/sphere/mongo/catsinstances/MongoFormatCatsInstancesTest.scala b/mongo/mongo-core/src/test/scala/io/sphere/mongo/catsinstances/MongoFormatCatsInstancesTest.scala index 47f37647..8ea87eb9 100644 --- a/mongo/mongo-core/src/test/scala/io/sphere/mongo/catsinstances/MongoFormatCatsInstancesTest.scala +++ b/mongo/mongo-core/src/test/scala/io/sphere/mongo/catsinstances/MongoFormatCatsInstancesTest.scala @@ -1,7 +1,7 @@ package io.sphere.mongo.catsinstances import cats.syntax.invariant._ -import io.sphere.mongo.format.DefaultMongoFormats._ +import io.sphere.mongo.format.DefaultMongoFormats.stringFormat import io.sphere.mongo.format._ import org.scalatest.matchers.must.Matchers import org.scalatest.wordspec.AnyWordSpec diff --git a/mongo/mongo-core/src/test/scala/io/sphere/mongo/format/BaseMoneyMongoFormatTest.scala b/mongo/mongo-core/src/test/scala/io/sphere/mongo/format/BaseMoneyMongoFormatTest.scala index 27ab0fd0..9638fd06 100644 --- a/mongo/mongo-core/src/test/scala/io/sphere/mongo/format/BaseMoneyMongoFormatTest.scala +++ b/mongo/mongo-core/src/test/scala/io/sphere/mongo/format/BaseMoneyMongoFormatTest.scala @@ -9,7 +9,7 @@ import org.bson.BSONObject import org.scalatest.wordspec.AnyWordSpec import org.scalatest.matchers.should.Matchers -import scala.collection.JavaConverters._ +import scala.jdk.CollectionConverters._ class BaseMoneyMongoFormatTest extends AnyWordSpec with Matchers { diff --git a/mongo/mongo-core/src/test/scala/io/sphere/mongo/format/DefaultMongoFormatsTest.scala b/mongo/mongo-core/src/test/scala/io/sphere/mongo/format/DefaultMongoFormatsTest.scala index f03efc81..a29bf5c7 100644 --- a/mongo/mongo-core/src/test/scala/io/sphere/mongo/format/DefaultMongoFormatsTest.scala +++ b/mongo/mongo-core/src/test/scala/io/sphere/mongo/format/DefaultMongoFormatsTest.scala @@ -12,7 +12,7 @@ import org.scalatest.matchers.must.Matchers import org.scalatest.wordspec.AnyWordSpec import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks -import scala.collection.JavaConverters._ +import scala.jdk.CollectionConverters._ object DefaultMongoFormatsTest { case class User(name: String) diff --git a/mongo/mongo-derivation-magnolia/dependencies.sbt b/mongo/mongo-derivation-magnolia/dependencies.sbt deleted file mode 100644 index 291cdeae..00000000 --- a/mongo/mongo-derivation-magnolia/dependencies.sbt +++ /dev/null @@ -1,3 +0,0 @@ -libraryDependencies ++= Seq( - "com.propensive" %% "magnolia" % "0.17.0" -) diff --git a/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoEmbedded.scala b/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoEmbedded.scala deleted file mode 100644 index cf2aa20a..00000000 --- a/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoEmbedded.scala +++ /dev/null @@ -1,5 +0,0 @@ -package io.sphere.mongo.generic - -import scala.annotation.StaticAnnotation - -class MongoEmbedded extends StaticAnnotation diff --git a/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoIgnore.scala b/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoIgnore.scala deleted file mode 100644 index b528da47..00000000 --- a/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoIgnore.scala +++ /dev/null @@ -1,5 +0,0 @@ -package io.sphere.mongo.generic - -import scala.annotation.StaticAnnotation - -class MongoIgnore extends StaticAnnotation diff --git a/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoKey.scala b/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoKey.scala deleted file mode 100644 index 8fe8c1cc..00000000 --- a/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoKey.scala +++ /dev/null @@ -1,5 +0,0 @@ -package io.sphere.mongo.generic - -import scala.annotation.StaticAnnotation - -case class MongoKey(value: String) extends StaticAnnotation diff --git a/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoTypeHint.scala b/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoTypeHint.scala deleted file mode 100644 index a56dde8d..00000000 --- a/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoTypeHint.scala +++ /dev/null @@ -1,5 +0,0 @@ -package io.sphere.mongo.generic - -import scala.annotation.StaticAnnotation - -case class MongoTypeHint(value: String) extends StaticAnnotation diff --git a/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoTypeHintField.scala b/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoTypeHintField.scala deleted file mode 100644 index 77f54dcb..00000000 --- a/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/MongoTypeHintField.scala +++ /dev/null @@ -1,10 +0,0 @@ -package io.sphere.mongo.generic - -import scala.annotation.StaticAnnotation - -case class MongoTypeHintField(value: String = MongoTypeHintField.defaultValue) - extends StaticAnnotation - -object MongoTypeHintField { - final val defaultValue: String = "type" -} diff --git a/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/package.scala b/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/package.scala deleted file mode 100644 index 019a1f85..00000000 --- a/mongo/mongo-derivation-magnolia/src/main/scala/io/sphere/mongo/generic/package.scala +++ /dev/null @@ -1,280 +0,0 @@ -package io.sphere.mongo - -import com.mongodb.{BasicDBObject, DBObject} -import io.sphere.mongo.format.{MongoFormat, MongoNothing, toMongo} -import io.sphere.util.{Logging, Memoizer} -import magnolia._ -import org.bson.BSONObject - -import scala.language.experimental.macros - -package object generic extends Logging { - - /** Creates a MongoFormat instance for an Enumeration type that encodes the `toString` - * representations of the enumeration values. - */ - def mongoEnum(e: Enumeration): MongoFormat[e.Value] = new MongoFormat[e.Value] { - def toMongoValue(a: e.Value): Any = a.toString - def fromMongoValue(any: Any): e.Value = e.withName(any.asInstanceOf[String]) - } - - type Typeclass[T] = MongoFormat[T] - - def deriveMongoFormat[T]: MongoFormat[T] = macro Magnolia.gen[T] - - private val addNoTypeHint: DBObject => Unit = Function.const(()) - - // derive for a case class - def combine[T](caseClass: CaseClass[MongoFormat, T]): MongoFormat[T] = new MongoFormat[T] { - private val mongoClass = getMongoClassMeta(caseClass) - private val _fields = mongoClass.fields - private val _withTypeHint = mongoClass.typeHint.isDefined - private val addTypeHint: DBObject => Unit = - mongoClass.typeHint.fold(addNoTypeHint)(th => dbo => dbo.put(th.field, th.value)) - - override def toMongoValue(r: T): Any = { - val dbo = new BasicDBObject - if (_withTypeHint) addTypeHint(dbo) - - var i = 0 - caseClass.parameters.foreach { p => - writeField(dbo, _fields(i), p.dereference(r))(p.typeclass) - i += 1 - } - dbo - } - - override def fromMongoValue(any: Any): T = any match { - case dbo: DBObject => - var i = -1 - val fieldValues: Seq[Any] = caseClass.parameters.map { p => - i += 1 - readField(_fields(i), dbo)(p.typeclass) - } - caseClass.rawConstruct(fieldValues) - case _ => sys.error("Deserialization failed. DBObject expected.") - } - - override val fields: Set[String] = calculateFields() - private def calculateFields(): Set[String] = { - val builder = Set.newBuilder[String] - var i = 0 - caseClass.parameters.foreach { p => - val f = _fields(i) - if (!f.ignored) { - if (f.embedded) - builder ++= p.typeclass.fields - else - builder += f.name - } - i += 1 - } - builder.result() - } - } - - // derive for a sealed trait - def dispatch[T](sealedTrait: SealedTrait[MongoFormat, T]): MongoFormat[T] = new MongoFormat[T] { - - private val allSelectors = sealedTrait.subtypes.map { subType => - typeSelector(subType) - } - private val readMapBuilder = Map.newBuilder[String, TypeSelector[_]] - private val writeMapBuilder = Map.newBuilder[TypeName, TypeSelector[_]] - allSelectors.foreach { s => - readMapBuilder += (s.typeValue -> s) - writeMapBuilder += (s.subType.typeName -> s) - } - private val readMap = readMapBuilder.result() - private val writeMap = writeMapBuilder.result() - - private val typeField = sealedTrait.annotations - .collectFirst { case a: MongoTypeHintField => - a.value - } - .getOrElse(defaultTypeFieldName) - - override def toMongoValue(t: T): Any = - sealedTrait.dispatch(t) { subtype => - writeMap.get(subtype.typeName) match { - case None => new BasicDBObject(typeField, defaultTypeValue(subtype.typeName)) - case Some(w) => - subtype.typeclass.toMongoValue(subtype.cast(t)) match { - case dbo: BSONObject => - findTypeValue(dbo, typeField) match { - case Some(_) => dbo - case None => - dbo.put(typeField, w.typeValue) - dbo - } - case _ => throw new Exception("Excepted 'BSONObject'") - } - } - } - - override def fromMongoValue(any: Any): T = - any match { - case dbo: BSONObject => - findTypeValue(dbo, typeField) match { - case Some(t) => - readMap.get(t) match { - case Some(r) => r.subType.typeclass.fromMongoValue(dbo).asInstanceOf[T] - case None => - sys.error("Invalid type value '" + t + "' in DBObject '%s'.".format(dbo)) - } - case None => - sys.error("Missing type field '" + typeField + "' in DBObject '%s'.".format(dbo)) - } - case _ => sys.error("DBObject expected.") - } - } - - private val defaultTypeFieldName: String = MongoTypeHintField.defaultValue - - private case class MongoClassMeta( - typeHint: Option[MongoClassMeta.TypeHint], - fields: IndexedSeq[MongoFieldMeta]) - private object MongoClassMeta { - case class TypeHint(field: String, value: String) - } - private case class MongoFieldMeta( - name: String, - default: Option[Any] = None, - embedded: Boolean = false, - ignored: Boolean = false - ) - - private val getMongoClassMeta = - new Memoizer[CaseClass[MongoFormat, _], MongoClassMeta](caseClass => { - log.trace("Initializing Mongo metadata for %s".format(caseClass.typeName.full)) - - val annotations = caseClass.annotations - - val typeHintFieldAnnot: Option[MongoTypeHintField] = annotations.collectFirst { - case h: MongoTypeHintField => h - } - val typeHintAnnot: Option[generic.MongoTypeHint] = annotations.collectFirst { - case h: generic.MongoTypeHint => h - } - val typeField = typeHintFieldAnnot.map(_.value) - val typeValue = typeHintAnnot.map(hintVal(caseClass.typeName)) - - MongoClassMeta( - typeHint = (typeField, typeValue) match { - case (Some(field), Some(hint)) => Some(MongoClassMeta.TypeHint(field, hint)) - case (None, Some(hint)) => Some(MongoClassMeta.TypeHint(defaultTypeFieldName, hint)) - case (Some(field), None) => - Some(MongoClassMeta.TypeHint(field, defaultTypeValue(caseClass.typeName))) - case (None, None) => None - }, - fields = getMongoFieldMeta(caseClass) - ) - }) - - private val getMongoClassMetaFromSubType = - new Memoizer[Subtype[MongoFormat, _], MongoClassMeta](subType => { - log.trace("Initializing Mongo metadata for %s".format(subType.typeName.full)) - - val annotations = subType.annotations - - val typeHintFieldAnnot: Option[MongoTypeHintField] = annotations.collectFirst { - case h: MongoTypeHintField => h - } - val typeHintAnnot: Option[generic.MongoTypeHint] = annotations.collectFirst { - case h: generic.MongoTypeHint => h - } - val typeField = typeHintFieldAnnot.map(_.value) - val typeValue = typeHintAnnot.map(hintVal(subType.typeName)) - - MongoClassMeta( - typeHint = (typeField, typeValue) match { - case (Some(field), Some(hint)) => Some(MongoClassMeta.TypeHint(field, hint)) - case (None, Some(hint)) => Some(MongoClassMeta.TypeHint(defaultTypeFieldName, hint)) - case (Some(field), None) => - Some(MongoClassMeta.TypeHint(field, defaultTypeValue(subType.typeName))) - case (None, None) => None - }, - fields = IndexedSeq[MongoFieldMeta]() - ) - }) - - private def getMongoFieldMeta(caseClass: CaseClass[MongoFormat, _]): IndexedSeq[MongoFieldMeta] = - caseClass.parameters.map { p => - val annotations = p.annotations - val name = annotations - .collectFirst { case h: MongoKey => - h - } - .fold(p.label)(_.value) - val embedded = annotations.exists { - case _: MongoEmbedded => true - case _ => false - } - val ignored = annotations.exists { - case _: MongoIgnore => true - case _ => false - } - if (ignored && p.default.isEmpty) { - throw new Exception("Ignored Mongo field '%s' must have a default value.".format(p.label)) - } - MongoFieldMeta(name, p.default, embedded, ignored) - }.toIndexedSeq - - private def writeField[A: MongoFormat](dbo: DBObject, field: MongoFieldMeta, e: A): Unit = - if (!field.ignored) { - if (field.embedded) - toMongo(e) match { - case dbo2: DBObject => dbo.putAll(dbo2) - case MongoNothing => () - case x => dbo.put(field.name, x) - } - else - toMongo(e) match { - case MongoNothing => () - case x => dbo.put(field.name, x) - } - } - - private def readField[A: MongoFormat](f: MongoFieldMeta, dbo: DBObject): A = { - val mf = MongoFormat[A] - def default = f.default.asInstanceOf[Option[A]].orElse(mf.default) - if (f.ignored) - default.getOrElse { - throw new Exception("Missing default for ignored field '%s'.".format(f.name)) - } - else if (f.embedded) mf.fromMongoValue(dbo) - else { - val value = dbo.get(f.name) - if (value != null) mf.fromMongoValue(value) - else { - default.getOrElse { - throw new Exception("Missing required field '%s' on deserialization.".format(f.name)) - } - } - } - } - - private def findTypeValue(dbo: BSONObject, typeField: String): Option[String] = - Option(dbo.get(typeField)).map(_.toString) - - private case class TypeSelector[A]( - val typeField: String, - val typeValue: String, - subType: Subtype[MongoFormat, A]) - - private def typeSelector[A](subType: Subtype[MongoFormat, A]): TypeSelector[A] = { - val (typeField, typeValue) = getMongoClassMetaFromSubType(subType).typeHint match { - case Some(hint) => (hint.field, hint.value) - case None => (defaultTypeFieldName, defaultTypeValue(subType.typeName)) - } - new TypeSelector[A](typeField, typeValue, subType) - } - - private def hintVal(typeName: TypeName)(h: generic.MongoTypeHint): String = - if (h.value.trim.isEmpty) defaultTypeValue(typeName) - else h.value - - private def defaultTypeValue(typeName: TypeName): String = - typeName.short.replace("$", "") - -} diff --git a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/MongoUtils.scala b/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/MongoUtils.scala deleted file mode 100644 index 026b53dc..00000000 --- a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/MongoUtils.scala +++ /dev/null @@ -1,9 +0,0 @@ -package io.sphere.mongo -import com.mongodb.BasicDBObject - -object MongoUtils { - - def dbObj(pairs: (String, Any)*) = - pairs.foldLeft(new BasicDBObject) { case (obj, (key, value)) => obj.append(key, value) } - -} diff --git a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/SerializationTest.scala b/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/SerializationTest.scala deleted file mode 100644 index e061e75e..00000000 --- a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/SerializationTest.scala +++ /dev/null @@ -1,61 +0,0 @@ -package io.sphere.mongo - -import com.mongodb.{BasicDBObject, DBObject} -import org.scalatest.matchers.must.Matchers -import io.sphere.mongo.format.MongoFormat -import io.sphere.mongo.format.DefaultMongoFormats._ -import org.scalatest.wordspec.AnyWordSpec - -object SerializationTest { - case class Something(a: Option[Int], b: Int = 2) - - object Color extends Enumeration { - val Blue, Red, Yellow = Value - } -} - -class SerializationTest extends AnyWordSpec with Matchers { - import SerializationTest._ - - "mongoProduct" must { - "deserialize mongo object" in { - val dbo = new BasicDBObject() - dbo.put("a", Integer.valueOf(3)) - dbo.put("b", Integer.valueOf(4)) - - val mongoFormat: MongoFormat[Something] = io.sphere.mongo.generic.deriveMongoFormat - val something = mongoFormat.fromMongoValue(dbo) - something must be(Something(Some(3), 4)) - } - - "generate a format that serializes optional fields with value None as BSON objects without that field" in { - val testFormat: MongoFormat[Something] = io.sphere.mongo.generic.deriveMongoFormat[Something] - val serializedObject = testFormat.toMongoValue(Something(None, 1)).asInstanceOf[DBObject] - serializedObject.keySet().contains("b") must be(true) - serializedObject.keySet().contains("a") must be(false) - } - - "generate a format that use default values" in { - val dbo = new BasicDBObject() - dbo.put("a", Integer.valueOf(3)) - - val mongoFormat: MongoFormat[Something] = io.sphere.mongo.generic.deriveMongoFormat - val something = mongoFormat.fromMongoValue(dbo) - something must be(Something(Some(3), 2)) - } - } - - "mongoEnum" must { - "serialize and deserialize enums" in { - val mongo: MongoFormat[Color.Value] = generic.mongoEnum(Color) - - // mongo java driver knows how to encode/decode Strings - val serializedObject = mongo.toMongoValue(Color.Red).asInstanceOf[String] - serializedObject must be("Red") - - val enumValue = mongo.fromMongoValue(serializedObject) - enumValue must be(Color.Red) - } - } - -} diff --git a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/format/OptionMongoFormatSpec.scala b/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/format/OptionMongoFormatSpec.scala deleted file mode 100644 index f8485610..00000000 --- a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/format/OptionMongoFormatSpec.scala +++ /dev/null @@ -1,96 +0,0 @@ -package io.sphere.mongo.format - -import io.sphere.mongo.generic._ -import org.scalatest.OptionValues -import org.scalatest.matchers.must.Matchers -import org.scalatest.wordspec.AnyWordSpec -import io.sphere.mongo.MongoUtils._ -import DefaultMongoFormats._ - -object OptionMongoFormatSpec { - - case class SimpleClass(value1: String, value2: Int) - - object SimpleClass { - implicit val mongo: MongoFormat[SimpleClass] = deriveMongoFormat - } - - case class ComplexClass(name: String, simpleClass: Option[SimpleClass]) - - object ComplexClass { - implicit val mongo: MongoFormat[ComplexClass] = deriveMongoFormat - } - -} - -class OptionMongoFormatSpec extends AnyWordSpec with Matchers with OptionValues { - import OptionMongoFormatSpec._ - - "MongoFormat[Option[_]]" should { - "handle presence of all fields" in { - val dbo = dbObj( - "value1" -> "a", - "value2" -> 45 - ) - val result = MongoFormat[Option[SimpleClass]].fromMongoValue(dbo) - result.value.value1 mustEqual "a" - result.value.value2 mustEqual 45 - } - - "handle presence of all fields mixed with ignored fields" in { - val dbo = dbObj( - "value1" -> "a", - "value2" -> 45, - "value3" -> "b" - ) - val result = MongoFormat[Option[SimpleClass]].fromMongoValue(dbo) - result.value.value1 mustEqual "a" - result.value.value2 mustEqual 45 - } - - "handle presence of not all the fields" in { - val dbo = dbObj("value1" -> "a") - an[Exception] mustBe thrownBy(MongoFormat[Option[SimpleClass]].fromMongoValue(dbo)) - } - - "handle absence of all fields" in { - val dbo = dbObj() - val result = MongoFormat[Option[SimpleClass]].fromMongoValue(dbo) - result mustEqual None - } - - "handle absence of all fields mixed with ignored fields" in { - val dbo = dbObj("value3" -> "a") - val result = MongoFormat[Option[SimpleClass]].fromMongoValue(dbo) - result mustEqual None - } - - "consider all fields if the data type does not impose any restriction" in { - val dbo = dbObj( - "key1" -> "value1", - "key2" -> "value2" - ) - val expected = Map("key1" -> "value1", "key2" -> "value2") - val result = MongoFormat[Map[String, String]].fromMongoValue(dbo) - result mustEqual expected - - val maybeResult = MongoFormat[Option[Map[String, String]]].fromMongoValue(dbo) - maybeResult.value mustEqual expected - } - - "parse optional element" in { - val dbo = dbObj( - "name" -> "ze name", - "simpleClass" -> dbObj( - "value1" -> "value1", - "value2" -> 42 - ) - ) - val result = MongoFormat[ComplexClass].fromMongoValue(dbo) - result.simpleClass.value.value1 mustEqual "value1" - result.simpleClass.value.value2 mustEqual 42 - - MongoFormat[ComplexClass].toMongoValue(result) mustEqual dbo - } - } -} diff --git a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/DefaultValuesSpec.scala b/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/DefaultValuesSpec.scala deleted file mode 100644 index 03e7dd2d..00000000 --- a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/DefaultValuesSpec.scala +++ /dev/null @@ -1,34 +0,0 @@ -package io.sphere.mongo.generic - -import io.sphere.mongo.MongoUtils._ -import io.sphere.mongo.format.DefaultMongoFormats._ -import io.sphere.mongo.format.MongoFormat -import org.scalatest.matchers.must.Matchers -import org.scalatest.wordspec.AnyWordSpec - -class DefaultValuesSpec extends AnyWordSpec with Matchers { - import DefaultValuesSpec._ - - "deriving MongoFormat" must { - "handle default values" in { - val dbo = dbObj() - val test = MongoFormat[Test].fromMongoValue(dbo) - test.value1 must be("hello") - test.value2 must be(None) - test.value3 must be(None) - test.value4 must be(Some("hi")) - } - } -} - -object DefaultValuesSpec { - case class Test( - value1: String = "hello", - value2: Option[String], - value3: Option[String] = None, - value4: Option[String] = Some("hi") - ) - object Test { - implicit val mongo: MongoFormat[Test] = deriveMongoFormat - } -} diff --git a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/DeriveMongoformatSpec.scala b/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/DeriveMongoformatSpec.scala deleted file mode 100644 index 201145f9..00000000 --- a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/DeriveMongoformatSpec.scala +++ /dev/null @@ -1,121 +0,0 @@ -package io.sphere.mongo.generic - -import io.sphere.mongo.format.MongoFormat -import io.sphere.mongo.format._ -import org.scalatest.matchers.must.Matchers -import org.scalatest.wordspec.AnyWordSpec -import io.sphere.mongo.format.DefaultMongoFormats._ -import io.sphere.mongo.MongoUtils._ - -class DeriveMongoformatSpec extends AnyWordSpec with Matchers { - import DeriveMongoformatSpec._ - - "deriving MongoFormat" must { - "read normal singleton values" in { - val user = fromMongo[UserWithPicture]( - dbObj( - "userId" -> "foo-123", - "pictureSize" -> dbObj("type" -> "Medium"), - "pictureUrl" -> "http://example.com")) - - user must be(UserWithPicture("foo-123", Medium, "http://example.com")) - } - - "read custom singleton values" in { - val user = fromMongo[UserWithPicture]( - dbObj( - "userId" -> "foo-123", - "pictureSize" -> dbObj("type" -> "bar", "width" -> 23, "height" -> 30), - "pictureUrl" -> "http://example.com")) - - user must be(UserWithPicture("foo-123", Custom(23, 30), "http://example.com")) - } - - "fail to read if singleton value is unknown" in { - a[Exception] must be thrownBy fromMongo[UserWithPicture]( - dbObj( - "userId" -> "foo-123", - "pictureSize" -> dbObj("type" -> "Unknown"), - "pictureUrl" -> "http://example.com")) - } - - "write normal singleton values" in { - val dbo = toMongo[UserWithPicture](UserWithPicture("foo-123", Medium, "http://example.com")) - dbo must be( - dbObj( - "userId" -> "foo-123", - "pictureSize" -> dbObj("type" -> "Medium"), - "pictureUrl" -> "http://example.com")) - } - - "write custom singleton values" in { - val dbo = - toMongo[UserWithPicture](UserWithPicture("foo-123", Custom(23, 30), "http://example.com")) - dbo must be( - dbObj( - "userId" -> "foo-123", - "pictureSize" -> dbObj("type" -> "bar", "width" -> 23, "height" -> 30), - "pictureUrl" -> "http://example.com")) - } - - "write and consequently read, which must produce the original value" in { - val originalUser = UserWithPicture("foo-123", Medium, "http://exmple.com") - val newUser = fromMongo[UserWithPicture](toMongo[UserWithPicture](originalUser)) - - newUser must be(originalUser) - } - - "read and write sealed trait with only one subtype" in { - val dbo = dbObj( - "userId" -> "foo-123", - "pictureSize" -> dbObj("type" -> "Medium"), - "pictureUrl" -> "http://example.com", - "access" -> dbObj("type" -> "Authorized", "project" -> "internal") - ) - val user = fromMongo[UserWithPicture](dbo) - - user must be( - UserWithPicture( - "foo-123", - Medium, - "http://example.com", - Some(Access.Authorized("internal")))) - val newDbo = toMongo[UserWithPicture](user) - newDbo must be(dbo) - - val newUser = fromMongo[UserWithPicture](newDbo) - newUser must be(user) - } - } -} - -object DeriveMongoformatSpec { - sealed trait PictureSize - case object Small extends PictureSize - case object Medium extends PictureSize - case object Big extends PictureSize - @MongoTypeHint(value = "bar") - case class Custom(width: Int, height: Int) extends PictureSize - - object PictureSize { - implicit val mongo: MongoFormat[PictureSize] = deriveMongoFormat[PictureSize] - } - - sealed trait Access - object Access { - // only one sub-type - case class Authorized(project: String) extends Access - - implicit val mongo: MongoFormat[Access] = deriveMongoFormat - } - - case class UserWithPicture( - userId: String, - pictureSize: PictureSize, - pictureUrl: String, - access: Option[Access] = None) - - object UserWithPicture { - implicit val mongo: MongoFormat[UserWithPicture] = deriveMongoFormat - } -} diff --git a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoEmbeddedSpec.scala b/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoEmbeddedSpec.scala deleted file mode 100644 index 12d1b52a..00000000 --- a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoEmbeddedSpec.scala +++ /dev/null @@ -1,145 +0,0 @@ -package io.sphere.mongo.generic - -import io.sphere.mongo.format.MongoFormat -import org.scalatest.OptionValues -import org.scalatest.matchers.must.Matchers -import io.sphere.mongo.format.DefaultMongoFormats._ -import io.sphere.mongo.MongoUtils._ -import org.scalatest.wordspec.AnyWordSpec - -import scala.util.Try - -object MongoEmbeddedSpec { - case class Embedded(value1: String, @MongoKey("_value2") value2: Int) - - object Embedded { - implicit val mongo: MongoFormat[Embedded] = deriveMongoFormat - } - - case class Test1(name: String, @MongoEmbedded embedded: Embedded) - - object Test1 { - implicit val mongo: MongoFormat[Test1] = deriveMongoFormat - } - - case class Test2(name: String, @MongoEmbedded embedded: Option[Embedded] = None) - - object Test2 { - implicit val mongo: MongoFormat[Test2] = deriveMongoFormat - } - - case class Test3( - @MongoIgnore name: String = "default", - @MongoEmbedded embedded: Option[Embedded] = None) - - object Test3 { - implicit val mongo: MongoFormat[Test3] = deriveMongoFormat - } - - case class SubTest4(@MongoEmbedded embedded: Embedded) - object SubTest4 { - implicit val mongo: MongoFormat[SubTest4] = deriveMongoFormat - } - - case class Test4(subField: Option[SubTest4] = None) - object Test4 { - implicit val mongo: MongoFormat[Test4] = deriveMongoFormat - } -} - -class MongoEmbeddedSpec extends AnyWordSpec with Matchers with OptionValues { - import MongoEmbeddedSpec._ - - "MongoEmbedded" should { - "flatten the db object in one object" in { - val dbo = dbObj( - "name" -> "ze name", - "value1" -> "ze value1", - "_value2" -> 45 - ) - val test1 = MongoFormat[Test1].fromMongoValue(dbo) - test1.name mustEqual "ze name" - test1.embedded.value1 mustEqual "ze value1" - test1.embedded.value2 mustEqual 45 - - val result = MongoFormat[Test1].toMongoValue(test1) - result mustEqual dbo - } - - "validate that the db object contains all needed fields" in { - val dbo = dbObj( - "name" -> "ze name", - "value1" -> "ze value1" - ) - Try(MongoFormat[Test1].fromMongoValue(dbo)).isFailure must be(true) - } - - "support optional embedded attribute" in { - val dbo = dbObj( - "name" -> "ze name", - "value1" -> "ze value1", - "_value2" -> 45 - ) - val test2 = MongoFormat[Test2].fromMongoValue(dbo) - test2.name mustEqual "ze name" - test2.embedded.value.value1 mustEqual "ze value1" - test2.embedded.value.value2 mustEqual 45 - - val result = MongoFormat[Test2].toMongoValue(test2) - result mustEqual dbo - } - - "ignore unknown fields" in { - val dbo = dbObj( - "name" -> "ze name", - "value1" -> "ze value1", - "_value2" -> 45, - "value4" -> true - ) - val test2 = MongoFormat[Test2].fromMongoValue(dbo) - test2.name mustEqual "ze name" - test2.embedded.value.value1 mustEqual "ze value1" - test2.embedded.value.value2 mustEqual 45 - } - - "ignore ignored fields" in { - val dbo = dbObj( - "value1" -> "ze value1", - "_value2" -> 45 - ) - val test3 = MongoFormat[Test3].fromMongoValue(dbo) - test3.name mustEqual "default" - test3.embedded.value.value1 mustEqual "ze value1" - test3.embedded.value.value2 mustEqual 45 - } - - "check for sub-fields" in { - val dbo = dbObj( - "subField" -> dbObj( - "value1" -> "ze value1", - "_value2" -> 45 - ) - ) - val test4 = MongoFormat[Test4].fromMongoValue(dbo) - test4.subField.value.embedded.value1 mustEqual "ze value1" - test4.subField.value.embedded.value2 mustEqual 45 - } - - "support the absence of optional embedded attribute" in { - val dbo = dbObj( - "name" -> "ze name" - ) - val test2 = MongoFormat[Test2].fromMongoValue(dbo) - test2.name mustEqual "ze name" - test2.embedded mustEqual None - } - - "validate the absence of some embedded attributes" in { - val dbo = dbObj( - "name" -> "ze name", - "value1" -> "ze value1" - ) - Try(MongoFormat[Test2].fromMongoValue(dbo)).isFailure must be(true) - } - } -} diff --git a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoKeySpec.scala b/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoKeySpec.scala deleted file mode 100644 index 3392124b..00000000 --- a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoKeySpec.scala +++ /dev/null @@ -1,48 +0,0 @@ -package io.sphere.mongo.generic - -import io.sphere.mongo.format.DefaultMongoFormats._ -import io.sphere.mongo.format.MongoFormat -import org.bson.BSONObject -import org.scalatest.matchers.must.Matchers -import org.scalatest.wordspec.AnyWordSpec - -import scala.collection.JavaConverters._ - -class MongoKeySpec extends AnyWordSpec with Matchers { - import MongoKeySpec._ - - "deriving MongoFormat" must { - "rename fields annotated with @MongoKey" in { - val test = - Test(value1 = "value1", value2 = "value2", subTest = SubTest(value2 = "other_value2")) - - val dbo = MongoFormat[Test].toMongoValue(test) - val map = dbo.asInstanceOf[BSONObject].toMap.asScala.toMap[Any, Any] - map.get("value1") must equal(Some("value1")) - map.get("value2") must equal(None) - map.get("new_value_2") must equal(Some("value2")) - map.get("new_sub_value_2") must equal(Some("other_value2")) - - val newTest = MongoFormat[Test].fromMongoValue(dbo) - newTest must be(test) - } - } -} - -object MongoKeySpec { - case class SubTest( - @MongoKey("new_sub_value_2") value2: String - ) - object SubTest { - implicit val mongo: MongoFormat[SubTest] = deriveMongoFormat - } - - case class Test( - value1: String, - @MongoKey("new_value_2") value2: String, - @MongoEmbedded subTest: SubTest - ) - object Test { - implicit val mongo: MongoFormat[Test] = deriveMongoFormat - } -} diff --git a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoTypeHintFieldWithAbstractClassSpec.scala b/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoTypeHintFieldWithAbstractClassSpec.scala deleted file mode 100644 index ffc923eb..00000000 --- a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoTypeHintFieldWithAbstractClassSpec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package io.sphere.mongo.generic - -import io.sphere.mongo.MongoUtils.dbObj -import io.sphere.mongo.format.{MongoFormat, fromMongo, toMongo} -import org.scalatest.matchers.must.Matchers -import org.scalatest.wordspec.AnyWordSpec -import io.sphere.mongo.format.DefaultMongoFormats._ - -class MongoTypeHintFieldWithAbstractClassSpec extends AnyWordSpec with Matchers { - import MongoTypeHintFieldWithAbstractClassSpec._ - - "MongoTypeHintField (with abstract class)" must { - "allow to set another field to distinguish between types (toMongo)" in { - val user = UserWithPicture("foo-123", Medium, "http://example.com") - val expected = dbObj( - "userId" -> "foo-123", - "pictureSize" -> dbObj("pictureType" -> "Medium"), - "pictureUrl" -> "http://example.com") - - val dbo = toMongo[UserWithPicture](user) - dbo must be(expected) - } - - "allow to set another field to distinguish between types (fromMongo)" in { - val initialDbo = dbObj( - "userId" -> "foo-123", - "pictureSize" -> dbObj("pictureType" -> "Medium"), - "pictureUrl" -> "http://example.com") - - val user = fromMongo[UserWithPicture](initialDbo) - - user must be(UserWithPicture("foo-123", Medium, "http://example.com")) - - val dbo = toMongo[UserWithPicture](user) - dbo must be(initialDbo) - } - } -} - -object MongoTypeHintFieldWithAbstractClassSpec { - - @MongoTypeHintField(value = "pictureType") - sealed abstract class PictureSize - case object Small extends PictureSize - case object Medium extends PictureSize - case object Big extends PictureSize - - object PictureSize { - implicit val mongo: MongoFormat[PictureSize] = deriveMongoFormat[PictureSize] - } - - case class UserWithPicture(userId: String, pictureSize: PictureSize, pictureUrl: String) - - object UserWithPicture { - implicit val mongo: MongoFormat[UserWithPicture] = deriveMongoFormat - } -} diff --git a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoTypeHintFieldWithSealedTraitSpec.scala b/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoTypeHintFieldWithSealedTraitSpec.scala deleted file mode 100644 index fce83a71..00000000 --- a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/MongoTypeHintFieldWithSealedTraitSpec.scala +++ /dev/null @@ -1,57 +0,0 @@ -package io.sphere.mongo.generic - -import io.sphere.mongo.MongoUtils.dbObj -import io.sphere.mongo.format.{MongoFormat, fromMongo, toMongo} -import org.scalatest.matchers.must.Matchers -import org.scalatest.wordspec.AnyWordSpec -import io.sphere.mongo.format.DefaultMongoFormats._ - -class MongoTypeHintFieldWithSealedTraitSpec extends AnyWordSpec with Matchers { - import MongoTypeHintFieldWithSealedTraitSpec._ - - "MongoTypeHintField (with sealed trait)" must { - "allow to set another field to distinguish between types (toMongo)" in { - val user = UserWithPicture("foo-123", Medium, "http://example.com") - val expected = dbObj( - "userId" -> "foo-123", - "pictureSize" -> dbObj("pictureType" -> "Medium"), - "pictureUrl" -> "http://example.com") - - val dbo = toMongo[UserWithPicture](user) - dbo must be(expected) - } - - "allow to set another field to distinguish between types (fromMongo)" in { - val initialDbo = dbObj( - "userId" -> "foo-123", - "pictureSize" -> dbObj("pictureType" -> "Medium"), - "pictureUrl" -> "http://example.com") - - val user = fromMongo[UserWithPicture](initialDbo) - - user must be(UserWithPicture("foo-123", Medium, "http://example.com")) - - val dbo = toMongo[UserWithPicture](user) - dbo must be(initialDbo) - } - } -} - -object MongoTypeHintFieldWithSealedTraitSpec { - - @MongoTypeHintField(value = "pictureType") - sealed trait PictureSize - case object Small extends PictureSize - case object Medium extends PictureSize - case object Big extends PictureSize - - object PictureSize { - implicit val mongo: MongoFormat[PictureSize] = deriveMongoFormat[PictureSize] - } - - case class UserWithPicture(userId: String, pictureSize: PictureSize, pictureUrl: String) - - object UserWithPicture { - implicit val mongo: MongoFormat[UserWithPicture] = deriveMongoFormat - } -} diff --git a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/SumTypesDerivingSpec.scala b/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/SumTypesDerivingSpec.scala deleted file mode 100644 index 5e909a56..00000000 --- a/mongo/mongo-derivation-magnolia/src/test/scala/io/sphere/mongo/generic/SumTypesDerivingSpec.scala +++ /dev/null @@ -1,172 +0,0 @@ -package io.sphere.mongo.generic - -import com.mongodb.DBObject -import org.scalatest.matchers.must.Matchers -import org.scalatest.wordspec.AnyWordSpec -import io.sphere.mongo.MongoUtils.dbObj -import io.sphere.mongo.format.DefaultMongoFormats._ -import io.sphere.mongo.format.MongoFormat -import org.scalatest.Assertion - -class SumTypesDerivingSpec extends AnyWordSpec with Matchers { - import SumTypesDerivingSpec._ - - "Serializing sum types" must { - "use 'type' as default field" in { - check(Color1.format, Color1.Red, dbObj("type" -> "Red")) - - check(Color1.format, Color1.Custom("2356"), dbObj("type" -> "Custom", "rgb" -> "2356")) - } - - "use custom field" in { - check(Color2.format, Color2.Red, dbObj("color" -> "Red")) - - check(Color2.format, Color2.Custom("2356"), dbObj("color" -> "Custom", "rgb" -> "2356")) - } - - "use custom values" in { - check(Color3.format, Color3.Red, dbObj("type" -> "red")) - - check(Color3.format, Color3.Custom("2356"), dbObj("type" -> "custom", "rgb" -> "2356")) - } - - "use custom field & values" in pendingUntilFixed { - check(Color4.format, Color4.Red, dbObj("color" -> "red")) - - check(Color4.format, Color4.Custom("2356"), dbObj("color" -> "custom", "rgb" -> "2356")) - } - - "not allow specifying different custom field" in pendingUntilFixed { - // to serialize Custom, should we use type "color" or "color-custom"? - "deriveMongoFormat[Color5]" mustNot compile - } - - "not allow specifying different custom field on intermediate level" in { - // to serialize Custom, should we use type "color" or "color-custom"? - "deriveMongoFormat[Color6]" mustNot compile - } - - "use intermediate level" in { - deriveMongoFormat[Color7] - } - - "do not use sealed trait info when using a case class directly" in { - check(Color8.format, Color8.Custom("2356"), dbObj("type" -> "Custom", "rgb" -> "2356")) - - check(Color8.Custom.format, Color8.Custom("2356"), dbObj("rgb" -> "2356")) - - // unless annotated - - check( - Color8.format, - Color8.CustomAnnotated("2356"), - dbObj("type" -> "CustomAnnotated", "rgb" -> "2356")) - - check( - Color8.CustomAnnotated.format, - Color8.CustomAnnotated("2356"), - dbObj("type" -> "CustomAnnotated", "rgb" -> "2356")) - } - - "use default values if custom values are empty" in { - check(Color9.format, Color9.Red, dbObj("type" -> "Red")) - - check(Color9.format, Color9.Custom("2356"), dbObj("type" -> "Custom", "rgb" -> "2356")) - } - } - -} - -object SumTypesDerivingSpec { - import Matchers._ - - def check[A, B <: A](format: MongoFormat[A], b: B, dbo: DBObject): Assertion = { - val serialized = format.toMongoValue(b) - serialized must be(dbo) - - format.fromMongoValue(serialized) must be(b) - } - - sealed trait Color1 - object Color1 { - case object Red extends Color1 - case class Custom(rgb: String) extends Color1 - val format = deriveMongoFormat[Color1] - } - - @MongoTypeHintField("color") - sealed trait Color2 - object Color2 { - case object Red extends Color2 - case class Custom(rgb: String) extends Color2 - val format = deriveMongoFormat[Color2] - } - - sealed trait Color3 - object Color3 { - @MongoTypeHint("red") case object Red extends Color3 - @MongoTypeHint("custom") case class Custom(rgb: String) extends Color3 - val format = deriveMongoFormat[Color3] - } - - @MongoTypeHintField("color") - sealed trait Color4 - object Color4 { - @MongoTypeHint("red") case object Red extends Color4 - @MongoTypeHint("custom") case class Custom(rgb: String) extends Color4 - val format = deriveMongoFormat[Color4] - } - - @MongoTypeHintField("color") - sealed trait Color5 - object Color5 { - @MongoTypeHint("red") - case object Red extends Color5 - @MongoTypeHintField("color-custom") - @MongoTypeHint("custom") - case class Custom(rgb: String) extends Color5 - } - - @MongoTypeHintField("color") - sealed trait Color6 - object Color6 { - @MongoTypeHintField("color-custom") - abstract class MyColor extends Color6 - @MongoTypeHint("red") - case object Red extends MyColor - @MongoTypeHint("custom") - case class Custom(rgb: String) extends MyColor - } - - sealed trait Color7 - sealed trait Color7a extends Color7 - object Color7 { - case object Red extends Color7a - case class Custom(rgb: String) extends Color7a - } - - sealed trait Color8 - object Color8 { - case object Red extends Color8 - case class Custom(rgb: String) extends Color8 - object Custom { - val format = deriveMongoFormat[Custom] - } - @MongoTypeHintField("type") - case class CustomAnnotated(rgb: String) extends Color8 - object CustomAnnotated { - val format = deriveMongoFormat[CustomAnnotated] - } - val format = deriveMongoFormat[Color8] - } - - sealed trait Color9 - object Color9 { - @MongoTypeHint("") - case object Red extends Color9 - @MongoTypeHint(" ") - case class Custom(rgb: String) extends Color9 - val format = deriveMongoFormat[Color9] - } - -} diff --git a/mongo/mongo-derivation/src/main/scala/io/sphere/mongo/generic/MongoFormatMacros.scala b/mongo/mongo-derivation/src/main/scala-2/io/sphere/mongo/generic/MongoFormatMacros.scala similarity index 99% rename from mongo/mongo-derivation/src/main/scala/io/sphere/mongo/generic/MongoFormatMacros.scala rename to mongo/mongo-derivation/src/main/scala-2/io/sphere/mongo/generic/MongoFormatMacros.scala index f5c2d34b..b6f343d3 100644 --- a/mongo/mongo-derivation/src/main/scala/io/sphere/mongo/generic/MongoFormatMacros.scala +++ b/mongo/mongo-derivation/src/main/scala-2/io/sphere/mongo/generic/MongoFormatMacros.scala @@ -28,7 +28,7 @@ private[generic] object MongoFormatMacros { } else Set.empty } else Set.empty - def mongoFormatProductApply(c: blackbox.Context)( + private def mongoFormatProductApply(c: blackbox.Context)( tpe: c.universe.Type, classSym: c.universe.ClassSymbol): c.universe.Tree = { import c.universe._ diff --git a/mongo/mongo-derivation/src/main/scala/io/sphere/mongo/generic/package.fmpp.scala b/mongo/mongo-derivation/src/main/scala-2/io/sphere/mongo/generic/package.fmpp.scala similarity index 99% rename from mongo/mongo-derivation/src/main/scala/io/sphere/mongo/generic/package.fmpp.scala rename to mongo/mongo-derivation/src/main/scala-2/io/sphere/mongo/generic/package.fmpp.scala index a6476eb9..5d039f1d 100644 --- a/mongo/mongo-derivation/src/main/scala/io/sphere/mongo/generic/package.fmpp.scala +++ b/mongo/mongo-derivation/src/main/scala-2/io/sphere/mongo/generic/package.fmpp.scala @@ -260,7 +260,7 @@ package object generic extends Logging { private def typeSelector[A: ClassTag: MongoFormat](): TypeSelector[_] = { val clazz = classTag[A].runtimeClass - val (typeField, typeValue) = getMongoClassMeta(clazz).typeHint match { + val (_, typeValue) = getMongoClassMeta(clazz).typeHint match { case Some(hint) => (hint.field, hint.value) case None => (defaultTypeFieldName, defaultTypeValue(clazz)) } diff --git a/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/SerializationTest.scala b/mongo/mongo-derivation/src/test/scala-2/io/sphere/mongo/SerializationTest.scala similarity index 99% rename from mongo/mongo-derivation/src/test/scala/io/sphere/mongo/SerializationTest.scala rename to mongo/mongo-derivation/src/test/scala-2/io/sphere/mongo/SerializationTest.scala index b9c6d784..9b610376 100644 --- a/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/SerializationTest.scala +++ b/mongo/mongo-derivation/src/test/scala-2/io/sphere/mongo/SerializationTest.scala @@ -30,7 +30,6 @@ class SerializationTest extends AnyWordSpec with Matchers { "generate a format that serializes optional fields with value None as BSON objects without that field" in { val testFormat: MongoFormat[Something] = io.sphere.mongo.generic.deriveMongoFormat - val serializedObject = testFormat.toMongoValue(Something(None, 1)).asInstanceOf[DBObject] serializedObject.keySet().contains("b") must be(true) serializedObject.keySet().contains("a") must be(false) diff --git a/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/SumTypesDerivingSpec.scala b/mongo/mongo-derivation/src/test/scala-2/io/sphere/mongo/generic/SumTypesDerivingSpec.scala similarity index 100% rename from mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/SumTypesDerivingSpec.scala rename to mongo/mongo-derivation/src/test/scala-2/io/sphere/mongo/generic/SumTypesDerivingSpec.scala diff --git a/mongo/mongo-derivation/src/test/scala-3/io/sphere/mongo/DerivationSpec.scala b/mongo/mongo-derivation/src/test/scala-3/io/sphere/mongo/DerivationSpec.scala new file mode 100644 index 00000000..479b7cba --- /dev/null +++ b/mongo/mongo-derivation/src/test/scala-3/io/sphere/mongo/DerivationSpec.scala @@ -0,0 +1,57 @@ +package io.sphere.mongo + +import io.sphere.mongo.generic.{MongoAnnotationReader, MongoEmbedded, MongoKey, MongoTypeHintField} +import io.sphere.mongo.format.DefaultMongoFormats.given +import org.scalatest.wordspec.AnyWordSpec +import org.scalatest.matchers.must.Matchers + +class DerivationSpec extends AnyWordSpec with Matchers { + + "MongoFormat derivation" should { + "support composition" in { + case class Container(i: Int, str: String, component: Component) + case class Component(i: Int) + + val format = io.sphere.mongo.generic.deriveMongoFormat[Container] + + val container = Container(123, "anything", Component(456)) + val bson = format.toMongoValue(container) + val roundtrip = format.fromMongoValue(bson) + + roundtrip mustBe container + } + + "support ADT" in { + sealed trait Root + case object Object1 extends Root + case object Object2 extends Root + case class Class(i: Int) extends Root + + val format = io.sphere.mongo.generic.deriveMongoFormat[Root] + + def roundtrip(member: Root): Unit = { + val bson = format.toMongoValue(member) + val roundtrip = format.fromMongoValue(bson) + roundtrip mustBe member + } + + roundtrip(Object1) + roundtrip(Object2) + roundtrip(Class(0)) + + } + + "annotations" in { + + case class InnerClass(x: String) + @MongoTypeHintField("pictureType") + sealed trait Root + case object Object1 extends Root + case object Object2 extends Root + case class Class(i: Int, @MongoEmbedded inner: InnerClass) extends Root + + val res = MongoAnnotationReader.readTypeMetaData[Root] + + } + } +} diff --git a/mongo/mongo-derivation/src/test/scala-3/io/sphere/mongo/SerializationTest.scala b/mongo/mongo-derivation/src/test/scala-3/io/sphere/mongo/SerializationTest.scala new file mode 100644 index 00000000..bff38c11 --- /dev/null +++ b/mongo/mongo-derivation/src/test/scala-3/io/sphere/mongo/SerializationTest.scala @@ -0,0 +1,187 @@ +package io.sphere.mongo + +import com.mongodb.BasicDBObject +import io.sphere.mongo.format.{DefaultMongoFormats, MongoFormat} +import io.sphere.mongo.generic.{MongoAnnotationReader, MongoTypeHint} +import io.sphere.mongo.MongoUtils.dbObj +import DefaultMongoFormats.given +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +import java.util.UUID + +object ProductTypes { + // For semi-automatic derivarion + default value argument + case class Something(a: Option[Int], b: Int = 2) + + // For Automatic derivation with `derives` + case class Frunfles(a: Option[Int], b: Int) derives MongoFormat + + // Union type field - doesn't compile! + // case class Identifier(idOrKey: UUID | String) derives TypedMongoFormat +} + +object SumTypes { + object Color extends Enumeration { + val Blue, Red, Yellow = Value + } + + sealed trait Coffee derives MongoFormat + + object Coffee { + case object Espresso extends Coffee + + case class Other(name: String) extends Coffee + } + + enum Visitor derives MongoFormat { + case User(email: String, password: String) + case Anonymous + @MongoTypeHint("Admin") case Administrator + } +} + +class SerializationTest extends AnyWordSpec with Matchers { + "mongoProduct" must { + import ProductTypes.* + + "deserialize mongo object" in { + val dbo = new BasicDBObject + dbo.put("a", Integer.valueOf(3)) + dbo.put("b", Integer.valueOf(4)) + + // Using backwards-compatible `deriveMongoFormat` + `implicit` + implicit val x: MongoFormat[Something] = io.sphere.mongo.generic.deriveMongoFormat + + val something = MongoFormat[Something].fromMongoValue(dbo) + something mustBe Something(Some(3), 4) + } + + "generate a format that serializes optional fields with value None as BSON objects without that field" in { + // Using new Scala 3 `derived` special method + `given` + given MongoFormat[Something] = MongoFormat.derived + + val something = Something(None, 1) + val serializedObject = + MongoFormat[Something].toMongoValue(something).asInstanceOf[BasicDBObject] + + serializedObject.keySet().contains("b") must be(true) + serializedObject.keySet().contains("a") must be(false) + MongoFormat[Something].fromMongoValue(serializedObject) must be(something) + } + + "generate a format that serializes optional fields with value None as BSON objects without that field (using derives)" in { + // Using an automatically-derived type via new Scala 3 `derives` directive + val frunfles = Frunfles(None, 1) + + val serializedObject = + MongoFormat[Frunfles].toMongoValue(frunfles).asInstanceOf[BasicDBObject] + + serializedObject.keySet().contains("b") must be(true) + serializedObject.keySet().contains("a") must be(false) + MongoFormat[Frunfles].fromMongoValue(serializedObject) must be(frunfles) + } + + // https://stackoverflow.com/questions/68421043/type-class-derivation-accessing-default-values + "generate a format that use default values" in { + val sthObj1 = { + val dbo = new BasicDBObject() + dbo.put("a", Integer.valueOf(3)) + dbo + } + val s1 = MongoFormat[Something].fromMongoValue(sthObj1) + s1 must be(Something(a = Some(3), b = 2)) + + val sthObj2 = new BasicDBObject() // an empty object + val s2 = MongoFormat[Something].fromMongoValue(sthObj2) + s2 must be(Something(a = None, b = 2)) + + val sthObj3 = { + val dbo = new BasicDBObject() + dbo.put("b", Integer.valueOf(33)) + dbo + } + val s3 = MongoFormat[Something].fromMongoValue(sthObj3) + s3 must be(Something(a = None, b = 33)) + + val sthObj4 = { + val dbo = new BasicDBObject() + dbo.put("a", Integer.valueOf(33)) + dbo.put("b", Integer.valueOf(44)) + dbo + } + val s4 = MongoFormat[Something].fromMongoValue(sthObj4) + s4 must be(Something(a = Some(33), b = 44)) + } + } + + // Both sealed hierarchies and enums get this "type" field, even the Singleton cases. + // Is this what we want, or should the Singleton cases be just a String? + "mongoSum" must { + import SumTypes.* + + "serialize and deserialize sealed hierarchies" in { + val mongo = MongoFormat[Coffee] + + val espressoObj = { + val dbo = new BasicDBObject + dbo.put("type", "Espresso") + dbo + } + val serializedEspresso = mongo.toMongoValue(Coffee.Espresso) + val deserializedEspresso = mongo.fromMongoValue(serializedEspresso) + serializedEspresso must be(espressoObj) + deserializedEspresso must be(Coffee.Espresso) + + val name = "Capuccino" + val capuccino = Coffee.Other(name) + val capuccinoObj = { + val dbo = new BasicDBObject + dbo.put("name", name) + dbo.put("type", "Other") + dbo + } + val serializedCapuccino = mongo.toMongoValue(capuccino) + val deserializedCapuccino = mongo.fromMongoValue(capuccinoObj) + serializedCapuccino must be(capuccinoObj) + deserializedCapuccino must be(capuccino) + } + + "serialize and deserialize enums" in { + val mongo = MongoFormat[Visitor] + + val serializedAnon = mongo.toMongoValue(Visitor.Anonymous) + val deserializedAnon = mongo.fromMongoValue(serializedAnon) + serializedAnon must be(dbObj("type" -> "Anonymous")) + deserializedAnon must be(Visitor.Anonymous) + + val serializedAdmin = mongo.toMongoValue(Visitor.Administrator) + val deserializedAdmin = mongo.fromMongoValue(serializedAdmin) + serializedAdmin must be(dbObj("type" -> "Admin")) + deserializedAdmin must be(Visitor.Administrator) + + val email = "ian@sosafe.com" + val password = "123456" + val user = Visitor.User(email, password) + val serializedUser = mongo.toMongoValue(user) + val deserializedUser = mongo.fromMongoValue(serializedUser) + serializedUser must be(dbObj("email" -> email, "password" -> password, "type" -> "User")) + deserializedUser must be(user) + } + + "serialize and deserialize enumerations" in { + val mongo: MongoFormat[Color.Value] = generic.mongoEnum(Color) + + val colors = List(Color.Red, Color.Yellow, Color.Blue) + val roundTripColors = colors.map(mongo.toMongoValue).map(mongo.fromMongoValue) + colors must be(roundTripColors) + + // mongo java driver knows how to encode/decode Strings + val serializedObject = mongo.toMongoValue(Color.Red).asInstanceOf[String] + serializedObject must be("Red") + + val enumValue = mongo.fromMongoValue(serializedObject) + enumValue must be(Color.Red) + } + } +} diff --git a/mongo/mongo-derivation/src/test/scala-3/io/sphere/mongo/generic/MongoTypeSwitchSpec.scala b/mongo/mongo-derivation/src/test/scala-3/io/sphere/mongo/generic/MongoTypeSwitchSpec.scala new file mode 100644 index 00000000..7a479929 --- /dev/null +++ b/mongo/mongo-derivation/src/test/scala-3/io/sphere/mongo/generic/MongoTypeSwitchSpec.scala @@ -0,0 +1,48 @@ +package io.sphere.mongo.generic + +import io.sphere.mongo.MongoUtils.* +import io.sphere.mongo.format.DefaultMongoFormats.given +import io.sphere.mongo.format.MongoFormat +import org.bson.BSONObject +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class MongoTypeSwitchSpec extends AnyWordSpec with Matchers { + + sealed trait A + case class B(int: Int) extends A + case class C(int: Int) extends A + @MongoTypeHint("D2") case class D(int: Int) extends A + + "mongoTypeSwitch" must { + "derive a subset of a sealed trait" in { + val format = mongoTypeSwitch[A, (B, C)] + + val b = B(123) + val bson = format.toMongoValue(b) + + val b2 = format.fromMongoValue(bson) + + b2 must be(b) + + val c = C(2345345) + val bsonC = format.toMongoValue(c) + + val c2 = format.fromMongoValue(bsonC) + + c2 must be(c) + } + + "derive a subset of a sealed trait with a mongoKey" in { + val format = mongoTypeSwitch[A, (B, D)] + + val d = D(123) + val bson = format.toMongoValue(d).asInstanceOf[BSONObject] + val d2 = format.fromMongoValue(bson) + + bson.get("type") must be("D2") + d2 must be(d) + + } + } +} diff --git a/mongo/mongo-derivation/src/test/scala-3/io/sphere/mongo/generic/SumTypesDerivingSpec.scala b/mongo/mongo-derivation/src/test/scala-3/io/sphere/mongo/generic/SumTypesDerivingSpec.scala new file mode 100644 index 00000000..24208d9a --- /dev/null +++ b/mongo/mongo-derivation/src/test/scala-3/io/sphere/mongo/generic/SumTypesDerivingSpec.scala @@ -0,0 +1,304 @@ +package io.sphere.mongo.generic + +import com.mongodb.DBObject +import io.sphere.mongo.MongoUtils.dbObj +import io.sphere.mongo.generic.deriveMongoFormat +import io.sphere.mongo.format.MongoFormat +import io.sphere.mongo.format.DefaultMongoFormats.given +import org.bson.BSONObject +import org.scalatest.Assertion +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class SumTypesDerivingSpec extends AnyWordSpec with Matchers { + import SumTypesDerivingSpec.* + + "Serializing sum types" must { + "use 'type' as default field" in { + check(Color1.format, Color1.Red, dbObj("type" -> "Red")) + + check(Color1.format, Color1.Custom("2356"), dbObj("type" -> "Custom", "rgb" -> "2356")) + } + + "use custom field" in { + check(Color2.format, Color2.Red, dbObj("color" -> "Red")) + + check(Color2.format, Color2.Custom("2356"), dbObj("color" -> "Custom", "rgb" -> "2356")) + } + + "use custom values" in { + check(Color3.format, Color3.Red, dbObj("type" -> "red")) + + check(Color3.format, Color3.Custom("2356"), dbObj("type" -> "custom", "rgb" -> "2356")) + } + + "use custom field & values" in { + check(Color4.format, Color4.Red, dbObj("color" -> "red")) + + check(Color4.format, Color4.Custom("2356"), dbObj("color" -> "custom", "rgb" -> "2356")) + } + + "ignore @MongoTypeHintField on case classes" in { + check(Color5.format, Color5.Red, dbObj("color" -> "red")) + check(Color5.format, Color5.Custom("123"), dbObj("color" -> "custom", "rgb" -> "123")) + } + + "nested trait 1: no duplicate names, 2 type discriminators" in { + check(Color6.format, Color6.Red, dbObj("custom-color" -> "mapped-red")) + } + + "nested trait 2: duplicate names, 1 type discriminator" in pendingUntilFixed { + // This doesn't fail currently, because we don't validate it. + val format = deriveMongoFormat[Color6a] + check(format, Color6b.Red, dbObj("type" -> "Red")) + check(format, Color6c.Red, dbObj("type" -> "Red")) + } + + "nested trait 3: 2 duplicate names, 2 type discriminators" in { + val format = deriveMongoFormat[Color6e] + check(format, Color6g.Red, dbObj("type" -> "Red")) + check(format, Color6f.Red, dbObj("color-custom" -> "Red")) + } + + "nested trait 4: no duplicates, 1 type discriminator" in { + check(Color7.format, Color7.Red, dbObj("type" -> "Red")) + check(Color7.format, Color7.Blue, dbObj("type" -> "Blue")) + check(Color7.format, Color7.Custom("234"), dbObj("rgb" -> "234", "type" -> "Custom")) + } + + "do not use sealed trait info when using a case class directly" in { + check(Color8.format, Color8.Custom("2356"), dbObj("type" -> "Custom", "rgb" -> "2356")) + + check(Color8.Custom.format, Color8.Custom("2356"), dbObj("rgb" -> "2356")) + + // unless annotated + check( + Color8.format, + Color8.CustomAnnotated("2356"), + dbObj("type" -> "CustomAnnotated", "rgb" -> "2356")) + } + + "use default values if custom values are empty" in { + check(Color9.format, Color9.Red, dbObj("type" -> "Red")) + + check(Color9.format, Color9.Custom("2356"), dbObj("type" -> "Custom", "rgb" -> "2356")) + } + + "allow for providing custom instances for objects" in { + check(Color10.format, Color10.Red, dbObj("type" -> "Red", "extraField" -> "panda")) + + check(Color10.format, Color10.Custom("2356"), dbObj("type" -> "Custom", "rgb" -> "2356")) + } + + "allow for providing custom instances for classes" in { + check(Color11.format, Color11.Red, dbObj("type" -> "Red")) + + check( + Color11.format, + Color11.Custom("2356"), + dbObj("type" -> "Custom", "rgb" -> "2356", "extraField" -> "panda")) + } + + "allow for providing custom instances for classes when the class has a type parameter with an upper bound" in { + check(ColorUpperBound.format, ColorUpperBound.Red, dbObj("type" -> "Red")) + + check( + ColorUpperBound.format, + ColorUpperBound.Custom("2356"), + dbObj("type" -> "Custom", "rgb" -> "2356", "extraField" -> "panda")) + } + + "allow for providing custom instances for classes when the class has an unbounded type parameter" in { + check(ColorUnbound.format, ColorUnbound.Red, dbObj("type" -> "Red")) + + check( + ColorUnbound.format, + ColorUnbound.Custom("2356"), + dbObj("type" -> "Custom", "rgb" -> "2356", "extraField" -> "panda")) + } + + } +} + +object SumTypesDerivingSpec { + import Matchers.* + + def check[A, B <: A](format: MongoFormat[A], b: B, dbo: DBObject): Assertion = { + val serialized = format.toMongoValue(b) + serialized must be(dbo) + + format.fromMongoValue(serialized) must be(b) + } + + sealed trait Color1 + object Color1 { + case object Red extends Color1 + case class Custom(rgb: String) extends Color1 + val format = deriveMongoFormat[Color1] + } + + @MongoTypeHintField("color") + sealed trait Color2 + object Color2 { + case object Red extends Color2 + case class Custom(rgb: String) extends Color2 + val format = deriveMongoFormat[Color2] + } + + sealed trait Color3 + object Color3 { + @MongoTypeHint("red") case object Red extends Color3 + @MongoTypeHint("custom") case class Custom(rgb: String) extends Color3 + val format = deriveMongoFormat[Color3] + } + + @MongoTypeHintField("color") + sealed trait Color4 + object Color4 { + @MongoTypeHint("red") case object Red extends Color4 + @MongoTypeHint("custom") case class Custom(rgb: String) extends Color4 + val format = deriveMongoFormat[Color4] + } + + @MongoTypeHintField("color") + sealed trait Color5 + object Color5 { + @MongoTypeHint("red") + case object Red extends Color5 + @MongoTypeHintField("color-custom") + @MongoTypeHint("custom") + case class Custom(rgb: String) extends Color5 + val format = deriveMongoFormat[Color5] + } + + @MongoTypeHintField("color") + sealed trait Color6 + object Color6 { + @MongoTypeHintField("custom-color") + sealed abstract class MyColor extends Color6 + @MongoTypeHint("mapped-red") + case object Red extends MyColor + @MongoTypeHint("custom") + case class Custom(rgb: String) extends MyColor + val format = deriveMongoFormat[Color6] + } + + sealed trait Color6a + sealed trait Color6b extends Color6a + object Color6b { + case object Red extends Color6b + } + sealed trait Color6c extends Color6a + object Color6c { + case object Red extends Color6c + } + + sealed trait Color6e + @MongoTypeHintField("color-custom") + sealed trait Color6f extends Color6e + object Color6f { + case object Red extends Color6f + } + sealed trait Color6g extends Color6e + object Color6g { + case object Red extends Color6g + } + + sealed trait Color7 + sealed trait Color7a extends Color7 + object Color7 { + case object Red extends Color7a + case class Custom(rgb: String) extends Color7a + case object Blue extends Color7 + def format = deriveMongoFormat[Color7] + } + + sealed trait Color8 + object Color8 { + // the formats must use `lazy` to make this code compile + + case object Red extends Color8 + case class Custom(rgb: String) extends Color8 + object Custom { + lazy val format = deriveMongoFormat[Custom] + } + @MongoTypeHintField("type") + case class CustomAnnotated(rgb: String) extends Color8 + object CustomAnnotated { + lazy val format = deriveMongoFormat[CustomAnnotated] + } + lazy val format = deriveMongoFormat[Color8] + } + + sealed trait Color9 + object Color9 { + @MongoTypeHint("") + case object Red extends Color9 + @MongoTypeHint(" ") + case class Custom(rgb: String) extends Color9 + val format = deriveMongoFormat[Color9] + } + + sealed trait Color10 + object Color10 { + case object Red extends Color10 + case class Custom(rgb: String) extends Color10 + + implicit val redFormatter: MongoFormat[Red.type] = new MongoFormat[Red.type] { + override def toMongoValue(a: Red.type): Any = + dbObj("type" -> "Red", "extraField" -> "panda") + override def fromMongoValue(any: Any): Red.type = Red + } + val format = deriveMongoFormat[Color10] + } + + sealed trait Color11 + object Color11 { + case object Red extends Color11 + case class Custom(rgb: String) extends Color11 + + implicit val customFormatter: MongoFormat[Custom] = new MongoFormat[Custom] { + override def toMongoValue(a: Custom): Any = + dbObj("type" -> "Custom", "rgb" -> a.rgb, "extraField" -> "panda") + override def fromMongoValue(any: Any): Custom = + Custom(any.asInstanceOf[BSONObject].get("rgb").asInstanceOf[String]) + } + val format = deriveMongoFormat[Color11] + } + + sealed trait ColorUpperBound + object ColorUpperBound { + + sealed trait Bound + case object B1 extends Bound + case class B2(int: Int) extends Bound + + case object Red extends ColorUpperBound + case class Custom[Type1 <: Bound](rgb: String) extends ColorUpperBound + + implicit def customFormatter[A <: Bound]: MongoFormat[Custom[A]] = + new MongoFormat[Custom[A]] { + override def toMongoValue(a: Custom[A]): Any = + dbObj("type" -> "Custom", "rgb" -> a.rgb, "extraField" -> "panda") + override def fromMongoValue(any: Any): Custom[A] = + Custom(any.asInstanceOf[BSONObject].get("rgb").asInstanceOf[String]) + } + + val format = deriveMongoFormat[ColorUpperBound] + } + + sealed trait ColorUnbound + object ColorUnbound { + case object Red extends ColorUnbound + case class Custom[A](rgb: String) extends ColorUnbound + + implicit def customFormatter[A]: MongoFormat[Custom[A]] = new MongoFormat[Custom[A]] { + override def toMongoValue(a: Custom[A]): Any = + dbObj("type" -> "Custom", "rgb" -> a.rgb, "extraField" -> "panda") + override def fromMongoValue(any: Any): Custom[A] = + Custom(any.asInstanceOf[BSONObject].get("rgb").asInstanceOf[String]) + } + + val format = deriveMongoFormat[ColorUnbound] + } +} diff --git a/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/format/OptionMongoFormatSpec.scala b/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/format/OptionMongoFormatSpec.scala index f8485610..09703b95 100644 --- a/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/format/OptionMongoFormatSpec.scala +++ b/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/format/OptionMongoFormatSpec.scala @@ -1,11 +1,11 @@ package io.sphere.mongo.format +import io.sphere.mongo.MongoUtils._ +import io.sphere.mongo.format.DefaultMongoFormats._ import io.sphere.mongo.generic._ import org.scalatest.OptionValues import org.scalatest.matchers.must.Matchers import org.scalatest.wordspec.AnyWordSpec -import io.sphere.mongo.MongoUtils._ -import DefaultMongoFormats._ object OptionMongoFormatSpec { diff --git a/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/DeriveMongoformatSpec.scala b/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/DeriveMongoformatSpec.scala index ebf1c7a6..67320d1d 100644 --- a/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/DeriveMongoformatSpec.scala +++ b/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/DeriveMongoformatSpec.scala @@ -1,11 +1,10 @@ package io.sphere.mongo.generic -import io.sphere.mongo.format.MongoFormat +import io.sphere.mongo.MongoUtils._ import io.sphere.mongo.format._ +import io.sphere.mongo.format.DefaultMongoFormats._ import org.scalatest.matchers.must.Matchers import org.scalatest.wordspec.AnyWordSpec -import io.sphere.mongo.format.DefaultMongoFormats._ -import io.sphere.mongo.MongoUtils._ class DeriveMongoformatSpec extends AnyWordSpec with Matchers { import DeriveMongoformatSpec._ diff --git a/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/MongoIgnoreSpec.scala b/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/MongoIgnoreSpec.scala new file mode 100644 index 00000000..39c06d5a --- /dev/null +++ b/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/MongoIgnoreSpec.scala @@ -0,0 +1,36 @@ +package io.sphere.mongo.generic + +import io.sphere.mongo.MongoUtils._ +import io.sphere.mongo.generic.deriveMongoFormat +import io.sphere.mongo.format.DefaultMongoFormats._ +import org.scalatest.OptionValues +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +object MongoIgnoreSpec { + private val aName = "aName" + private val dbo = dbObj("name" -> aName) + + private case class MissingDefault(name: String, @MongoIgnore age: Int) + + private val defaultAge = 100 + private case class Complete(name: String, @MongoIgnore age: Int = defaultAge) +} + +class MongoIgnoreSpec extends AnyWordSpec with Matchers with OptionValues { + import MongoIgnoreSpec._ + + "MongoIgnore" when { + "annotated field has no default" must { + "fail with a suitable message" in { + val e = the[Exception] thrownBy deriveMongoFormat[MissingDefault].fromMongoValue(dbo) + e.getMessage mustBe "Ignored Mongo field 'age' must have a default value." + } + } + "annotated field has also a default" must { + "omit the field in serialization" in { + deriveMongoFormat[Complete].toMongoValue(Complete(aName)) mustBe dbo + } + } + } +} diff --git a/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/MongoKeySpec.scala b/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/MongoKeySpec.scala index 3392124b..2edd6d21 100644 --- a/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/MongoKeySpec.scala +++ b/mongo/mongo-derivation/src/test/scala/io/sphere/mongo/generic/MongoKeySpec.scala @@ -6,7 +6,7 @@ import org.bson.BSONObject import org.scalatest.matchers.must.Matchers import org.scalatest.wordspec.AnyWordSpec -import scala.collection.JavaConverters._ +import scala.jdk.CollectionConverters._ class MongoKeySpec extends AnyWordSpec with Matchers { import MongoKeySpec._ diff --git a/project/Fmpp.scala b/project/Fmpp.scala index 31b41844..6bdc8197 100644 --- a/project/Fmpp.scala +++ b/project/Fmpp.scala @@ -9,7 +9,7 @@ import java.io.File // inspiration: https://github.com/sbt/sbt-fmpp/blob/master/src/main/scala/FmppPlugin.scala object Fmpp { - private lazy val fmpp = TaskKey[Seq[File]]("fmpp") + lazy val fmpp = TaskKey[Seq[File]]("fmpp") private lazy val fmppOptions = SettingKey[Seq[String]]("fmppOptions") private lazy val FmppConfig = config("fmpp") hide @@ -23,7 +23,6 @@ object Fmpp { private def fmppConfigSettings(c: Configuration): Seq[Setting[_]] = inConfig(c)( Seq( - Compile / sourceGenerators += fmpp.taskValue, fmpp := fmppTask.value, sources := managedSources.value )) diff --git a/util/src/main/scala-3/AnnotationReader.scala b/util/src/main/scala-3/AnnotationReader.scala new file mode 100644 index 00000000..f5a09429 --- /dev/null +++ b/util/src/main/scala-3/AnnotationReader.scala @@ -0,0 +1,154 @@ +package io.sphere.util + +import scala.quoted.{Expr, Quotes, Type, Varargs} + +case class Field( + scalaName: String, + embedded: Boolean, + ignored: Boolean, + key: Option[String], + defaultArgument: Option[Any]) { + val serializedName: String = key.getOrElse(scalaName) +} + +case class TypeMetaData( + scalaName: String, + typeHintRaw: Option[String], + fields: Vector[Field] +) { + val typeHint: Option[String] = + typeHintRaw.filterNot(_.toList.forall(_ == ' ')) + + val serializedName: String = typeHint.getOrElse(scalaName) +} + +/** This class also works for case classes not only traits, in case of case classes only the `top` + * field would be populated + */ +case class TraitMetaData( + top: TypeMetaData, + typeHintFieldRaw: Option[String], + subtypes: Map[String, TypeMetaData] +) { + def isTrait: Boolean = subtypes.nonEmpty + + private val defaultTypeDiscriminatorName = "type" + val typeDiscriminator: String = + typeHintFieldRaw.getOrElse(defaultTypeDiscriminatorName) + + val serializedNamesOfSubTypes: Map[String, String] = subtypes.map { case (scalaName, classMeta) => + scalaName -> classMeta.typeHint.getOrElse(scalaName) + } +} + +class AnnotationReader(using q: Quotes)( + embeddedExists: q.reflect.Tree => Boolean, + ignoredExists: q.reflect.Tree => Boolean, + findKey: q.reflect.Tree => Option[Expr[String]], + findTypeHint: q.reflect.Tree => Option[Expr[String]], + findTypeHintField: q.reflect.Tree => Option[Expr[String]] +) { + import q.reflect.* + + def readTypeMetaData[T: Type]: Expr[TypeMetaData] = { + val tpe = TypeRepr.of[T] + val termSym = tpe.termSymbol + val typeSym = tpe.typeSymbol + if (termSym.flags.is(Flags.Enum) && typeSym.flags.is(Flags.Enum)) + typeMetaDataForEnumObjects(termSym) + else + typeMetaData(typeSym) + } + + private def typeMetaDataForEnumObjects(sym: Symbol): Expr[TypeMetaData] = { + val name = Expr(sym.name) + val typeHint = sym.annotations.map(findTypeHint).find(_.isDefined).flatten match { + case Some(th) => '{ Some($th) } + case None => '{ None } + } + '{ + TypeMetaData( + scalaName = $name, + typeHintRaw = $typeHint, + fields = Vector.empty + ) + } + } + + private def typeMetaData(sym: Symbol): Expr[TypeMetaData] = { + val caseParams = sym.primaryConstructor.paramSymss.take(1).flatten + val fields = Varargs(caseParams.zipWithIndex.map(collectFieldInfo(sym.companionModule))) + val name = + if (sym.flags.is(Flags.Case) && sym.flags.is(Flags.Module)) + Expr(sym.name.stripSuffix("$")) + else + Expr(sym.name) + val typeHint = sym.annotations.map(findTypeHint).find(_.isDefined).flatten match { + case Some(th) => '{ Some($th) } + case None => '{ None } + } + + '{ + TypeMetaData( + scalaName = $name, + typeHintRaw = $typeHint, + fields = Vector($fields*) + ) + } + } + + private def collectFieldInfo(companion: Symbol)(s: Symbol, paramIdx: Int): Expr[Field] = { + val embedded = Expr(s.annotations.exists(embeddedExists)) + val ignored = Expr(s.annotations.exists(ignoredExists)) + val name = Expr(s.name) + val key = s.annotations.map(findKey).find(_.isDefined).flatten match { + case Some(k) => '{ Some($k) } + case None => '{ None } + } + val defArgOpt = companion + .methodMember(s"$$lessinit$$greater$$default$$${paramIdx + 1}") + .headOption + .map(dm => Ref(dm).asExprOf[Any]) match { + case Some(k) => '{ Some($k) } + case None => '{ None } + } + + '{ + Field( + scalaName = $name, + embedded = $embedded, + ignored = $ignored, + key = $key, + defaultArgument = $defArgOpt) + } + } + + private def subtypeAnnotation(sym: Symbol): Expr[(String, TypeMetaData)] = { + val name = Expr(sym.name) + val annots = typeMetaData(sym) + '{ ($name, $annots) } + } + + private def subtypeAnnotations(sym: Symbol): Expr[Map[String, TypeMetaData]] = { + val subtypes = Varargs(sym.children.map(subtypeAnnotation)) + '{ Map($subtypes*) } + } + + def readTraitMetaData[T: Type]: Expr[TraitMetaData] = { + val sym = TypeRepr.of[T].typeSymbol + val typeHintField = + sym.annotations.map(findTypeHintField).find(_.isDefined).flatten match { + case Some(thf) => '{ Some($thf) } + case None => '{ None } + } + + '{ + TraitMetaData( + top = ${ typeMetaData(sym) }, + typeHintFieldRaw = $typeHintField, + subtypes = ${ subtypeAnnotations(sym) } + ) + } + } + +} diff --git a/util/src/test/scala/DomainObjectsGen.scala b/util/src/test/scala/DomainObjectsGen.scala index 97536bfd..b654f020 100644 --- a/util/src/test/scala/DomainObjectsGen.scala +++ b/util/src/test/scala/DomainObjectsGen.scala @@ -4,7 +4,7 @@ import java.util.Currency import org.scalacheck.Gen -import scala.collection.JavaConverters._ +import scala.jdk.CollectionConverters._ object DomainObjectsGen { diff --git a/util/src/test/scala/HighPrecisionMoneySpec.scala b/util/src/test/scala/HighPrecisionMoneySpec.scala index 1b80016b..386467bb 100644 --- a/util/src/test/scala/HighPrecisionMoneySpec.scala +++ b/util/src/test/scala/HighPrecisionMoneySpec.scala @@ -168,20 +168,22 @@ class HighPrecisionMoneySpec extends AnyFunSpec with Matchers with ScalaCheckDri } it("should validate fractionDigits (min)") { - val Invalid(errors) = HighPrecisionMoney.fromPreciseAmount(123456L, 1, Euro, None) + val Invalid(errors) = HighPrecisionMoney.fromPreciseAmount(123456L, 1, Euro, None): @unchecked errors.toList must be( List("fractionDigits must be > 2 (default fraction digits defined by currency EUR).")) } it("should validate fractionDigits (max)") { - val Invalid(errors) = HighPrecisionMoney.fromPreciseAmount(123456L, 100, Euro, None) + val Invalid(errors) = + HighPrecisionMoney.fromPreciseAmount(123456L, 100, Euro, None): @unchecked errors.toList must be(List("fractionDigits must be <= 20.")) } it("should validate centAmount") { - val Invalid(errors) = HighPrecisionMoney.fromPreciseAmount(123456L, 4, Euro, Some(1)) + val Invalid(errors) = + HighPrecisionMoney.fromPreciseAmount(123456L, 4, Euro, Some(1)): @unchecked errors.toList must be( List(