From 0f3be2e08b8849244da02a7039fd4d725e1c1f17 Mon Sep 17 00:00:00 2001 From: tarao Date: Tue, 19 Dec 2023 14:17:06 +0900 Subject: [PATCH] Implement conversion from/to native JS objects. --- build.sbt | 5 + .../ArrayRecordPlatformSpecific.scala | 23 ++ .../record4s/RecordPlatformSpecific.scala | 88 ++++++++ .../tarao/record4s/JSArrayRecordSpec.scala | 183 ++++++++++++++++ .../github/tarao/record4s/JSRecordSpec.scala | 201 ++++++++++++++++++ .../tarao/record4s/PlatformSpecific.scala | 4 + .../tarao/record4s/PlatformSpecific.scala | 4 + .../github/tarao/record4s/ArrayRecord.scala | 4 +- .../com/github/tarao/record4s/Record.scala | 2 +- 9 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 modules/core/.js/src/main/scala/com/github/tarao/record4s/ArrayRecordPlatformSpecific.scala create mode 100644 modules/core/.js/src/main/scala/com/github/tarao/record4s/RecordPlatformSpecific.scala create mode 100644 modules/core/.js/src/test/scala/com/github/tarao/record4s/JSArrayRecordSpec.scala create mode 100644 modules/core/.js/src/test/scala/com/github/tarao/record4s/JSRecordSpec.scala create mode 100644 modules/core/.jvm/src/main/scala/com/github/tarao/record4s/PlatformSpecific.scala create mode 100644 modules/core/.native/src/main/scala/com/github/tarao/record4s/PlatformSpecific.scala diff --git a/build.sbt b/build.sbt index 5775c19..4511825 100644 --- a/build.sbt +++ b/build.sbt @@ -73,6 +73,11 @@ lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) "org.scalatest" %%% "scalatest" % scalaTestVersion % Test, ), ) + .jsSettings( + libraryDependencies ++= Seq( + "org.getshaka" %%% "native-converter" % "0.9.0", + ), + ) lazy val circe = crossProject(JVMPlatform, JSPlatform, NativePlatform) .crossType(CrossType.Pure) diff --git a/modules/core/.js/src/main/scala/com/github/tarao/record4s/ArrayRecordPlatformSpecific.scala b/modules/core/.js/src/main/scala/com/github/tarao/record4s/ArrayRecordPlatformSpecific.scala new file mode 100644 index 0000000..223e897 --- /dev/null +++ b/modules/core/.js/src/main/scala/com/github/tarao/record4s/ArrayRecordPlatformSpecific.scala @@ -0,0 +1,23 @@ +package com.github.tarao.record4s + +import org.getshaka.nativeconverter.NativeConverter +import scala.scalajs.js + +trait ArrayRecordPlatformSpecific { + def fromJS[T <: Tuple](obj: js.Any)(using + nc: NativeConverter[ArrayRecord[T]], + ): ArrayRecord[T] = nc.fromNative(obj) + + def fromJSON[T <: Tuple](json: String)(using + nc: NativeConverter[ArrayRecord[T]], + ): ArrayRecord[T] = nc.fromJson(json) + + extension [R](record: ArrayRecord[R]) { + def toJS(using NativeConverter[ArrayRecord[R]]): js.Any = record.toNative + + def toJSON(using NativeConverter[ArrayRecord[R]]): String = record.toJson + } + + inline given nativeConverter[R]: NativeConverter[ArrayRecord[R]] = + NativeConverter.derived +} diff --git a/modules/core/.js/src/main/scala/com/github/tarao/record4s/RecordPlatformSpecific.scala b/modules/core/.js/src/main/scala/com/github/tarao/record4s/RecordPlatformSpecific.scala new file mode 100644 index 0000000..842a3a7 --- /dev/null +++ b/modules/core/.js/src/main/scala/com/github/tarao/record4s/RecordPlatformSpecific.scala @@ -0,0 +1,88 @@ +package com.github.tarao.record4s + +import org.getshaka.nativeconverter.{NativeConverter, ParseState} +import scala.compiletime.{constValue, erasedValue, summonInline} +import scala.scalajs.js + +trait RecordPlatformSpecific { + def fromJS[R <: %](obj: js.Any)(using nc: NativeConverter[R]): R = + nc.fromNative(obj) + + def fromJSON[R <: %](json: String)(using nc: NativeConverter[R]): R = + nc.fromJson(json) + + extension [R <: %](record: R) { + def toJS(using NativeConverter[R]): js.Any = record.toNative + + def toJSON(using NativeConverter[R]): String = record.toJson + } + + private type ImplicitlyJsAny = + String | Boolean | Byte | Short | Int | Float | Double | Null | js.Any + + private inline def fieldsToNative[Types, Labels]( + record: Map[String, Any], + res: js.Dynamic = js.Object().asInstanceOf[js.Dynamic], + ): js.Any = + inline (erasedValue[Types], erasedValue[Labels]) match { + case _: (EmptyTuple, EmptyTuple) => + res + + case _: (tpe *: types, label *: labels) => + val labelStr = constValue[label & String] + val nativeElem = + inline erasedValue[tpe] match { + case _: ImplicitlyJsAny => + record(labelStr).asInstanceOf[js.Any] + case _ => + val nc = summonInline[NativeConverter[tpe]] + val elem = record(labelStr).asInstanceOf[tpe] + nc.toNative(elem) + } + res.updateDynamic(labelStr)(nativeElem) + + fieldsToNative[types, labels](record, res) + } + + private inline def nativeToFields[Types, Labels]( + dict: js.Dictionary[js.Any], + ps: ParseState, + res: Seq[(String, Any)] = Seq.empty, + ): Seq[(String, Any)] = + inline (erasedValue[Types], erasedValue[Labels]) match { + case _: (EmptyTuple, EmptyTuple) => + res + + case _: (tpe *: types, label *: labels) => + val nc = summonInline[NativeConverter[tpe]] + val labelStr = constValue[label & String] + val jsElem = dict.getOrElse(labelStr, null) + val elem = nc.fromNative(ps.atKey(labelStr, jsElem)) + nativeToFields[types, labels](dict, ps, res :+ (labelStr, elem)) + } + + private def asDict(ps: ParseState): js.Dictionary[js.Any] = + ps.json match { + case o: js.Object => o.asInstanceOf[js.Dictionary[js.Any]] + case _ => ps.fail("js.Object") + } + + inline given nativeConverter[R <: %](using + r: RecordLike[R], + ): NativeConverter[R] = { + type Types = r.ElemTypes + type Labels = r.ElemLabels + + new NativeConverter[R] { + extension (record: R) { + def toNative: js.Any = + fieldsToNative[Types, Labels](r.iterableOf(record).toMap) + } + + def fromNative(ps: ParseState): R = { + val iterable = nativeToFields[Types, Labels](asDict(ps), ps) + Record.newMapRecord[R](iterable) + } + } + } +} diff --git a/modules/core/.js/src/test/scala/com/github/tarao/record4s/JSArrayRecordSpec.scala b/modules/core/.js/src/test/scala/com/github/tarao/record4s/JSArrayRecordSpec.scala new file mode 100644 index 0000000..11df880 --- /dev/null +++ b/modules/core/.js/src/test/scala/com/github/tarao/record4s/JSArrayRecordSpec.scala @@ -0,0 +1,183 @@ +package com.github.tarao.record4s + +import scala.scalajs.js + +class JSArrayRecordSpec extends helper.UnitSpec { + describe("ArrayRecord in JS") { + describe("fromJS") { + it("should convert js.Any to ArrayRecord") { + val obj: js.Any = js.Dynamic.literal(name = "tarao", age = 3) + val r = ArrayRecord.fromJS[(("name", String), ("age", Int))](obj) + r shouldStaticallyBe a[ArrayRecord[(("name", String), ("age", Int))]] + r.name shouldBe "tarao" + r.age shouldBe 3 + } + + it("should convert js.Any to nested ArrayRecord") { + val obj: js.Any = js + .Dynamic + .literal( + name = "tarao", + age = 3, + email = js + .Dynamic + .literal( + local = "tarao", + domain = "example.com", + ), + ) + val r = ArrayRecord.fromJS[ + ( + ("name", String), + ("age", Int), + ("email", ArrayRecord[(("local", String), ("domain", String))]), + ), + ](obj) + r shouldStaticallyBe a[ArrayRecord[ + ( + ("name", String), + ("age", Int), + ("email", ArrayRecord[(("local", String), ("domain", String))]), + ), + ]] + r.name shouldBe "tarao" + r.age shouldBe 3 + r.email.local shouldBe "tarao" + r.email.domain shouldBe "example.com" + } + + it("should ignore extra fields") { + val obj: js.Any = js.Dynamic.literal(name = "tarao", age = 3) + val r = ArrayRecord.fromJS[("name", String) *: EmptyTuple](obj) + r shouldStaticallyBe a[ArrayRecord[("name", String) *: EmptyTuple]] + r.name shouldBe "tarao" + "r.age" shouldNot typeCheck + } + + it("should fill nulls for missing fields") { + val obj: js.Any = js.Dynamic.literal() + val r = ArrayRecord.fromJS[(("name", String), ("age", Int))](obj) + r shouldStaticallyBe a[ArrayRecord[(("name", String), ("age", Int))]] + r.name shouldBe null + r.age shouldBe 0 + } + + it("should throw if an object field is missing") { + val obj: js.Any = js.Dynamic.literal(name = "tarao", age = 3) + a[java.lang.RuntimeException] should be thrownBy + ArrayRecord.fromJS[ + ( + ("name", String), + ("age", Int), + ("email", ArrayRecord[(("local", String), ("domain", String))]), + ), + ](obj) + } + } + + describe("fromJSON") { + it("should convert JSON String to ArrayRecord") { + val json = """{"name":"tarao","age":3}""" + val r = ArrayRecord.fromJSON[(("name", String), ("age", Int))](json) + r shouldStaticallyBe a[ArrayRecord[(("name", String), ("age", Int))]] + r.name shouldBe "tarao" + r.age shouldBe 3 + } + + it("should convert JSON String to nested ArrayRecord") { + val json = + """{"name":"tarao","age":3,"email":{"local":"tarao","domain":"example.com"}}""" + val r = ArrayRecord.fromJSON[ + ( + ("name", String), + ("age", Int), + ("email", ArrayRecord[(("local", String), ("domain", String))]), + ), + ](json) + r shouldStaticallyBe a[ArrayRecord[ + ( + ("name", String), + ("age", Int), + ("email", ArrayRecord[(("local", String), ("domain", String))]), + ), + ]] + r.name shouldBe "tarao" + r.age shouldBe 3 + r.email.local shouldBe "tarao" + r.email.domain shouldBe "example.com" + } + + it("should ignore extra fields") { + val json = """{"name":"tarao","age":3}""" + val r = ArrayRecord.fromJSON[("name", String) *: EmptyTuple](json) + r shouldStaticallyBe a[ArrayRecord[("name", String) *: EmptyTuple]] + r.name shouldBe "tarao" + "r.age" shouldNot typeCheck + } + + it("should fill nulls for missing fields") { + val json = """{}""" + val r = ArrayRecord.fromJSON[(("name", String), ("age", Int))](json) + r shouldStaticallyBe a[ArrayRecord[(("name", String), ("age", Int))]] + r.name shouldBe null + r.age shouldBe 0 + } + + it("should throw if an object field is missing") { + val json = """{"name":"tarao","age":3}""" + a[java.lang.RuntimeException] should be thrownBy + ArrayRecord.fromJSON[ + ( + ("name", String), + ("age", Int), + ("email", ArrayRecord[(("local", String), ("domain", String))]), + ), + ](json) + } + } + + describe("toJS") { + it("should convert ArrayRecord to js.Any") { + val r = ArrayRecord(name = "tarao", age = 3) + val obj = r.toJS + obj shouldStaticallyBe a[js.Any] + js.JSON.stringify(obj) shouldBe """{"name":"tarao","age":3}""" + } + + it("should convert nested ArrayRecord to js.Any") { + val r = ArrayRecord( + name = "tarao", + age = 3, + email = ArrayRecord( + local = "tarao", + domain = "example.com", + ), + ) + val obj = r.toJS + obj shouldStaticallyBe a[js.Any] + js.JSON.stringify(obj) shouldBe """{"name":"tarao","age":3,"email":{"local":"tarao","domain":"example.com"}}""" + } + } + + describe("toJSON") { + it("should convert ArrayRecord to JSON String") { + val r = ArrayRecord(name = "tarao", age = 3) + val json = r.toJSON + json shouldBe """{"name":"tarao","age":3}""" + } + + it("should convert nested ArrayRecord to JSON String") { + val r = ArrayRecord( + name = "tarao", + age = 3, + email = ArrayRecord( + local = "tarao", + domain = "example.com", + ), + ) + val json = r.toJSON + json shouldBe """{"name":"tarao","age":3,"email":{"local":"tarao","domain":"example.com"}}""" + } + } + } +} diff --git a/modules/core/.js/src/test/scala/com/github/tarao/record4s/JSRecordSpec.scala b/modules/core/.js/src/test/scala/com/github/tarao/record4s/JSRecordSpec.scala new file mode 100644 index 0000000..40259be --- /dev/null +++ b/modules/core/.js/src/test/scala/com/github/tarao/record4s/JSRecordSpec.scala @@ -0,0 +1,201 @@ +package com.github.tarao.record4s + +import scala.scalajs.js + +class JSRecordSpec extends helper.UnitSpec { + describe("% in JS") { + describe("fromJS") { + it("should convert js.Any to %") { + val obj: js.Any = js.Dynamic.literal(name = "tarao", age = 3) + val r = Record.fromJS[% { val name: String; val age: Int }](obj) + r shouldStaticallyBe a[% { val name: String; val age: Int }] + r.name shouldBe "tarao" + r.age shouldBe 3 + } + + it("should convert js.Any to nested ArrayRecord") { + val obj: js.Any = js + .Dynamic + .literal( + name = "tarao", + age = 3, + email = js + .Dynamic + .literal( + local = "tarao", + domain = "example.com", + ), + ) + val r = Record.fromJS[ + % { + val name: String + val age: Int + val email: % { + val local: String + val domain: String + } + }, + ](obj) + r shouldStaticallyBe a[ + % { + val name: String + val age: Int + val email: % { + val local: String + val domain: String + } + }, + ] + r.name shouldBe "tarao" + r.age shouldBe 3 + r.email.local shouldBe "tarao" + r.email.domain shouldBe "example.com" + } + + it("should ignore extra fields") { + val obj: js.Any = js.Dynamic.literal(name = "tarao", age = 3) + val r = Record.fromJS[% { val name: String }](obj) + r shouldStaticallyBe a[% { val name: String }] + r.name shouldBe "tarao" + "r.age" shouldNot typeCheck + } + + it("should fill nulls for missing fields") { + val obj: js.Any = js.Dynamic.literal() + val r = Record.fromJS[% { val name: String; val age: Int }](obj) + r shouldStaticallyBe a[% { val name: String; val age: Int }] + r.name shouldBe null + r.age shouldBe 0 + } + + it("should throw if an object field is missing") { + val obj: js.Any = js.Dynamic.literal(name = "tarao", age = 3) + a[java.lang.RuntimeException] should be thrownBy + Record.fromJS[ + % { + val name: String + val age: Int + val email: % { + val local: String + val domain: String + } + }, + ](obj) + } + } + + describe("fromJSON") { + it("should convert JSON String to %") { + val json = """{"name":"tarao","age":3}""" + val r = Record.fromJSON[% { val name: String; val age: Int }](json) + r shouldStaticallyBe a[% { val name: String; val age: Int }] + r.name shouldBe "tarao" + r.age shouldBe 3 + } + + it("should convert JSON String to nested ArrayRecord") { + val json = + """{"name":"tarao","age":3,"email":{"local":"tarao","domain":"example.com"}}""" + val r = Record.fromJSON[ + % { + val name: String + val age: Int + val email: % { + val local: String + val domain: String + } + }, + ](json) + r shouldStaticallyBe a[ + % { + val name: String + val age: Int + val email: % { + val local: String + val domain: String + } + }, + ] + r.name shouldBe "tarao" + r.age shouldBe 3 + r.email.local shouldBe "tarao" + r.email.domain shouldBe "example.com" + } + + it("should ignore extra fields") { + val json = """{"name":"tarao","age":3}""" + val r = Record.fromJSON[% { val name: String }](json) + r shouldStaticallyBe a[% { val name: String }] + r.name shouldBe "tarao" + "r.age" shouldNot typeCheck + } + + it("should fill nulls for missing fields") { + val json = """{}""" + val r = Record.fromJSON[% { val name: String; val age: Int }](json) + r shouldStaticallyBe a[% { val name: String; val age: Int }] + r.name shouldBe null + r.age shouldBe 0 + } + + it("should throw if an object field is missing") { + val json = """{"name":"tarao","age":3}""" + a[java.lang.RuntimeException] should be thrownBy + Record.fromJS[ + % { + val name: String + val age: Int + val email: % { + val local: String + val domain: String + } + }, + ](json) + } + } + + describe("toJS") { + it("should convert % to js.Any") { + val r = %(name = "tarao", age = 3) + val obj = r.toJS + obj shouldStaticallyBe a[js.Any] + js.JSON.stringify(obj) shouldBe """{"name":"tarao","age":3}""" + } + + it("should convert nested % to js.Any") { + val r = %( + name = "tarao", + age = 3, + email = %( + local = "tarao", + domain = "example.com", + ), + ) + val obj = r.toJS + obj shouldStaticallyBe a[js.Any] + js.JSON.stringify(obj) shouldBe """{"name":"tarao","age":3,"email":{"local":"tarao","domain":"example.com"}}""" + } + } + + describe("toJSON") { + it("should convert % to JSON String") { + val r = %(name = "tarao", age = 3) + val json = r.toJSON + json shouldBe """{"name":"tarao","age":3}""" + } + + it("should convert nested % to JSON String") { + val r = %( + name = "tarao", + age = 3, + email = %( + local = "tarao", + domain = "example.com", + ), + ) + val json = r.toJSON + json shouldBe """{"name":"tarao","age":3,"email":{"local":"tarao","domain":"example.com"}}""" + } + } + } +} diff --git a/modules/core/.jvm/src/main/scala/com/github/tarao/record4s/PlatformSpecific.scala b/modules/core/.jvm/src/main/scala/com/github/tarao/record4s/PlatformSpecific.scala new file mode 100644 index 0000000..dd9f97b --- /dev/null +++ b/modules/core/.jvm/src/main/scala/com/github/tarao/record4s/PlatformSpecific.scala @@ -0,0 +1,4 @@ +package com.github.tarao.record4s + +trait RecordPlatformSpecific +trait ArrayRecordPlatformSpecific diff --git a/modules/core/.native/src/main/scala/com/github/tarao/record4s/PlatformSpecific.scala b/modules/core/.native/src/main/scala/com/github/tarao/record4s/PlatformSpecific.scala new file mode 100644 index 0000000..dd9f97b --- /dev/null +++ b/modules/core/.native/src/main/scala/com/github/tarao/record4s/PlatformSpecific.scala @@ -0,0 +1,4 @@ +package com.github.tarao.record4s + +trait RecordPlatformSpecific +trait ArrayRecordPlatformSpecific diff --git a/modules/core/src/main/scala/com/github/tarao/record4s/ArrayRecord.scala b/modules/core/src/main/scala/com/github/tarao/record4s/ArrayRecord.scala index 2354630..f8d8ba0 100644 --- a/modules/core/src/main/scala/com/github/tarao/record4s/ArrayRecord.scala +++ b/modules/core/src/main/scala/com/github/tarao/record4s/ArrayRecord.scala @@ -132,7 +132,9 @@ abstract class ArrayRecord[R] extends ProductRecord with Dynamic { ${ ArrayRecordMacros.selectImpl('this, 'name) } } -object ArrayRecord extends ArrayRecord.Extensible[EmptyTuple] { +object ArrayRecord + extends ArrayRecord.Extensible[EmptyTuple] + with ArrayRecordPlatformSpecific { import scala.compiletime.{constValue, erasedValue, summonInline} import typing.withPotentialTypingError diff --git a/modules/core/src/main/scala/com/github/tarao/record4s/Record.scala b/modules/core/src/main/scala/com/github/tarao/record4s/Record.scala index 2f8f907..a62077c 100644 --- a/modules/core/src/main/scala/com/github/tarao/record4s/Record.scala +++ b/modules/core/src/main/scala/com/github/tarao/record4s/Record.scala @@ -25,7 +25,7 @@ import typing.Record.{Aux, Concat, Lookup, Select, Unselect} */ trait Record -object Record { +object Record extends RecordPlatformSpecific { import typing.withPotentialTypingError /** An empty record. */