Skip to content

Commit

Permalink
Implement conversion from/to native JS objects.
Browse files Browse the repository at this point in the history
  • Loading branch information
tarao committed Dec 19, 2023
1 parent 8f550b1 commit 0f3be2e
Show file tree
Hide file tree
Showing 9 changed files with 512 additions and 2 deletions.
5 changes: 5 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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"}}"""
}
}
}
}
Loading

0 comments on commit 0f3be2e

Please sign in to comment.