From a9d68a98d067de87b6d924ae8919c178c8504457 Mon Sep 17 00:00:00 2001 From: tarao Date: Tue, 7 Nov 2023 23:11:27 +0900 Subject: [PATCH 1/3] Circe support. --- build.sbt | 21 ++- .../github/tarao/record4s/circe/Codec.scala | 44 +++++ .../tarao/record4s/circe/CodecSpec.scala | 175 ++++++++++++++++++ .../src/test/scala/helper/EitherValues.scala | 33 ++++ .../core/src/test/scala/helper/UnitSpec.scala | 1 + 5 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 modules/circe/src/main/scala/com/github/tarao/record4s/circe/Codec.scala create mode 100644 modules/circe/src/test/scala/com/github/tarao/record4s/circe/CodecSpec.scala create mode 100644 modules/core/src/test/scala/helper/EitherValues.scala diff --git a/build.sbt b/build.sbt index 5f6495d..35d826f 100644 --- a/build.sbt +++ b/build.sbt @@ -30,6 +30,9 @@ ThisBuild / githubWorkflowJavaVersions := Seq( JavaSpec.temurin("17"), ) +val circeVersion = "0.14.6" +val scalaTestVersion = "3.2.17" + lazy val compileSettings = Def.settings( // Default options are set by sbt-typelevel-settings tlFatalWarnings := true, @@ -53,7 +56,7 @@ lazy val commonSettings = Def.settings( ) lazy val root = tlCrossRootProject - .aggregate(core) + .aggregate(core, circe) .settings(commonSettings) .settings( console := (core.jvm / Compile / console).value, @@ -68,7 +71,21 @@ lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) .settings(commonSettings) .settings( libraryDependencies ++= Seq( - "org.scalatest" %%% "scalatest" % "3.2.17" % Test, + "org.scalatest" %%% "scalatest" % scalaTestVersion % Test, + ), + ) + +lazy val circe = crossProject(JVMPlatform, JSPlatform, NativePlatform) + .crossType(CrossType.Pure) + .dependsOn(core % "compile->compile;test->test") + .in(file("modules/circe")) + .settings(commonSettings) + .settings( + libraryDependencies ++= Seq( + "io.circe" %%% "circe-core" % circeVersion, + "io.circe" %%% "circe-generic" % circeVersion % Test, + "io.circe" %%% "circe-parser" % circeVersion % Test, + "org.scalatest" %%% "scalatest" % scalaTestVersion % Test, ), ) diff --git a/modules/circe/src/main/scala/com/github/tarao/record4s/circe/Codec.scala b/modules/circe/src/main/scala/com/github/tarao/record4s/circe/Codec.scala new file mode 100644 index 0000000..12cb090 --- /dev/null +++ b/modules/circe/src/main/scala/com/github/tarao/record4s/circe/Codec.scala @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 record4s authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.github.tarao.record4s +package circe + +import io.circe.{Decoder, Encoder, HCursor, Json} + +object Codec { + inline given encoder[R <: %, RR <: ProductRecord](using + ar: typing.ArrayRecord.Aux[R, RR], + enc: Encoder[RR], + ): Encoder[R] = new Encoder[R] { + final def apply(record: R): Json = enc(ArrayRecord.from(record)) + } + + inline given decoder[R <: %](using + r: RecordLike[R], + dec: Decoder[ArrayRecord[r.TupledFieldTypes]], + c: typing.Record.Concat[%, ArrayRecord[r.TupledFieldTypes]], + ev: c.Out =:= R, + ): Decoder[R] = new Decoder[R] { + final def apply(c: HCursor): Decoder.Result[R] = + dec(c).map(ar => ev(ar.toRecord)) + } +} diff --git a/modules/circe/src/test/scala/com/github/tarao/record4s/circe/CodecSpec.scala b/modules/circe/src/test/scala/com/github/tarao/record4s/circe/CodecSpec.scala new file mode 100644 index 0000000..7ac4d32 --- /dev/null +++ b/modules/circe/src/test/scala/com/github/tarao/record4s/circe/CodecSpec.scala @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2023 record4s authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package com.github.tarao.record4s +package circe + +import io.circe.generic.auto.* +import io.circe.parser.parse +import io.circe.syntax.* + +class CodecSpec extends helper.UnitSpec { + describe("ArrayRecord") { + // ArrayRecord can be encoded/decoded without any special codec + + describe("encoding") { + it("should encode an array record to json") { + val r = ArrayRecord(name = "tarao", age = 3) + val json = r.asJson.noSpaces + json shouldBe """{"name":"tarao","age":3}""" + } + + it("should encode a nested array record to json") { + val r = ArrayRecord( + name = "tarao", + age = 3, + email = ArrayRecord(user = "tarao", domain = "example.com"), + ) + val json = r.asJson.noSpaces + json shouldBe """{"name":"tarao","age":3,"email":{"user":"tarao","domain":"example.com"}}""" + } + } + + describe("decoding") { + it("should decode json to an array record") { + val json = """{"name":"tarao","age":3}""" + val ShouldBeRight(jsonObj) = parse(json) + val ShouldBeRight(record) = + jsonObj.as[ArrayRecord[(("name", String), ("age", Int))]] + record.name shouldBe "tarao" + record.age shouldBe 3 + } + + it("should decode json to a nested array record") { + val json = + """{"name":"tarao","age":3,"email":{"user":"tarao","domain":"example.com"}}""" + val ShouldBeRight(jsonObj) = parse(json) + val ShouldBeRight(record) = jsonObj.as[ArrayRecord[ + ( + ("name", String), + ("age", Int), + ("email", ArrayRecord[(("user", String), ("domain", String))]), + ), + ]] + record.name shouldBe "tarao" + record.age shouldBe 3 + record.email.user shouldBe "tarao" + record.email.domain shouldBe "example.com" + } + + it("can decode partially") { + locally { + val json = """{"name":"tarao","age":3}""" + val ShouldBeRight(jsonObj) = parse(json) + val ShouldBeRight(record) = + jsonObj.as[ArrayRecord[("name", String) *: EmptyTuple]] + record.name shouldBe "tarao" + "record.age" shouldNot typeCheck + } + + locally { + val json = + """{"name":"tarao","age":3,"email":{"user":"tarao","domain":"example.com"}}""" + val ShouldBeRight(jsonObj) = parse(json) + val ShouldBeRight(record) = jsonObj.as[ArrayRecord[ + ("email", ArrayRecord[("domain", String) *: EmptyTuple]) *: + EmptyTuple, + ]] + "record.name" shouldNot typeCheck + "record.age" shouldNot typeCheck + "record.email.user" shouldNot typeCheck + record.email.domain shouldBe "example.com" + } + } + } + } + + describe("%") { + import Codec.{decoder, encoder} + + describe("encoder") { + it("should encode a record to json") { + val r = %(name = "tarao", age = 3) + val json = r.asJson.noSpaces + json shouldBe """{"name":"tarao","age":3}""" + } + + it("should encode a nested record to json") { + val r = %( + name = "tarao", + age = 3, + email = %(user = "tarao", domain = "example.com"), + ) + val json = r.asJson.noSpaces + json shouldBe """{"name":"tarao","age":3,"email":{"user":"tarao","domain":"example.com"}}""" + } + } + + describe("decoder") { + it("should decode json to a record") { + val json = """{"name":"tarao","age":3}""" + val ShouldBeRight(jsonObj) = parse(json) + val ShouldBeRight(record) = + jsonObj.as[% { val name: String; val age: Int }] + record.name shouldBe "tarao" + record.age shouldBe 3 + } + + it("should decode json to a nested record") { + val json = + """{"name":"tarao","age":3,"email":{"user":"tarao","domain":"example.com"}}""" + val ShouldBeRight(jsonObj) = parse(json) + val ShouldBeRight(record) = jsonObj.as[ + % { + val name: String; val age: Int; + val email: % { val user: String; val domain: String } + }, + ] + record.name shouldBe "tarao" + record.age shouldBe 3 + record.email.user shouldBe "tarao" + record.email.domain shouldBe "example.com" + } + + it("can decode partially") { + locally { + val json = """{"name":"tarao","age":3}""" + val ShouldBeRight(jsonObj) = parse(json) + val ShouldBeRight(record) = jsonObj.as[% { val name: String }] + record.name shouldBe "tarao" + "record.age" shouldNot typeCheck + } + + locally { + val json = + """{"name":"tarao","age":3,"email":{"user":"tarao","domain":"example.com"}}""" + val ShouldBeRight(jsonObj) = parse(json) + val ShouldBeRight(record) = + jsonObj.as[% { val email: % { val domain: String } }] + "record.name" shouldNot typeCheck + "record.age" shouldNot typeCheck + "record.email.user" shouldNot typeCheck + record.email.domain shouldBe "example.com" + } + } + } + } +} diff --git a/modules/core/src/test/scala/helper/EitherValues.scala b/modules/core/src/test/scala/helper/EitherValues.scala new file mode 100644 index 0000000..7b6a4b6 --- /dev/null +++ b/modules/core/src/test/scala/helper/EitherValues.scala @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 record4s authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package helper + +trait EitherValues { + self: org.scalatest.Assertions => + + object ShouldBeRight { + def unapply[A, B](x: Either[A, B]): Some[B] = x match { + case Right(value) => Some(value) + case _ => fail(s"$x was not Right") + } + } +} diff --git a/modules/core/src/test/scala/helper/UnitSpec.scala b/modules/core/src/test/scala/helper/UnitSpec.scala index bcecdf0..cdc20b3 100644 --- a/modules/core/src/test/scala/helper/UnitSpec.scala +++ b/modules/core/src/test/scala/helper/UnitSpec.scala @@ -32,5 +32,6 @@ abstract class UnitSpec with matchers.should.Matchers with StaticTypeMatcher with OptionValues + with EitherValues with Inside with Inspectors From 94fb68417c4664bfb97dfcfcc6b4d690aeff0fe0 Mon Sep 17 00:00:00 2001 From: tarao Date: Tue, 7 Nov 2023 23:45:32 +0900 Subject: [PATCH 2/3] JVM options. --- .jvmopts | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .jvmopts diff --git a/.jvmopts b/.jvmopts new file mode 100644 index 0000000..04aedfc --- /dev/null +++ b/.jvmopts @@ -0,0 +1,7 @@ +-Dfile.encoding=UTF8 +-Xms1G +-Xmx6G +-XX:MaxMetaspaceSize=512M +-XX:ReservedCodeCacheSize=250M +-XX:+TieredCompilation +-XX:-UseGCOverheadLimit From 2ff832be2650426d25b6e21dbd8f6b6b02075c45 Mon Sep 17 00:00:00 2001 From: tarao Date: Wed, 8 Nov 2023 13:54:09 +0900 Subject: [PATCH 3/3] Fix module names. --- .github/workflows/ci.yml | 2 +- build.sbt | 22 +++++++++++++--------- project/Implicits.scala | 37 +++++++++++++++++++++++++++++++++++++ project/ProjectKeys.scala | 7 +++++++ 4 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 project/Implicits.scala create mode 100644 project/ProjectKeys.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2d2f9b..f3fb957 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,5 +169,5 @@ jobs: - name: Submit Dependencies uses: scalacenter/sbt-dependency-submission@v2 with: - modules-ignore: record4s_3 benchmark_2_11_2.11 record4s_3 benchmark_2_13_2.13 record4s_3 record4s_3 + modules-ignore: benchmark_3_3 benchmark_2_11_2.11 rootjs_3 benchmark_2_13_2.13 rootjvm_3 rootnative_3 configs-ignore: test scala-tool scala-doc-tool test-internal diff --git a/build.sbt b/build.sbt index 35d826f..9ad188d 100644 --- a/build.sbt +++ b/build.sbt @@ -1,8 +1,11 @@ -val groupId = "com.github.tarao" -val projectName = "record4s" -val rootPkg = s"$groupId.$projectName" +import ProjectKeys._ +import Implicits._ -ThisBuild / organization := groupId +ThisBuild / projectName := "record4s" +ThisBuild / groupId := "com.github.tarao" +ThisBuild / rootPkg := "${groupId.value}.${projectName.value}" + +ThisBuild / organization := groupId.value ThisBuild / organizationName := "record4s authors" ThisBuild / startYear := Some(2023) ThisBuild / licenses := Seq(License.MIT) @@ -12,8 +15,7 @@ ThisBuild / developers := List( ) lazy val metadataSettings = Def.settings( - name := projectName, - organization := groupId, + organization := groupId.value, description := "Extensible records for Scala", homepage := Some(url("https://github.com/tarao/record4s")), ) @@ -51,7 +53,7 @@ lazy val commonSettings = Def.settings( metadataSettings, compileSettings, initialCommands := s""" - import $rootPkg.* + import ${rootPkg.value}.* """, ) @@ -67,7 +69,7 @@ lazy val root = tlCrossRootProject lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) .crossType(CrossType.Pure) .withoutSuffixFor(JVMPlatform) - .in(file("modules/core")) + .asModuleWithoutSuffix .settings(commonSettings) .settings( libraryDependencies ++= Seq( @@ -77,10 +79,12 @@ lazy val core = crossProject(JVMPlatform, JSPlatform, NativePlatform) lazy val circe = crossProject(JVMPlatform, JSPlatform, NativePlatform) .crossType(CrossType.Pure) + .withoutSuffixFor(JVMPlatform) .dependsOn(core % "compile->compile;test->test") - .in(file("modules/circe")) + .asModule .settings(commonSettings) .settings( + description := "Circe integration for record4s", libraryDependencies ++= Seq( "io.circe" %%% "circe-core" % circeVersion, "io.circe" %%% "circe-generic" % circeVersion % Test, diff --git a/project/Implicits.scala b/project/Implicits.scala new file mode 100644 index 0000000..026f859 --- /dev/null +++ b/project/Implicits.scala @@ -0,0 +1,37 @@ +import sbt._ +import sbt.Keys._ +import sbtcrossproject.{CrossPlugin, CrossProject} +import scala.language.implicitConversions +import ProjectKeys.projectName + +object Implicits { + implicit class CrossProjectOps(private val p: CrossProject) extends AnyVal { + def asModuleWithoutSuffix: CrossProject = asModule(true) + + def asModule: CrossProject = asModule(false) + + private def asModule(noSuffix: Boolean): CrossProject = { + val project = p.componentProjects(0) + val s = project.settings(0) + p + .settings( + moduleName := { + if (noSuffix) + (ThisBuild / projectName).value + else + s"${(ThisBuild / projectName).value}-${(project / name).value}" + }, + CrossPlugin.autoImport.crossProjectBaseDirectory := { + val dir = file(s"modules/${(project / name).value}") + IO.resolve((LocalRootProject / baseDirectory).value, dir) + }, + ) + .configure(project => + project.in(file("modules") / project.base.getPath), + ) + } + } + + implicit def builderOps(b: CrossProject.Builder): CrossProjectOps = + new CrossProjectOps(b.build()) +} diff --git a/project/ProjectKeys.scala b/project/ProjectKeys.scala new file mode 100644 index 0000000..33da41d --- /dev/null +++ b/project/ProjectKeys.scala @@ -0,0 +1,7 @@ +import sbt.settingKey + +object ProjectKeys { + lazy val projectName = settingKey[String]("project name") + lazy val groupId = settingKey[String]("artifact group ID") + lazy val rootPkg = settingKey[String]("root package") +}