diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 00000000..d8cb4251 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,46 @@ +# Performance + +JSON serialization enchmarks I found in github often measured (IMO) silly things like how fast a parser +could handle a small list of Int. For this benchmark I used a more substantial model + JSON. It's still +not large by any measure, but it does have some nested objects and collections that make it a more +realistic test. + +The test is run via jmh, a popular benchmarking tool. The JVM is stock--not tuned to within an inch +of its life, again to be a more realistic use case. + +Run benchmark from the ScalaJack/benchmark directory (not the main ScalaJack project directory): +``` +sbt "jmh:run -i 10 -wi 10 -f 2 -t 1 co.blocke.WritingBenchmark" +``` + +## Writing Performance: + +| Benchmark | Mode | Cnt | Score | Error | Units | +|------------------|-------|-----|-------------|--------------|-------| +| Hand-Tooled | thrpt | 20 | 2575393.513 | ± 178731.952 | ops/s | +| Circe | thrpt | 20 | 1939339.085 | ± 6279.547 | ops/s | +| ScalaJack 8 | thrpt | 20 | 1703256.521 | ± 12260.518 | ops/s | +| ZIO JSON | thrpt | 20 | 818228.736 | ± 3070.298 | ops/s | +| Argonaut | thrpt | 20 | 716228.404 | ± 6241.145 | ops/s | +| Play JSON | thrpt | 20 | 438538.475 | ± 16319.198 | ops/s | +| ScalaJack 7 | thrpt | 20 | 106292.338 | ± 330.111 | ops/s | + +### Writing Interpretation + +The Hand-Tooled case is straight, manual JSON creation in code. I included it to show a likely +upper-bound on achievable performance. No parser, with whatever logic it must do, can be faster +than hand-tooled code that is hard-wired in its output and requires zero logic. + +We see that both Circe and ScalaJack are very close in performance--close to each other and +shockingly close to hand-tooled code. + +Circe is the gold standard for JSON serializers due to its many features, excellent performance, and +widespread adoption. The one cost Circe imposes is the same one virtually all other serializers +require: that boilerplate be provided to define encoders/decoders to aid the serializion. Circe's +boilerplate is actually not terrible. Others require a fair bit of extra code per class serialized. + +ScalaJack's focus is first and foremost to be frictionless--no drama to the user. The very slight +difference in maximal performance is a worthy expense--its still blazing fast, and positioned well +vs the pack. ScalaJack requires zero boilerplate--you can throw any Scala object at it with no +pre-preparation and it will serialize it. You'll notice the order-of-magnitude improvement ScalaJack 8 +has over ScalaJack 7, due to moving everything possible into compile-time macros for speed. diff --git a/benchmark/build.sbt b/benchmark/build.sbt new file mode 100644 index 00000000..2b1ad05b --- /dev/null +++ b/benchmark/build.sbt @@ -0,0 +1,59 @@ +ThisBuild / organization := "co.blocke" + +val compilerOptions = Seq( + "-deprecation", + "-encoding", + "UTF-8", + "-feature", + "-language:existentials", + "-language:higherKinds", + "-unchecked", + "-Ywarn-dead-code", + "-Ywarn-numeric-widen", + "-Xfuture" +) + +val circeVersion = "0.15.0-M1" +val scalaTestVersion = "3.2.11" +ThisBuild / scalaVersion := "3.3.0" + +def priorTo2_13(scalaVersion: String): Boolean = + CrossVersion.partialVersion(scalaVersion) match { + case Some((2, minor)) if minor < 13 => true + case _ => false + } + +val baseSettings = Seq( + scalacOptions ++= compilerOptions +) + +lazy val benchmark = project + .in(file(".")) + .settings(baseSettings ++ noPublishSettings) + .settings( + libraryDependencies ++= Seq( + "io.circe" %% "circe-core", + "io.circe" %% "circe-generic", + "io.circe" %% "circe-parser" + ).map(_ % circeVersion), + libraryDependencies ++= Seq( + "org.playframework" %% "play-json" % "3.0.1", + "io.argonaut" %% "argonaut" % "6.3.9", + "co.blocke" %% "scalajack" % "826a30_unknown", + "co.blocke" %% "scala-reflection" % "sj_fixes_edbef8", + "dev.zio" %% "zio-json" % "0.6.1", + // "io.circe" %% "circe-derivation" % "0.15.0-M1", + // "io.circe" %% "circe-jackson29" % "0.14.0", + // "org.json4s" %% "json4s-jackson" % "4.0.4", + // "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-core" % "2.13.17", + // "com.github.plokhotnyuk.jsoniter-scala" %% "jsoniter-scala-macros" % "2.13.17", + "org.scalatest" %% "scalatest" % scalaTestVersion % Test + ) + ) + .enablePlugins(JmhPlugin) + +lazy val noPublishSettings = Seq( + publish := {}, + publishLocal := {}, + publishArtifact := false +) diff --git a/benchmark/project/build.properties b/benchmark/project/build.properties new file mode 100644 index 00000000..27430827 --- /dev/null +++ b/benchmark/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.9.6 diff --git a/benchmark/project/plugins.sbt b/benchmark/project/plugins.sbt new file mode 100644 index 00000000..514aeb2e --- /dev/null +++ b/benchmark/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.6") diff --git a/benchmark/src/main/scala/co.blocke/Argonaut.scala b/benchmark/src/main/scala/co.blocke/Argonaut.scala new file mode 100644 index 00000000..99424827 --- /dev/null +++ b/benchmark/src/main/scala/co.blocke/Argonaut.scala @@ -0,0 +1,26 @@ +package co.blocke + +import argonaut._, Argonaut._ +import org.openjdk.jmh.annotations._ + + +implicit val CodecPet: CodecJson[Pet] = + casecodec3(Pet.apply, (a: Pet) => Option((a.name, a.species, a.age)))("name","species","age") + +implicit val CodecFriend: CodecJson[Friend] = + casecodec3(Friend.apply, (a: Friend) => Option((a.name, a.age, a.email)))("name","age","email") + +implicit val CodecAddress: CodecJson[Address] = + casecodec4(Address.apply, (a: Address) => Option((a.street, a.city, a.state, a.postal_code)))("street","city","state","postal_code") + +implicit val CodecPerson: CodecJson[Person] = + casecodec6(Person.apply, (a: Person) => Option((a.name, a.age, a.address, a.email, a.phone_numbers, a.is_employed)))("name", "age","address","email","phone_numbers","is_employed") + +implicit val CodecRecord: CodecJson[Record] = + casecodec4(Record.apply, (a: Record) => Option((a.person, a.hobbies, a.friends, a.pets)))("person", "hobbies", "friends", "pets") + + +trait ArgonautWritingBenchmark { + @Benchmark + def writeRecordArgonaut = record.asJson +} diff --git a/benchmark/src/main/scala/co.blocke/Benchmark.scala b/benchmark/src/main/scala/co.blocke/Benchmark.scala new file mode 100644 index 00000000..a5210215 --- /dev/null +++ b/benchmark/src/main/scala/co.blocke/Benchmark.scala @@ -0,0 +1,96 @@ +package co.blocke + +import org.openjdk.jmh.annotations._ + +import java.util.concurrent.TimeUnit + +import co.blocke.scalajack.* + +import io.circe.syntax.* +import io.circe.* +import io.circe.generic.semiauto.* + +val record = ScalaJack.read[Record](jsData) + +implicit val recordDecoder: Decoder[Record] = deriveDecoder[Record] +implicit val recordEncoder: Encoder[Record] = deriveEncoder[Record] + +implicit val personDecoder: Decoder[Person] = deriveDecoder[Person] +implicit val personEncoder: Encoder[Person] = deriveEncoder[Person] + +implicit val addressDecoder: Decoder[Address] = deriveDecoder[Address] +implicit val addressEncoder: Encoder[Address] = deriveEncoder[Address] + +implicit val friendDecoder: Decoder[Friend] = deriveDecoder[Friend] +implicit val friendEncoder: Encoder[Friend] = deriveEncoder[Friend] + +implicit val petDecoder: Decoder[Pet] = deriveDecoder[Pet] +implicit val petEncoder: Encoder[Pet] = deriveEncoder[Pet] + + + +trait CirceReadingBenchmark{ + val circeJson = record.asJson + def readRecordCirce = circeJson.as[Record] +} + +// trait ScalaJackReadingBenchmark{ +// def readRecordScalaJack = ScalaJack.read[Record](jsData) +// } + +trait CirceWritingBenchmark { + @Benchmark + def writeRecordCirce = record.asJson +} + +trait ScalaJackWritingBenchmark { + @Benchmark + def writeRecordScalaJack = ScalaJack.write(record) +} + +trait HandTooledWritingBenchmark { + @Benchmark + def writeRecordHandTooled = + val sb = new StringBuilder() + sb.append("{") + sb.append("\"person\":{") + sb.append("\"name:\":\""+record.person.name+"\",") + sb.append("\"age:\":"+record.person.age+",") + sb.append("\"address:\":{\"street\":"+record.person.address.street+"\",") + sb.append("\"city\":\""+record.person.address.city+"\",") + sb.append("\"state\":\""+record.person.address.state+"\",") + sb.append("\"postal_code\":\""+record.person.address.postal_code+"\"},") + sb.append("\"email:\":\""+record.person.email+"\",") + sb.append("\"phone_numbers:\":[") + record.person.phone_numbers.map(p => sb.append("\""+p+"\",")) + sb.append("],") + sb.append("\"is_employed:\":"+record.person.is_employed+"},") + sb.append("\"hobbies:\":[") + record.hobbies.map(p => sb.append("\""+p+"\",")) + sb.append("],") + sb.append("\"friends:\":[") + record.friends.map(f=>sb.append(s"""{"name":"${f.name},"age":${f.age},"email":"${f.email}"},""")) + sb.append("],") + sb.append("\"pets:\":[") + record.pets.map(f=>sb.append(s"""{"name":"${f.name},"species":"${f.species}"","age":${f.age}},""")) + sb.append("]}") + sb.toString + } + +// @State(Scope.Thread) +// @BenchmarkMode(Array(Mode.Throughput)) +// @OutputTimeUnit(TimeUnit.SECONDS) +// class ReadingBenchmark +// extends CirceReadingBenchmark +// with ScalaJackReadingBenchmark + +@State(Scope.Thread) +@BenchmarkMode(Array(Mode.Throughput)) +@OutputTimeUnit(TimeUnit.SECONDS) +class WritingBenchmark + extends CirceWritingBenchmark + with ScalaJackWritingBenchmark + with HandTooledWritingBenchmark + with ArgonautWritingBenchmark + with PlayWritingBenchmark + with ZIOJsonWritingBenchmark diff --git a/benchmark/src/main/scala/co.blocke/PlayJson.scala b/benchmark/src/main/scala/co.blocke/PlayJson.scala new file mode 100644 index 00000000..9e1ccd27 --- /dev/null +++ b/benchmark/src/main/scala/co.blocke/PlayJson.scala @@ -0,0 +1,46 @@ +package co.blocke + +import play.api.libs.json._ +import play.api.libs.json.Reads._ +import play.api.libs.functional.syntax._ +import org.openjdk.jmh.annotations._ + +implicit val friendWrites: Writes[Friend] = ( + (JsPath \ "name").write[String] and + (JsPath \ "age").write[Int] and + (JsPath \ "email").write[String] +)(unlift((a: Friend) => Option((a.name, a.age, a.email)))) + +implicit val petWrites: Writes[Pet] = ( + (JsPath \ "name").write[String] and + (JsPath \ "species").write[String] and + (JsPath \ "age").write[Int] +)(unlift((a: Pet) => Option((a.name, a.species, a.age)))) + +implicit val addressWrites: Writes[Address] = ( + (JsPath \ "street").write[String] and + (JsPath \ "city").write[String] and + (JsPath \ "state").write[String] and + (JsPath \ "postal_code").write[String] +)(unlift((a: Address) => Option((a.street, a.city, a.state, a.postal_code)))) + +implicit val personWrites: Writes[Person] = ( + (JsPath \ "namet").write[String] and + (JsPath \ "age").write[Int] and + (JsPath \ "address").write[Address] and + (JsPath \ "email").write[String] and + (JsPath \ "phone_numbers").write[List[String]] and + (JsPath \ "is_employed").write[Boolean] +)(unlift((a: Person) => Option((a.name, a.age, a.address, a.email, a.phone_numbers, a.is_employed)))) + +implicit val recordWrites: Writes[Record] = ( + (JsPath \ "person").write[Person] and + (JsPath \ "hobbies").write[List[String]] and + (JsPath \ "friends").write[List[Friend]] and + (JsPath \ "pets").write[List[Pet]] +)(unlift((a: Record) => Option((a.person, a.hobbies, a.friends, a.pets)))) + +trait PlayWritingBenchmark { + @Benchmark + def writeRecordPlay = Json.toJson(record) +} diff --git a/benchmark/src/main/scala/co.blocke/Record.scala b/benchmark/src/main/scala/co.blocke/Record.scala new file mode 100644 index 00000000..dc8a9e60 --- /dev/null +++ b/benchmark/src/main/scala/co.blocke/Record.scala @@ -0,0 +1,86 @@ +package co.blocke + +case class Person( + name: String, + age: Int, + address: Address, + email: String, + phone_numbers: List[String], + is_employed: Boolean +) + +case class Address( + street: String, + city: String, + state: String, + postal_code: String +) + +case class Friend( + name: String, + age: Int, + email: String +) + +case class Pet( + name: String, + species: String, + age: Int +) + +case class Record( + person: Person, + hobbies: List[String], + friends: List[Friend], + pets: List[Pet] +) + + +val jsData = + """{ + "person": { + "name": "John Doe", + "age": 30, + "address": { + "street": "123 Main Street", + "city": "Anytown", + "state": "CA", + "postal_code": "12345" + }, + "email": "john.doe@example.com", + "phone_numbers": [ + "555-555-5555", + "555-123-4567" + ], + "is_employed": true + }, + "hobbies": [ + "reading", + "swimming", + "traveling" + ], + "friends": [ + { + "name": "Jane Smith", + "age": 28, + "email": "jane.smith@example.com" + }, + { + "name": "Bob Johnson", + "age": 32, + "email": "bob.johnson@example.com" + } + ], + "pets": [ + { + "name": "Fido", + "species": "Dog", + "age": 5 + }, + { + "name": "Whiskers", + "species": "Cat", + "age": 3 + } + ] + }""" \ No newline at end of file diff --git a/benchmark/src/main/scala/co.blocke/Run.scala b/benchmark/src/main/scala/co.blocke/Run.scala new file mode 100644 index 00000000..5a1d8a31 --- /dev/null +++ b/benchmark/src/main/scala/co.blocke/Run.scala @@ -0,0 +1,8 @@ +package co.blocke + +case class Foo() extends ZIOJsonWritingBenchmark + +object RunMe extends App: + + val f = Foo() + println(f.writeRecordZIOJson) \ No newline at end of file diff --git a/benchmark/src/main/scala/co.blocke/ZIOJson.scala b/benchmark/src/main/scala/co.blocke/ZIOJson.scala new file mode 100644 index 00000000..d0462744 --- /dev/null +++ b/benchmark/src/main/scala/co.blocke/ZIOJson.scala @@ -0,0 +1,20 @@ +package co.blocke + +import zio.json._ +import org.openjdk.jmh.annotations._ + +implicit val decoder1: JsonDecoder[Address] = DeriveJsonDecoder.gen[Address] +implicit val decoder2: JsonDecoder[Pet] = DeriveJsonDecoder.gen[Pet] +implicit val decoder3: JsonDecoder[Friend] = DeriveJsonDecoder.gen[Friend] +implicit val decoder4: JsonDecoder[Person] = DeriveJsonDecoder.gen[Person] +implicit val decoder5: JsonDecoder[Record] = DeriveJsonDecoder.gen[Record] +implicit val encoder1: JsonEncoder[Address] = DeriveJsonEncoder.gen[Address] +implicit val encoder2: JsonEncoder[Pet] = DeriveJsonEncoder.gen[Pet] +implicit val encoder3: JsonEncoder[Friend] = DeriveJsonEncoder.gen[Friend] +implicit val encoder4: JsonEncoder[Person] = DeriveJsonEncoder.gen[Person] +implicit val encoder5: JsonEncoder[Record] = DeriveJsonEncoder.gen[Record] + +trait ZIOJsonWritingBenchmark { + @Benchmark + def writeRecordZIOJson = record.toJson +} \ No newline at end of file diff --git a/build.sbt b/build.sbt index ceeca868..98a371dc 100644 --- a/build.sbt +++ b/build.sbt @@ -35,11 +35,12 @@ lazy val root = project Test / parallelExecution := false, scalafmtOnCompile := !isCI, libraryDependencies ++= Seq( - "co.blocke" %% "scala-reflection" % "sj_fixes_58a385", - "org.apache.commons" % "commons-text" % "1.10.0", - "org.scalameta" %% "munit" % "1.0.0-M9" % Test, - "org.json4s" %% "json4s-core" % "4.0.6" % Test, - "org.json4s" %% "json4s-native" % "4.0.6" % Test + "co.blocke" %% "scala-reflection" % "sj_fixes_edbef8", + "org.apache.commons" % "commons-text" % "1.10.0", + "io.github.kitlangton" %% "neotype" % "0.0.9", + "org.scalatest" %% "scalatest" % "3.2.17" % Test, + "org.json4s" %% "json4s-core" % "4.0.6" % Test, + "org.json4s" %% "json4s-native" % "4.0.6" % Test ) ) @@ -68,8 +69,7 @@ ThisBuild / githubWorkflowPublish := Seq( //========================== lazy val settings = Seq( javacOptions ++= Seq("-source", "1.8", "-target", "1.8"), - scalacOptions ++= compilerOptions, - testFrameworks += new TestFramework("munit.Framework") + scalacOptions ++= compilerOptions ) lazy val compilerOptions = Seq( diff --git a/src/main/scala/co.blocke.scalajack/ScalaJack.scala b/src/main/scala/co.blocke.scalajack/ScalaJack.scala index 56f43fe9..7974fb9a 100644 --- a/src/main/scala/co.blocke.scalajack/ScalaJack.scala +++ b/src/main/scala/co.blocke.scalajack/ScalaJack.scala @@ -1,26 +1,23 @@ package co.blocke.scalajack -import co.blocke.scala_reflection.TypedName +import co.blocke.scala_reflection.{RTypeRef, TypedName} import co.blocke.scala_reflection.reflect.ReflectOnType import co.blocke.scala_reflection.reflect.rtypeRefs.ClassRef -import scala.collection.mutable.HashMap +import scala.collection.mutable.{HashMap, Map} import scala.quoted.* import quoted.Quotes import json.* object ScalaJack: - inline def write[T](t: T)(using cfg: JsonConfig = JsonConfig()): String = ${ writeImpl[T]('t, 'cfg) } + inline def write[T](a: T)(using cfg: JsonConfig = JsonConfig()): String = ${ writeImpl[T]('a, 'cfg) } - def writeImpl[T: Type](t: Expr[T], cfg: Expr[JsonConfig])(using q: Quotes): Expr[String] = - import quotes.reflect.* - - val rtRef = ReflectOnType[T](q)(TypeRepr.of[T])(using scala.collection.mutable.Map.empty[TypedName, Boolean]) - val fn = JsonWriter.writeJsonFn[T](rtRef) - '{ - val sb = new StringBuilder() - $fn($t, sb, $cfg).toString - } + def writeImpl[T](aE: Expr[T], cfg: Expr[JsonConfig])(using q: Quotes, tt: Type[T]): Expr[String] = + import q.reflect.* + val ref = ReflectOnType(q)(TypeRepr.of[T], true)(using Map.empty[TypedName, Boolean]).asInstanceOf[RTypeRef[T]] + val sbE = '{ new StringBuilder() } + val classesSeen = Map.empty[TypedName, RTypeRef[?]] + '{ ${ JsonWriter.refWrite[T](cfg, ref, aE, sbE)(using classesSeen) }.toString } // --------------------------------------------------------------------- @@ -43,21 +40,3 @@ object ScalaJack: case Right(v) => v case Left(t) => throw t } - - /* - inline def foo[T](str: String)(using cfg: Config): T = ${ fooImpl[T]('str, 'cfg) } - - def fooImpl[T: Type](str: Expr[String], cfg: Expr[Config])(using q: Quotes): Expr[T] = - import quotes.reflect.* - - // How can I get some configuration here??? - - '{ // run-time - doSomething($str, $cfg) // can use str and cfg here - } - - Parameters may only be: - * Quoted parameters or fields - * Literal values of primitive types - * References to `inline val`s - */ diff --git a/src/main/scala/co.blocke.scalajack/json/JsonConfig.scala b/src/main/scala/co.blocke.scalajack/json/JsonConfig.scala index 8982166c..5aa07adb 100644 --- a/src/main/scala/co.blocke.scalajack/json/JsonConfig.scala +++ b/src/main/scala/co.blocke.scalajack/json/JsonConfig.scala @@ -5,6 +5,7 @@ case class JsonConfig( noneAsNull: Boolean = false, forbidNullsInInput: Boolean = false, tryFailureHandling: TryOption = TryOption.NO_WRITE, + permissivePrimitives: Boolean = false, // -------------------------- typeHintLabel: String = "_hint", typeHintLabelByTrait: Map[String, String] = Map.empty[String, String], // Trait name -> type hint label diff --git a/src/main/scala/co.blocke.scalajack/json/JsonParser.scala b/src/main/scala/co.blocke.scalajack/json/JsonParser.scala index 928df8d5..4b558da6 100644 --- a/src/main/scala/co.blocke.scalajack/json/JsonParser.scala +++ b/src/main/scala/co.blocke.scalajack/json/JsonParser.scala @@ -69,7 +69,14 @@ case class JsonParser(js: String, cache: Map[TypedName, (JsonConfig, JsonParser) case 'f' if i + 4 < max && jsChars(i + 1) == 'a' && jsChars(i + 2) == 'l' && jsChars(i + 3) == 's' && jsChars(i + 4) == 'e' => i += 5 Right(false) - case x => Left(JsonParseError(showError(s"Unexpected character '$x' where beginning of boolean value expected at position [$i]"))) + case x => + if cfg.permissivePrimitives && jsChars(i) == '"' then + for { + _ <- expectQuote + result <- expectBoolean(cfg, p) + _ <- expectQuote + } yield result + else Left(JsonParseError(showError(s"Unexpected character '$x' where beginning of boolean value expected at position [$i]"))) def expectLong(cfg: JsonConfig, p: JsonParser): Either[ParseError, Long] = val mark = i @@ -81,11 +88,18 @@ case class JsonParser(js: String, cache: Map[TypedName, (JsonConfig, JsonParser) Try(js.substring(mark, i).toLong) match case Success(g) => Right(g) case Failure(f) => - val msg = - if mark == i then s"""Int/Long expected but couldn't parse from "${jsChars(i)}" at position [$i]""" - else s"""Int/Long expected but couldn't parse from "${js.substring(mark, i)}" at position [$i]""" - i = mark - Left(JsonParseError(showError(msg))) + if cfg.permissivePrimitives && jsChars(i) == '"' then + for { + _ <- expectQuote + result <- expectLong(cfg, p) + _ <- expectQuote + } yield result + else + val msg = + if mark == i then s"""Int/Long expected but couldn't parse from "${jsChars(i)}" at position [$i]""" + else s"""Int/Long expected but couldn't parse from "${js.substring(mark, i)}" at position [$i]""" + i = mark + Left(JsonParseError(showError(msg))) def expectBigLong(cfg: JsonConfig, p: JsonParser): Either[ParseError, BigInt] = nullCheck match @@ -101,11 +115,18 @@ case class JsonParser(js: String, cache: Map[TypedName, (JsonConfig, JsonParser) Try(BigInt(js.substring(mark, i))) match case Success(g) => Right(g) case Failure(f) => - val msg = - if mark == i then s"""Int/Long expected but couldn't parse from "${jsChars(i)}" at position [$i]""" - else s"""Int/Long expected but couldn't parse from "${js.substring(mark, i)}" at position [$i]""" - i = mark - Left(JsonParseError(showError(msg))) + if cfg.permissivePrimitives && jsChars(i) == '"' then + for { + _ <- expectQuote + result <- expectBigLong(cfg, p) + _ <- expectQuote + } yield result + else + val msg = + if mark == i then s"""Int/Long expected but couldn't parse from "${jsChars(i)}" at position [$i]""" + else s"""Int/Long expected but couldn't parse from "${js.substring(mark, i)}" at position [$i]""" + i = mark + Left(JsonParseError(showError(msg))) def expectDouble(cfg: JsonConfig, p: JsonParser): Either[ParseError, Double] = val mark = i @@ -117,11 +138,18 @@ case class JsonParser(js: String, cache: Map[TypedName, (JsonConfig, JsonParser) Try(js.substring(mark, i).toDouble) match case Success(g) => Right(g) case Failure(_) => - val msg = - if mark == i then s"Float/Double expected but couldn't parse from \"${jsChars(i)}\" at position [$i]" - else s"Float/Double expected but couldn't parse from \"${js.substring(mark, i)}\" at position [$i]" - i = mark - Left(JsonParseError(showError(msg))) + if cfg.permissivePrimitives && jsChars(i) == '"' then + for { + _ <- expectQuote + result <- expectDouble(cfg, p) + _ <- expectQuote + } yield result + else + val msg = + if mark == i then s"Float/Double expected but couldn't parse from \"${jsChars(i)}\" at position [$i]" + else s"Float/Double expected but couldn't parse from \"${js.substring(mark, i)}\" at position [$i]" + i = mark + Left(JsonParseError(showError(msg))) def expectBigDouble(cfg: JsonConfig, p: JsonParser): Either[ParseError, BigDecimal] = nullCheck match @@ -137,11 +165,18 @@ case class JsonParser(js: String, cache: Map[TypedName, (JsonConfig, JsonParser) Try(BigDecimal(js.substring(mark, i))) match case Success(g) => Right(g) case Failure(_) => - val msg = - if mark == i then s"Float/Double expected but couldn't parse from \"${jsChars(i)}\" at position [$i]" - else s"Float/Double expected but couldn't parse from \"${js.substring(mark, i)}\" at position [$i]" - i = mark - Left(JsonParseError(showError(msg))) + if cfg.permissivePrimitives && jsChars(i) == '"' then + for { + _ <- expectQuote + result <- expectBigDouble(cfg, p) + _ <- expectQuote + } yield result + else + val msg = + if mark == i then s"Float/Double expected but couldn't parse from \"${jsChars(i)}\" at position [$i]" + else s"Float/Double expected but couldn't parse from \"${js.substring(mark, i)}\" at position [$i]" + i = mark + Left(JsonParseError(showError(msg))) def expectString(cfg: JsonConfig, p: JsonParser): Either[ParseError, String] = nullCheck match @@ -161,7 +196,7 @@ case class JsonParser(js: String, cache: Map[TypedName, (JsonConfig, JsonParser) case _ => i += 1 i += 1 Right(captured.getOrElse(js.substring(mark, i - 1))) - case x => Left(JsonParseError(showError(s"Unexpected character '$x' where beginning of label expected at position [$i]"))) + case x => Left(JsonParseError(showError(s"Unexpected character '$x' where beginning of a string expected at position [$i]"))) def expectList[T](cfg: JsonConfig, expectElement: (JsonConfig, JsonParser) => Either[ParseError, T]): Either[ParseError, List[T]] = nullCheck match diff --git a/src/main/scala/co.blocke.scalajack/json/JsonWriter.scala b/src/main/scala/co.blocke.scalajack/json/JsonWriter.scala index fc6952cc..d78bc15a 100644 --- a/src/main/scala/co.blocke.scalajack/json/JsonWriter.scala +++ b/src/main/scala/co.blocke.scalajack/json/JsonWriter.scala @@ -1,17 +1,34 @@ package co.blocke.scalajack package json -import co.blocke.scala_reflection.reflect.rtypeRefs.* -import co.blocke.scala_reflection.reflect.* -import co.blocke.scala_reflection.{RTypeRef, TypedName} -import co.blocke.scala_reflection.rtypes.* -import co.blocke.scala_reflection.Liftables.TypedNameToExpr import scala.quoted.* -import co.blocke.scala_reflection.RType -import scala.jdk.CollectionConverters.* -import java.util.concurrent.ConcurrentHashMap -import scala.util.{Failure, Success} -import org.apache.commons.text.StringEscapeUtils +import co.blocke.scala_reflection.* +import co.blocke.scala_reflection.rtypes.{ScalaClassRType, TraitRType} +import co.blocke.scala_reflection.reflect.{ReflectOnType, TypeSymbolMapper} +import co.blocke.scala_reflection.reflect.rtypeRefs.* +import scala.util.{Failure, Success, Try} +import scala.quoted.staging.* + +/* + TODO: + [ ] - Scala non-case class + [ ] - Java class (Do I still want to support this???) + [ ] - Enum + [ ] - Enumeration + [ ] - Java Enum + [ ] - Java Collections + [ ] - Java Map + [ ] - Intersection + [ ] - Union + [ ] - Either + [ ] - Object (How???) + [ ] - Sealed Trait (How???) + [*] - SelfRef + [ ] - Tuple + [ ] - Unknown (throw exception) + [ ] - Scala 2 (throw exception) + [ ] - TypeSymbol (throw exception) + */ object JsonWriter: @@ -24,353 +41,180 @@ object JsonWriter: case Failure(_) if cfg.tryFailureHandling == TryOption.NO_WRITE => false case _ => true - val refCache = new ConcurrentHashMap[TypedName, (?, StringBuilder, JsonConfig) => StringBuilder] - - def writeJsonFn[T](rtRef: RTypeRef[T], isMapKey: Boolean = false)(using tt: Type[T], q: Quotes): Expr[(T, StringBuilder, JsonConfig) => StringBuilder] = + def refWrite[T]( + cfgE: Expr[JsonConfig], + ref: RTypeRef[T], + aE: Expr[T], + sbE: Expr[StringBuilder], + isMapKey: Boolean = false + )(using classesSeen: scala.collection.mutable.Map[TypedName, RTypeRef[?]])(using Quotes, Type[T]): Expr[StringBuilder] = import quotes.reflect.* - rtRef match - case rt: PrimitiveRef[?] if rt.family == PrimFamily.Stringish => - val nullable = Expr(rt.isNullable) - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - if $nullable && a == null then sb.append("null") - else - sb.append('"') - sb.append(StringEscapeUtils.escapeJson(a.toString)) - sb.append('"') - } - case rt: PrimitiveRef[?] => - val nullable = Expr(rt.isNullable) + ref match + case t: PrimitiveRef[?] if t.family == PrimFamily.Stringish => '{ if $aE == null then $sbE.append("null") else $sbE.append("\"" + $aE.toString + "\"") } + case t: PrimitiveRef[?] => if isMapKey then - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - if $nullable && a == null then sb.append("null") + '{ + if $aE == null then $sbE.append("\"null\"") else - sb.append('"') - sb.append(a.toString) - sb.append('"') - } - else - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - if $nullable && a == null then sb.append("null") - else sb.append(a.toString) + $sbE.append('"') + $sbE.append($aE.toString) + $sbE.append('"') } + else '{ if $aE == null then $sbE.append("null") else $sbE.append($aE.toString) } - case rt: AliasRef[?] => - val fn = writeJsonFn[rt.T](rt.unwrappedType.asInstanceOf[RTypeRef[rt.T]], isMapKey)(using Type.of[rt.T]) - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => $fn(a, sb, cfg) } + case t: SeqRef[?] => + if isMapKey then throw new JsonError("Seq instances cannot be map keys") - case rt: ArrayRef[?] => - if isMapKey then throw new JsonError("Arrays cannot be map keys.") - rt.elementRef.refType match - case '[t] => - val elementFn = writeJsonFn[t](rt.elementRef.asInstanceOf[RTypeRef[t]]) - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - if a == null then sb.append("null") + t.elementRef.refType match + case '[e] => + '{ + val sb = $sbE + if $aE == null then sb.append("null") else sb.append('[') val sbLen = sb.length - a.asInstanceOf[Array[t]].foreach { e => - if isOkToWrite(e, cfg) then - $elementFn(e.asInstanceOf[t], sb, cfg) + + $aE.asInstanceOf[Seq[e]].foldLeft(sb) { (acc, one) => + if isOkToWrite(one, $cfgE) then + ${ refWrite[e](cfgE, t.elementRef.asInstanceOf[RTypeRef[e]], '{ one }, '{ acc }) } sb.append(',') + else sb } + if sbLen == sb.length then sb.append(']') else sb.setCharAt(sb.length() - 1, ']') } + case t: ArrayRef[?] => + if isMapKey then throw new JsonError("Arrays cannot be map keys") + + t.elementRef.refType match + case '[e] => + '{ + val sb = $sbE + if $aE == null then sb.append("null") + else + sb.append('[') + val sbLen = sb.length - case rt: ClassRef[?] => - if isMapKey then throw new JsonError("Classes cannot be map keys.") - val fieldFns = rt.fields.map { f => - f.fieldRef.refType match - case '[t] => (writeJsonFn[t](f.fieldRef.asInstanceOf[RTypeRef[t]]), f) - } - val typedName = Expr(rt.typedName) + $aE.asInstanceOf[Array[e]].foldLeft(sb) { (acc, one) => + if isOkToWrite(one, $cfgE) then + ${ refWrite[e](cfgE, t.elementRef.asInstanceOf[RTypeRef[e]], '{ one }, '{ acc }) } + sb.append(',') + else sb + } + + if sbLen == sb.length then sb.append(']') + else sb.setCharAt(sb.length() - 1, ']') + } + + case t: ClassRef[?] => + classesSeen.put(t.typedName, t) '{ - val classFn = (a: T, sb: StringBuilder, cfg: JsonConfig) => + val sb = $sbE + if $aE == null then sb.append("null") + else sb.append('{') val sbLen = sb.length ${ - val hintStmt = (rt match - case s: ScalaClassRef[?] if s.renderTrait.isDefined => - val traitName = Expr(s.renderTrait.get) - '{ - sb.append('"') - sb.append(cfg.typeHintLabelByTrait.getOrElse($traitName, cfg.typeHintLabel)) - sb.append('"') - sb.append(':') - sb.append('"') - val hint = cfg.typeHintTransformer.get(a.getClass.getName) match - case Some(xform) => xform(a) - case None => cfg.typeHintDefaultTransformer(a.getClass.getName) - sb.append(hint) - sb.append('"') - sb.append(',') - () - } - case _ => '{ () } - ) // .asInstanceOf[Expr[Unit]] - val stmts = hintStmt :: fieldFns.map { case (fn, field) => - '{ - val fieldValue = ${ - Select.unique('{ a }.asTerm, field.name).asExpr - } - if isOkToWrite(fieldValue, cfg) then - ${ - field.fieldRef.refType match - case '[t] => - '{ - sb.append('"') - sb.append(${ Expr(field.name) }) - sb.append('"') - sb.append(':') - val fn2 = $fn.asInstanceOf[(t, StringBuilder, JsonConfig) => StringBuilder] - fn2(fieldValue.asInstanceOf[t], sb, cfg) - sb.append(',') - } + t.fields.foldLeft('{ sb }) { (accE, f) => + f.fieldRef.refType match + case '[e] => + val fieldValue = Select.unique(aE.asTerm, f.name).asExprOf[e] + val name = Expr(f.name) + '{ + val acc = $accE + if isOkToWrite($fieldValue, $cfgE) then + acc.append('"') + acc.append($name) + acc.append('"') + acc.append(':') + val b = ${ refWrite[e](cfgE, f.fieldRef.asInstanceOf[RTypeRef[e]], fieldValue, '{ acc }) } + acc.append(',') + else acc } - } } - Expr.ofList(stmts) } if sbLen == sb.length then sb.append('}') else sb.setCharAt(sb.length() - 1, '}') - refCache.put($typedName, classFn) - classFn } - case rt: TraitRef[?] => - if isMapKey then throw new JsonError("Traits cannot be map keys.") - val typedName = Expr(rt.typedName) - val traitType = rt.expr - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - val comboName = ($traitType.typedName.toString + "::" + a.getClass.getName).asInstanceOf[TypedName] - Option(refCache.get(comboName)) match - case Some(writerFn) => - writerFn.asInstanceOf[(T, StringBuilder, JsonConfig) => StringBuilder](a, sb, cfg) - case None => - val writerFn = ReflectUtil.inTermsOf[T]($traitType, a, sb, cfg) - refCache.put(comboName, writerFn) - writerFn(a, sb, cfg) - } - - case rt: SeqRef[?] => - if isMapKey then throw new JsonError("Seq instances cannot be map keys.") - rt.elementRef.refType match - case '[t] => - val elementFn = writeJsonFn[t](rt.elementRef.asInstanceOf[RTypeRef[t]]) - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - sb.append('[') - val sbLen = sb.length - a.asInstanceOf[Seq[?]].foreach { e => - if isOkToWrite(e, cfg) then - $elementFn(e.asInstanceOf[t], sb, cfg) - sb.append(',') - } - if sbLen == sb.length then sb.append(']') - else sb.setCharAt(sb.length() - 1, ']') - } - - case rt: OptionRef[?] => - if isMapKey then throw new JsonError("Options cannot be map keys.") - rt.optionParamType.refType match - case '[t] => - val fn = writeJsonFn[t](rt.optionParamType.asInstanceOf[RTypeRef[t]]) - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - a match - case None => sb.append("null") - case Some(v) => $fn(v.asInstanceOf[t], sb, cfg) - } - - case rt: TryRef[?] => - if isMapKey then throw new JsonError("Try cannot be map keys.") - rt.tryRef.refType match - case '[t] => - val fn = writeJsonFn[t](rt.tryRef.asInstanceOf[RTypeRef[t]]) - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - a match - case Success(v) => $fn(v.asInstanceOf[t], sb, cfg) - case Failure(_) if cfg.tryFailureHandling == TryOption.AS_NULL => sb.append("null") - case Failure(v) => - sb.append('"') - sb.append(v.getMessage) - sb.append('"') - } - - case rt: MapRef[?] => - if isMapKey then throw new JsonError("Maps cannot be map keys.") - rt.elementRef.refType match - case '[k] => - rt.elementRef2.refType match - case '[v] => - val keyFn = writeJsonFn[k](rt.elementRef.asInstanceOf[RTypeRef[k]], true) - val valueFn = writeJsonFn[v](rt.elementRef2.asInstanceOf[RTypeRef[v]]) - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - sb.append('{') - val sbLen = sb.length - a.asInstanceOf[Map[?, ?]].foreach { case (key, value) => - if isOkToWrite(value, cfg) then - $keyFn(key.asInstanceOf[k], sb, cfg) - sb.append(':') - $valueFn(value.asInstanceOf[v], sb, cfg) - sb.append(',') - } - if sbLen == sb.length then sb.append('}') - else sb.setCharAt(sb.length() - 1, '}') - } - - case rt: LeftRightRef[?] => - if isMapKey then throw new JsonError("Union, intersection, or Either cannot be map keys.") - rt.leftRef.refType match - case '[lt] => - val leftFn = writeJsonFn[lt](rt.leftRef.asInstanceOf[RTypeRef[lt]]) - rt.rightRef.refType match - case '[rt] => - val rightFn = writeJsonFn[rt](rt.rightRef.asInstanceOf[RTypeRef[rt]]) - val rtypeExpr = rt.expr - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - $rtypeExpr match - case r if r.clazz == classOf[Either[_, _]] => - a match - case Left(v) => - $leftFn(v.asInstanceOf[lt], sb, cfg) - case Right(v) => - $rightFn(v.asInstanceOf[rt], sb, cfg) - case _: RType[?] => // Intersection & Union types.... take your best shot! It's all we've got. No definitive info here. - val trial = new StringBuilder() - if scala.util.Try($rightFn(a.asInstanceOf[rt], trial, cfg)).isFailure then $leftFn(a.asInstanceOf[lt], sb, cfg) - else sb ++= trial - } - - case rt: EnumRef[?] => - val expr = rt.expr - val isMapKeyExpr = Expr(isMapKey) + case t: TraitRef[?] => + classesSeen.put(t.typedName, t) + val rt = t.expr '{ - val rtype = $expr.asInstanceOf[EnumRType[?]] - (a: T, sb: StringBuilder, cfg: JsonConfig) => - val enumAsId = cfg.enumsAsIds match - case '*' => true - case aList: List[String] if aList.contains(rtype.name) => true - case _ => false - if enumAsId then - val enumVal = rtype.ordinal(a.toString).getOrElse(throw new JsonError(s"Value $a is not a valid enum value for ${rtype.name}")) - if $isMapKeyExpr then - sb.append('"') - sb.append(enumVal.toString) - sb.append('"') - else sb.append(enumVal.toString) - else - sb.append('"') - sb.append(a.toString) - sb.append('"') - } - - // TODO: Not sure this is right! - case rt: SealedTraitRef[?] => - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - sb.append('"') - a.toString - sb.append('"') - } - - case rt: TupleRef[?] => - if isMapKey then throw new JsonError("Tuples cannot be map keys.") - val elementFns = rt.tupleRefs.map { f => - f.refType match - case '[t] => (writeJsonFn[t](f.asInstanceOf[RTypeRef[t]]), f) - } - val numElementsExpr = Expr(elementFns.size) - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - sb.append('[') - val sbLen = sb.length - ${ - val stmts = elementFns.zipWithIndex.map { case ((fn, e), i) => - '{ - val fieldValue = ${ - Select.unique('{ a }.asTerm, "_" + (i + 1)).asExpr - } - ${ - e.refType match - case '[t] => - '{ - val fn2 = $fn.asInstanceOf[(t, StringBuilder, JsonConfig) => StringBuilder] - fn2(fieldValue.asInstanceOf[t], sb, cfg) - sb.append(',') - } - } - } - } - Expr.ofList(stmts) + given Compiler = Compiler.make($aE.getClass.getClassLoader) + val fn = (q: Quotes) ?=> { + import q.reflect.* + val sb = $sbE + val classRType = RType.inTermsOf[T]($aE.getClass).asInstanceOf[ScalaClassRType[T]].copy(renderTrait = Some($rt.name)).asInstanceOf[RType[T]] + JsonWriterRT.refWriteRT[classRType.T]($cfgE, classRType, $aE.asInstanceOf[classRType.T], $sbE)(using scala.collection.mutable.Map.empty[TypedName, RType[?]]) + Expr(1) // do-nothing... '{} requires Expr(something) be returned, so... } - if sbLen == sb.length then sb.append(']') - else sb.setCharAt(sb.length() - 1, ']') - } - - case rt: SelfRefRef[?] => - if isMapKey then throw new JsonError("Classes or traits cannot be map keys.") - val e = rt.expr - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - val fn = refCache.get($e.typedName).asInstanceOf[(T, StringBuilder, JsonConfig) => StringBuilder] - fn(a, sb, cfg) + quoted.staging.run(fn) + $sbE } - case rt: JavaCollectionRef[?] => - if isMapKey then throw new JsonError("Collections cannot be map keys.") - rt.elementRef.refType match - case '[t] => - val elementFn = writeJsonFn[t](rt.elementRef.asInstanceOf[RTypeRef[t]]) - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - sb.append('[') - val sbLen = sb.length - a.asInstanceOf[java.util.Collection[?]].toArray.foreach { e => - if isOkToWrite(e, cfg) then - $elementFn(e.asInstanceOf[t], sb, cfg) - sb.append(',') - } - if sbLen == sb.length then sb.append(']') - else sb.setCharAt(sb.length() - 1, ']') + case t: OptionRef[?] => + if isMapKey then throw new JsonError("Option valuess cannot be map keys") + t.optionParamType.refType match + case '[e] => + '{ + $aE match + case None => $sbE.append("null") + case Some(v) => + ${ refWrite[e](cfgE, t.optionParamType.asInstanceOf[RTypeRef[e]], '{ v }.asInstanceOf[Expr[e]], sbE) } } - case rt: JavaMapRef[?] => - if isMapKey then throw new JsonError("Maps cannot be map keys.") - rt.elementRef.refType match + case t: MapRef[?] => + if isMapKey then throw new JsonError("Map values cannot be map keys") + t.elementRef.refType match case '[k] => - rt.elementRef2.refType match + t.elementRef2.refType match case '[v] => - val keyFn = writeJsonFn[k](rt.elementRef.asInstanceOf[RTypeRef[k]], true) - val valueFn = writeJsonFn[v](rt.elementRef.asInstanceOf[RTypeRef[v]]) - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - sb.append('{') - val sbLen = sb.length - a.asInstanceOf[java.util.Map[?, ?]].asScala.foreach { case (key, value) => - if isOkToWrite(value, cfg) then - $keyFn(key.asInstanceOf[k], sb, cfg) - sb.append(':') - $valueFn(value.asInstanceOf[v], sb, cfg) - sb.append(',') - } - if sbLen == sb.length then sb.append('}') - else sb.setCharAt(sb.length() - 1, '}') + '{ + val sb = $sbE + if $aE == null then sb.append("null") + else + sb.append('{') + val sbLen = sb.length + $aE.asInstanceOf[Map[?, ?]].foreach { case (key, value) => + if isOkToWrite(value, $cfgE) then + val b = ${ refWrite[k](cfgE, t.elementRef.asInstanceOf[RTypeRef[k]], '{ key }.asInstanceOf[Expr[k]], sbE, true) } + b.append(':') + val b2 = ${ refWrite[v](cfgE, t.elementRef2.asInstanceOf[RTypeRef[v]], '{ value }.asInstanceOf[Expr[v]], sbE) } + b2.append(',') + } + if sbLen == sb.length then sb.append('}') + else sb.setCharAt(sb.length() - 1, '}') } - case rt: ObjectRef => - val name = Expr(rt.name) - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - sb.append('"') - sb.append($name) - sb.append('"') - } + case t: TryRef[?] => + if isMapKey then throw new JsonError("Try values (Succeed/Fail) cannot be map keys") + t.tryRef.refType match + case '[e] => + '{ + $aE match + case Success(v) => + ${ refWrite[e](cfgE, t.tryRef.asInstanceOf[RTypeRef[e]], '{ v }.asInstanceOf[Expr[e]], sbE) } + case Failure(_) if $cfgE.tryFailureHandling == TryOption.AS_NULL => $sbE.append("null") + case Failure(v) => + $sbE.append('"') + $sbE.append(v.getMessage) + $sbE.append('"') + } - case rt: Scala2Ref[?] => - val name = Expr(rt.name) - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - sb.append('"') - sb.append($name) - sb.append('"') - } + case t: AliasRef[?] => + t.unwrappedType.refType match + case '[e] => + refWrite[e](cfgE, t.unwrappedType.asInstanceOf[RTypeRef[e]], aE.asInstanceOf[Expr[e]], sbE) - case rt: UnknownRef[?] => - '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => - sb.append('"') - sb.append("unknown") - sb.append('"') + case t: SelfRefRef[?] => + if isMapKey then throw new JsonError("Classes or traits cannot be map keys.") + import quotes.reflect.* + val againE = classesSeen.getOrElse(t.typedName, throw new JsonError("Dangling self-reference: " + t.name)).asInstanceOf[RTypeRef[T]].expr + '{ + val again = $againE.asInstanceOf[RType[T]] + JsonWriterRT.refWriteRT[T]($cfgE, again, $aE.asInstanceOf[T], $sbE)(using scala.collection.mutable.Map.empty[TypedName, RType[?]]) + $sbE } diff --git a/src/main/scala/co.blocke.scalajack/json/JsonWriter.scalax b/src/main/scala/co.blocke.scalajack/json/JsonWriter.scalax new file mode 100644 index 00000000..d33919ef --- /dev/null +++ b/src/main/scala/co.blocke.scalajack/json/JsonWriter.scalax @@ -0,0 +1,394 @@ +package co.blocke.scalajack +package json + +import co.blocke.scala_reflection.reflect.rtypeRefs.* +import co.blocke.scala_reflection.reflect.* +import co.blocke.scala_reflection.{RTypeRef, TypedName} +import co.blocke.scala_reflection.rtypes.* +import co.blocke.scala_reflection.Liftables.TypedNameToExpr +import scala.quoted.* +import co.blocke.scala_reflection.RType +import scala.jdk.CollectionConverters.* +import java.util.concurrent.ConcurrentHashMap +import scala.util.{Failure, Success} +import org.apache.commons.text.StringEscapeUtils + +object JsonWriter: + + // Tests whether we should write something or not--mainly in the case of Option, or wrapped Option + def isOkToWrite(a: Any, cfg: JsonConfig) = + a match + case None if !cfg.noneAsNull => false + case Left(None) if !cfg.noneAsNull => false + case Right(None) if !cfg.noneAsNull => false + case Failure(_) if cfg.tryFailureHandling == TryOption.NO_WRITE => false + case _ => true + + val refCache = new ConcurrentHashMap[TypedName, (?, StringBuilder, JsonConfig) => StringBuilder] + + def writeJsonFn[T](rtRef: RTypeRef[T], isMapKey: Boolean = false)(using tt: Type[T], q: Quotes): Expr[(T, StringBuilder, JsonConfig) => StringBuilder] = + import quotes.reflect.* + + rtRef match +//***** + case rt: PrimitiveRef[?] if rt.family == PrimFamily.Stringish => + val nullable = Expr(rt.isNullable) + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + if $nullable && a == null then sb.append("null") + else + sb.append('"') + sb.append(StringEscapeUtils.escapeJson(a.toString)) + sb.append('"') + } +//***** + case rt: PrimitiveRef[?] => + val nullable = Expr(rt.isNullable) + if isMapKey then + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + if $nullable && a == null then sb.append("null") + else + sb.append('"') + sb.append(a.toString) + sb.append('"') + } + else + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + if $nullable && a == null then sb.append("null") + else sb.append(a.toString) + } + +//***** + case rt: AliasRef[?] => + val fn = writeJsonFn[rt.T](rt.unwrappedType.asInstanceOf[RTypeRef[rt.T]], isMapKey)(using Type.of[rt.T]) + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => $fn(a, sb, cfg) } + +//***** + case rt: ArrayRef[?] => + if isMapKey then throw new JsonError("Arrays cannot be map keys.") + rt.elementRef.refType match + case '[t] => + val elementFn = writeJsonFn[t](rt.elementRef.asInstanceOf[RTypeRef[t]]) + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + if a == null then sb.append("null") + else + sb.append('[') + val sbLen = sb.length + a.asInstanceOf[Array[t]].foreach { e => + if isOkToWrite(e, cfg) then + $elementFn(e.asInstanceOf[t], sb, cfg) + sb.append(',') + } + if sbLen == sb.length then sb.append(']') + else sb.setCharAt(sb.length() - 1, ']') + } + +//***** + case rt: ClassRef[?] => + if isMapKey then throw new JsonError("Classes cannot be map keys.") + val fieldFns = rt.fields.map { f => + f.fieldRef.refType match + case '[t] => (writeJsonFn[t](f.fieldRef.asInstanceOf[RTypeRef[t]]), f) + } + val typedName = Expr(rt.typedName) + '{ + val classFn = (a: T, sb: StringBuilder, cfg: JsonConfig) => + sb.append('{') + val sbLen = sb.length + ${ + val hintStmt = (rt match + case s: ScalaClassRef[?] if s.renderTrait.isDefined => + val traitName = Expr(s.renderTrait.get) + '{ + sb.append('"') + sb.append(cfg.typeHintLabelByTrait.getOrElse($traitName, cfg.typeHintLabel)) + sb.append('"') + sb.append(':') + sb.append('"') + val hint = cfg.typeHintTransformer.get(a.getClass.getName) match + case Some(xform) => xform(a) + case None => cfg.typeHintDefaultTransformer(a.getClass.getName) + sb.append(hint) + sb.append('"') + sb.append(',') + () + } + case _ => '{ () } + ) // .asInstanceOf[Expr[Unit]] + val stmts = hintStmt :: fieldFns.map { case (fn, field) => + '{ + val fieldValue = ${ + Select.unique('{ a }.asTerm, field.name).asExpr + } + if isOkToWrite(fieldValue, cfg) then + ${ + field.fieldRef.refType match + case '[t] => + '{ + sb.append('"') + sb.append(${ Expr(field.name) }) + sb.append('"') + sb.append(':') + val fn2 = $fn.asInstanceOf[(t, StringBuilder, JsonConfig) => StringBuilder] + fn2(fieldValue.asInstanceOf[t], sb, cfg) + sb.append(',') + } + } + } + } + Expr.ofList(stmts) + } + if sbLen == sb.length then sb.append('}') + else sb.setCharAt(sb.length() - 1, '}') + refCache.put($typedName, classFn) + classFn + } + + case rt: TraitRef[?] => + if isMapKey then throw new JsonError("Traits cannot be map keys.") + val typedName = Expr(rt.typedName) + val traitType = rt.expr + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + val comboName = ($traitType.typedName.toString + "::" + a.getClass.getName).asInstanceOf[TypedName] + Option(refCache.get(comboName)) match + case Some(writerFn) => + writerFn.asInstanceOf[(T, StringBuilder, JsonConfig) => StringBuilder](a, sb, cfg) + case None => + val writerFn = ReflectUtil.inTermsOf[T]($traitType, a, sb, cfg) + refCache.put(comboName, writerFn) + writerFn(a, sb, cfg) + } + +//***** + case rt: SeqRef[?] => + if isMapKey then throw new JsonError("Seq instances cannot be map keys.") + rt.elementRef.refType match + case '[t] => + val elementFn = writeJsonFn[t](rt.elementRef.asInstanceOf[RTypeRef[t]]) + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + sb.append('[') + val sbLen = sb.length + a.asInstanceOf[Seq[?]].foreach { e => + if isOkToWrite(e, cfg) then + $elementFn(e.asInstanceOf[t], sb, cfg) + sb.append(',') + } + if sbLen == sb.length then sb.append(']') + else sb.setCharAt(sb.length() - 1, ']') + } + +//***** + case rt: OptionRef[?] => + if isMapKey then throw new JsonError("Options cannot be map keys.") + rt.optionParamType.refType match + case '[t] => + val fn = writeJsonFn[t](rt.optionParamType.asInstanceOf[RTypeRef[t]]) + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + a match + case None => sb.append("null") + case Some(v) => $fn(v.asInstanceOf[t], sb, cfg) + } + +//***** + case rt: TryRef[?] => + if isMapKey then throw new JsonError("Try cannot be map keys.") + rt.tryRef.refType match + case '[t] => + val fn = writeJsonFn[t](rt.tryRef.asInstanceOf[RTypeRef[t]]) + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + a match + case Success(v) => $fn(v.asInstanceOf[t], sb, cfg) + case Failure(_) if cfg.tryFailureHandling == TryOption.AS_NULL => sb.append("null") + case Failure(v) => + sb.append('"') + sb.append(v.getMessage) + sb.append('"') + } + +//***** + case rt: MapRef[?] => + if isMapKey then throw new JsonError("Maps cannot be map keys.") + rt.elementRef.refType match + case '[k] => + rt.elementRef2.refType match + case '[v] => + val keyFn = writeJsonFn[k](rt.elementRef.asInstanceOf[RTypeRef[k]], true) + val valueFn = writeJsonFn[v](rt.elementRef2.asInstanceOf[RTypeRef[v]]) + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + sb.append('{') + val sbLen = sb.length + a.asInstanceOf[Map[?, ?]].foreach { case (key, value) => + if isOkToWrite(value, cfg) then + $keyFn(key.asInstanceOf[k], sb, cfg) + sb.append(':') + $valueFn(value.asInstanceOf[v], sb, cfg) + sb.append(',') + } + if sbLen == sb.length then sb.append('}') + else sb.setCharAt(sb.length() - 1, '}') + } + + case rt: LeftRightRef[?] => + if isMapKey then throw new JsonError("Union, intersection, or Either cannot be map keys.") + rt.leftRef.refType match + case '[lt] => + val leftFn = writeJsonFn[lt](rt.leftRef.asInstanceOf[RTypeRef[lt]]) + rt.rightRef.refType match + case '[rt] => + val rightFn = writeJsonFn[rt](rt.rightRef.asInstanceOf[RTypeRef[rt]]) + val rtypeExpr = rt.expr + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + $rtypeExpr match + case r if r.clazz == classOf[Either[_, _]] => + a match + case Left(v) => + $leftFn(v.asInstanceOf[lt], sb, cfg) + case Right(v) => + $rightFn(v.asInstanceOf[rt], sb, cfg) + case _: RType[?] => // Intersection & Union types.... take your best shot! It's all we've got. No definitive info here. + val trial = new StringBuilder() + if scala.util.Try($rightFn(a.asInstanceOf[rt], trial, cfg)).isFailure then $leftFn(a.asInstanceOf[lt], sb, cfg) + else sb ++= trial + } + + case rt: EnumRef[?] => + val expr = rt.expr + val isMapKeyExpr = Expr(isMapKey) + '{ + val rtype = $expr.asInstanceOf[EnumRType[?]] + (a: T, sb: StringBuilder, cfg: JsonConfig) => + val enumAsId = cfg.enumsAsIds match + case '*' => true + case aList: List[String] if aList.contains(rtype.name) => true + case _ => false + if enumAsId then + val enumVal = rtype.ordinal(a.toString).getOrElse(throw new JsonError(s"Value $a is not a valid enum value for ${rtype.name}")) + if $isMapKeyExpr then + sb.append('"') + sb.append(enumVal.toString) + sb.append('"') + else sb.append(enumVal.toString) + else + sb.append('"') + sb.append(a.toString) + sb.append('"') + } + + // TODO: Not sure this is right! + case rt: SealedTraitRef[?] => + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + sb.append('"') + a.toString + sb.append('"') + } + + case rt: TupleRef[?] => + if isMapKey then throw new JsonError("Tuples cannot be map keys.") + val elementFns = rt.tupleRefs.map { f => + f.refType match + case '[t] => (writeJsonFn[t](f.asInstanceOf[RTypeRef[t]]), f) + } + val numElementsExpr = Expr(elementFns.size) + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + sb.append('[') + val sbLen = sb.length + ${ + val stmts = elementFns.zipWithIndex.map { case ((fn, e), i) => + '{ + val fieldValue = ${ + Select.unique('{ a }.asTerm, "_" + (i + 1)).asExpr + } + ${ + e.refType match + case '[t] => + '{ + val fn2 = $fn.asInstanceOf[(t, StringBuilder, JsonConfig) => StringBuilder] + fn2(fieldValue.asInstanceOf[t], sb, cfg) + sb.append(',') + } + } + } + } + Expr.ofList(stmts) + } + if sbLen == sb.length then sb.append(']') + else sb.setCharAt(sb.length() - 1, ']') + } + + case rt: SelfRefRef[?] => + if isMapKey then throw new JsonError("Classes or traits cannot be map keys.") + val e = rt.expr + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + val fn = refCache.get($e.typedName).asInstanceOf[(T, StringBuilder, JsonConfig) => StringBuilder] + fn(a, sb, cfg) + } + + case rt: JavaCollectionRef[?] => + if isMapKey then throw new JsonError("Collections cannot be map keys.") + rt.elementRef.refType match + case '[t] => + val elementFn = writeJsonFn[t](rt.elementRef.asInstanceOf[RTypeRef[t]]) + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + sb.append('[') + val sbLen = sb.length + a.asInstanceOf[java.util.Collection[?]].toArray.foreach { e => + if isOkToWrite(e, cfg) then + $elementFn(e.asInstanceOf[t], sb, cfg) + sb.append(',') + } + if sbLen == sb.length then sb.append(']') + else sb.setCharAt(sb.length() - 1, ']') + } + + case rt: JavaMapRef[?] => + if isMapKey then throw new JsonError("Maps cannot be map keys.") + rt.elementRef.refType match + case '[k] => + rt.elementRef2.refType match + case '[v] => + val keyFn = writeJsonFn[k](rt.elementRef.asInstanceOf[RTypeRef[k]], true) + val valueFn = writeJsonFn[v](rt.elementRef.asInstanceOf[RTypeRef[v]]) + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + sb.append('{') + val sbLen = sb.length + a.asInstanceOf[java.util.Map[?, ?]].asScala.foreach { case (key, value) => + if isOkToWrite(value, cfg) then + $keyFn(key.asInstanceOf[k], sb, cfg) + sb.append(':') + $valueFn(value.asInstanceOf[v], sb, cfg) + sb.append(',') + } + if sbLen == sb.length then sb.append('}') + else sb.setCharAt(sb.length() - 1, '}') + } + + case rt: ObjectRef => + val name = Expr(rt.name) + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + sb.append('"') + sb.append($name) + sb.append('"') + } + + case rt: Scala2Ref[?] => + val name = Expr(rt.name) + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + sb.append('"') + sb.append($name) + sb.append('"') + } + + // case rt: NeoTypeRef[?] => + // import neotype.* + // rt.refType match + // case '[r] => + // '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + // def goGet: T = summon[Newtype.WithType[?, T]] + // val unwrapped = a.asInstanceOf[Newtype[?]].unwrap + // } + + case rt: UnknownRef[?] => + '{ (a: T, sb: StringBuilder, cfg: JsonConfig) => + sb.append('"') + sb.append("unknown") + sb.append('"') + } diff --git a/src/main/scala/co.blocke.scalajack/json/JsonWriter2.scalax b/src/main/scala/co.blocke.scalajack/json/JsonWriter2.scalax new file mode 100644 index 00000000..bb25f426 --- /dev/null +++ b/src/main/scala/co.blocke.scalajack/json/JsonWriter2.scalax @@ -0,0 +1,251 @@ +package co.blocke.scalajack +package json + +import scala.quoted.* +import co.blocke.scala_reflection.* +import co.blocke.scala_reflection.rtypes.* +import co.blocke.scala_reflection.reflect.{ReflectOnType, TypeSymbolMapper} +import co.blocke.scala_reflection.reflect.rtypeRefs.* +import scala.util.{Failure, Success, Try} +import scala.quoted.staging.* + +object JsonWriter2: + + // Tests whether we should write something or not--mainly in the case of Option, or wrapped Option + def isOkToWrite(a: Any, cfg: JsonConfig) = + a match + case None if !cfg.noneAsNull => false + case Left(None) if !cfg.noneAsNull => false + case Right(None) if !cfg.noneAsNull => false + case Failure(_) if cfg.tryFailureHandling == TryOption.NO_WRITE => false + case _ => true + + def refWrite[T](cfgE: Expr[JsonConfig], rt: RType[T], aE: Expr[T], sbE: Expr[StringBuilder], isMapKey: Boolean = false)(using Quotes)(using tt: Type[T]): Expr[StringBuilder] = + import quotes.reflect.* + + rt match + case StringRType() | CharRType() | JavaCharacterRType() => '{ if $aE == null then $sbE.append("null") else $sbE.append("\"" + $aE.toString + "\"") } + case t: PrimitiveRType => + if isMapKey then + '{ + if $aE == null then $sbE.append("\"null\"") + else + $sbE.append('"') + $sbE.append($aE.toString) + $sbE.append('"') + } + else '{ if $aE == null then $sbE.append("null") else $sbE.append($aE.toString) } + + case t: SeqRType[?] => + if isMapKey then throw new JsonError("Seq instances cannot be map keys") + + t.elementType.toType(quotes) match + case '[e] => + '{ + val sb = $sbE + if $aE == null then sb.append("null") + else + sb.append('[') + val sbLen = sb.length + + $aE.asInstanceOf[Seq[e]].foldLeft(sb) { (acc, one) => + if isOkToWrite(one, $cfgE) then + ${ refWrite[e](cfgE, t.elementType.asInstanceOf[RType[e]], '{ one }, '{ acc }) } + sb.append(',') + else sb + } + + if sbLen == sb.length then sb.append(']') + else sb.setCharAt(sb.length() - 1, ']') + } + case t: ArrayRType[?] => + if isMapKey then throw new JsonError("Arrays instances cannot be map keys") + + t.elementType.toType(quotes) match + case '[e] => + '{ + val sb = $sbE + if $aE == null then sb.append("null") + else + sb.append('[') + val sbLen = sb.length + + $aE.asInstanceOf[Seq[e]].foldLeft(sb) { (acc, one) => + if isOkToWrite(one, $cfgE) then + ${ refWrite[e](cfgE, t.elementType.asInstanceOf[RType[e]], '{ one }, '{ acc }) } + sb.append(',') + else sb + } + + if sbLen == sb.length then sb.append(']') + else sb.setCharAt(sb.length() - 1, ']') + } + + case t: ClassRType[?] => + '{ + val sb = $sbE + if $aE == null then sb.append("null") + else + sb.append('{') + val sbLen = sb.length + ${ + t.fields.foldLeft('{ sb }) { (accE, f) => + f.fieldType.toType(quotes) match + case '[e] => + val fieldValue = Select.unique(aE.asTerm, f.name).asExprOf[e] + val name = Expr(f.name) + '{ + val acc = $accE + if isOkToWrite($fieldValue, $cfgE) then + acc.append('"') + acc.append($name) + acc.append('"') + acc.append(':') + val b = ${ refWrite[e](cfgE, f.fieldType.asInstanceOf[RType[e]], fieldValue, '{ acc }) } + acc.append(',') + else acc + } + } + } + if sbLen == sb.length then sb.append('}') + else sb.setCharAt(sb.length() - 1, '}') + } + + case t: TraitRType[?] => + val foo: Expr[RType[T]] = '{ + // given Compiler = Compiler.make($aE.getClass.getClassLoader) + val sb = $sbE + sb.append('{') + sb.append(s"\"_hint\":\"${$aE.getClass.getName}\"") + sb.append('}') + RType.inTermsOf[T]($aE.getClass).asInstanceOf[RType[T]] + val rt: Expr[RType[T]] = ??? + ${refWrite[T](cfgE, rt.value, aE, sbE)} + } + + + // val fn = (q: Quotes) ?=> { + // import q.reflect.* + // ReflectUtil.inTermsOf2(RType.of[T].asInstanceOf[TraitRType[T]], $aE.getClass).expr.asInstanceOf[Expr[RTypeRef[?]]] + // } + // val classRType = quoted.staging.run(fn).asInstanceOf[RType[T]] + // ${ + // t.toType(quotes) match + // case '[e] => + + // } + // $sbE + // val cRT: ScalaClassRType[T] = ??? + // refWrite[T](cfgE, cRT, aE, sbE) + + /* + given Compiler = Compiler.make(this.getClass.getClassLoader) + '{ + val refMakerFn = (q: Quotes) ?=> + import q.reflect.* + (traitRef: TraitRef[T], inst: T, instE: Expr[T], cfgE: Expr[JsonConfig], sbE2: Expr[StringBuilder]) => + val clazz = inst.getClass + val classRef = ReflectOnType(quotes)(quotes.reflect.TypeRepr.typeConstructorOf(clazz), false)(using scala.collection.mutable.Map.empty[TypedName, Boolean]).asInstanceOf[ScalaClassRef[?]] + val inTermsOfRef = + if traitRef.typeParamSymbols.nonEmpty then + val seenBefore = scala.collection.mutable.Map.empty[TypedName, Boolean] + val paths = classRef.typePaths.getOrElse(traitRef.name, throw new ReflectException(s"No path in class ${classRef.name} for trait ${traitRef.name}")) + traitRef.refType match + case '[e] => + val typeParamTypes = TypeSymbolMapper.runPath(quotes)(paths, TypeRepr.of[e]) + val classQuotedTypeRepr = TypeRepr.typeConstructorOf(clazz) + ReflectOnType(q)(classQuotedTypeRepr.appliedTo(typeParamTypes))(using seenBefore) + else classRef + classRef.refType match + case '[e] => + ${ + val y = refWrite[e](cfgE, classRef.asInstanceOf[RTypeRef[e]], instE.asInstanceOf[Expr[e]], sbE2) + '{ y } + } + quoted.staging.run(refMakerFn(t, $aE, aE, cfgE, sbE)) + } + */ + + /* + '{ + val fn = (quotes: Quotes) ?=> + (ref2: TraitRef[T], aE2: Expr[T], a2: T, cfgE2: Expr[JsonConfig]) => + import quotes.reflect.* + val clazz = a2.getClass + val classRef = ReflectOnType(quotes)(quotes.reflect.TypeRepr.typeConstructorOf(clazz), false)(using scala.collection.mutable.Map.empty[TypedName, Boolean]).asInstanceOf[ScalaClassRef[?]] + val inTermsOfRef = + if ref2.typeParamSymbols.nonEmpty then + val seenBefore = scala.collection.mutable.Map.empty[TypedName, Boolean] + val paths = classRef.typePaths.getOrElse(ref2.name, throw new ReflectException(s"No path in class ${classRef.name} for trait ${ref2.name}")) + ref2.refType match + case '[e] => + val typeParamTypes = TypeSymbolMapper.runPath(quotes)(paths, TypeRepr.of[e]) + val classQuotedTypeRepr = TypeRepr.typeConstructorOf(clazz) + ReflectOnType(quotes)(classQuotedTypeRepr.appliedTo(typeParamTypes))(using seenBefore) + else classRef + + inTermsOfRef.refType match + case '[e] => + val asClassRef = inTermsOfRef.asInstanceOf[ScalaClassRef[e]].copy(renderTrait = Some(ref2.name)) // set renderTrait so ClassRef writer code renders type hint + refWrite[e](cfgE, asClassRef.asInstanceOf[RTypeRef[e]], aE2.asInstanceOf[Expr[e]], sbE) + + given Compiler = Compiler.make(this.getClass.getClassLoader) + quoted.staging.run(fn(t, aE, $aE, cfgE)) + } + */ + + /* + case t: OptionRef[?] => + if isMapKey then throw new JsonError("Option valuess cannot be map keys") + t.optionParamType.refType match + case '[e] => + '{ + $aE match + case None => $sbE.append("null") + case Some(v) => + ${ refWrite[e](cfgE, t.optionParamType.asInstanceOf[RTypeRef[e]], '{ v }.asInstanceOf[Expr[e]], sbE) } + } + + case t: MapRef[?] => + if isMapKey then throw new JsonError("Map values cannot be map keys") + t.elementRef.refType match + case '[k] => + t.elementRef2.refType match + case '[v] => + '{ + val sb = $sbE + if $aE == null then sb.append("null") + else + sb.append('{') + val sbLen = sb.length + $aE.asInstanceOf[Map[?, ?]].foreach { case (key, value) => + if isOkToWrite(value, $cfgE) then + val b = ${ refWrite[k](cfgE, t.elementRef.asInstanceOf[RTypeRef[k]], '{ key }.asInstanceOf[Expr[k]], sbE, true) } + b.append(':') + val b2 = ${ refWrite[v](cfgE, t.elementRef2.asInstanceOf[RTypeRef[v]], '{ value }.asInstanceOf[Expr[v]], sbE) } + b2.append(',') + } + if sbLen == sb.length then sb.append('}') + else sb.setCharAt(sb.length() - 1, '}') + } + + case t: TryRef[?] => + if isMapKey then throw new JsonError("Try values (Succeed/Fail) cannot be map keys") + t.tryRef.refType match + case '[e] => + '{ + $aE match + case Success(v) => + ${ refWrite[e](cfgE, t.tryRef.asInstanceOf[RTypeRef[e]], '{ v }.asInstanceOf[Expr[e]], sbE) } + case Failure(_) if $cfgE.tryFailureHandling == TryOption.AS_NULL => $sbE.append("null") + case Failure(v) => + $sbE.append('"') + $sbE.append(v.getMessage) + $sbE.append('"') + } + + case t: AliasRef[?] => + t.unwrappedType.refType match + case '[e] => + refWrite[e](cfgE, t.unwrappedType.asInstanceOf[RTypeRef[e]], aE.asInstanceOf[Expr[e]], sbE) +*/ \ No newline at end of file diff --git a/src/main/scala/co.blocke.scalajack/json/JsonWriterRT.scala b/src/main/scala/co.blocke.scalajack/json/JsonWriterRT.scala new file mode 100644 index 00000000..9731c81d --- /dev/null +++ b/src/main/scala/co.blocke.scalajack/json/JsonWriterRT.scala @@ -0,0 +1,160 @@ +package co.blocke.scalajack +package json + +import co.blocke.scala_reflection.* +import co.blocke.scala_reflection.rtypes.* +import co.blocke.scala_reflection.reflect.{ReflectOnType, TypeSymbolMapper} +import co.blocke.scala_reflection.reflect.rtypeRefs.* +import scala.util.{Failure, Success, Try} + +/** + * This class is horrible. It is a mirror of JsonWriter, except this one executes at runtime, and hence + * doens't have any Expr's. This is bad becuause it's slow--we're not able to take advantage of doing work + * at compile-time. This is ONLY used becuase of trait handling. The problem is we need to express some + * class C in terms of trait T. We know T at compile-time but don't know C until compile-time. There's + * (so far) no good way to get the information passed across the bridge, so... What we need to do in + * JsonWriter is leverage RType.inTermsOf to do the C->T magic, but this returns an RType, not an + * RTypeRef. We must render the rest of the trait (and any structures below it) at runtime, necessitating + * this class, JsonWriterRT. + * + * It should only be used for this special trait handling. Everything else leverages the compile-time + * JsonWriter. + */ + +object JsonWriterRT: + + // Tests whether we should write something or not--mainly in the case of Option, or wrapped Option + def isOkToWrite(a: Any, cfg: JsonConfig) = + a match + case None if !cfg.noneAsNull => false + case Left(None) if !cfg.noneAsNull => false + case Right(None) if !cfg.noneAsNull => false + case Failure(_) if cfg.tryFailureHandling == TryOption.NO_WRITE => false + case _ => true + + def refWriteRT[T]( + cfg: JsonConfig, + rt: RType[T], + a: T, + sb: StringBuilder, + isMapKey: Boolean = false + )(using classesSeen: scala.collection.mutable.Map[TypedName, RType[?]]): StringBuilder = + + rt match + case StringRType() | CharRType() | JavaCharacterRType() => if a == null then sb.append("null") else sb.append("\"" + a.toString + "\"") + case t: PrimitiveRType => + if isMapKey then + if a == null then sb.append("\"null\"") + else + sb.append('"') + sb.append(a.toString) + sb.append('"') + else if a == null then sb.append("null") else sb.append(a.toString) + + case t: SeqRType[?] => + if isMapKey then throw new JsonError("Seq instances cannot be map keys") + if a == null then sb.append("null") + else + sb.append('[') + val sbLen = sb.length + a.asInstanceOf[Seq[t.elementType.T]].foldLeft(sb) { (acc, one) => + if isOkToWrite(one, cfg) then + refWriteRT[t.elementType.T](cfg, t.elementType.asInstanceOf[RType[t.elementType.T]], one, acc) + sb.append(',') + else sb + } + if sbLen == sb.length then sb.append(']') + else sb.setCharAt(sb.length() - 1, ']') + + case t: ArrayRType[?] => + if isMapKey then throw new JsonError("Arrays instances cannot be map keys") + if a == null then sb.append("null") + else + sb.append('[') + val sbLen = sb.length + a.asInstanceOf[Seq[t.elementType.T]].foldLeft(sb) { (acc, one) => + if isOkToWrite(one, cfg) then + refWriteRT[t.elementType.T](cfg, t.elementType.asInstanceOf[RType[t.elementType.T]], one, acc) + sb.append(',') + else sb + } + if sbLen == sb.length then sb.append(']') + else sb.setCharAt(sb.length() - 1, ']') + + case t: ScalaClassRType[?] => + classesSeen.put(t.typedName, t) + if a == null then sb.append("null") + else + sb.append('{') + val sbLen = sb.length + t.renderTrait.map( traitName => sb.append(s"\"_hint\":\"$traitName\",") ) + t.fields.foldLeft(sb) { (acc, f) => + val m = a.getClass.getMethod(f.name) + m.setAccessible(true) + val fieldValue = m.invoke(a).asInstanceOf[f.fieldType.T] + if isOkToWrite(fieldValue, cfg) then + acc.append('"') + acc.append(f.name) + acc.append('"') + acc.append(':') + refWriteRT[f.fieldType.T](cfg, f.fieldType.asInstanceOf[RType[f.fieldType.T]], fieldValue, acc) + acc.append(',') + else acc + } + if sbLen == sb.length then sb.append('}') + else sb.setCharAt(sb.length() - 1, '}') + + case t: TraitRType[?] => + classesSeen.put(t.typedName, t) + val classRType = RType.inTermsOf[T](a.getClass).asInstanceOf[ScalaClassRType[T]].copy(renderTrait = Some(t.name)).asInstanceOf[RType[T]] + JsonWriterRT.refWriteRT[classRType.T](cfg, classRType, a.asInstanceOf[classRType.T], sb) + + case t: OptionRType[?] => + if isMapKey then throw new JsonError("Option valuess cannot be map keys") + a match + case None => sb.append("null") + case Some(v) => + refWriteRT[t.optionParamType.T](cfg, t.optionParamType.asInstanceOf[RType[t.optionParamType.T]], v.asInstanceOf[t.optionParamType.T], sb) + + case t: MapRType[?] => + if isMapKey then throw new JsonError("Map values cannot be map keys") + if a == null then sb.append("null") + else + sb.append('{') + val sbLen = sb.length + a.asInstanceOf[Map[?, ?]].foreach { case (key, value) => + if isOkToWrite(value, cfg) then + val b = refWriteRT[t.elementType.T](cfg, t.elementType.asInstanceOf[RType[t.elementType.T]], key.asInstanceOf[t.elementType.T], sb, true) + b.append(':') + val b2 = refWriteRT[t.elementType2.T](cfg, t.elementType2.asInstanceOf[RType[t.elementType2.T]], value.asInstanceOf[t.elementType2.T], sb) + b2.append(',') + } + if sbLen == sb.length then sb.append('}') + else sb.setCharAt(sb.length() - 1, '}') + + case t: TryRType[?] => + if isMapKey then throw new JsonError("Try values (Succeed/Fail) cannot be map keys") + a match + case Success(v) => + refWriteRT[t.tryType.T](cfg, t.tryType.asInstanceOf[RType[t.tryType.T]], v.asInstanceOf[t.tryType.T], sb) + case Failure(_) if cfg.tryFailureHandling == TryOption.AS_NULL => sb.append("null") + case Failure(v) => + sb.append('"') + sb.append(v.getMessage) + sb.append('"') + + case t: AliasRType[?] => + refWriteRT[t.unwrappedType.T](cfg, t.unwrappedType.asInstanceOf[RType[t.unwrappedType.T]], a.asInstanceOf[t.unwrappedType.T], sb) + + case t: SelfRefRType[?] => + if isMapKey then throw new JsonError("Classes or traits cannot be map keys.") + val again = classesSeen.getOrElse( + t.typedName, + { + // Need to add to cache. Since we're coming from compile-time side, the runtime side may not have seen this class before... + val v = RType.of[T] + classesSeen.put(t.typedName, v) + v + } + ) + JsonWriterRT.refWriteRT[again.T](cfg, again, a.asInstanceOf[again.T], sb) diff --git a/src/main/scala/co.blocke.scalajack/json/ReflectUtil.scala b/src/main/scala/co.blocke.scalajack/json/ReflectUtil.scala deleted file mode 100644 index 950e333c..00000000 --- a/src/main/scala/co.blocke.scalajack/json/ReflectUtil.scala +++ /dev/null @@ -1,52 +0,0 @@ -package co.blocke.scalajack -package json - -import co.blocke.scala_reflection.{ReflectException, RType, RTypeRef, TypedName} -import co.blocke.scala_reflection.rtypes.TraitRType -import co.blocke.scala_reflection.reflect.rtypeRefs.* -import co.blocke.scala_reflection.reflect.* -import scala.quoted.* -import scala.quoted.staging.* - -object ReflectUtil: - - /** This function takes the RType of a trait and an instance of T and does two things. - * First it expresses the instance's class *in terms of* the trait's concrete type parameters (if any). - * Then it generates a writer function for the now correctly-typed class. - * - * @param traitType - * @param a - * @param sb - * @param cfg - * @return - */ - def inTermsOf[T](traitType: RType[?], a: T, sb: StringBuilder, cfg: JsonConfig) = - given Compiler = Compiler.make(getClass.getClassLoader) - - val clazz = a.getClass - - val fn = (quotes: Quotes) ?=> { - import quotes.reflect.* - - val classRef = ReflectOnType(quotes)(quotes.reflect.TypeRepr.typeConstructorOf(clazz), false)(using scala.collection.mutable.Map.empty[TypedName, Boolean]).asInstanceOf[ScalaClassRef[?]] - - val inTermsOfRef = traitType match - case ttype: TraitRType[?] if ttype.typeParamSymbols.nonEmpty => - val seenBefore = scala.collection.mutable.Map.empty[TypedName, Boolean] - val paths = classRef.typePaths.getOrElse(ttype.name, throw new ReflectException(s"No path in class ${classRef.name} for trait ${ttype.name}")) - ttype.toType(quotes) match - case '[t] => - val typeParamTypes = TypeSymbolMapper.runPath(quotes)(paths, TypeRepr.of[t]) - val classQuotedTypeRepr = TypeRepr.typeConstructorOf(clazz) - ReflectOnType(quotes)(classQuotedTypeRepr.appliedTo(typeParamTypes))(using seenBefore) - - case traitRef: TraitRType[?] => classRef - case x => throw new ReflectException(s"${x.name} is not of type trait") - - inTermsOfRef.refType match - case '[t] => - val asClassRef = inTermsOfRef.asInstanceOf[ScalaClassRef[t]].copy(renderTrait = Some(traitType.name)) - JsonWriter.writeJsonFn[t](asClassRef.asInstanceOf[RTypeRef[t]]) - } - val writeFn = run(fn) - writeFn.asInstanceOf[(T, StringBuilder, JsonConfig) => StringBuilder] diff --git a/src/main/scala/co.blocke.scalajack/json/ReflectUtil.scalax b/src/main/scala/co.blocke.scalajack/json/ReflectUtil.scalax new file mode 100644 index 00000000..c8045491 --- /dev/null +++ b/src/main/scala/co.blocke.scalajack/json/ReflectUtil.scalax @@ -0,0 +1,59 @@ +package co.blocke.scalajack +package json + +import co.blocke.scala_reflection.{ReflectException, RType, RTypeRef, TypedName} +import co.blocke.scala_reflection.rtypes.TraitRType +import co.blocke.scala_reflection.reflect.rtypeRefs.* +import co.blocke.scala_reflection.reflect.* +import scala.quoted.* +import scala.quoted.staging.* + +object ReflectUtil: + + /** This function takes the RType of a trait and an instance of T and does two things. + * First it expresses the instance's class *in terms of* the trait's concrete type parameters (if any). + * Then it generates a writer function for the now correctly-typed class. + */ +// Returns: StringBuilder + def inTermsOf[T](traitType: TraitRType[?], a: T, aE: Expr[T], sbE: Expr[StringBuilder], cfgE: Expr[JsonConfig]) = + given Compiler = Compiler.make(getClass.getClassLoader) + + val clazz = a.getClass + + val fn = (quotes: Quotes) ?=> { + import quotes.reflect.* + + val classRef = ReflectOnType(quotes)(quotes.reflect.TypeRepr.typeConstructorOf(clazz), false)(using scala.collection.mutable.Map.empty[TypedName, Boolean]).asInstanceOf[ScalaClassRef[?]] + + val inTermsOfRef = traitType.toType(quotes) match + case '[t] => + if traitType.typeParamSymbols.nonEmpty then + val seenBefore = scala.collection.mutable.Map.empty[TypedName, Boolean] + val paths = classRef.typePaths.getOrElse(traitType.name, throw new ReflectException(s"No path in class ${classRef.name} for trait ${traitType.name}")) + val typeParamTypes = TypeSymbolMapper.runPath(quotes)(paths, TypeRepr.of[t]) + val classQuotedTypeRepr = TypeRepr.typeConstructorOf(clazz) + ReflectOnType(quotes)(classQuotedTypeRepr.appliedTo(typeParamTypes))(using seenBefore) + else classRef + + inTermsOfRef.refType match + case '[e] => + val asClassRef = inTermsOfRef.asInstanceOf[ScalaClassRef[e]].copy(renderTrait = Some(traitType.name)).asInstanceOf[co.blocke.scala_reflection.RTypeRef[e]] + JsonWriter.refWrite[e](cfgE, asClassRef, aE.asInstanceOf[Expr[e]], sbE) + } + run(fn) + + def inTermsOf2[T](traitType: TraitRType[?], clazz: Class[?])(using Quotes) = + import quotes.reflect.* + + val classRef = ReflectOnType(quotes)(quotes.reflect.TypeRepr.typeConstructorOf(clazz), false)(using scala.collection.mutable.Map.empty[TypedName, Boolean]).asInstanceOf[ScalaClassRef[?]] + + (traitType.toType(quotes) match + case '[t] => + if traitType.typeParamSymbols.nonEmpty then + val seenBefore = scala.collection.mutable.Map.empty[TypedName, Boolean] + val paths = classRef.typePaths.getOrElse(traitType.name, throw new ReflectException(s"No path in class ${classRef.name} for trait ${traitType.name}")) + val typeParamTypes = TypeSymbolMapper.runPath(quotes)(paths, TypeRepr.of[t]) + val classQuotedTypeRepr = TypeRepr.typeConstructorOf(clazz) + ReflectOnType(quotes)(classQuotedTypeRepr.appliedTo(typeParamTypes))(using seenBefore) + else classRef + ).asInstanceOf[ScalaClassRef[?]] diff --git a/src/main/scala/co.blocke.scalajack/json/readers/PrimitiveReader.scala b/src/main/scala/co.blocke.scalajack/json/readers/PrimitiveReader.scala index a00cc8d2..d290ac89 100644 --- a/src/main/scala/co.blocke.scalajack/json/readers/PrimitiveReader.scala +++ b/src/main/scala/co.blocke.scalajack/json/readers/PrimitiveReader.scala @@ -73,9 +73,11 @@ case class PrimitiveReader(next: ReaderModule, root: ReaderModule) extends Reade '{ (j: JsonConfig, p: JsonParser) => p.expectString(j, p) .flatMap(s => - s.toArray.headOption match - case Some(c) => Right(c.asInstanceOf[T]) - case None => Left(JsonParseError(p.showError(s"Cannot convert value '$s' into a Char."))) + if s == null then Left(JsonParseError(p.showError(s"Char typed values cannot be null at position [${p.getPos}]"))) + else + s.toArray.headOption match + case Some(c) => Right(c.asInstanceOf[T]) + case None => Left(JsonParseError(p.showError(s"Cannot convert value '$s' into a Char at position [${p.getPos}]"))) ) } @@ -235,7 +237,7 @@ case class PrimitiveReader(next: ReaderModule, root: ReaderModule) extends Reade else scala.util.Try(java.util.UUID.fromString(u)) match case Success(uuid) => Right(uuid.asInstanceOf[T]) - case Failure(_) => Left(JsonParseError(p.showError(s"Unable to marshal UUID from value '$u'."))) + case Failure(_) => Left(JsonParseError(p.showError(s"Unable to marshal UUID from value '$u' at position [${p.getPos}]"))) ) } diff --git a/src/main/scala/co.blocke.scalajack/run/Play.scala b/src/main/scala/co.blocke.scalajack/run/Play.scala index 4a459d71..78482968 100644 --- a/src/main/scala/co.blocke.scalajack/run/Play.scala +++ b/src/main/scala/co.blocke.scalajack/run/Play.scala @@ -19,16 +19,55 @@ object RunMe extends App: given json.JsonConfig = json .JsonConfig() + .copy(noneAsNull = true) // .copy(enumsAsIds = '*') try + val v = + Person( + "Greg", + Some( + Person( + "Lili", + Some( + Person( + "Katie", + None + ) + ) + ) + ) + ) + + println("HERE: " + ScalaJack.write[Person[Boolean]](v.asInstanceOf[Person[Boolean]])) + + // println("HERE: " + ScalaJack.write(Person("Greg", Foom('z'), Some(Person("Lili", Foom('x'), None))))) + + // val now = System.currentTimeMillis() + // for i <- 1 to 10000000 do ScalaJack.write(List(List(1, 2, 3), List(4, 5, 6), List(7, 8, 9))) + // val now2 = System.currentTimeMillis() + // println("Macro-based: " + (now2 - now)) + + // val person = Person("David", 16) + // val minor = MinorPerson.make(person) match + // case Right(r) => r + // case _ => throw new Exception("boom") + + // val p = SampleNeo(NonEmptyString("Greg"), "MVP", minor) + // val js = ScalaJack.write(x) + // println(js) + + /* val x = Blah("foo", WeekDay.Fri) val js = ScalaJack.write(x) println(js) val inst = ScalaJack.read[Blah](js) println(inst) + */ catch { - case t: Throwable => println(s"BOOM ($t): " + t.getMessage) + case t: Throwable => + println(s"BOOM ($t): " + t.getMessage) + t.printStackTrace } diff --git a/src/main/scala/co.blocke.scalajack/run/Sample.scala b/src/main/scala/co.blocke.scalajack/run/Sample.scala index b0519080..f37fbb3b 100644 --- a/src/main/scala/co.blocke.scalajack/run/Sample.scala +++ b/src/main/scala/co.blocke.scalajack/run/Sample.scala @@ -1,39 +1,60 @@ package co.blocke.scalajack.run -opaque type BigName = String +import neotype.* -case class Person(name: String, age: Int, isOk: List[Boolean], favColor: Colors, boss: BigName) +// opaque type BigName = String -trait Animal: - val name: String - val numLegs: Int - val friend: Option[Animal] +// case class Person(name: String, age: Int, isOk: List[Boolean], favColor: Colors, boss: BigName) -trait Animal2: - val name: String - val numLegs: Int - val friend: Option[Animal2] +// trait Animal: +// val name: String +// val numLegs: Int +// val friend: Option[Animal] -case class Dog(name: String, numLegs: Int, carsChased: Int, friend: Option[Animal2]) extends Animal2 +// trait Animal2: +// val name: String +// val numLegs: Int +// val friend: Option[Animal2] -enum Colors: - case Red, Blue, Green +// case class Dog(name: String, numLegs: Int, carsChased: Int, friend: Option[Animal2]) extends Animal2 -import scala.collection.immutable.* -enum Vehicle: - case Car, Bus, Train +// enum Colors: +// case Red, Blue, Green -object WeekDay extends Enumeration { - type WeekDay = Value - val Mon, Tue, Wed, Thu, Fri, Sat, Sun = Value -} -import WeekDay.* +// import scala.collection.immutable.* +// enum Vehicle: +// case Car, Bus, Train -case class Simple(a: Int, b: Boolean, c: Option[Simple], z: Int = 5) +// object WeekDay extends Enumeration { +// type WeekDay = Value +// val Mon, Tue, Wed, Thu, Fri, Sat, Sun = Value +// } +// import WeekDay.* -case class Blah(msg: String, stuff: WeekDay) +// case class Simple(a: Int, b: Boolean, c: Option[Simple], z: Int = 5) -object Talk: - def say(s: String): String = s"Say $s!" +// case class Blah(msg: String, stuff: WeekDay) -case class M1(v: Map[Long, Int], v2: HashMap[Colors, Int], v3: Map[co.blocke.scala_reflection.TypedName, Int]) +// object Talk: +// def say(s: String): String = s"Say $s!" + +// case class M1(v: Map[Long, Int], v2: HashMap[Colors, Int], v3: Map[co.blocke.scala_reflection.TypedName, Int]) + +trait Miss[E] { val x: E } +case class Foom[X](x: X) extends Miss[X] + +// case class Person[Y](name: String, age: Miss[Y], again: Option[Person[Y]]) + +case class Person[Y](name: String, again: Option[Person[Y]]) + +// type NonEmptyString = NonEmptyString.Type +// given NonEmptyString: Newtype[String] with +// inline def validate(input: String): Boolean = +// input.nonEmpty + +// type MinorPerson = MinorPerson.Type +// given MinorPerson: Newtype[Person] with +// inline def validate(input: Person): Boolean = +// input.age < 18 + +// case class SampleNeo(name: NonEmptyString, label: String, unknown: MinorPerson) diff --git a/src/test/scala/co.blocke.scalajack/JsonDiff.scala b/src/test/scala/co.blocke.scalajack/JsonDiff.scala index acafcefa..7412ceed 100644 --- a/src/test/scala/co.blocke.scalajack/JsonDiff.scala +++ b/src/test/scala/co.blocke.scalajack/JsonDiff.scala @@ -1,25 +1,21 @@ package co.blocke.scalajack package json -import org.json4s.JsonAST.{ JNothing, JObject, JValue } +import org.json4s.JsonAST.{JNothing, JObject, JValue} object JsonDiff { - def compare( - left: JValue, - right: JValue, - leftLabel: String = "left", - rightLabel: String = "right"): Seq[JsonDiff] = { + def compare(left: JValue, right: JValue, leftLabel: String = "left", rightLabel: String = "right"): Seq[JsonDiff] = (left, right) match { case (JObject(leftFields), JObject(rightFields)) => val allFieldNames = (leftFields.map(_._1) ++ rightFields.map(_._1)).distinct allFieldNames.sorted flatMap { fieldName => val leftFieldValue = leftFields - .collectFirst({ case (`fieldName`, fieldValue) => fieldValue }) + .collectFirst { case (`fieldName`, fieldValue) => fieldValue } .getOrElse(JNothing) val rightFieldValue = rightFields - .collectFirst({ case (`fieldName`, fieldValue) => fieldValue }) + .collectFirst { case (`fieldName`, fieldValue) => fieldValue } .getOrElse(JNothing) compare(leftFieldValue, rightFieldValue, leftLabel, rightLabel) } @@ -35,7 +31,7 @@ object JsonDiff { // } case _ => - if (left == right) { + if left == right then { Seq.empty } else { val outerLeft = left @@ -48,7 +44,6 @@ object JsonDiff { }) } } - } } diff --git a/src/test/scala/co.blocke.scalajack/JsonMatcher.scala b/src/test/scala/co.blocke.scalajack/JsonMatcher.scala index acc73607..9531db62 100644 --- a/src/test/scala/co.blocke.scalajack/JsonMatcher.scala +++ b/src/test/scala/co.blocke.scalajack/JsonMatcher.scala @@ -2,60 +2,27 @@ package co.blocke.scalajack package json import org.json4s.JsonAST.JValue -import org.json4s.native.JsonMethods._ - -import munit.internal.MacroCompat -import munit.{Location, Compare} -import munit.internal.console.StackTraces -import munit.internal.difflib.ComparisonFailExceptionHandler - -// object JsonMatcher { - -// def jsonMatches( expected: String, actual: String ): Boolean = -// val diffs = JsonDiff.compare( -// parseJValue(expected), -// parseJValue(actual), -// "expected", -// "actual" -// ) -// diffs.isEmpty - -// implicit def parseJValue(string: String): JValue = parse(string) -// } - - -object BlockeUtil extends BlockeUtil -trait BlockeUtil extends MacroCompat.CompileErrorMacro with munit.Assertions: - - // val munitLines = new Lines - - // def munitAnsiColors: Boolean = true - +import org.json4s.native.JsonMethods.* + +import org.scalatest.* +import matchers.* + +trait JsonMatchers { + class JsonMatchesMatcher(expectedJson: String) extends Matcher[String]: + def apply(srcJson: String) = + val diffs = JsonDiff.compare( + parseJValue(srcJson), + parseJValue(expectedJson), + "expected", + "actual" + ) + MatchResult( + diffs.isEmpty, + s"""JSON values did not match""", + s"""JSON values matched""" + ) + + def matchJson(targetJson: String) = new JsonMatchesMatcher(targetJson) implicit def parseJValue(string: String): JValue = parse(string) - - /** - * Asserts that two JSON strings are equal according to the `Compare[A, B]` type-class. - * - * By default, uses `==` to compare values. - * - * JSON is unorderd, so two JSON strings are equal if they contain the same elements, in any order - */ - def jsonMatches( - obtained: String, - expected: String, - clue: => Any = "values are not the same" - )(implicit loc: Location, compare: Compare[String, String]): Unit = { - StackTraces.dropInside { - val areEqual = - val diffs = JsonDiff.compare( - parseJValue(obtained), - parseJValue(expected), - "obtained", - "expected" - ) - diffs.isEmpty - - if (!areEqual) - compare.failEqualsComparison(obtained, expected, clue, loc, this) - } - } \ No newline at end of file +} +object JsonMatchers extends JsonMatchers diff --git a/src/test/scala/co.blocke.scalajack/TestUtil.scala b/src/test/scala/co.blocke.scalajack/TestUtil.scala index c8365bdf..b8a5d5d5 100644 --- a/src/test/scala/co.blocke.scalajack/TestUtil.scala +++ b/src/test/scala/co.blocke.scalajack/TestUtil.scala @@ -1,19 +1,21 @@ package co.blocke.scalajack -import munit.internal.console +// import munit.internal.console object TestUtil { - inline def describe(message: String, color: String = Console.MAGENTA): Unit = println(s"$color$message${Console.RESET}") - inline def pending = describe(" << Test Pending (below) >>", Console.YELLOW) + // inline def describe(message: String, color: String = Console.MAGENTA): Unit = TestingConsole.out.println(s"$color$message${Console.RESET}") + // inline def pending = describe(" << Test Pending (below) >>", Console.YELLOW) + + inline def colorString(str: String, color: String = Console.MAGENTA): String = + str.split("\n").map(s => s"$color$s${Console.RESET}").mkString("\n") def hexStringToByteArray(s: String): Array[Byte] = { val len = s.length val data = new Array[Byte](len / 2) var i = 0 - while ({ - i < len - }) { + while i < len + do { data(i / 2) = ((Character.digit(s.charAt(i), 16) << 4) + Character.digit( s.charAt(i + 1), 16 @@ -26,17 +28,16 @@ object TestUtil { // Utility to generate test code quickly def showException(label: String, fnStr: String, fn: () => Any) = - try { + try fn() - } catch { + catch { case x: IndexOutOfBoundsException => throw x case t: Throwable => - if (!t.getMessage.contains("\n")) - throw t + if !t.getMessage.contains("\n") then throw t val msg = "\"\"\"" + t.getMessage().replace("\n", "\n |") + "\"\"\"" println( label + " >> " + t.getClass.getName + "\n-----------------------\n" + s"val msg = $msg.stripMargin\nthe[${t.getClass.getName}] thrownBy $fnStr should have message msg\n" ) } -} \ No newline at end of file +} diff --git a/src/test/scala/co.blocke.scalajack/json/primitives/Model.scala b/src/test/scala/co.blocke.scalajack/json/primitives/Model.scala index 451c9854..ae207055 100644 --- a/src/test/scala/co.blocke.scalajack/json/primitives/Model.scala +++ b/src/test/scala/co.blocke.scalajack/json/primitives/Model.scala @@ -2,29 +2,13 @@ package co.blocke.scalajack package json.primitives import java.util.UUID -import java.lang.{ - Boolean => JBoolean, - Byte => JByte, - Character => JChar, - Double => JDouble, - Float => JFloat, - Integer => JInt, - Long => JLong, - Number => JNumber, - Short => JShort -} -import java.math.{ BigDecimal => JBigDecimal, BigInteger => JBigInteger } -import java.time._ -import scala.math._ +import java.lang.{Boolean as JBoolean, Byte as JByte, Character as JChar, Double as JDouble, Float as JFloat, Integer as JInt, Long as JLong, Number as JNumber, Short as JShort} +import java.math.{BigDecimal as JBigDecimal, BigInteger as JBigInteger} +import java.time.* +import scala.math.* // === Scala -case class SampleBigDecimal( - bd1: BigDecimal, - bd2: BigDecimal, - bd3: BigDecimal, - bd4: BigDecimal, - bd5: BigDecimal, - bd6: BigDecimal) +case class SampleBigDecimal(bd1: BigDecimal, bd2: BigDecimal, bd3: BigDecimal, bd4: BigDecimal, bd5: BigDecimal, bd6: BigDecimal) case class SampleBigInt(bi1: BigInt, bi2: BigInt, bi3: BigInt, bi4: BigInt) case class SampleBinary(b1: Array[Byte], b2: Array[Byte]) case class SampleBoolean(bool1: Boolean, bool2: Boolean) @@ -39,19 +23,13 @@ object SizeWithType extends Enumeration { type SizeWithType = Value val Little, Grand = Value } -import SizeWithType._ -case class SampleEnum( - e1: Size.Value, - e2: Size.Value, - e3: Size.Value, - e4: Size.Value, - e5: Size.Value, - e6: SizeWithType) +import SizeWithType.* +case class SampleEnum(e1: Size.Value, e2: Size.Value, e3: Size.Value, e4: Size.Value, e5: Size.Value, e6: SizeWithType) enum Color { - case Red, Blue, Green + case Red, Blue, Green } -case class TVColors( color1: Color, color2: Color ) +case class TVColors(color1: Color, color2: Color) sealed trait Flavor case object Vanilla extends Flavor @@ -63,8 +41,8 @@ case class Truck(numberOfWheels: Int) extends Vehicle case class Car(numberOfWheels: Int, color: String) extends Vehicle case class Plane(numberOfEngines: Int) extends Vehicle -case class Ride( wheels: Vehicle ) -case class Favorite( flavor: Flavor ) +case class Ride(wheels: Vehicle) +case class Favorite(flavor: Flavor) case class SampleFloat(f1: Float, f2: Float, f3: Float, f4: Float) case class SampleInt(i1: Int, i2: Int, i3: Int, i4: Int) @@ -73,103 +51,27 @@ case class SampleShort(s1: Short, s2: Short, s3: Short, s4: Short) case class SampleString(s1: String, s2: String, s3: String) // === Java -case class SampleJBigDecimal( - bd1: JBigDecimal, - bd2: JBigDecimal, - bd3: JBigDecimal, - bd4: JBigDecimal, - bd5: JBigDecimal) -case class SampleJBigInteger( - bi1: JBigInteger, - bi2: JBigInteger, - bi3: JBigInteger, - bi4: JBigInteger, - bi5: JBigInteger, - bi6: JBigInteger, - bi7: JBigInteger) -case class SampleJBoolean( - bool1: JBoolean, - bool2: JBoolean, - bool3: JBoolean, - bool4: JBoolean, - bool5: JBoolean) +case class SampleJBigDecimal(bd1: JBigDecimal, bd2: JBigDecimal, bd3: JBigDecimal, bd4: JBigDecimal, bd5: JBigDecimal) +case class SampleJBigInteger(bi1: JBigInteger, bi2: JBigInteger, bi3: JBigInteger, bi4: JBigInteger, bi5: JBigInteger, bi6: JBigInteger, bi7: JBigInteger) +case class SampleJBoolean(bool1: JBoolean, bool2: JBoolean, bool3: JBoolean, bool4: JBoolean, bool5: JBoolean) case class SampleJByte(b1: JByte, b2: JByte, b3: JByte, b4: JByte, b5: JByte) case class SampleJChar(c1: JChar, c2: JChar, c3: JChar) -case class SampleJDouble( - d1: JDouble, - d2: JDouble, - d3: JDouble, - d4: JDouble, - d5: JDouble) -case class SampleJFloat( - f1: JFloat, - f2: JFloat, - f3: JFloat, - f4: JFloat, - f5: JFloat) +case class SampleJDouble(d1: JDouble, d2: JDouble, d3: JDouble, d4: JDouble, d5: JDouble) +case class SampleJFloat(f1: JFloat, f2: JFloat, f3: JFloat, f4: JFloat, f5: JFloat) case class SampleJInt(i1: JInt, i2: JInt, i3: JInt, i4: JInt, i5: JInt) case class SampleJLong(l1: JLong, l2: JLong, l3: JLong, l4: JLong, l5: JLong) -case class SampleJNumber( - n1: JNumber, - n2: JNumber, - n3: JNumber, - n4: JNumber, - n5: JNumber, - n6: JNumber, - n7: JNumber, - n8: JNumber, - n9: JNumber, - n10: JNumber, - n11: JNumber, - n12: JNumber, - n13: JNumber, - n14: JNumber, - n15: JNumber, - n16: JNumber, - n17: JNumber) -case class SampleJShort( - s1: JShort, - s2: JShort, - s3: JShort, - s4: JShort, - s5: JShort) +case class SampleJNumber(n1: JNumber, n2: JNumber, n3: JNumber, n4: JNumber, n5: JNumber, n6: JNumber, n7: JNumber, n8: JNumber, n9: JNumber, n10: JNumber, n11: JNumber, n12: JNumber, n13: JNumber, n14: JNumber, n15: JNumber, n16: JNumber, n17: JNumber) +case class SampleJShort(s1: JShort, s2: JShort, s3: JShort, s4: JShort, s5: JShort) case class SampleUUID(u1: UUID, u2: UUID) // === Java Time case class SampleDuration(d1: Duration, d2: Duration, d3: Duration) -case class SampleInstant( - i1: Instant, - i2: Instant, - i3: Instant, - i4: Instant, - i5: Instant) -case class SampleLocalDateTime( - d1: LocalDateTime, - d2: LocalDateTime, - d3: LocalDateTime, - d4: LocalDateTime) -case class SampleLocalDate( - d1: LocalDate, - d2: LocalDate, - d3: LocalDate, - d4: LocalDate) -case class SampleLocalTime( - d1: LocalTime, - d2: LocalTime, - d3: LocalTime, - d4: LocalTime, - d5: LocalTime, - d6: LocalTime) -case class SampleOffsetDateTime( - o1: OffsetDateTime, - o2: OffsetDateTime, - o3: OffsetDateTime, - o4: OffsetDateTime) -case class SampleOffsetTime( - o1: OffsetTime, - o2: OffsetTime, - o3: OffsetTime, - o4: OffsetTime) +case class SampleInstant(i1: Instant, i2: Instant, i3: Instant, i4: Instant, i5: Instant) +case class SampleLocalDateTime(d1: LocalDateTime, d2: LocalDateTime, d3: LocalDateTime, d4: LocalDateTime) +case class SampleLocalDate(d1: LocalDate, d2: LocalDate, d3: LocalDate, d4: LocalDate) +case class SampleLocalTime(d1: LocalTime, d2: LocalTime, d3: LocalTime, d4: LocalTime, d5: LocalTime, d6: LocalTime) +case class SampleOffsetDateTime(o1: OffsetDateTime, o2: OffsetDateTime, o3: OffsetDateTime, o4: OffsetDateTime) +case class SampleOffsetTime(o1: OffsetTime, o2: OffsetTime, o3: OffsetTime, o4: OffsetTime) case class SamplePeriod(p1: Period, p2: Period, p3: Period) case class SampleZonedDateTime(o1: ZonedDateTime, o2: ZonedDateTime) @@ -194,4 +96,4 @@ case class VCUUID(vc: UUID) extends AnyVal case class VCNumber(vc: Number) extends AnyVal // === Permissives test -case class Holder[T](value: T) \ No newline at end of file +case class Holder[T](value: T) diff --git a/src/test/scala/co.blocke.scalajack/json/primitives/ScalaPrim.scala b/src/test/scala/co.blocke.scalajack/json/primitives/ScalaPrim.scala index b33705c0..b0eb163b 100644 --- a/src/test/scala/co.blocke.scalajack/json/primitives/ScalaPrim.scala +++ b/src/test/scala/co.blocke.scalajack/json/primitives/ScalaPrim.scala @@ -2,328 +2,301 @@ package co.blocke.scalajack package json package primitives -import co.blocke.scala_reflection._ +import co.blocke.scala_reflection.* import scala.math.BigDecimal import java.util.UUID -import TestUtil._ -import munit._ -import munit.internal.console +import TestUtil.* +// import munit.* +// import munit.internal.console +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers.* +import org.scalatest.* -class ScalaPrim() extends FunSuite with BlockeUtil: +class ScalaPrim() extends AnyFunSpec with JsonMatchers: - test("Introduction") { - describe("---------------------------\n: Scala Primitive Tests :\n---------------------------", Console.YELLOW) - describe("+++ Positive Tests +++") - } + describe(colorString("---------------------------\n: Scala Primitive Tests :\n---------------------------", Console.YELLOW)) { + describe(colorString("+++ Positive Tests +++")) { + it("BigDecimal must work") { + val inst = SampleBigDecimal( + BigDecimal(123L), + BigDecimal(1.23), + BigDecimal(0), + BigDecimal("123.456"), + BigDecimal( + "0.1499999999999999944488848768742172978818416595458984375" + ), + null + ) - test("BigDecimal must work") { - val inst = SampleBigDecimal( - BigDecimal(123L), - BigDecimal(1.23), - BigDecimal(0), - BigDecimal("123.456"), - BigDecimal( - "0.1499999999999999944488848768742172978818416595458984375" - ), - null - ) + val js = ScalaJack.write(inst) + js should matchJson("""{"bd1":123,"bd2":1.23,"bd3":0,"bd4":123.456,"bd5":0.1499999999999999944488848768742172978818416595458984375,"bd6":null}""") + inst shouldEqual ScalaJack.read[SampleBigDecimal](js) + } - val js = ScalaJack.write(inst) - assertEquals(js, - """{"bd1":123,"bd2":1.23,"bd3":0,"bd4":123.456,"bd5":0.1499999999999999944488848768742172978818416595458984375,"bd6":null}""" - ) - assertEquals(inst, ScalaJack.read[SampleBigDecimal](js)) - } + it("BigInt must work") { + val inst = SampleBigInt( + BigInt("-90182736451928374653345"), + BigInt("90182736451928374653345"), + BigInt(0), + null + ) + val js = ScalaJack.write(inst) + js should matchJson("""{"bi1":-90182736451928374653345,"bi2":90182736451928374653345,"bi3":0,"bi4":null}""") + inst shouldEqual ScalaJack.read[SampleBigInt](js) + } - test("BigInt must work") { - val inst = SampleBigInt( - BigInt("-90182736451928374653345"), - BigInt("90182736451928374653345"), - BigInt(0), - null - ) - val js = ScalaJack.write(inst) - assertEquals(js, - """{"bi1":-90182736451928374653345,"bi2":90182736451928374653345,"bi3":0,"bi4":null}""" - ) - assertEquals(inst, ScalaJack.read[SampleBigInt](js)) - } + it("Binary must work") { + val inst = SampleBinary( + null, + hexStringToByteArray("e04fd020ea3a6910a2d808002b30309d") + ) + val js = ScalaJack.write(inst) + val inst2 = ScalaJack.read[SampleBinary](js) + js should matchJson("""{"b1":null,"b2":[-32,79,-48,32,-22,58,105,16,-94,-40,8,0,43,48,48,-99]}""") + inst2.b1 shouldEqual null + inst.b2.toList shouldEqual inst2.b2.toList + } - test("Binary must work") { - val inst = SampleBinary( - null, - hexStringToByteArray("e04fd020ea3a6910a2d808002b30309d") - ) - val js = ScalaJack.write(inst) - val inst2 = ScalaJack.read[SampleBinary](js) - assert(null == inst2.b1) - assertEquals(inst.b2.toList, inst2.b2.toList) - } + it("Boolean must work (not nullable)") { + val inst = SampleBoolean(bool1 = true, bool2 = false) + val js = ScalaJack.write(inst) + js should matchJson("""{"bool1":true,"bool2":false}""") + inst shouldEqual ScalaJack.read[SampleBoolean](js) + } - test("Boolean must work (not nullable)") { - val inst = SampleBoolean(bool1 = true, bool2 = false) - val js = ScalaJack.write(inst) - jsonMatches(js, """{"bool1":true,"bool2":false}""") - assertEquals(inst, ScalaJack.read[SampleBoolean](js)) - } + it("Byte must work (not nullable)") { + val inst = SampleByte(Byte.MaxValue, Byte.MinValue, 0, 64) + val js = ScalaJack.write(inst) + js should matchJson("""{"b1":127,"b2":-128,"b3":0,"b4":64}""") + inst shouldEqual ScalaJack.read[SampleByte](js) + } - test("Byte must work (not nullable)") { - val inst = SampleByte(Byte.MaxValue, Byte.MinValue, 0, 64) - val js = ScalaJack.write(inst) - jsonMatches(js, """{"b1":127,"b2":-128,"b3":0,"b4":64}""") - assertEquals(inst, ScalaJack.read[SampleByte](js)) - } + it("Char must work (not nullable)") { + val inst = SampleChar(Char.MaxValue, 'Z', '\u20A0') + val js = ScalaJack.write(inst) + js should matchJson("""{"c1":"\""" + """uffff","c2":"Z","c3":"\""" + """u20a0"}""") + inst shouldEqual ScalaJack.read[SampleChar](js) + } - test("Char must work (not nullable)") { - val inst = SampleChar(Char.MaxValue, 'Z', '\u20A0') - val js = ScalaJack.write(inst) - jsonMatches(js,"""{"c1":"\""" + """uffff","c2":"Z","c3":"\""" + """u20a0"}""") - assertEquals(inst, ScalaJack.read[SampleChar](js)) - } + it("Double must work (not nullable)") { + val inst = + SampleDouble(Double.MaxValue, Double.MinValue, 0.0, -123.4567) + val js = ScalaJack.write(inst) + js should matchJson("""{"d1":1.7976931348623157E308,"d2":-1.7976931348623157E308,"d3":0.0,"d4":-123.4567}""") + inst shouldEqual ScalaJack.read[SampleDouble](js) + } - test("Double must work (not nullable)") { - val inst = - SampleDouble(Double.MaxValue, Double.MinValue, 0.0, -123.4567) - val js = ScalaJack.write(inst) - jsonMatches(js, - """{"d1":1.7976931348623157E308,"d2":-1.7976931348623157E308,"d3":0.0,"d4":-123.4567}""") - assertEquals(inst, ScalaJack.read[SampleDouble](js)) - } + it("Float must work") { + val inst = SampleFloat(Float.MaxValue, Float.MinValue, 0.0f, -123.4567f) + val js = ScalaJack.write(inst) + js should matchJson("""{"f1":3.4028235E38,"f2":-3.4028235E38,"f3":0.0,"f4":-123.4567}""") + inst shouldEqual ScalaJack.read[SampleFloat](js) + } - test("Float must work") { - val inst = SampleFloat(Float.MaxValue, Float.MinValue, 0.0F, -123.4567F) - val js = ScalaJack.write(inst) - jsonMatches(js, - """{"f1":3.4028235E38,"f2":-3.4028235E38,"f3":0.0,"f4":-123.4567}""") - assertEquals(inst, ScalaJack.read[SampleFloat](js)) - } + it("Int must work (not nullable)") { + val inst = SampleInt(Int.MaxValue, Int.MinValue, 0, 123) + val js = ScalaJack.write(inst) + js should matchJson("""{"i1":2147483647,"i2":-2147483648,"i3":0,"i4":123}""") + inst shouldEqual ScalaJack.read[SampleInt](js) + } - test("Int must work (not nullable)") { - val inst = SampleInt(Int.MaxValue, Int.MinValue, 0, 123) - val js = ScalaJack.write(inst) - jsonMatches(js, """{"i1":2147483647,"i2":-2147483648,"i3":0,"i4":123}""") - assertEquals(inst, ScalaJack.read[SampleInt](js)) - } + it("Long must work (not nullable)") { + val inst = SampleLong(Long.MaxValue, Long.MinValue, 0L, 123L) + val js = ScalaJack.write(inst) + js should matchJson("""{"l1":9223372036854775807,"l2":-9223372036854775808,"l3":0,"l4":123}""") + inst shouldEqual ScalaJack.read[SampleLong](js) + } - test("Long must work (not nullable)") { - val inst = SampleLong(Long.MaxValue, Long.MinValue, 0L, 123L) - val js = ScalaJack.write(inst) - jsonMatches(js, - """{"l1":9223372036854775807,"l2":-9223372036854775808,"l3":0,"l4":123}""") - assertEquals(inst, ScalaJack.read[SampleLong](js)) - } + it("Short must work (not nullable)") { + val inst = SampleShort(Short.MaxValue, Short.MinValue, 0, 123) + val js = ScalaJack.write(inst) + js should matchJson("""{"s1":32767,"s2":-32768,"s3":0,"s4":123}""") + inst shouldEqual ScalaJack.read[SampleShort](js) + } - test("Short must work (not nullable)") { - val inst = SampleShort(Short.MaxValue, Short.MinValue, 0, 123) - val js = ScalaJack.write(inst) - jsonMatches(js,"""{"s1":32767,"s2":-32768,"s3":0,"s4":123}""") - assertEquals(inst, ScalaJack.read[SampleShort](js)) - } + it("String must work") { + val inst = SampleString("something\b\n\f\r\t☆", "", null) + val js = ScalaJack.write(inst) + // The weird '+' here is to break up the unicode so it won't be interpreted and wreck the test. + js should matchJson("""{"s1":"something\b\n\f\r\t\""" + """u2606","s2":"","s3":null}""") + inst shouldEqual ScalaJack.read[SampleString](js) + } - test("String must work") { - val inst = SampleString("something\b\n\f\r\t☆", "", null) - val js = ScalaJack.write(inst) - // The weird '+' here is to break up the unicode so it won't be interpreted and wreck the test. - jsonMatches(js, - """{"s1":"something\b\n\f\r\t\""" + """u2606","s2":"","s3":null}""") - assertEquals(inst, ScalaJack.read[SampleString](js)) - } - - test("UUID must work") { - val inst = SampleUUID( - null, - UUID.fromString("580afe0d-81c0-458f-9e09-4486c7af0fe9") - ) - val js = ScalaJack.write(inst) - jsonMatches(js, - """{"u1":null,"u2":"580afe0d-81c0-458f-9e09-4486c7af0fe9"}""") - assertEquals(inst, ScalaJack.read[SampleUUID](js)) - } + it("UUID must work") { + val inst = SampleUUID( + null, + UUID.fromString("580afe0d-81c0-458f-9e09-4486c7af0fe9") + ) + val js = ScalaJack.write(inst) + js should matchJson("""{"u1":null,"u2":"580afe0d-81c0-458f-9e09-4486c7af0fe9"}""") + inst shouldEqual ScalaJack.read[SampleUUID](js) + } + } + // -------------------------------------------------------- - //-------------------------------------------------------- + describe(colorString("--- Negative Tests ---")) { + it("BigDecimal must break") { + val js = + """{"bd1":123,"bd2":1.23,"bd3":0,"bd4":123.456,"bd5":"0.1499999999999999944488848768742172978818416595458984375","bd6":null}""" + val msg: String = + """Float/Double expected but couldn't parse from """" + "\"" + """" at position [50] + |{"bd1":123,"bd2":1.23,"bd3":0,"bd4":123.456,"bd5":"0.149999999999999994448884... + |--------------------------------------------------^""".stripMargin + val thrown = the[JsonParseError] thrownBy ScalaJack.read[SampleBigDecimal](js) + thrown.getMessage should equal(msg) + } -/* - test("BigDecimal must break") { - describe("--- Negative Tests ---") - val js = - """{"bd1":123,"bd2":1.23,"bd3":0,"bd4":123.456,"bd5":"0.1499999999999999944488848768742172978818416595458984375","bd6":null}""" - val msg = - """Expected a Number here - |{"bd1":123,"bd2":1.23,"bd3":0,"bd4":123.456,"bd5":"0.149999999999999994448884... - |--------------------------------------------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg){ - ScalaJack.read[SampleBigDecimal](js) - } - } + it("BigInt must break") { + val js = + """{"bi1":"-90182736451928374653345","bi2":90182736451928374653345,"bi3":0,"bi4":null}""" + val msg = + """Int/Long expected but couldn't parse from """" + "\"" + """" at position [7] + |{"bi1":"-90182736451928374653345","bi2":90182736451928374653345,"bi3":0,"bi4"... + |-------^""".stripMargin + val thrown = the[JsonParseError] thrownBy ScalaJack.read[SampleBigInt](js) + thrown.getMessage should equal(msg) + } - test("BigInt must break") { - val js = - """{"bi1":"-90182736451928374653345","bi2":90182736451928374653345,"bi3":0,"bi4":null}""" - val msg = - """Expected a Number here - |{"bi1":"-90182736451928374653345","bi2":90182736451928374653345,"bi3":0,"bi4"... - |-------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg){ - ScalaJack.read[SampleBigInt](js) - } - } - - test("Boolean must break") { - val js = """{"bool1":true,"bool2":"false"}""" - val msg = """Expected a Boolean here - |{"bool1":true,"bool2":"false"} - |----------------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg){ - ScalaJack.read[SampleBoolean](js) - } - val js2 = """{"bool1":true,"bool2":123}""" - val msg2 = """Expected a Boolean here - |{"bool1":true,"bool2":123} - |----------------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg2){ - ScalaJack.read[SampleBoolean](js2) - } - val js3 = """{"bool1":true,"bool2":null}""" - val msg3 = """Expected a Boolean here - |{"bool1":true,"bool2":null} - |----------------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg3){ - ScalaJack.read[SampleBoolean](js3) - } - } + it("Boolean must break") { + val js = """{"bool1":true,"bool2":"false"}""" + val msg = """Unexpected character '"' where beginning of boolean value expected at position [22] + |{"bool1":true,"bool2":"false"} + |----------------------^""".stripMargin + val thrown = the[JsonParseError] thrownBy ScalaJack.read[SampleBoolean](js) + thrown.getMessage should equal(msg) + val js2 = """{"bool1":true,"bool2":123}""" + val msg2 = """Unexpected character '1' where beginning of boolean value expected at position [22] + |{"bool1":true,"bool2":123} + |----------------------^""".stripMargin + val thrown2 = the[JsonParseError] thrownBy ScalaJack.read[SampleBoolean](js2) + thrown2.getMessage should equal(msg2) + val js3 = """{"bool1":true,"bool2":null}""" + val msg3 = """Unexpected character 'n' where beginning of boolean value expected at position [22] + |{"bool1":true,"bool2":null} + |----------------------^""".stripMargin + val thrown3 = the[JsonParseError] thrownBy ScalaJack.read[SampleBoolean](js3) + thrown3.getMessage should equal(msg3) + } - test("Byte must break") { - val js = """{"b1":true,"b2":-128,"b3":0,"b4":64}""" - val msg = """Expected a Number here - |{"b1":true,"b2":-128,"b3":0,"b4":64} - |------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg){ - ScalaJack.read[SampleByte](js) - } - val js2 = """{"b1":12,"b2":-128,"b3":0,"b4":null}""" - val msg2 = """Expected a Number here - |{"b1":12,"b2":-128,"b3":0,"b4":null} - |-------------------------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg2){ - ScalaJack.read[SampleByte](js2) - } - } + it("Byte must break") { + val js = """{"b1":true,"b2":-128,"b3":0,"b4":64}""" + val msg = """Int/Long expected but couldn't parse from "t" at position [6] + |{"b1":true,"b2":-128,"b3":0,"b4":64} + |------^""".stripMargin + val thrown = the[JsonParseError] thrownBy ScalaJack.read[SampleByte](js) + thrown.getMessage should equal(msg) + val js2 = """{"b1":12,"b2":-128,"b3":0,"b4":null}""" + val msg2 = """Int/Long expected but couldn't parse from "n" at position [31] + |{"b1":12,"b2":-128,"b3":0,"b4":null} + |-------------------------------^""".stripMargin + val thrown2 = the[JsonParseError] thrownBy ScalaJack.read[SampleByte](js2) + thrown2.getMessage should equal(msg2) + } - test("Char must break") { - val js = """{"c1":null,"c2":"Y","c3":"Z"}""" - val msg = """A Char typed value cannot be null - |{"c1":null,"c2":"Y","c3":"Z"} - |---------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg){ - ScalaJack.read[SampleChar](js) - } - val js2 = """{"c1":"","c2":"Y","c3":"Z"}""" - val msg2 = """Tried to read a Char but empty string found - |{"c1":"","c2":"Y","c3":"Z"} - |-------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg2){ - ScalaJack.read[SampleChar](js2) - } - } + it("Char must break") { + val js = """{"c1":null,"c2":"Y","c3":"Z"}""" + val msg = """Char typed values cannot be null at position [10] + |{"c1":null,"c2":"Y","c3":"Z"} + |----------^""".stripMargin + val thrown = the[JsonParseError] thrownBy ScalaJack.read[SampleChar](js) + thrown.getMessage should equal(msg) + val js2 = """{"c1":"","c2":"Y","c3":"Z"}""" + val msg2 = """Cannot convert value '' into a Char at position [8] + |{"c1":"","c2":"Y","c3":"Z"} + |--------^""".stripMargin + val thrown2 = the[JsonParseError] thrownBy ScalaJack.read[SampleChar](js2) + thrown2.getMessage should equal(msg2) + } - test("Double must break") { - val js = - """{"d1":1.79769313486E23157E308,"d2":-1.7976931348623157E308,"d3":0.0,"d4":-123.4567}""" - val msg = - """Cannot parse an Double from value - |{"d1":1.79769313486E23157E308,"d2":-1.7976931348623157E308,"d3":0.0,"d4":-123... - |----------------------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg){ - ScalaJack.read[SampleDouble](js) - } - } + it("Double must break") { + val js = + """{"d1":1.79769313486E23157E308,"d2":-1.7976931348623157E308,"d3":0.0,"d4":-123.4567}""" + val msg = + """Float/Double expected but couldn't parse from "1.79769313486E23157E308" at position [29] + |{"d1":1.79769313486E23157E308,"d2":-1.7976931348623157E308,"d3":0.0,"d4":-123... + |------^""".stripMargin + val thrown = the[JsonParseError] thrownBy ScalaJack.read[SampleDouble](js) + thrown.getMessage should equal(msg) + } - test("Float must break") { - val js = - """{"f1":3.4028235E38,"f2":"-3.4028235E38","f3":0.0,"f4":-123.4567}""" - val msg = - """Expected a Number here - |{"f1":3.4028235E38,"f2":"-3.4028235E38","f3":0.0,"f4":-123.4567} - |------------------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg){ - ScalaJack.read[SampleFloat](js) - } - } + it("Float must break") { + val js = + """{"f1":3.4028235E38,"f2":"-3.4028235E38","f3":0.0,"f4":-123.4567}""" + val msg = + """Float/Double expected but couldn't parse from """" + "\"" + """" at position [24] + |{"f1":3.4028235E38,"f2":"-3.4028235E38","f3":0.0,"f4":-123.4567} + |------------------------^""".stripMargin + val thrown = the[JsonParseError] thrownBy ScalaJack.read[SampleFloat](js) + thrown.getMessage should equal(msg) + } - test("Int must break") { - val js = """{"i1":2147483647,"i2":-2147483648,"i3":"0","i4":123}""" - val msg = """Expected a Number here - |{"i1":2147483647,"i2":-2147483648,"i3":"0","i4":123} - |---------------------------------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg){ - ScalaJack.read[SampleInt](js) - } - val js2 = """{"i1":2147483647,"i2":-2147483648,"i3":2.3,"i4":123}""" - val msg2 = """Cannot parse an Int from value - |{"i1":2147483647,"i2":-2147483648,"i3":2.3,"i4":123} - |-----------------------------------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg2){ - ScalaJack.read[SampleInt](js2) - } - } + it("Int must break") { + val js = """{"i1":2147483647,"i2":-2147483648,"i3":"0","i4":123}""" + val msg = """Int/Long expected but couldn't parse from """" + "\"" + """" at position [39] + |{"i1":2147483647,"i2":-2147483648,"i3":"0","i4":123} + |---------------------------------------^""".stripMargin + val thrown = the[JsonParseError] thrownBy ScalaJack.read[SampleInt](js) + thrown.getMessage should equal(msg) + val js2 = """{"i1":2147483647,"i2":-2147483648,"i3":2.3,"i4":123}""" + val msg2 = """Comma expected at position [40] + |{"i1":2147483647,"i2":-2147483648,"i3":2.3,"i4":123} + |----------------------------------------^""".stripMargin + val thrown2 = the[CommaExpected] thrownBy ScalaJack.read[SampleInt](js2) + thrown2.getMessage should equal(msg2) + } - test("Long must break") { - val js = - """{"l1":9223372036854775807,"l2":-9223372036854775808,"l3":true,"l4":123}""" - val msg = - """Expected a Number here - |...23372036854775807,"l2":-9223372036854775808,"l3":true,"l4":123} - |----------------------------------------------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg){ - ScalaJack.read[SampleLong](js) - } - val js2 = - """{"l1":9223372036854775807,"l2":-9223372036854775808,"l3":0.3,"l4":123}""" - val msg2 = - """Cannot parse an Long from value - |...372036854775807,"l2":-9223372036854775808,"l3":0.3,"l4":123} - |----------------------------------------------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg2){ - ScalaJack.read[SampleLong](js2) - } - } + it("Long must break") { + val js = + """{"l1":9223372036854775807,"l2":-9223372036854775808,"l3":true,"l4":123}""" + val msg = + """Int/Long expected but couldn't parse from "t" at position [57] + |...23372036854775807,"l2":-9223372036854775808,"l3":true,"l4":123} + |----------------------------------------------------^""".stripMargin + val thrown = the[JsonParseError] thrownBy ScalaJack.read[SampleLong](js) + thrown.getMessage should equal(msg) + val js2 = + """{"l1":9223372036854775807,"l2":-9223372036854775808,"l3":0.3,"l4":123}""" + val msg2 = + """Comma expected at position [58] + |...3372036854775807,"l2":-9223372036854775808,"l3":0.3,"l4":123} + |----------------------------------------------------^""".stripMargin + val thrown2 = the[CommaExpected] thrownBy ScalaJack.read[SampleLong](js2) + thrown2.getMessage should equal(msg2) + } - test("Short must break") { - val js = """{"s1":32767,"s2":true,"s3":0,"s4":123}""" - val msg = """Expected a Number here - |{"s1":32767,"s2":true,"s3":0,"s4":123} - |-----------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg){ - ScalaJack.read[SampleShort](js) - } - val js2 = """{"s1":32767,"s2":3.4,"s3":0,"s4":123}""" - val msg2 = """Cannot parse an Short from value - |{"s1":32767,"s2":3.4,"s3":0,"s4":123} - |-------------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg2){ - ScalaJack.read[SampleShort](js2) - } - } + it("Short must break") { + val js = """{"s1":32767,"s2":true,"s3":0,"s4":123}""" + val msg = """Int/Long expected but couldn't parse from "t" at position [17] + |{"s1":32767,"s2":true,"s3":0,"s4":123} + |-----------------^""".stripMargin + val thrown = the[JsonParseError] thrownBy ScalaJack.read[SampleShort](js) + thrown.getMessage should equal(msg) + val js2 = """{"s1":32767,"s2":3.4,"s3":0,"s4":123}""" + val msg2 = """Comma expected at position [18] + |{"s1":32767,"s2":3.4,"s3":0,"s4":123} + |------------------^""".stripMargin + val thrown2 = the[CommaExpected] thrownBy ScalaJack.read[SampleShort](js2) + thrown2.getMessage should equal(msg2) + } - test("String must break") { - val js = """{"s1":"something","s2":-19,"s3":null}""" - val msg = """Expected a String here - |{"s1":"something","s2":-19,"s3":null} - |-----------------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg){ - ScalaJack.read[SampleString](js) - } - } + it("String must break") { + val js = """{"s1":"something","s2":-19,"s3":null}""" + val msg = """Unexpected character '-' where beginning of a string expected at position [23] + |{"s1":"something","s2":-19,"s3":null} + |-----------------------^""".stripMargin + val thrown = the[JsonParseError] thrownBy ScalaJack.read[SampleString](js) + thrown.getMessage should equal(msg) + } - test("UUID must break") { - val js = - """{"u1":"bogus","u2":"580afe0d-81c0-458f-9e09-4486c7af0fe9"}""" - val msg = """Failed to create UUID value from parsed text bogus - |{"u1":"bogus","u2":"580afe0d-81c0-458f-9e09-4486c7af0fe9"} - |------------^""".stripMargin - interceptMessage[co.blocke.scalajack.ScalaJackError](msg){ - ScalaJack.read[SampleUUID](js) + it("UUID must break") { + val js = + """{"u1":"bogus","u2":"580afe0d-81c0-458f-9e09-4486c7af0fe9"}""" + val msg = """Unable to marshal UUID from value 'bogus' at position [13] + |{"u1":"bogus","u2":"580afe0d-81c0-458f-9e09-4486c7af0fe9"} + |-------------^""".stripMargin + val thrown = the[JsonParseError] thrownBy ScalaJack.read[SampleUUID](js) + thrown.getMessage should equal(msg) + } } } -*/ \ No newline at end of file