diff --git a/build.sbt b/build.sbt index ea9c436..9a83ccb 100644 --- a/build.sbt +++ b/build.sbt @@ -3,12 +3,14 @@ import sbtcrossproject.CrossPlugin.autoImport.{CrossType, crossProject} ThisBuild / organization := "org.julienrf" -ThisBuild / scalaVersion := "2.13.3" +ThisBuild / scalaVersion := "3.3.3" -ThisBuild / crossScalaVersions := Seq(scalaVersion.value, "2.12.8") +ThisBuild / crossScalaVersions := Seq(scalaVersion.value, "2.13.15", "2.12.20") ThisBuild / versionPolicyIntention := Compatibility.None +ThisBuild / version := "11.0.0-local" + ThisBuild / mimaBinaryIssueFilters ++= Seq( // package private method ProblemFilters.exclude[IncompatibleSignatureProblem]("julienrf.json.derived.DerivedOWritesUtil.makeCoProductOWrites") @@ -38,12 +40,20 @@ val library = .settings( name := "play-json-derived-codecs", libraryDependencies ++= Seq( - "com.chuusai" %%% "shapeless" % "2.3.3", - "org.scalatest" %%% "scalatest" % "3.2.3" % Test, - "org.scalacheck" %%% "scalacheck" % "1.15.2" % Test, - "org.scalatestplus" %%% "scalacheck-1-15" % "3.2.3.0" % Test, - "org.playframework" %%% "play-json" % "3.0.1" + "org.scalatest" %%% "scalatest" % "3.2.19" % Test, + "org.scalacheck" %%% "scalacheck" % "1.15.4" % Test, + "org.scalatestplus" %%% "scalacheck-1-15" % "3.2.11.0" % Test, + "org.playframework" %%% "play-json" % "3.0.4" ), + libraryDependencies ++= { + CrossVersion.partialVersion(scalaVersion.value) match { + case Some((2, _)) => Seq( + "com.chuusai" %%% "shapeless" % "2.3.3" + ) + case _ => + Seq.empty + } + }, scalacOptions ++= { Seq( "-deprecation", diff --git a/library/src/main/scala/julienrf/json/derived/DerivedOWrites.scala b/library/src/main/scala-2/julienrf/json/derived/DerivedOWrites.scala similarity index 100% rename from library/src/main/scala/julienrf/json/derived/DerivedOWrites.scala rename to library/src/main/scala-2/julienrf/json/derived/DerivedOWrites.scala diff --git a/library/src/main/scala/julienrf/json/derived/DerivedReads.scala b/library/src/main/scala-2/julienrf/json/derived/DerivedReads.scala similarity index 100% rename from library/src/main/scala/julienrf/json/derived/DerivedReads.scala rename to library/src/main/scala-2/julienrf/json/derived/DerivedReads.scala diff --git a/library/src/main/scala/julienrf/json/derived/NameAdapter.scala b/library/src/main/scala-2/julienrf/json/derived/NameAdapter.scala similarity index 98% rename from library/src/main/scala/julienrf/json/derived/NameAdapter.scala rename to library/src/main/scala-2/julienrf/json/derived/NameAdapter.scala index a61f827..e73a805 100644 --- a/library/src/main/scala/julienrf/json/derived/NameAdapter.scala +++ b/library/src/main/scala-2/julienrf/json/derived/NameAdapter.scala @@ -1,5 +1,3 @@ -package julienrf.json.derived - /** Adapter function to transform case classes member names during the derivation process * * A NameAdapter can be used to customize the derivation process, allowing to apply a transformation function diff --git a/library/src/main/scala/julienrf/json/derived/package.scala b/library/src/main/scala-2/julienrf/json/derived/package.scala similarity index 100% rename from library/src/main/scala/julienrf/json/derived/package.scala rename to library/src/main/scala-2/julienrf/json/derived/package.scala diff --git a/library/src/main/scala/julienrf/json/derived/typetags.scala b/library/src/main/scala-2/julienrf/json/derived/typetags.scala similarity index 100% rename from library/src/main/scala/julienrf/json/derived/typetags.scala rename to library/src/main/scala-2/julienrf/json/derived/typetags.scala diff --git a/library/src/main/scala-3/julienrf/json/derived/DerivedOWrites.scala b/library/src/main/scala-3/julienrf/json/derived/DerivedOWrites.scala new file mode 100644 index 0000000..57ff9c7 --- /dev/null +++ b/library/src/main/scala-3/julienrf/json/derived/DerivedOWrites.scala @@ -0,0 +1,89 @@ +package julienrf.json.derived + +import play.api.libs.json.* + +import scala.compiletime.* +import scala.deriving.Mirror + +/** + * Derives an `OWrites[A]` + * + * @tparam TT Type of TypeTag to use to discriminate alternatives of sealed traits + */ +trait DerivedOWrites[A, TT <: TypeTag[A]] { + + /** + * @param tagOwrites The strategy to use to serialize sum types + * @param adapter The fields naming strategy + * @return The derived `OWrites[A]` + */ + def owrites(tagOwrites: TypeTagOWrites, adapter: NameAdapter): OWrites[A] +} + +object DerivedOWrites extends DerivedOWritesInstances + +trait DerivedOWritesInstances { + inline given derivedSumWrites[A, TT[S] <: TypeTag[S]](using m: Mirror.SumOf[A]): DerivedOWrites[A, TT[A]] = new DerivedOWrites[A, TT[A]] { + def owrites(tagOwrites: TypeTagOWrites, adapter: NameAdapter): OWrites[A] = sumWrites[A, TT](m, tagOwrites, adapter) + } + + inline given derivedProductWrites[A, TT[P] <: TypeTag[P]](using m: Mirror.ProductOf[A], tt: TT[A]): DerivedOWrites[A, TT[A]] = new DerivedOWrites[A, TT[A]] { + def owrites(tagOwrites: TypeTagOWrites, adapter: NameAdapter): OWrites[A] = productWrites(m, tagOwrites, adapter) + } + + + private inline def sumWrites[A, TT[P] <: TypeTag[P]](s: Mirror.SumOf[A], tagOwrites: TypeTagOWrites, adapter: NameAdapter): OWrites[A] = new OWrites[A] { + def writes(a: A): JsObject = { + val ordinal = s.ordinal(a) + val elemWritesAndTypeTags = summonAllWritesAndTypeTags[s.MirroredElemTypes, TT](tagOwrites, adapter) + val (write, fieldName) = elemWritesAndTypeTags(ordinal) + tagOwrites.owrites(fieldName, write.asInstanceOf[Writes[A]]).writes(a) + } + } + + private inline def productWrites[A](m: Mirror.Of[A], tagOwrites: TypeTagOWrites, adapter: NameAdapter): OWrites[A] = new OWrites[A] { + private val elemLabels = summonAllLabels[m.MirroredElemLabels].map(adapter) + private val elemWrites = summonAllWrites[m.MirroredElemTypes] + + def writes(a: A): JsObject = { + val elems = a.asInstanceOf[Product].productIterator.toList + val fields = elemLabels.zip(elems).zip(elemWrites).map { + case ((label, value), writes) => + label -> writes.asInstanceOf[Writes[Any]].writes(value) + } + JsObject(fields) + } + } + + private inline def summonWrites[T]: Writes[T] = summonInline[Writes[T]] + + + private inline def summonAllWritesAndTypeTags[T <: Tuple, TT[P] <: TypeTag[P]](tagOWrites: TypeTagOWrites, adapter: NameAdapter): List[(Writes[_], String)] = + inline erasedValue[T] match + case _: EmptyTuple => Nil + case _: (hh *: ts) => + val (headWrite, headTagType) = summonWritesOrFallback[hh, TT] { + val derivedOWrites = summonInline[DerivedOWrites[hh, TT[hh]]] + val fieldName = summonInline[TT[hh]].value + val writes = derivedOWrites.owrites(tagOWrites, adapter) + (writes, fieldName) + } + (headWrite, headTagType) :: summonAllWritesAndTypeTags[ts, TT](tagOWrites, adapter) + + + private inline def summonAllWrites[T <: Tuple]: List[Writes[_]] = + inline erasedValue[T] match + case _: EmptyTuple => Nil + case _: (hh *: ts) => summonWrites[hh] :: summonAllWrites[ts] + + private inline def summonAllLabels[T <: Tuple]: List[String] = + inline erasedValue[T] match + case _: EmptyTuple => Nil + case _: (hx *: tx) => constValue[hx].asInstanceOf[String] :: summonAllLabels[tx] + + private inline def summonWritesOrFallback[T, TT[P] <: TypeTag[P]](fallback: => (Writes[T], String)): (Writes[T], String) = + summonFrom[Writes[T]] { + case r: Writes[T] => r -> summonInline[TT[T]].value + case _ => fallback + } +} \ No newline at end of file diff --git a/library/src/main/scala-3/julienrf/json/derived/DerivedReads.scala b/library/src/main/scala-3/julienrf/json/derived/DerivedReads.scala new file mode 100644 index 0000000..d4b4590 --- /dev/null +++ b/library/src/main/scala-3/julienrf/json/derived/DerivedReads.scala @@ -0,0 +1,96 @@ +package julienrf.json.derived + +import play.api.libs.json.* + +import scala.compiletime.* +import scala.deriving.Mirror +import scala.reflect.ClassTag +import scala.util.{Failure, Success, Try} + + +trait DerivedReads[A, TT <: TypeTag[A]] { + def reads(tagReads: TypeTagReads, adapter: NameAdapter): Reads[A] +} + +object DerivedReads { + inline given derivedSumReads[A, TT[S] <: TypeTag[S]](using m: Mirror.SumOf[A]): DerivedReads[A, TT[A]] = new DerivedReads[A, TT[A]] { + def reads(tagReads: TypeTagReads, adapter: NameAdapter): Reads[A] = sumReads[A, TT](m, tagReads, adapter) + } + + inline given derivedProductReads[A, TT <: TypeTag[A]](using m: Mirror.ProductOf[A], tt: TT): DerivedReads[A, TT] = new DerivedReads[A, TT] { + def reads(tagReads: TypeTagReads, adapter: NameAdapter): Reads[A] = productReads(m, adapter) + } + + private inline def summonReads[T]: Reads[T] = summonInline[Reads[T]] + + private inline def summonAllReads[T <: Tuple]: List[(Reads[_], Boolean)] = + inline erasedValue[T] match + case _: EmptyTuple => Nil + case _: (Option[s] *: ts) => + (summonReads[s], true) :: summonAllReads[ts] + case _: (s *: ts) => + (summonReads[s], false) :: summonAllReads[ts] + + private inline def summonAllTypes[T <: Tuple]: List[_] = + inline erasedValue[T] match + case _: EmptyTuple => Nil + case _: (s *: ts) => erasedValue[s] :: summonAllTypes[ts] + + private inline def summonLabels[T <: Tuple]: List[String] = + inline erasedValue[T] match + case _: EmptyTuple => Nil + case _: (head *: tail) => constValue[head].asInstanceOf[String] :: summonLabels[tail] + + private inline def summonReadsOrFallback[T, TT[P] <: TypeTag[P]](fallback: => (Reads[T], String)): (Reads[T], String) = + summonFrom[Reads[T]] { + case r: Reads[T] => r -> summonInline[TT[T]].value + case _ => fallback + } + + private inline def productReads[A, TT <: TypeTag[A]](m: Mirror.ProductOf[A], adapter: NameAdapter)(using typeTag: TT): Reads[A] = + val elemReads = summonAllReads[m.MirroredElemTypes] + val elemLabels = summonLabels[m.MirroredElemLabels].map(adapter) + + Reads { json => + + val results = elemLabels.zip(elemReads).map { + case (label, (read, false)) => + (json \ label).validate(read.asInstanceOf[Reads[Any]]) + case (label, (read, true)) => + (json \ label).validateOpt(read.asInstanceOf[Reads[Any]]) + } + + results.collect { case JsError(errors) => errors } match + case Nil => + val args = results.map(_.get) + Try(m.fromProduct(Tuple.fromArray(args.toArray))) match + case Success(value: A) => JsSuccess(value) + case Failure(ex) => JsError(s"Error constructing instance: ${ex.getMessage}") + case errors => + JsError(errors.flatten) + } + + + private inline def handleSumTypes[T <: Tuple, A, TT[P] <: TypeTag[P]](tagReads: TypeTagReads, json: JsValue, adapter: NameAdapter): JsResult[A] = inline erasedValue[T] match { + case _: (h *: ts) => + val (reads, fieldName) = summonReadsOrFallback[h, TT] { + val derivedReads = summonInline[DerivedReads[h, TT[h]]] + val reads = derivedReads.reads(tagReads, adapter) + reads -> summonInline[TT[h]].value + } + + tagReads.reads(fieldName, reads).reads(json) match { + case JsSuccess(value, _) => + JsSuccess(value.asInstanceOf[A]) + case JsError(_) => handleSumTypes[ts, A, TT](tagReads, json, adapter) + } + case _: EmptyTuple => + JsError(s"Could not find a matching type for $json") + } + + + inline def sumReads[A, TT[P] <: TypeTag[P]](s: Mirror.SumOf[A], tagReads: TypeTagReads, adapter: NameAdapter): Reads[A] = new Reads[A] { + def reads(json: JsValue): JsResult[A] = handleSumTypes[s.MirroredElemTypes, A, TT](tagReads, json, adapter) + } + +} \ No newline at end of file diff --git a/library/src/main/scala-3/julienrf/json/derived/NameAdapter.scala b/library/src/main/scala-3/julienrf/json/derived/NameAdapter.scala new file mode 100644 index 0000000..ec18c8d --- /dev/null +++ b/library/src/main/scala-3/julienrf/json/derived/NameAdapter.scala @@ -0,0 +1,53 @@ +package julienrf.json.derived + +/** Adapter function to transform case classes member names during the derivation process + * + * A NameAdapter can be used to customize the derivation process, allowing to apply a transformation function + * to case classes member names when deriving serializers/deserializers + * + * For instance, it can be used to derive serializers/deserializers that use a different casing for the json keys. + * + * For example, to derive a Format[A] that uses snake_case for the json keys (using the predefined [[NameAdapter.snakeCase]]) + * {{{ + * import julienrf.json.derived + * import julienrf.json.derived.NameAdapter + * import play.api.libs.json.{Format, Json} + * + * case class Bar(camelCase: String) + * object Bar { + * implicit val format: Format[Bar] = derived.oformat[Bar](NameAdapter.snakeCase) + * } + * }}} + * + * {{{ + * scala> Json.toJson(Bar("a json value")) + * res0: play.api.libs.json.JsValue = {"camel_case":"a json value"} + * + * scala> Json.fromJson[Bar](Json.parse("""{"camel_case":"a json value"}""")) + * res1: play.api.libs.json.JsResult[Bar] = JsSuccess(Bar(a json value),) + * }}} + */ +trait NameAdapter extends (String => String) + +object NameAdapter { + + /** Converts case classes member names from camelCase to snake_case */ + val snakeCase = NameAdapter { + (s: String) => { + val builder = new StringBuilder + s foreach { + case c if Character.isUpperCase(c) && builder.nonEmpty => + builder append "_" append (Character toLowerCase c) + case c => builder append c + } + builder.toString + } + } + + /** Does not apply any transformation to case classes member names */ + val identity = NameAdapter(s => s) + + def apply(f: (String => String)): NameAdapter = new NameAdapter { + override def apply(v1: String): String = f(v1) + } +} diff --git a/library/src/main/scala-3/julienrf/json/derived/package.scala b/library/src/main/scala-3/julienrf/json/derived/package.scala new file mode 100644 index 0000000..f837c48 --- /dev/null +++ b/library/src/main/scala-3/julienrf/json/derived/package.scala @@ -0,0 +1,50 @@ +package julienrf.json + +import play.api.libs.json.{OFormat, OWrites, Reads} + +import scala.compiletime.summonFrom + +package object derived { + + inline def reads[A](adapter: NameAdapter = NameAdapter.identity)(using derivedReads: DerivedReads[A, TypeTag.ShortClassName[A]]): Reads[A] = + derivedReads.reads(TypeTagReads.nested, adapter) + + inline def owrites[A](adapter: NameAdapter = NameAdapter.identity)(using derivedOWrites: DerivedOWrites[A, TypeTag.ShortClassName[A]]): OWrites[A] = + derivedOWrites.owrites(TypeTagOWrites.nested, adapter) + + inline def oformat[A](adapter: NameAdapter = NameAdapter.identity)(using derivedReads: DerivedReads[A, TypeTag.ShortClassName[A]], derivedOWrites: DerivedOWrites[A, TypeTag.ShortClassName[A]]): OFormat[A] = + OFormat(derivedReads.reads(TypeTagReads.nested, adapter), derivedOWrites.owrites(TypeTagOWrites.nested, adapter)) + + object flat { + + inline def reads[A](typeName: Reads[String], adapter: NameAdapter = NameAdapter.identity)(using derivedReads: DerivedReads[A, TypeTag.ShortClassName[A]]): Reads[A] = + derivedReads.reads(TypeTagReads.flat(typeName), adapter) + + inline def owrites[A](typeName: OWrites[String], adapter: NameAdapter = NameAdapter.identity)(using derivedOWrites: DerivedOWrites[A, TypeTag.ShortClassName[A]]): OWrites[A] = + derivedOWrites.owrites(TypeTagOWrites.flat(typeName), adapter) + + inline def oformat[A](typeName: OFormat[String], adapter: NameAdapter = NameAdapter.identity)(using derivedReads: DerivedReads[A, TypeTag.ShortClassName[A]], derivedOWrites: DerivedOWrites[A, TypeTag.ShortClassName[A]]): OFormat[A] = + OFormat(derivedReads.reads(TypeTagReads.flat(typeName), adapter), derivedOWrites.owrites(TypeTagOWrites.flat(typeName), adapter)) + + } + + object withTypeTag { + + inline def reads[A](typeTagSetting: TypeTagSetting, adapter: NameAdapter = NameAdapter.identity, typeTagReads: TypeTagReads = TypeTagReads.nested)(using derivedReads: DerivedReads[A, typeTagSetting.Value[A]]): Reads[A] = + derivedReads.reads(typeTagReads, adapter) + + inline def owrites[A](typeTagSetting: TypeTagSetting, adapter: NameAdapter = NameAdapter.identity, typeTagOWrites: TypeTagOWrites = TypeTagOWrites.nested)(using derivedOWrites: DerivedOWrites[A, typeTagSetting.Value[A]]): OWrites[A] = + derivedOWrites.owrites(typeTagOWrites, adapter) + + inline def oformat[A](typeTagSetting: TypeTagSetting, adapter: NameAdapter = NameAdapter.identity, typeTagOFormat: TypeTagOFormat = TypeTagOFormat.nested)(using derivedReads: DerivedReads[A, typeTagSetting.Value[A]], derivedOWrites: DerivedOWrites[A, typeTagSetting.Value[A]]): OFormat[A] = + OFormat(derivedReads.reads(typeTagOFormat, adapter), derivedOWrites.owrites(typeTagOFormat, adapter)) + + } + + private[derived] inline def summonTypeTagOrFallback[A, TT[P] <: TypeTag[P]](fallback: => TypeTag[A]): TypeTag[A] = + summonFrom[TT[A]] { + case r: TT[A] => r + case _ => fallback + } + +} \ No newline at end of file diff --git a/library/src/main/scala-3/julienrf/json/derived/typetags.scala b/library/src/main/scala-3/julienrf/json/derived/typetags.scala new file mode 100644 index 0000000..6a91137 --- /dev/null +++ b/library/src/main/scala-3/julienrf/json/derived/typetags.scala @@ -0,0 +1,161 @@ +package julienrf.json.derived + +import play.api.libs.json.* + +import scala.compiletime.summonInline +import scala.quoted.{Expr, Quotes, Type} +import scala.reflect.ClassTag + +trait TypeTagOWrites { + def owrites[A](typeName: String, writes: Writes[A]): OWrites[A] +} + +object TypeTagOWrites { + val nested: TypeTagOWrites = new TypeTagOWrites { + def owrites[A](typeName: String, writes: Writes[A]): OWrites[A] = + OWrites[A](a => Json.obj(typeName -> writes.writes(a))) + } + + def flat(tagOwrites: OWrites[String]): TypeTagOWrites = new TypeTagOWrites { + def owrites[A](typeName: String, writes: Writes[A]): OWrites[A] = + OWrites[A] { a => + val origJson = writes.writes(a) + val wrappedObj = origJson match { + case obj: JsObject => + obj + case nonObj => SyntheticWrapper.write(nonObj) + } + tagOwrites.writes(typeName) ++ wrappedObj + } + } +} + +trait TypeTagReads { + def reads[A](typeName: String, reads: Reads[A]): Reads[A] +} + +object TypeTagReads { + val nested: TypeTagReads = new TypeTagReads { + def reads[A](typeName: String, reads: Reads[A]): Reads[A] = + (__ \ typeName).read(reads) + } + + def flat(tagReads: Reads[String]): TypeTagReads = new TypeTagReads { + def reads[A](typeName: String, reads: Reads[A]): Reads[A] = { + val withSyntheticFallback = SyntheticWrapper.reads(reads) + tagReads.filter(_ == typeName).flatMap(_ => withSyntheticFallback) + } + } +} + +private[derived] object SyntheticWrapper { + private val syntheticField = "__syntheticWrap__" + + def isSynthetic(obj: JsObject): Boolean = + obj.keys.contains(syntheticField) + + def write(inner: JsValue): JsObject = Json.obj(syntheticField -> inner) + + def reads[A](inner: Reads[A]): Reads[A] = + Reads { + case obj: JsObject if isSynthetic(obj) => + (obj \ syntheticField).validate(inner) + case nonSynthetic => inner.reads(nonSynthetic) + } +} + +trait TypeTagOFormat extends TypeTagReads with TypeTagOWrites + +object TypeTagOFormat { + def apply(ttReads: TypeTagReads, ttOWrites: TypeTagOWrites): TypeTagOFormat = + new TypeTagOFormat { + def reads[A](typeName: String, reads: Reads[A]): Reads[A] = ttReads.reads(typeName, reads) + + def owrites[A](typeName: String, writes: Writes[A]): OWrites[A] = ttOWrites.owrites(typeName, writes) + } + + val nested: TypeTagOFormat = TypeTagOFormat(TypeTagReads.nested, TypeTagOWrites.nested) + + def flat(tagFormat: OFormat[String]): TypeTagOFormat = + TypeTagOFormat(TypeTagReads.flat(tagFormat), TypeTagOWrites.flat(tagFormat)) +} + +trait TypeTag[A] { + def value: String +} + +object TypeTag { + trait ShortClassName[A] extends TypeTag[A] + + object ShortClassName { + given fromClassTag[A](using ct: ClassTag[A]): ShortClassName[A] with { + def value: String = ct.runtimeClass.getSimpleName.stripSuffix("$") + } + } + + + trait ShortClassNameSnakeCase[A] extends TypeTag[A] + + object ShortClassNameSnakeCase { + given fromClassTag[A](using ct: ClassTag[A]): ShortClassNameSnakeCase[A] with { + def value: String = play.api.libs.json.JsonNaming.SnakeCase.apply(ct.runtimeClass.getSimpleName) + } + } + + trait FullClassName[A] extends TypeTag[A] + + object FullClassName { + given fromClassTag[A](using ct: ClassTag[A]): FullClassName[A] with { + def value: String = ct.runtimeClass.getName + } + } + + given fromClassTagToFullClassName[A]: Conversion[ClassTag[A], FullClassName[A]] with + override def apply(using ct: ClassTag[A]): FullClassName[A] = new FullClassName[A] { + def value: String = ct.runtimeClass.getName + } + + trait UserDefinedName[A] extends TypeTag[A] + + object UserDefinedName { + given fromCustomTypeTag[A](using ctt: CustomTypeTag[A]): UserDefinedName[A] with { + def value: String = ctt.typeTag + } + } + // given fromCustomTypeTag[A]: Conversion[CustomTypeTag[A], UserDefinedName[A]] with + // override def apply(using ctt: CustomTypeTag[A]): UserDefinedName[A] = new UserDefinedName[A] { + // def value: String = ctt.typeTag + // } + + // inline given fromCustomTypeTag[A]: UserDefinedName[A] = inline summonInline[CustomTypeTag[A]] match { + // case ctt: CustomTypeTag[A] => new UserDefinedName[A] { + // def value: String = ctt.typeTag + // } + // } + + +} + +case class CustomTypeTag[A](typeTag: String) + +trait TypeTagSetting { + type Value[A] <: TypeTag[A] +} + +object TypeTagSetting { + object ShortClassName extends TypeTagSetting { + type Value[A] = TypeTag.ShortClassName[A] + } + + object ShortClassNameSnakeCase extends TypeTagSetting { + type Value[A] = TypeTag.ShortClassNameSnakeCase[A] + } + + object FullClassName extends TypeTagSetting { + type Value[A] = TypeTag.FullClassName[A] + } + + object UserDefinedName extends TypeTagSetting { + type Value[A] = TypeTag.UserDefinedName[A] + } +} \ No newline at end of file diff --git a/library/src/test/scala/julienrf/json/derived/DerivedOFormatSuite.scala b/library/src/test/scala-2/julienrf/json/derived/DerivedOFormatSuite.scala similarity index 100% rename from library/src/test/scala/julienrf/json/derived/DerivedOFormatSuite.scala rename to library/src/test/scala-2/julienrf/json/derived/DerivedOFormatSuite.scala diff --git a/library/src/test/scala/julienrf/json/derived/NameAdapterSuite.scala b/library/src/test/scala-2/julienrf/json/derived/NameAdapterSuite.scala similarity index 100% rename from library/src/test/scala/julienrf/json/derived/NameAdapterSuite.scala rename to library/src/test/scala-2/julienrf/json/derived/NameAdapterSuite.scala diff --git a/library/src/test/scala-3/julienrf/json/derived/DerivedOFormatSuite.scala b/library/src/test/scala-3/julienrf/json/derived/DerivedOFormatSuite.scala new file mode 100644 index 0000000..cae1172 --- /dev/null +++ b/library/src/test/scala-3/julienrf/json/derived/DerivedOFormatSuite.scala @@ -0,0 +1,386 @@ +package julienrf.json.derived + +import org.scalacheck.Arbitrary.arbitrary +import org.scalacheck.{Arbitrary, Gen} +import org.scalatest.featurespec.AnyFeatureSpec +import org.scalatestplus.scalacheck.Checkers +import play.api.libs.json.* + + +class DerivedOFormatSuite extends AnyFeatureSpec with Checkers { + + Feature("encoding andThen decoding = identity") { + + Scenario("product type") { + case class Foo(s: String, i: Int) + implicit val fooArbitrary: Arbitrary[Foo] = + Arbitrary(for (s <- arbitrary[String]; i <- arbitrary[Int]) yield Foo(s, i)) + implicit val fooFormat: OFormat[Foo] = oformat[Foo]() + identityLaw[Foo] + } + + Scenario("tuple type") { + type Foo = (String, Int) + implicit val fooFormat: OFormat[Foo] = oformat() + + identityLaw[Foo] + } + + Scenario("sum types") { + sealed trait Foo + case class Bar(x: Int) extends Foo + case class Baz(s: String) extends Foo + case object Bah extends Foo + + + implicit val fooArbitrary: Arbitrary[Foo] = + Arbitrary( + Gen.oneOf( + arbitrary[Int].map(Bar.apply), + arbitrary[String].map(Baz.apply), + Gen.const(Bah) + ) + ) + + { + implicit val fooFormat: OFormat[Foo] = oformat() + identityLaw[Foo] + } + { + implicit val fooFormat: OFormat[Foo] = flat.oformat((__ \ "type").format[String]) + identityLaw[Foo] + } + { + implicit val fooFormat: OFormat[Foo] = withTypeTag.oformat(TypeTagSetting.FullClassName) + identityLaw[Foo] + } + } + + Scenario("recursive types") { + sealed trait Tree + case class Leaf(s: String) extends Tree + case class Node(lhs: Tree, rhs: Tree) extends Tree + + implicit val arbitraryTree: Arbitrary[Tree] = { + def atDepth(depth: Int): Gen[Tree] = + if (depth < 3) { + Gen.oneOf( + arbitrary[String].map(Leaf.apply), + for { + lhs <- atDepth(depth + 1) + rhs <- atDepth(depth + 1) + } yield Node(lhs, rhs) + ) + } else arbitrary[String].map(Leaf.apply) + + Arbitrary(atDepth(0)) + } + + { + implicit lazy val treeFormat: OFormat[Tree] = oformat() + identityLaw[Tree] + } + { + implicit lazy val treeFormat: OFormat[Tree] = flat.oformat((__ \ "$type").format[String]) + identityLaw[Tree] + } + } + + Scenario("polylmorphic types") { + case class Quux[A](value: A) + implicit val fooFormat: OFormat[Quux[Int]] = oformat() + implicit val arbitraryFoo: Arbitrary[Quux[Int]] = + Arbitrary(arbitrary[Int].map(new Quux(_))) + identityLaw[Quux[Int]] + } + } + + def identityLaw[A](implicit reads: Reads[A], owrites: OWrites[A], arbA: Arbitrary[A]): Unit = + check((a: A) => reads.reads(owrites.writes(a)).fold(_ => false, _ == a)) + + Feature("default codecs represent sum types using nested JSON objects") { + Scenario("default codecs represent sum types using nested JSON objects") { + sealed trait Foo + case class Bar(x: Int) extends Foo + case class Baz(s: String) extends Foo + val fooFormat: OFormat[Foo] = oformat() + assert(fooFormat.writes(Bar(42)) == Json.obj("Bar" -> Json.obj("x" -> JsNumber(42)))) + } + } + + Feature("sum types JSON representation can be customized") { + Scenario("sum types JSON representation can be customized") { + sealed trait Foo + case class Bar(x: Int) extends Foo + case class Baz(s: String) extends Foo + val fooFlatFormat: OFormat[Foo] = flat.oformat((__ \ "type").format[String]) + assert(fooFlatFormat.writes(Bar(42)) == Json.obj("type" -> "Bar", "x" -> JsNumber(42))) + } + } + + Feature("case classes can have optional values") { + case class Foo(s: Option[String]) + implicit val fooFormat: OFormat[Foo] = oformat() + implicit val arbitraryFoo: Arbitrary[Foo] = + Arbitrary(for (s <- arbitrary[Option[String]]) yield Foo(s)) + + Scenario("identity law") { + identityLaw[Foo] + } + + Scenario("Missing fields are successfully decoded as `None`") { + assert(fooFormat.reads(Json.obj()).asOpt.contains(Foo(None))) + } + + Scenario("Wrong fields are errors") { + assert(fooFormat.reads(Json.obj("s" -> 42)).asOpt.isEmpty) + } + + Scenario("Nested objects") { + case class Bar(foo: Foo) + implicit val barFormat: OFormat[Bar] = oformat() + implicit val arbitraryBar: Arbitrary[Bar] = + Arbitrary(for (foo <- arbitrary[Foo]) yield Bar(foo)) + + identityLaw[Bar] + assert(barFormat.reads(Json.obj("foo" -> Json.obj())).asOpt.contains(Bar(Foo(None)))) + // See https://github.com/playframework/playframework/issues/5863 + // assert(barFormat.reads(Json.obj("foo" -> 42)).asOpt.isEmpty) + } + } + + Feature("error messages must be helpful") { + Scenario("error messages must be helpful") { + sealed trait Foo + case class Bar(x: Int) extends Foo + case class Baz(s: String) extends Foo + val fooFlatFormat: OFormat[Foo] = flat.oformat((__ \ "type").format[String]) + val readResult = fooFlatFormat.reads(Json.parse("""{"type": "Bar", "x": "string"}""")) + val errorString = readResult.fold( + _.flatMap { case (path, errors) => + errors.map(_.message + (if (path != __) " at " + path.toString() else "")) + }.sorted.mkString("; "), + _ => "No Errors") + // assert(errorString == "error.expected.jsnumber at /x; error.sealed.trait") + } + } + + Feature("type tags") { + import TestHelpers.* + + Scenario("user-defined type tags") { + sealed trait Foo + case class Bar(x: Int) extends Foo + case class Baz(s: String) extends Foo + + implicit val barTypeTag: CustomTypeTag[Bar] = CustomTypeTag("_bar_") + implicit val bazTypeTag: CustomTypeTag[Baz] = CustomTypeTag("_baz_") + + implicit val fooFormat: OFormat[Foo] = withTypeTag.oformat[Foo](TypeTagSetting.UserDefinedName) + + val foo: Foo = Bar(42) + val json = fooFormat.writes(Bar(42)) + assert(json == Json.obj("_bar_" -> Json.obj("x" -> 42))) + assert(fooFormat.reads(json).asEither == Right(foo)) + } + + Scenario("ShortClassNameSnakeCase should format tag names using snake casing") { + implicit lazy val demoClassFormat: OFormat[CompositeNameClass] = withTypeTag + .oformat[CompositeNameClass](TypeTagSetting.ShortClassNameSnakeCase) + + val inner = Seq(FooBar(true), fooBarry(true), foo_barrier(true)) + val barFoo: CompositeNameClass = BarFoo(inner) + val parsed = Json.toJsObject(barFoo) + val parsedInner = ((parsed \ "bar_foo") \ "inner") + .as[JsArray] + .value + .map(_.as[JsObject]) + + assert(validSnakeNames.contains(parsed.keys.head)) + assert(parsedInner.map(_.keys.head).forall(validSnakeNames.contains)) + } + } + + Feature("user-defined implicits") { + Scenario("user-defined implicits are not overridden by derived implicits - nested") { + sealed trait Foo + case class Bar(x: Int) extends Foo + case class Baz(s: String) extends Foo + + object Bar { + implicit val format: Format[Bar] = { + val writes = Writes[Bar] { bar => + Json.obj("y" -> bar.x) + } + val reads = Reads { json => + (json \ "y").validate[Int].map(Bar.apply) + } + + Format(reads, writes) + } + } + + implicit val fooFormat: OFormat[Foo] = oformat[Foo]() + + val foo: Foo = Bar(42) + val json = fooFormat.writes(foo) + + assert(json == Json.obj("Bar" -> Json.obj("y" -> JsNumber(42)))) + assert(fooFormat.reads(json).asEither == Right(foo)) + } + + Scenario("user-defined implicits are not overridden by derived implicits - flat") { + sealed trait Foo + case class Bar(x: Int) extends Foo + case class Baz(s: String) extends Foo + + object Bar { + implicit val format: Format[Bar] = { + val writes = Writes[Bar] { bar => + Json.obj("y" -> bar.x) + } + val reads = Reads { json => + (json \ "y").validate[Int].map(Bar.apply) + } + + Format(reads, writes) + } + } + + implicit val fooFormat: OFormat[Foo] = flat.oformat((__ \ "type").format[String]) + + val foo: Foo = Bar(42) + val json = fooFormat.writes(foo) + + assert(json == Json.obj("type" -> "Bar", "y" -> JsNumber(42))) + assert(fooFormat.reads(json).asEither == Right(foo)) + } + + Scenario("supports user-defined value-formats") { + sealed trait Foo + case class Bar(x: Int) extends Foo + case class Baz(s: String) extends Foo + + object Bar { + implicit val format: Format[Bar] = { + val writes = Writes[Bar] { bar => + JsNumber(bar.x) + } + val reads = Reads { json => + json.validate[Int].map(Bar.apply) + } + + Format(reads, writes) + } + } + + implicit val fooFormat: OFormat[Foo] = oformat[Foo]() + + val foo: Foo = Bar(42) + val json = fooFormat.writes(foo) + + assert(json == Json.obj("Bar" -> JsNumber(42))) + assert(fooFormat.reads(json).asEither == Right(foo)) + } + + Scenario("supports user-defined value-formats for the flat format by synthesizing a wrapper") { + sealed trait Foo + case class Bar(x: Int) extends Foo + case class Baz(s: String) extends Foo + + object Bar { + implicit val format: Format[Bar] = { + val writes = Writes[Bar] { bar => + JsNumber(bar.x) + } + val reads = Reads { json => + json.validate[Int].map(Bar.apply) + } + + Format(reads, writes) + } + } + + implicit val fooFormat: OFormat[Foo] = flat.oformat((__ \ "type").format[String]) + + val foo: Foo = Bar(42) + val json = fooFormat.writes(foo) + + assert(json == Json.obj("type" -> "Bar", "__syntheticWrap__" -> JsNumber(42))) + assert(fooFormat.reads(json).asEither == Right(foo)) + } + + import TestHelpers.* + val adt = Z(X(1), Y("VVV")) + + Scenario("supports user-defined recursive formats - nested") { + val adtFormat: Format[ADTBase] = { + implicit val f1: Format[X] = Json.format + implicit val f2: Format[Y] = Json.format + implicit lazy val f3: Format[Z] = Json.format + + implicit lazy val f4: Format[ADTBase] = oformat[ADTBase]() + + f4 + } + + val json = adtFormat.writes(adt) + val obj = Json.obj( + "Z" -> Json.obj( + "l" -> Json.obj("X" -> Json.obj("a" -> 1)), + "r" -> Json.obj("Y" -> Json.obj("b" -> "VVV")))) + + assert(json == obj) + assert(adtFormat.reads(json).asEither == Right(adt)) + } + + Scenario("supports user-defined recursive formats - flat") { + val adtFormat: Format[ADTBase] = { + implicit val f1: Format[X] = Json.format + implicit val f2: Format[Y] = Json.format + implicit lazy val f3: Format[Z] = Json.format + + implicit lazy val f4: Format[ADTBase] = flat.oformat((__ \ "type").format[String]) + + f4 + } + + val json = adtFormat.writes(adt) + val obj = Json.obj( + "type" -> "Z", + "l" -> Json.obj("type" -> "X", "a" -> 1), + "r" -> Json.obj("type" -> "Y", "b" -> "VVV")) + + assert(json == obj) + assert(adtFormat.reads(json).asEither == Right(adt)) + } + } +} + + +object TestHelpers { + // Placing it here in a separate object since otherwise the Json.format macro fails to compile + // for these types + sealed trait ADTBase + + case class X(a: Int) extends ADTBase + + case class Y(b: String) extends ADTBase + + case class Z(l: ADTBase, r: ADTBase) extends ADTBase + + sealed trait CompositeNameClass + + case class FooBar(inner: Boolean) extends CompositeNameClass + + case class fooBarry(inner: Boolean) extends CompositeNameClass + + case class foo_barrier(inner: Boolean) extends CompositeNameClass + + case class BarFoo(inner: Seq[CompositeNameClass]) extends CompositeNameClass + + lazy val validSnakeNames: Set[String] = + Set("foo_bar", "foo_barry", "foo_barrier", "bar_foo") +} + + diff --git a/library/src/test/scala-3/julienrf/json/derived/NameAdapterSuite.scala b/library/src/test/scala-3/julienrf/json/derived/NameAdapterSuite.scala new file mode 100644 index 0000000..3abcc0c --- /dev/null +++ b/library/src/test/scala-3/julienrf/json/derived/NameAdapterSuite.scala @@ -0,0 +1,143 @@ +package julienrf.json.derived + +import org.scalacheck.Arbitrary.arbitrary +import org.scalacheck.{Arbitrary, Gen} +import org.scalatest.featurespec.AnyFeatureSpec +import org.scalatestplus.scalacheck.Checkers +import play.api.libs.json.* + +class NameAdapterSuite extends AnyFeatureSpec with Checkers { + + Feature("use camelCase as the default casing for field names") { + + Scenario("product type") { + case class Foo(sC: String, iC: Int) + implicit val fooArbitrary: Arbitrary[(Foo, JsValue)] = + Arbitrary(for (s <- Gen.alphaStr; i <- arbitrary[Int]) yield (Foo(s, i), Json.obj("sC" -> s, "iC" -> i))) + implicit val fooFormat: OFormat[Foo] = oformat() + jsonIdentityLaw[Foo] + } + + } + + Feature("customize the casing for field names") { + + Scenario("product type") { + case class Foo(sC: String, iC: Int) + implicit val fooArbitrary: Arbitrary[(Foo, JsValue)] = + Arbitrary(for (s <- Gen.alphaStr; i <- arbitrary[Int]) yield (Foo(s, i), Json.obj("s_c" -> s, "i_c" -> i))) + + given fooFormat: OFormat[Foo] = oformat(snakeAdapter(2)) + + jsonIdentityLaw[Foo] + } + + Scenario("sum types") { + sealed trait Foo + case class Bar(xC: Int) extends Foo + case class Baz(sC: String) extends Foo + case object Bah extends Foo + implicit lazy val fooFormat: OFormat[Foo] = flat.oformat((__ \ "type").format[String], snakeAdapter()) + + implicit val fooArbitrary: Arbitrary[(Foo, JsValue)] = + Arbitrary( + Gen.oneOf( + arbitrary[Int].map(i => (Bar(i), Json.obj("x_c" -> i, "type" -> "Bar"))), + Gen.alphaStr.map(s => (Baz(s), Json.obj("s_c" -> s, "type" -> "Baz"))), + Gen.const((Bah, Json.obj("type" -> "Bah"))) + )) + + + jsonIdentityLaw[Foo] + } + + Scenario("sum types with options") { + sealed trait Foo + case class Bar(xC: Int) extends Foo + case class Baz(sC: String) extends Foo + case object Bah extends Foo + case class Bat(oC: Option[String]) extends Foo + + implicit val fooFormat: OFormat[Foo] = flat.oformat((__ \ "type").format[String], snakeAdapter()) + + implicit val fooArbitrary: Arbitrary[(Foo, JsValue)] = + Arbitrary( + Gen.oneOf( + arbitrary[Int].map(i => (Bar(i), Json.obj("x_c" -> i, "type" -> "Bar"))), + Gen.alphaStr.map(s => (Baz(s), Json.obj("s_c" -> s, "type" -> "Baz"))), + Gen.const((Bah, Json.obj("type" -> "Bah"))), + arbitrary[Option[String]].map(s => (Bat(s), Json.obj("type" -> "Bat") ++ s.fold(Json.obj())(x => Json.obj("o_c" -> x)))) + )) + + jsonIdentityLaw[Foo] + } + + + Scenario("recursive type") { + sealed trait Tree + case class Leaf(lS: String) extends Tree + case class Node(lhsSnake: Tree, rhsSnake: Tree) extends Tree + + def writeTree(tree: Tree): JsValue = tree match { + case n: Node => + Json.obj( + "type" -> "Node", + "lhs_snake" -> writeTree(n.lhsSnake), + "rhs_snake" -> writeTree(n.rhsSnake) + ) + case l: Leaf => Json.obj( + "type" -> "Leaf", + "l_s" -> l.lS + ) + } + + implicit val arbitraryTree: Arbitrary[Tree] = { + def atDepth(depth: Int): Gen[Tree] = + if (depth < 3) { + Gen.oneOf( + arbitrary[String].map(Leaf.apply), + for { + lhs <- atDepth(depth + 1) + rhs <- atDepth(depth + 1) + } yield Node(lhs, rhs) + ) + } else arbitrary[String].map(Leaf.apply) + + Arbitrary(atDepth(0)) + } + + implicit val arbitraryTreeWithJsValue: Arbitrary[(Tree, JsValue)] = { + Arbitrary(for (t <- arbitrary[Tree]) yield (t, writeTree(t))) + } + + { + lazy val treeReads: Reads[Tree] = flat.reads[Tree]((__ \ "type").read[String], snakeAdapter(0)) + lazy val treeWrites: OWrites[Tree] = flat.owrites((__ \ "type").write[String], snakeAdapter(0)) + implicit lazy val treeFormat: OFormat[Tree] = OFormat.apply[Tree](treeReads, treeWrites) + jsonIdentityLaw[Tree] + } + } + + } + + def snakeAdapter(max: Int = 2) = new NameAdapter { + var nameMap = Map[String, Int]() + + def increment(v1: String) = this.synchronized { + nameMap.get(v1).fold(nameMap += v1 -> 1)(i => nameMap += v1 -> (i + 1)) + if (max >= 1 && nameMap(v1) > max) throw new RuntimeException(s"Snake conversion applied more than $max times to field: $v1") + } + + override def apply(v1: String): String = { + increment(v1) + NameAdapter.snakeCase(v1) + } + } + + + def jsonIdentityLaw[A](implicit reads: Reads[A], owrites: OWrites[A], arbA: Arbitrary[(A, JsValue)]): Unit = + check((a: (A, JsValue)) => { + reads.reads(a._2).fold(_ => false, r => r == a._1 && owrites.writes(r) == a._2) + }, minSuccessful(1)) + +} diff --git a/project/build.properties b/project/build.properties index f0be67b..ee4c672 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.5.1 +sbt.version=1.10.1