From 3b99bf1b663219820010e416f3623f82b1eeadbb Mon Sep 17 00:00:00 2001 From: Thijs Broersen Date: Fri, 5 Sep 2025 23:57:18 +0200 Subject: [PATCH 1/2] feat: string-based-union support --- .../src-3/upickle/implicits/Readers.scala | 12 ++++ .../upickle/implicits/UnionDerivation.scala | 60 +++++++++++++++++++ .../src-3/upickle/implicits/Writers.scala | 7 ++- .../test/src-3/upickletest/UnionTests.scala | 27 +++++++++ 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 upickle/implicits/src-3/upickle/implicits/UnionDerivation.scala create mode 100644 upickle/test/src-3/upickletest/UnionTests.scala diff --git a/upickle/implicits/src-3/upickle/implicits/Readers.scala b/upickle/implicits/src-3/upickle/implicits/Readers.scala index 2de2135a2..74b9b91c8 100644 --- a/upickle/implicits/src-3/upickle/implicits/Readers.scala +++ b/upickle/implicits/src-3/upickle/implicits/Readers.scala @@ -206,5 +206,17 @@ trait ReadersVersionSpecific else new HugeCaseObjectContext[T](paramCount) with ObjectContext } + inline given derivedStringBasedUnionEnumerationReader[T <: String](using IsUnionOf[String, T]): Reader[T] = + lazy val values = UnionDerivation.constValueUnionTuple[String, T] + + lazy val valuesList = values.toList.asInstanceOf[List[T]] + // Codec.string.validate(validator.asInstanceOf[Validator[String]]).map(_.asInstanceOf[T])(_.asInstanceOf[String]) + new SimpleReader[T] { + override def expectedMsg = s"expected oneOf ${valuesList.asInstanceOf[List[T]].mkString(", ")}" + override def visitString(s: CharSequence, index: Int) = + val v = s.toString + if (valuesList.contains(v)) v.asInstanceOf[T] + else throw new Abort(expectedMsg) + } end ReadersVersionSpecific diff --git a/upickle/implicits/src-3/upickle/implicits/UnionDerivation.scala b/upickle/implicits/src-3/upickle/implicits/UnionDerivation.scala new file mode 100644 index 000000000..28ceac9d2 --- /dev/null +++ b/upickle/implicits/src-3/upickle/implicits/UnionDerivation.scala @@ -0,0 +1,60 @@ +package upickle.implicits + +import scala.compiletime.* +import scala.deriving.* +import scala.quoted.* + +@scala.annotation.implicitNotFound("${A} is not a union of ${T}") +private[implicits] sealed trait IsUnionOf[T, A] + +private[implicits] object IsUnionOf: + + private val singleton: IsUnionOf[Any, Any] = new IsUnionOf[Any, Any] {} + + transparent inline given derived[T, A]: IsUnionOf[T, A] = ${ deriveImpl[T, A] } + + private def deriveImpl[T, A](using quotes: Quotes, t: Type[T], a: Type[A]): Expr[IsUnionOf[T, A]] = + import quotes.reflect.* + val tpe: TypeRepr = TypeRepr.of[A] + val bound: TypeRepr = TypeRepr.of[T] + + def validateTypes(tpe: TypeRepr): Unit = + tpe.dealias match + case o: OrType => + validateTypes(o.left) + validateTypes(o.right) + case o => + if o <:< bound then () + else report.errorAndAbort(s"${o.show} is not a subtype of ${bound.show}") + + tpe.dealias match + case o: OrType => + validateTypes(o) + ('{ IsUnionOf.singleton.asInstanceOf[IsUnionOf[T, A]] }).asExprOf[IsUnionOf[T, A]] + case o => + if o <:< bound then ('{ IsUnionOf.singleton.asInstanceOf[IsUnionOf[T, A]] }).asExprOf[IsUnionOf[T, A]] + else report.errorAndAbort(s"${tpe.show} is not a Union") + +private[implicits] object UnionDerivation: + transparent inline def constValueUnionTuple[T, A](using IsUnionOf[T, A]): Tuple = ${ constValueUnionTupleImpl[T, A] } + + private def constValueUnionTupleImpl[T: Type, A: Type](using Quotes): Expr[Tuple] = + Expr.ofTupleFromSeq(constTypes[T, A]) + + private def constTypes[T: Type, A: Type](using Quotes): List[Expr[Any]] = + import quotes.reflect.* + val tpe: TypeRepr = TypeRepr.of[A] + val bound: TypeRepr = TypeRepr.of[T] + + def transformTypes(tpe: TypeRepr): List[TypeRepr] = + tpe.dealias match + case o: OrType => + transformTypes(o.left) ::: transformTypes(o.right) + case o: Constant if o <:< bound && o.isSingleton => + o :: Nil + case o => + report.errorAndAbort(s"${o.show} is not a subtype of ${bound.show}") + + transformTypes(tpe).distinct.map(_.asType match + case '[t] => '{ constValue[t] }) + diff --git a/upickle/implicits/src-3/upickle/implicits/Writers.scala b/upickle/implicits/src-3/upickle/implicits/Writers.scala index db2791446..278d74ccb 100644 --- a/upickle/implicits/src-3/upickle/implicits/Writers.scala +++ b/upickle/implicits/src-3/upickle/implicits/Writers.scala @@ -85,4 +85,9 @@ trait WritersVersionSpecific inline def derived[T](using Mirror.Of[T], ClassTag[T]): Writer[T] = macroWAll[T] end WriterExtension - + inline given derivedStringBasedUnionEnumerationWriter[T <: String](using IsUnionOf[String, T]): Writer[T] = + new Writer[T] { + override def isJsonDictKey = true + def writeString(v: T): String = v + def write0[R](out: Visitor[_, R], v: T): R = out.visitString(writeString(v), -1) + } diff --git a/upickle/test/src-3/upickletest/UnionTests.scala b/upickle/test/src-3/upickletest/UnionTests.scala new file mode 100644 index 000000000..4f6cc1c13 --- /dev/null +++ b/upickle/test/src-3/upickletest/UnionTests.scala @@ -0,0 +1,27 @@ +package upickletest + +import ujson.ParseException +import upickletest.TestUtil.rw +import upickle.core.AbortException + +import scala.language.implicitConversions +import utest.{assert, intercept, *} +import upickle.default.* + +type AorB = "A" | "B" +type AorBorC = AorB | "C" + +object UnionTests extends TestSuite { + + + val tests = Tests { + test("literal union"){ + test("strings"){ + rw[AorB]("A", "\"A\"") + rw[AorBorC]("C", "\"C\"") + } + } + } +} + + From 685463d0d39715c5c8b81308c1ec3a90cb80b90e Mon Sep 17 00:00:00 2001 From: Thijs Broersen Date: Sat, 6 Sep 2025 00:02:47 +0200 Subject: [PATCH 2/2] test expected compile errors --- upickle/test/src-3/upickletest/UnionTests.scala | 2 ++ 1 file changed, 2 insertions(+) diff --git a/upickle/test/src-3/upickletest/UnionTests.scala b/upickle/test/src-3/upickletest/UnionTests.scala index 4f6cc1c13..87a9014b0 100644 --- a/upickle/test/src-3/upickletest/UnionTests.scala +++ b/upickle/test/src-3/upickletest/UnionTests.scala @@ -19,6 +19,8 @@ object UnionTests extends TestSuite { test("strings"){ rw[AorB]("A", "\"A\"") rw[AorBorC]("C", "\"C\"") + compileError("""rw[AorBorC]("D", "\"D\"")""") + compileError("""rw[AorBorC]("A": String, "\"A\"")""") } } }