diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 377af8ed..5f730f65 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: strategy: matrix: os: [ubuntu-22.04] - scala: [3, 2.13, 2.12] + scala: [3, 2.13] java: [temurin@8] project: [rootJS, rootJVM, rootNative] runs-on: ${{ matrix.os }} @@ -187,36 +187,6 @@ jobs: tar xf targets.tar rm targets.tar - - name: Download target directories (2.12, rootJS) - uses: actions/download-artifact@v6 - with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootJS - - - name: Inflate target directories (2.12, rootJS) - run: | - tar xf targets.tar - rm targets.tar - - - name: Download target directories (2.12, rootJVM) - uses: actions/download-artifact@v6 - with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootJVM - - - name: Inflate target directories (2.12, rootJVM) - run: | - tar xf targets.tar - rm targets.tar - - - name: Download target directories (2.12, rootNative) - uses: actions/download-artifact@v6 - with: - name: target-${{ matrix.os }}-${{ matrix.java }}-2.12-rootNative - - - name: Inflate target directories (2.12, rootNative) - run: | - tar xf targets.tar - rm targets.tar - - name: Import signing key if: env.PGP_SECRET != '' && env.PGP_PASSPHRASE == '' env: @@ -274,5 +244,5 @@ jobs: - name: Submit Dependencies uses: scalacenter/sbt-dependency-submission@v2 with: - modules-ignore: rootjs_3 rootjs_2.13 rootjs_2.12 rootjvm_3 rootjvm_2.13 rootjvm_2.12 rootnative_3 rootnative_2.13 rootnative_2.12 + modules-ignore: rootjs_3 rootjs_2.13 rootjvm_3 rootjvm_2.13 rootnative_3 rootnative_2.13 configs-ignore: test scala-tool scala-doc-tool test-internal diff --git a/.scalafmt.conf b/.scalafmt.conf index fe73b2c2..eddb42c7 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,5 +1,5 @@ # https://scalameta.org/scalafmt/docs/configuration.html -version = 3.11.0 +version = 3.11.1 runner { dialect = scala3 } diff --git a/README.md b/README.md index 2faf130a..c942a1a1 100755 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ However, I was shocked to see how well my little library performed compared to t The focus of this project is minimalism and flexibility. To that end, the features are somewhat sparse: - Support for JVM, Scala.js, and Scala Native -- Support for Scala 2.12, 2.13, and 3.x +- Support for Scala 2.13 and 3.x - AST for representation of `Map`, `Array`, `Numeric`, `String`, `Boolean`, and `null` in a type-safe and immutable way - Clean DSL to create tree structures - Deep merging support @@ -51,10 +51,10 @@ The focus of this project is minimalism and flexibility. To that end, the featur ### Setup For SBT simply include: -`libraryDependencies += "org.typelevel" %%% "fabric-core" % "1.26.0"` +`libraryDependencies += "org.typelevel" %%% "fabric-core" % "1.30.0"` For parsing support include: -`libraryDependencies += "org.typelevel" %%% "fabric-io" % "1.26.0"` +`libraryDependencies += "org.typelevel" %%% "fabric-io" % "1.30.0"` ### Create @@ -271,6 +271,42 @@ given RW[Cat | Dog] = RW.gen[Cat | Dog] For collision unions where multiple variants share the same base class (e.g. `Id[String] | Id[Int]`), the `_generic` field is used to distinguish between them on the deserialization side. +### Open Polymorphism with PolyType + +When a hierarchy is *open* — a library defines a base trait and downstream applications add their own subtypes — +`RW.gen` and `RW.poly` can't see those subtypes at compile time. `PolyType[T]` is a runtime-registerable poly RW for +this case: + +```scala +import fabric.rw._ + +trait Mode { def name: String } +case object ConversationMode extends Mode { val name = "ConversationMode" } + +object Mode extends PolyType[Mode] { + register(RW.static(ConversationMode)) +} + +// Apps add their own at startup: +case object WorkflowMode extends Mode { val name = "WorkflowMode" } +Mode.register(RW.static(WorkflowMode)) +``` + +Each `PolyType` exposes a `name` namespace for typed-name construction: + +```scala +val n: PolyName[Mode] = Mode.name.of(ConversationMode) // PolyName("ConversationMode") +val all: Set[PolyName[Mode]] = Mode.name.registered // current live set +val maybe: Option[PolyName[Mode]] = Mode.name.from("X") // validated lookup +``` + +> ⚠️ **Mutability warning.** `PolyType` is the one place in fabric that uses mutable state. **Register subtypes at +> startup, before any serialization or `Definition` access.** Any `Definition` snapshot taken before registration +> will see an incomplete poly — most often visible as missing dispatchers in generated schemas. Centralize your +> registrations in one place (e.g. an application init block) to avoid order-dependence bugs. + +If your hierarchy is closed (all subtypes known at compile time), prefer `RW.poly` or `RW.gen` for sealed traits/unions. + ### Inspection & Generation ```scala diff --git a/build.sbt b/build.sbt index 684804fe..e343422d 100644 --- a/build.sbt +++ b/build.sbt @@ -1,15 +1,13 @@ // Scala versions val scala213 = "2.13.18" -val scala212 = "2.12.21" +val scala3 = "3.3.8" -val scala3 = "3.3.7" - -val scala2 = List(scala213, scala212) +val scala2 = List(scala213) val scalaVersions = scala3 :: scala2 name := "fabric" -ThisBuild / tlBaseVersion := "1.26" +ThisBuild / tlBaseVersion := "1.30" ThisBuild / organization := "org.typelevel" ThisBuild / startYear := Some(2021) ThisBuild / licenses := Seq(License.MIT) @@ -18,6 +16,11 @@ ThisBuild / javaOptions ++= Seq("-Xss50M") ThisBuild / javacOptions ++= Seq("-source", "1.8", "-target", "1.8") ThisBuild / crossScalaVersions := scalaVersions +// Scala Native test-interface patch versions are binary compatible — scalatest 3.2.20 / scalacheck 1.18.0 +// pin older 0.5.x versions but Scala Native itself depends on 0.5.11. Downgrade the eviction error to a +// warning so the build resolves to 0.5.11. +ThisBuild / evictionErrorLevel := Level.Warn + ThisBuild / publishTo := sonatypePublishToBundle.value ThisBuild / sonatypeProfileName := "org.typelevel" ThisBuild / licenses := Seq( @@ -47,11 +50,11 @@ val scalaCheckVersion: String = "3.2.19.0" // Parse module dependencies val literallyVersion: String = "1.2.0" -val jacksonVersion: String = "2.21.2" +val jacksonVersion: String = "2.21.4" val apacheCommonsTextVersion: String = "1.15.0" -val typesafeConfigVersion: String = "1.4.6" +val typesafeConfigVersion: String = "1.4.9" val jsoniterJavaVersion: String = "0.9.23" diff --git a/core/shared/src/main/scala-2/fabric/rw/RWMacros.scala b/core/shared/src/main/scala-2/fabric/rw/RWMacros.scala index 67a8ee03..f06988eb 100755 --- a/core/shared/src/main/scala-2/fabric/rw/RWMacros.scala +++ b/core/shared/src/main/scala-2/fabric/rw/RWMacros.scala @@ -211,19 +211,16 @@ object RWMacros { .filter(_.annotations.exists(_.tree.tpe =:= typeOf[notSerialized])) .map(_.asTerm.name.decodedName.toString) .toSet - val fieldDefs = fields.zipWithIndex - .filterNot { case (field, _) => transientNames.contains(field.asTerm.name.decodedName.toString) } - .map { case (field, index) => + val fieldDefs = + fields.filterNot(field => transientNames.contains(field.asTerm.name.decodedName.toString)).map { field => val name = field.asTerm.name val key = name.decodedName.toString val returnType = tpe.decl(name).typeSignature.asSeenFrom(tpe, tpe.typeSymbol.asClass) val descAnn = field.annotations.find(_.tree.tpe =:= typeOf[description]) - val baseDef = - if (defaults.contains(index)) { - q"implicitly[RW[$returnType]].definition.opt" - } else { - q"implicitly[RW[$returnType]].definition" - } + // Defaults are not represented by wrapping in Opt — `defaultValue` on the Definition + // (set later via applyFieldDefaults) signals "this can be omitted at the JSON level." + // Fields whose Scala type is Option[T] still produce an Opt naturally via their RW. + val baseDef = q"implicitly[RW[$returnType]].definition" descAnn match { case Some(ann) => ann.tree.children.tail.head match { case l: LiteralApi => diff --git a/core/shared/src/main/scala-3/fabric/rw/CompileRW.scala b/core/shared/src/main/scala-3/fabric/rw/CompileRW.scala index 1d8436ef..71d0b1e2 100755 --- a/core/shared/src/main/scala-3/fabric/rw/CompileRW.scala +++ b/core/shared/src/main/scala-3/fabric/rw/CompileRW.scala @@ -158,16 +158,36 @@ trait CompileRW { override def definition: FabricDefinition = FabricDefinition(DefType.Obj(Map.empty), className = Some(getFullTypeName[T])) } - // Enumeration support for sealed traits with only case objects + // Enumeration support for sealed traits with only case objects. Wire discriminator is the simpleClassName + // form (class-chain, e.g. "VehicleType.Car"), matching the polymorphic dispatch convention. Reading + // accepts legacy leaf-form ("Car") unambiguously and throws when leaf names collide. inline def enumeration[T](instances: List[T]): RW[T] = new RW[T] { - private val nameToInstance = instances.map(i => i.toString -> i).toMap - private val instanceToName = instances.map(i => i -> i.toString).toMap + private val instanceToName: Map[T, String] = instances.map { i => + i -> fabric.define.Definition.simpleClassName(RW.cleanClassName(i.getClass.getName)) + }.toMap + private val nameToInstance: Map[String, T] = instanceToName.map(_.swap) + private val legacyLeafIndex: Map[String, List[T]] = instanceToName.toList + .groupBy { case (_, sn) => sn.split('.').lastOption.getOrElse(sn).toLowerCase } + .view + .mapValues(_.map(_._1)) + .toMap override def read(value: T): Json = Str(instanceToName.getOrElse(value, value.toString)) override def write(json: Json): T = json match { case Str(name, _) => - nameToInstance.getOrElse(name, throw RWException(s"Unknown enumeration value: $name")) + nameToInstance.get(name).orElse { + legacyLeafIndex.get(name.toLowerCase) match { + case Some(single :: Nil) => Some(single) + case Some(many) if many.size > 1 => + throw RWException( + s"Ambiguous legacy enumeration value '$name' — matches ${many.size} cases: " + + s"[${many.map(instanceToName).mkString(", ")}]. Migrate the stored records to " + + s"the new wire form." + ) + case _ => None + } + }.getOrElse(throw RWException(s"Unknown enumeration value: $name")) case _ => throw RWException(s"Expected string for enumeration, got: $json") } @@ -185,29 +205,23 @@ trait CompileRW { ${ CompileRW.fromNameImpl[T]('name) } inline def toClassName[T](using ct: ClassTag[T]): Option[String] = - Some(ct.runtimeClass.getName.replace("$", ".")) + Some(RW.cleanClassName(ct.runtimeClass.getName)) inline def toDefinition[T](using p: Mirror.ProductOf[T], ct: ClassTag[T]): FabricDefinition = { FabricDefinition(DefType.Obj(toDefinitionElems[T, p.MirroredElemTypes, p.MirroredElemLabels](0)), className = toClassName[T]) } inline def toDefinitionElems[A, T <: Tuple, L <: Tuple](index: Int): Map[String, FabricDefinition] = { - // Use the name-only variant here — we only need to know WHICH fields have defaults to mark their - // definitions as optional. Building the full value map per-field would eagerly invoke every default - // method (including stateful ones like id generators) multiple times per `definition` access. - val defaultNames = getDefaultParamNames[A] - toDefinitionElemsImpl[A, T, L](index, defaultNames) - } - - inline def toDefinitionElemsImpl[A, T <: Tuple, L <: Tuple](index: Int, defaultNames: Set[String]): Map[String, FabricDefinition] = { inline erasedValue[T] match { case _: (hd *: tl) => { inline erasedValue[L] match { case _: (hdLabel *: tlLabels) => val hdLabelValue = constValue[hdLabel].asInstanceOf[String] val rw = summonInline[RW[hd]] - val d = if (defaultNames.contains(hdLabelValue)) rw.definition.opt else rw.definition - VectorMap(hdLabelValue -> d) ++ toDefinitionElemsImpl[A, tl, tlLabels](index + 1, defaultNames) + // Defaults are not represented by wrapping in Opt — `defaultValue` on the Definition + // (set later via applyFieldDefaults) signals "this can be omitted at the JSON level." + // Fields whose Scala type is Option[T] still produce an Opt naturally via their RW. + VectorMap(hdLabelValue -> rw.definition) ++ toDefinitionElems[A, tl, tlLabels](index + 1) case EmptyTuple => sys.error("Not possible") } } @@ -380,13 +394,10 @@ object CompileRW extends CompileRW { import quotes.reflect._ val tpe = TypeRepr.of[T] val symbol = tpe.typeSymbol - - // Get the simple name without package - val fullName = symbol.fullName - val simpleName = fullName.split('.').last - - // Handle case objects by stripping the trailing $ - Expr(simpleName.stripSuffix("$")) + // Class-chain form (package segments dropped, nested-class chain preserved). Matches + // `Definition.simpleClassName` so the inner dispatch key in macro-generated RWs agrees with the + // outer polymorphic dispatch key. + Expr(fabric.define.Definition.simpleClassName(symbol.fullName)) } def getFullTypeNameImpl[T: Type](using Quotes): Expr[String] = { @@ -470,22 +481,52 @@ object CompileRW extends CompileRW { // Extract enum case names for DefType.Poly val tpe = TypeRepr.of[T] val typeSymbol = tpe.typeSymbol + val parentSimpleName = fabric.define.Definition.simpleClassName(typeSymbol.fullName) val caseNames = typeSymbol.children.filter(c => c.flags.is(Flags.Case)).map(_.name.stripSuffix("$")) - val caseNamesExpr = Expr(caseNames) - val classNameExpr = Expr(typeSymbol.fullName.replace("$", ".")) + val variantSimpleNames = caseNames.map(cn => s"$parentSimpleName.$cn") + // (productPrefix, simpleName) pairs — runtime read uses productPrefix to look up the simpleName; + // runtime write uses simpleName (the wire `"type"` value) to recover the productPrefix for valueOf. + val prefixSimpleNamePairs = caseNames.zip(variantSimpleNames) + val prefixSimpleNamePairsExpr = Expr(prefixSimpleNamePairs) + val variantSimpleNamesExpr = Expr(variantSimpleNames) + val parentClassNameExpr = Expr(typeSymbol.fullName.replace("$", ".")) '{ new RW[T] { - override def read(value: T): Json = Str(value.asInstanceOf[scala.reflect.Enum].productPrefix) + private lazy val prefixToSimpleName: Map[String, String] = $prefixSimpleNamePairsExpr.toMap + private lazy val simpleNameToPrefix: Map[String, String] = prefixToSimpleName.map(_.swap) + // Case-insensitive leaf-only fallback for legacy persisted records: dispatch on the variant's + // leaf name when the new class-chain key isn't directly present. + private lazy val legacyLeafIndex: Map[String, List[String]] = + prefixToSimpleName.toList.groupBy(_._1.toLowerCase).view.mapValues(_.map(_._1)).toMap + + override def read(value: T): Json = { + val pp = value.asInstanceOf[scala.reflect.Enum].productPrefix + Str(prefixToSimpleName.getOrElse(pp, pp)) + } override def write(json: Json): T = json match { - case Str(name, _) => $valueOfExpr(name) + case Str(name, _) => + val pp = simpleNameToPrefix.get(name) + .orElse { + legacyLeafIndex.get(name.toLowerCase) match { + case Some(single :: Nil) => Some(single) + case Some(many) if many.size > 1 => + throw RWException( + s"Ambiguous legacy enum discriminator '$name' — matches ${many.size} cases: " + + s"[${many.mkString(", ")}]. Migrate the stored records to the new wire form." + ) + case _ => None + } + } + .getOrElse(name) + $valueOfExpr(pp) case _ => throw RWException(s"Expected string for enum, got: $json") } override val definition: FabricDefinition = FabricDefinition( - DefType.Poly($caseNamesExpr.map(n => n -> FabricDefinition(DefType.Null)).toMap.to(VectorMap)), - className = Some($classNameExpr) + DefType.Poly($variantSimpleNamesExpr.map(n => n -> FabricDefinition(DefType.Null)).toMap.to(VectorMap)), + className = Some($parentClassNameExpr) ) } } @@ -531,15 +572,40 @@ object CompileRW extends CompileRW { val ref = Ref(childSym.termRef.termSymbol).asExprOf[t] '{ RW.static[t]($ref) } } else if (childSym.flags.is(Flags.Enum) && childSym.flags.is(Flags.Case) && !childSym.isClassDef) { - // Simple enum case (e.g., `case Point` in a mixed enum) — treated as singleton + // Simple enum case (e.g., `case Point` in a mixed enum) — + // treated as a singleton. Scala 3 represents + // parameterless enum cases as anonymous classes + // (`$$anon$N`), so `value.getClass.getName` (which + // `RW.static` reads) leaks `..anon.N` into the + // `Definition.className` after the standard `$ → .` + // cleanup. Override with the case's semantic full name + // from the symbol so consumers reading `className` see + // the case itself (`Shape.Point`), not the anonymous + // wrapper. val ref = Ref(childSym.termRef.termSymbol).asExprOf[t] - '{ RW.static[t]($ref) } + // Scala 3's `Symbol.fullName` already uses `.` for the + // enum-case path (`spec.MixedEnumTest.Shape.Point`); only + // residual `$` markers from the JVM encoding of nested + // companions need stripping. Replace `$.` and `$` (in + // that order) to collapse the doubled separators without + // creating `..`. + val classNameExpr = Expr(childSym.fullName.replace("$.", ".").replace("$", ".")) + '{ + val base = RW.static[t]($ref) + new RW[t] { + override def read(value: t): fabric.Json = base.read(value) + override def write(json: fabric.Json): t = base.write(json) + override def definition: fabric.define.Definition = + base.definition.withClassName($classNameExpr) + } + } } else { report.errorAndAbort(s"No RW found for child type ${childType.show}. Provide an RW instance or make it a case class.") } } - val name = getSimpleTypeNameFromType(childType) - '{ (${ Expr(name) }, $rw.asInstanceOf[RW[_]]) } + val productPrefix = childSym.name.stripSuffix("$") + val simpleName = fabric.define.Definition.simpleClassName(childSym.fullName) + '{ (${ Expr(productPrefix) }, ${ Expr(simpleName) }, $rw.asInstanceOf[RW[_]]) } } } val childRWsExpr = Expr.ofList(childExprs) @@ -547,9 +613,21 @@ object CompileRW extends CompileRW { } /** Shared polymorphic RW generation for sealed traits and union types. - * Uses "type" discriminator field by default (consistent with RW.poly), - * configurable via @typeField("customName") annotation on the type. */ - private def genPolyRW[T: Type](childRWsExpr: Expr[List[(String, RW[_])]])(using Quotes): Expr[RW[T]] = { + * + * Each child entry is `(productPrefix, simpleName, RW)`: + * - `productPrefix` is the Scala-level case name (`p.productPrefix` at runtime) — used to map a + * received instance back to its dispatch entry without needing `getClass.getName` (which is + * anon-wrapped for parameterless enum cases). + * - `simpleName` is `Definition.simpleClassName` of the child's symbol — class-chain form, the + * wire `"type"` discriminator stamped on the JSON, and the lookup key on the write side. + * + * Uses `"type"` as the discriminator field by default; configurable via the `@typeField` annotation. + * + * A case-insensitive leaf-only fallback index covers legacy persisted records written under the + * pre-1.29 wire format (`{"type": "Success"}` rather than `{"type": "WriteFileOutput.Success"}`). + * Unambiguous matches dispatch through the fallback; ambiguous matches throw. + */ + private def genPolyRW[T: Type](childRWsExpr: Expr[List[(String, String, RW[_])]])(using Quotes): Expr[RW[T]] = { import quotes.reflect._ val tpe = TypeRepr.of[T] @@ -565,23 +643,46 @@ object CompileRW extends CompileRW { }.getOrElse("type") val fieldNameExpr = Expr(fieldName) - val fullTypeName = typeSymbol.fullName.replace("$", ".") + val fullTypeName = fabric.define.Definition.simpleClassName(typeSymbol.fullName) val fullTypeNameExpr = Expr(fullTypeName) '{ new RW[T] { private val typeField = $fieldNameExpr - private lazy val childRWs = $childRWsExpr + private lazy val children: List[(String, String, RW[_])] = $childRWsExpr + + // Legacy-leaf fallback: case-insensitive index keyed by the last segment of each simpleName. + // Lets persisted records written under the pre-1.29 wire format dispatch through unambiguously, + // and throw with a clear message on ambiguity (rather than silently routing wrong). + private lazy val legacyLeafIndex: Map[String, List[(String, String, RW[_])]] = + children.groupBy { case (_, simpleName, _) => + simpleName.split('.').lastOption.getOrElse(simpleName).toLowerCase + } - override def read(value: T): Json = { - val typeName = safeTypeName(value) - val (_, rw) = childRWs.find(_._1 == typeName).getOrElse { - throw RWException(s"Unknown subtype: $typeName") + private def lookupByWireName(rawType: String): Option[(String, String, RW[_])] = + children.find(_._2 == rawType).orElse { + legacyLeafIndex.get(rawType.toLowerCase) match { + case Some(single :: Nil) => Some(single) + case Some(many) if many.size > 1 => + val cns = many.map(_._2).mkString(", ") + throw RWException( + s"Ambiguous legacy discriminator '$rawType' — matches ${many.size} variants: [$cns]. " + + s"Migrate the stored records to the new wire form." + ) + case _ => None + } } - rw.asInstanceOf[RW[T]].read(value) match { - case obj: Obj => obj.merge(Obj(typeField -> Str(typeName))) - case other => Obj(typeField -> Str(typeName), "value" -> other) + override def read(value: T): Json = { + val pp = safeTypeName(value) + children.find(_._1 == pp) match { + case Some((_, sn, rw)) => + rw.asInstanceOf[RW[T]].read(value) match { + case obj: Obj => obj.merge(Obj(typeField -> Str(sn))) + case other => Obj(typeField -> Str(sn), "value" -> other) + } + case None => + throw RWException(s"Unknown subtype: $pp") } } @@ -589,8 +690,8 @@ object CompileRW extends CompileRW { case obj @ Obj(map) => map.get(typeField) match { case Some(Str(typeName, _)) => - childRWs.find(_._1 == typeName) match { - case Some((_, rw)) => + lookupByWireName(typeName) match { + case Some((_, _, rw)) => val cleanedMap = map - typeField val cleanedJson = if (cleanedMap.isEmpty && map.size == 2 && map.contains("value")) { map("value") @@ -609,9 +710,7 @@ object CompileRW extends CompileRW { } override lazy val definition: FabricDefinition = { - val childDefs = childRWs.map { case (name, rw) => - name -> rw.definition - }.toMap + val childDefs = children.map { case (_, sn, rw) => sn -> rw.definition }.toMap FabricDefinition(DefType.Poly(childDefs), className = Some($fullTypeNameExpr)) } } @@ -651,8 +750,9 @@ object CompileRW extends CompileRW { report.errorAndAbort(s"No RW found for union member type ${memberType.show}. Ensure all union member types have an RW instance.") } } - val name = getSimpleTypeNameFromType(memberType) - '{ (${ Expr(name) }, $rw.asInstanceOf[RW[_]]) } + val productPrefix = memberType.typeSymbol.name.stripSuffix("$") + val simpleName = fabric.define.Definition.simpleClassName(memberType.typeSymbol.fullName) + '{ (${ Expr(productPrefix) }, ${ Expr(simpleName) }, $rw.asInstanceOf[RW[_]]) } } } val childRWsExpr = Expr.ofList(childExprs) @@ -681,9 +781,10 @@ object CompileRW extends CompileRW { report.errorAndAbort(s"No RW found for union member type ${memberType.show}.") } } + val productPrefix = memberType.typeSymbol.name.stripSuffix("$") val simpleName = getSimpleTypeNameFromType(memberType) val fullName = fullTypeName(memberType) - '{ (${ Expr(simpleName) }, ${ Expr(fullName) }, $rw.asInstanceOf[RW[_]]) } + '{ (${ Expr(productPrefix) }, ${ Expr(simpleName) }, ${ Expr(fullName) }, $rw.asInstanceOf[RW[_]]) } } } val childListExpr = Expr.ofList(childExprs) @@ -694,37 +795,37 @@ object CompileRW extends CompileRW { '{ new RW[T] { private val typeField = "type" - private lazy val childRWs: List[(String, String, RW[_])] = $childListExpr + // (productPrefix, simpleName, fullName, rw). productPrefix matches `safeTypeName(value)` at read + // time; simpleName is the wire `"type"` discriminator; fullName is used for `_generic` matching + // when multiple candidates share the same productPrefix (the generic-collision case). + private lazy val childRWs: List[(String, String, String, RW[_])] = $childListExpr - private def matchGeneric(json: Json, candidates: List[(String, String, RW[_])]): Option[(String, RW[_])] = { + private def matchGeneric(json: Json, candidates: List[(String, String, String, RW[_])]): Option[(String, RW[_])] = { json match { case Obj(map) => map.get("_generic") match { case Some(genericJson) => - // Match by comparing _generic content against each candidate's definition.genericTypes - candidates.find { case (_, _, rw) => + candidates.find { case (_, _, _, rw) => val expected = Obj(rw.definition.genericTypes.map(gt => gt.name -> gt.definition.json): _*) expected == genericJson - }.map(c => (c._2, c._3)) + }.map(c => (c._2, c._4)) case None => - // No _generic field — take first candidate - candidates.headOption.map(c => (c._2, c._3)) + candidates.headOption.map(c => (c._2, c._4)) } - case _ => candidates.headOption.map(c => (c._2, c._3)) + case _ => candidates.headOption.map(c => (c._2, c._4)) } } override def read(value: T): Json = { - val simpleName = safeTypeName(value) - val candidates = childRWs.filter(_._1 == simpleName) - // Use first candidate for read — the child RW will embed _generic in its output + val pp = safeTypeName(value) + val candidates = childRWs.filter(_._1 == pp) candidates.headOption match { - case Some((_, _, rw)) => + case Some((_, sn, _, rw)) => rw.asInstanceOf[RW[T]].read(value) match { - case obj: Obj => obj.merge(Obj(typeField -> Str(simpleName))) - case other => Obj(typeField -> Str(simpleName), "value" -> other) + case obj: Obj => obj.merge(Obj(typeField -> Str(sn))) + case other => Obj(typeField -> Str(sn), "value" -> other) } - case None => throw RWException(s"Unknown subtype: $simpleName") + case None => throw RWException(s"Unknown subtype: $pp") } } @@ -732,15 +833,15 @@ object CompileRW extends CompileRW { case obj @ Obj(map) => map.get(typeField) match { case Some(Str(typeName, _)) => - val candidates = childRWs.filter(_._1 == typeName) + val candidates = childRWs.filter(_._2 == typeName) val (_, rw) = if (candidates.size > 1) { // Collision — use _generic to disambiguate val cleanedJson = Obj(map - typeField) matchGeneric(cleanedJson, candidates).getOrElse( - throw RWException(s"Cannot disambiguate type '$typeName' — no matching _generic found. Available: ${candidates.map(_._2).mkString(", ")}") + throw RWException(s"Cannot disambiguate type '$typeName' — no matching _generic found. Available: ${candidates.map(_._3).mkString(", ")}") ) } else { - candidates.headOption.map(c => (c._2, c._3)).getOrElse( + candidates.headOption.map(c => (c._3, c._4)).getOrElse( throw RWException(s"Unknown type discriminator: $typeName") ) } @@ -759,7 +860,7 @@ object CompileRW extends CompileRW { } override lazy val definition: FabricDefinition = { - val childDefs = childRWs.map { case (_, fullName, rw) => + val childDefs = childRWs.map { case (_, _, fullName, rw) => fullName -> rw.definition }.toMap.to(VectorMap) FabricDefinition(DefType.Poly(childDefs), className = Some($fullTypeNameExpr)) @@ -843,9 +944,7 @@ object CompileRW extends CompileRW { import quotes.reflect._ val typeRepr = tpe.asInstanceOf[TypeRepr] val symbol = typeRepr.typeSymbol - val fullName = symbol.fullName - val simpleName = fullName.split('.').last - simpleName.stripSuffix("$") + fabric.define.Definition.simpleClassName(symbol.fullName) } def genAnyValMacro[T: Type](using Quotes): Expr[RW[T]] = { diff --git a/core/shared/src/main/scala/fabric/define/DefType.scala b/core/shared/src/main/scala/fabric/define/DefType.scala index cf5465b2..4ab10afa 100644 --- a/core/shared/src/main/scala/fabric/define/DefType.scala +++ b/core/shared/src/main/scala/fabric/define/DefType.scala @@ -130,7 +130,18 @@ object DefType { * - Sealed trait `Shape { Circle(r: Double), Rect(w: Double, h: Double) }` → * `Poly(Map("Circle" -> Definition(Obj("r" -> ...)), "Rect" -> Definition(Obj("w" -> ..., "h" -> ...))))` */ - case class Poly(values: Map[String, Definition]) extends DefType + /** + * Polymorphic Definition. + * + * @param values per-subtype Definition map (key is the subtype's discriminator on the wire — typically + * `Product.productPrefix` for case classes, or the enum case name). + * @param commonFields fields shared by every subtype in `values`. Computed by [[fabric.rw.PolyType.register]] as + * the type-compatible intersection across each subtype's `DefType.Obj` field map. Empty for + * compile-time `RW.poly` Definitions; populated for open registries built via + * `PolyType[T]`. Codegen consumers use this to emit abstract-parent fields without + * re-deriving the intersection. + */ + case class Poly(values: Map[String, Definition], commonFields: Map[String, Definition] = Map.empty) extends DefType object Poly { def apply(entries: (String, Definition)*): Poly = Poly(VectorMap(entries*)) } diff --git a/core/shared/src/main/scala/fabric/define/Definition.scala b/core/shared/src/main/scala/fabric/define/Definition.scala index b73f00be..c01dd9a4 100644 --- a/core/shared/src/main/scala/fabric/define/Definition.scala +++ b/core/shared/src/main/scala/fabric/define/Definition.scala @@ -107,6 +107,22 @@ case class Definition( */ lazy val defaultValue: Option[Json] = defaultValueThunk() + /** + * Class-chain form of `className` — package segments (lowercase-leading) dropped, nested-class chain + * (uppercase-leading segments) preserved. `Some` whenever `className` is `Some`. The dispatch key used + * by `RW.poly` and the wire `"type"` discriminator emitted by polymorphic RWs. + * + * Examples (input → output): + * - `Some("com.example.foo.Bar")` → `Some("Bar")` + * - `Some("com.example.foo.Outer.Inner")` → `Some("Outer.Inner")` + * - `Some("com.example.foo.Outer\$Inner")` → `Some("Outer.Inner")` + * - `Some("test.X.Y.Z")` (all-uppercase chain) → `Some("X.Y.Z")` + * + * Independent of package layout — moving a type to a different package leaves `simpleClassName` + * unchanged. Distinct from a bare leaf name which would collide across enclosing classes. + */ + lazy val simpleClassName: Option[String] = className.map(Definition.simpleClassName) + /** * Returns true if the underlying type is optional (`DefType.Opt`). */ @@ -156,7 +172,7 @@ case class Definition( case DefType.Bool => bool(config.bool(path)) case DefType.Json => config.json(path) case DefType.Null => Null - case DefType.Poly(values) => config.poly(path, values) + case DefType.Poly(values, _) => config.poly(path, values) } /** @@ -213,6 +229,30 @@ object Definition { */ val NoDefault: () => Option[Json] = () => None + /** + * Derive the class-chain form of a className string. Companion form of [[Definition.simpleClassName]] + * for callers working with a raw FQN string rather than a full `Definition` — typically polymorphic + * dispatch from `instance.getClass.getName` (which `cleanClassName` should normalize first). + * + * Splits the FQN on `.` and drops any leading lowercase-first segments (the package portion). The + * remaining uppercase-first segments (the class chain) are rejoined with `.`. When the input is all + * lowercase (no class chain), falls back to the last segment. + */ + def simpleClassName(rawClassName: String): String = { + // Normalize $ → . (Scala 3 source-level fullNames can include $ markers for nested companions, + // and raw JVM getClass.getName uses $ between enclosing classes). Strip trailing `.` produced by + // cleaning a case-object's trailing `$`. Mirrors `RW.cleanClassName` so callers can pass either + // source-level FQNs or raw JVM names uniformly. + val cleaned = rawClassName.replace("$u0020", " ").replace("$", ".") match { + case s if s.endsWith(".") => s.substring(0, s.length - 1) + case s => s + } + val parts = cleaned.split('.').filter(_.nonEmpty) + val chain = parts.dropWhile(p => p.charAt(0).isLower) + if (chain.isEmpty) parts.lastOption.getOrElse(cleaned) + else chain.mkString(".") + } + /** * Wrap an eager `Option[Json]` as a thunk for `defaultValueThunk`. Cheaply memoizes. */ @@ -350,10 +390,15 @@ object Definition { case DefType.Bool => obj("type" -> str("boolean")) case DefType.Json => obj("type" -> str("json")) case DefType.Null => obj("type" -> str("null")) - case DefType.Poly(values) => obj( + case DefType.Poly(values, commonFields) => + val baseFields = Vector[(String, Json)]( "type" -> str("poly"), "values" -> fabric.Obj(values.map { case (key, inner) => key -> toJson(inner) }) ) + val commonField = + if (commonFields.isEmpty) Vector.empty + else Vector("commonFields" -> fabric.Obj(commonFields.map { case (k, v) => k -> toJson(v) })) + obj((baseFields ++ commonField)*) } var result = withOptional( withOptional(withOptional(base, "className", d.className), "description", d.description), diff --git a/core/shared/src/main/scala/fabric/define/FabricGenerator.scala b/core/shared/src/main/scala/fabric/define/FabricGenerator.scala index 7d42ac7b..721b4009 100644 --- a/core/shared/src/main/scala/fabric/define/FabricGenerator.scala +++ b/core/shared/src/main/scala/fabric/define/FabricGenerator.scala @@ -78,7 +78,7 @@ object FabricGenerator { case DefType.Int => "Long" case DefType.Dec => "BigDecimal" case DefType.Bool => "Boolean" - case DefType.Poly(_) => throw new RuntimeException("Unsupported") + case DefType.Poly(_, _) => throw new RuntimeException("Unsupported") case DefType.Json => "Json" case DefType.Null => throw new RuntimeException( "Null type found in definition! Not supported for code generation!" diff --git a/core/shared/src/main/scala/fabric/rw/EnhancedRW.scala b/core/shared/src/main/scala/fabric/rw/EnhancedRW.scala index 1a39281e..ea38efd7 100644 --- a/core/shared/src/main/scala/fabric/rw/EnhancedRW.scala +++ b/core/shared/src/main/scala/fabric/rw/EnhancedRW.scala @@ -23,9 +23,13 @@ package fabric.rw import fabric.Json import fabric.define.Definition -case class EnhancedRW[T](rw: RW[T], preWrite: List[Json => Json] = Nil, postRead: List[(T, Json) => Json] = Nil) - extends RW[T] { - override def definition: Definition = rw.definition +case class EnhancedRW[T]( + rw: RW[T], + preWrite: List[Json => Json] = Nil, + postRead: List[(T, Json) => Json] = Nil, + postDefinition: List[Definition => Definition] = Nil +) extends RW[T] { + override def definition: Definition = postDefinition.foldLeft(rw.definition)((d, f) => f(d)) override def write(value: Json): T = { val json = preWrite.foldLeft(value)((j, f) => f(j)) @@ -40,4 +44,6 @@ case class EnhancedRW[T](rw: RW[T], preWrite: List[Json => Json] = Nil, postRead override def withPreWrite(f: Json => Json): RW[T] = copy(preWrite = preWrite ::: List(f)) override def withPostRead(f: (T, Json) => Json): RW[T] = copy(postRead = postRead ::: List(f)) + + override def withDefinition(f: Definition => Definition): RW[T] = copy(postDefinition = postDefinition ::: List(f)) } diff --git a/core/shared/src/main/scala/fabric/rw/PolyName.scala b/core/shared/src/main/scala/fabric/rw/PolyName.scala new file mode 100644 index 00000000..81946ab4 --- /dev/null +++ b/core/shared/src/main/scala/fabric/rw/PolyName.scala @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2021 Typelevel + * + * 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 fabric.rw + +/** + * A typed wrapper around a short class name of a subtype registered in a [[PolyType]]. Constructed only via each + * `PolyType`'s `name` namespace (see [[PolyType.name]]) — apps never build one directly, so every `PolyName[T]` in + * hand was derived from (or validated against) `T`'s registration. + * + * Useful as a reusable primitive wherever code needs to identify "which subtype of `T`" without carrying a full + * instance and without resorting to raw strings. + * + * @param name + * the short (simple) class name of the subtype (e.g. `"Cat"` for `Cat`, not `"com.example.Cat"`) + */ +final case class PolyName[T] private[rw] (name: String) + +object PolyName { + implicit def rw[T]: RW[PolyName[T]] = RW.string[PolyName[T]]( + asString = _.name, + fromString = s => new PolyName[T](s) + ) +} diff --git a/core/shared/src/main/scala/fabric/rw/PolyType.scala b/core/shared/src/main/scala/fabric/rw/PolyType.scala new file mode 100644 index 00000000..4c7e8f56 --- /dev/null +++ b/core/shared/src/main/scala/fabric/rw/PolyType.scala @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2021 Typelevel + * + * 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 fabric.rw + +import fabric.Json +import fabric.define.{Definition, DefType} + +import scala.reflect.ClassTag + +/** + * `PolyType[T]` is an open polymorphic registry — a thin wrapper around [[RW.poly]] that allows subtypes of `T` to be + * registered dynamically at runtime, after the trait/object is defined. + * + * This is the typical design choice for ''open'' hierarchies: a library defines a trait `T` with some baseline + * implementations, and downstream applications add their own subtypes without modifying the original. The standard + * [[RW.poly]] (and `RW.gen` for sealed traits / unions) requires all subtypes to be known at compile time at the + * call site — `PolyType` lifts that restriction. + * + * '''⚠️ Mutability warning:''' `PolyType` is the one place in `fabric` that uses mutable state — its registry of + * subtypes is a `var` updated by [[register]]. This is an intentional trade-off to support open-hierarchy use + * cases, but it has implications: + * + * - '''Register at startup, before any serialization.''' Anyone who reads a `Definition` or serializes a value + * before registration sees an incomplete poly. Lazy `Definition` accesses elsewhere in your object graph can + * snapshot an empty `Poly` — often visible as missing dispatchers in generated schemas/code. + * - '''Single source of registrations.''' Centralize registrations in one place (e.g. a `Registrations` object + * called from your application's main). Scattered registrations make ordering bugs hard to reproduce. + * - '''Class-loader / module ordering.''' If multiple modules register subtypes, ensure the first call happens + * after all modules are loaded. + * + * If your hierarchy is ''closed'' (all subtypes known at compile time), prefer the immutable [[RW.poly]] or + * `RW.gen` for sealed traits / unions instead. + * + * Example: + * {{{ + * trait Mode { def name: String } + * case object ConversationMode extends Mode { val name = "ConversationMode" } + * object Mode extends PolyType[Mode] { + * // Built-in subtype registered eagerly: + * register(RW.static(ConversationMode)) + * } + * + * // Apps add their own at startup: + * case object WorkflowMode extends Mode { val name = "WorkflowMode" } + * Mode.register(RW.static(WorkflowMode)) + * }}} + * + * @tparam T + * the polymorphic base type + */ +abstract class PolyType[T](implicit protected val classTag: ClassTag[T]) { + + @volatile private var types: List[RW[? <: T]] = Nil + @volatile private var _poly: RW[T] = generate() + + /** + * Build the underlying RW from the registered subtype list, then overlay the + * type-compatible intersection of subtype field maps as `commonFields` on the + * poly Definition. Codegen consumers (e.g. spice's Dart generator) read + * `commonFields` directly to emit abstract-parent fields without re-deriving + * the intersection — and without needing to know about the parent trait's + * surface separately from each subtype's. + */ + private def generate(): RW[T] = { + val baseRw = RW.poly[T]()(types*) + new RW[T] { + override def read(t: T): Json = baseRw.read(t) + override def write(json: Json): T = baseRw.write(json) + override def definition: Definition = withCommonFields(baseRw.definition) + } + } + + private def withCommonFields(d: Definition): Definition = d.defType match { + case p: DefType.Poly => d.copy(defType = p.copy(commonFields = computeCommonFields(p))) + case _ => d + } + + /** + * Intersect the field maps of every subtype's `DefType.Obj`, keeping + * entries whose names + types match across ALL subtypes. Subtypes whose + * Definition isn't an Obj (case-object-style enum cases backed by + * `DefType.Null`, or other non-record shapes) contribute no fields and + * therefore cause the intersection to drop to empty — that's the right + * answer for a heterogeneous poly. Empty when there are no registered + * subtypes. + */ + private def computeCommonFields(p: DefType.Poly): Map[String, Definition] = + if (p.values.isEmpty) Map.empty + else { + val perSubtype: List[Map[String, Definition]] = p.values.values.toList.map { defn => + defn.defType match { + case o: DefType.Obj => o.map.toMap + case _ => Map.empty[String, Definition] + } + } + if (perSubtype.exists(_.isEmpty)) Map.empty + else { + val commonNames = perSubtype.iterator.map(_.keySet).reduce(_ intersect _) + commonNames.iterator.flatMap { name => + val defs = perSubtype.flatMap(_.get(name)) + // Keep the field only if every subtype's entry agrees on the + // serialized type. Compares `defType` shape — same primitive, + // same Obj structure, etc. — to avoid promoting a name that + // means different things on different subtypes. + val head = defs.head + if (defs.forall(_.defType == head.defType)) Some(name -> head) else None + }.toMap + } + } + + /** + * Register additional `T` subtypes into the poly RW. + * + * Call this at backend startup, before any serialization or `Definition` access. Any `Definition` snapshots taken + * before registration will not see the newly registered subtypes. + */ + def register(types: RW[? <: T]*): Unit = synchronized { + this.types = this.types ++ types.toList + _poly = generate() + } + + private def shortName(fullName: String): String = { + val lastDot = fullName.lastIndexOf('.') + val lastDollar = fullName.lastIndexOf('$') + val start = math.max(lastDot, lastDollar) + 1 + fullName.substring(start) + } + + /** + * Namespace for typed-name construction and lookup against this `PolyType`'s live registration. + * + * - `name.of(instance)` — derive a `PolyName[T]` from a concrete instance + * - `name.of[S]` — derive a `PolyName[T]` from a subtype at compile time + * - `name.from(s)` — validated lookup against registered subtypes + * - `name.registered` — the live set of registered names + * + * Each `PolyType`'s `name.*` naturally scopes to its own registration — no need to pass the `PolyType` reference + * around. + */ + object name { + + /** + * Build a `PolyName[T]` from a concrete `T` instance. Uses the simple class name (trailing `$` stripped for + * case objects). + */ + def of(instance: T): PolyName[T] = new PolyName[T](instance.getClass.getSimpleName.stripSuffix("$")) + + /** + * Build a `PolyName[T]` from a subtype at compile time. Uses the simple class name (trailing `$` stripped for + * case objects). + */ + def of[S <: T](implicit ct: ClassTag[S]): PolyName[T] = + new PolyName[T](ct.runtimeClass.getSimpleName.stripSuffix("$")) + + /** + * Validated lookup: returns `Some(PolyName)` if `n` matches a registered subtype, else `None`. + */ + def from(n: String): Option[PolyName[T]] = if (registered.exists(_.name == n)) Some(new PolyName[T](n)) else None + + /** + * The set of subtype names currently registered. Derived from each registered RW's `Definition.className`. + */ + def registered: Set[PolyName[T]] = synchronized { + types.flatMap(_.definition.className.map(n => new PolyName[T](shortName(n)))).toSet + } + } + + /** + * The polymorphic RW. Subclasses can expose this as their own `rw` if they need to combine `PolyType` with another + * RW interface (avoiding self-recursion that summoning the given would cause). + */ + protected val polyRW: RW[T] = new RW[T] { + override def read(t: T): Json = _poly.read(t) + override def write(json: Json): T = _poly.write(json) + override def definition: Definition = _poly.definition + } + + implicit val rw: RW[T] = polyRW +} + +object PolyType { + + /** + * Construct a `PolyType[T]` from a `ClassTag`. Useful when you want a standalone registry rather than mixing + * `PolyType` into a companion object. + * + * Example: + * {{{ + * val Modes = PolyType[Mode] + * Modes.register(RW.static(ConversationMode)) + * }}} + */ + def apply[T](implicit ct: ClassTag[T]): PolyType[T] = new PolyType[T] {} +} diff --git a/core/shared/src/main/scala/fabric/rw/RW.scala b/core/shared/src/main/scala/fabric/rw/RW.scala index 910d634e..ea32f2cb 100755 --- a/core/shared/src/main/scala/fabric/rw/RW.scala +++ b/core/shared/src/main/scala/fabric/rw/RW.scala @@ -36,6 +36,32 @@ trait RW[T] extends Reader[T] with Writer[T] { def withPreWrite(f: Json => Json): RW[T] = EnhancedRW[T](this, preWrite = List(f)) def withPostRead(f: (T, Json) => Json): RW[T] = EnhancedRW[T](this, postRead = List(f)) + def withDefinition(f: Definition => Definition): RW[T] = EnhancedRW[T](this, postDefinition = List(f)) + + def mapType(fieldName: String = "type")(f: String => String): RW[T] = withPostRead { case (_, json) => + if (json.isStr) { + str(f(json.asString)) + } else if (json.isObj) { + val o = json.asObj + o.value.get(fieldName) match { + case Some(string: Str) => o.merge( + obj( + fieldName -> str(f(string.value)) + ) + ) + case _ => json + } + } else { + json + } + }.withDefinition(d => RW.mapPolyNames(f)(d)) + + def leaf: RW[T] = mapType() { s => + s.substring(s.lastIndexOf('.') + 1) + } + + def lowerCase: RW[T] = mapType()(_.toLowerCase) + def upperCase: RW[T] = mapType()(_.toUpperCase) } object RW extends CompileRW { @@ -56,18 +82,56 @@ object RW extends CompileRW { override def definition: Definition = d } + /** + * Apply a name transform to the top-level [[DefType.Poly]] variant keys (the discriminator + * names) so a `mapType`/`leaf`/`lowerCase` transform is reflected in the schema as well as on + * the wire. Top-level only (mirroring the runtime transform, which touches only the outer + * discriminator) and a no-op for non-poly definitions. + */ + private[rw] def mapPolyNames(f: String => String)(d: Definition): Definition = d.defType match { + case DefType.Poly(values, commonFields) => + d.copy(defType = DefType.Poly(VectorMap.from(values.toSeq.map { case (k, v) => f(k) -> v }), commonFields)) + case _ => d + } + def enumeration[T: ClassTag]( list: List[T], - asString: T => String = (t: T) => defaultClassNameMapping(t.getClass.getName), + asString: T => String = (t: T) => Definition.simpleClassName(cleanClassName(t.getClass.getName)), caseSensitive: Boolean = false ): RW[T] = new RW[T] { - val className: String = implicitly[ClassTag[T]].runtimeClass.getName.replace('$', '.') + val className: String = cleanClassName(implicitly[ClassTag[T]].runtimeClass.getName) private def fixString(s: String): String = if (caseSensitive) s else s.toLowerCase private lazy val map = list.map(t => fixString(asString(t)) -> t).toMap - override def write(value: Json): T = map(fixString(value.asString)) + private lazy val leafIndex: Map[String, List[T]] = list.groupBy { t => + val s = asString(t) + fixString(s.split('.').filter(_.nonEmpty).lastOption.getOrElse(s)) + } + + override def write(value: Json): T = { + val raw = value.asString + val key = fixString(raw) + map + .get(key) + .orElse { + leafIndex.get(key) match { + case Some(single :: Nil) => Some(single) + case Some(many) if many.size > 1 => + throw RWException( + s"Ambiguous enumeration value '$raw' — matches ${many.size} cases in $className. " + + s"Use a distinct `asString` mapping or the fully-qualified form." + ) + case _ => None + } + } + .getOrElse( + throw RWException( + s"Unknown enumeration value '$raw' for $className. Valid values: [${map.keys.mkString(", ")}]" + ) + ) + } override def read(t: T): Json = str(asString(t)) @@ -150,54 +214,155 @@ object RW extends CompileRW { ) /** - * Convenience functionality for working with polymorphic types + * Convenience functionality for working with polymorphic types. + * + * Dispatch keys are the class-chain form of each subtype's `Definition.className` — packages stripped, + * nested-class chain preserved — so distinct enums declaring same-named cases (`Foo.Success` vs + * `Bar.Success`) never collide. The wire JSON `"type"` field stamps the same string. Both registration + * and instance dispatch derive the key via `Definition.simpleClassName`, so the two sides are + * symmetric by construction. + * + * A legacy-leaf fallback index lets persisted records written under the historical leaf-name + * convention (`{"type": "Success"}`) continue to be read, provided the leaf is unambiguous across + * registered subtypes. Ambiguous legacy discriminators throw at read time with an actionable message + * (declare `typeAliases` to pin the intended target). Registration-time collision detection on the + * primary keys fails fast if two registered RWs produce the same `simpleClassName`. * * @param fieldName - * the field name stored in the value (defaults to "type") - * @param classNameMapping - * a function to convert from the actual class name to a stringified class name + * the JSON field name carrying the type discriminator (defaults to "type") * @param typeAliases - * allows aliases to RW types "Alias" -> "ExistingType", mostly for backwards compatability after class renaming + * `("alias" -> "primaryKey")` entries that route an old wire discriminator to a registered subtype. + * Use this for renames, migrations off the legacy-leaf format, or pinning ambiguous legacy data. */ def poly[P: ClassTag]( fieldName: String = "type", - classNameMapping: String => String = defaultClassNameMapping, typeAliases: List[(String, String)] = Nil )( types: RW[? <: P]* ): RW[P] = { - def typeName(rw: RW[? <: P]): String = { - val className = rw.definition.className.getOrElse(throw new RuntimeException(s"No className defined for $rw")) - classNameMapping(className) + def typeName(rw: RW[? <: P]): String = + rw.definition.simpleClassName.getOrElse(throw new RuntimeException(s"No className defined for $rw")) + + // Auto-recurse into nested polymorphic subtypes: when a registered subtype's defType is itself a + // Poly (e.g. the registered RW is an enum or sealed trait), each of its variants gets a typeMap entry + // pointing back to the parent's RW. Instance dispatch on a variant type then routes through the + // parent's RW (which handles the actual variant dispatch internally) without requiring per-variant + // registration. No-op for flat hierarchies (case-class subtypes have non-Poly defTypes). + val directPairs = types.toList.flatMap { rw => + val parent = typeName(rw) -> rw + val nested = rw.definition.defType match { + case p: DefType.Poly => p.values.toList.flatMap { case (_, variantDefn) => + variantDefn.simpleClassName.map(_ -> rw) + } + case _ => Nil + } + parent :: nested } - val directTypeMappings = Map(types.toList.map(rw => typeName(rw).toLowerCase -> rw): _*) - val aliasedTypeMappings = Map(typeAliases.map { case (alias, direct) => - val rw = directTypeMappings.getOrElse( - direct.toLowerCase, + + // Registration-time collision guardrail: two subtypes producing the same primary key from + // DIFFERENT className sources is a registration bug (different types whose `simpleClassName` + // collides — by definition unrecoverable at dispatch time). Two entries with the SAME className + // are a harmless duplicate (typically a consumer registering the same RW twice through different + // discovery paths — its own outputRW plus a framework-shipped catalog entry); dedupe silently + // and keep the first occurrence. + directPairs.groupBy(_._1).collectFirst { + case (key, occurrences) if occurrences.map(_._2.definition.className).distinct.size > 1 => + val cns = occurrences.flatMap(_._2.definition.className).distinct.mkString(", ") throw new RuntimeException( - s"Unable to map $alias -> $direct as $direct not found in ${directTypeMappings.keys.mkString(", ")}" + s"Duplicate polymorphic dispatch key '$key' from multiple registered types: [$cns]. " + + "Each subtype must produce a unique simpleClassName (class-chain form). Rename one of the " + + "colliding types, or use typeAliases to pin distinct wire discriminators." ) - ) - alias.toLowerCase -> rw - }: _*) - val className: String = implicitly[ClassTag[P]].runtimeClass.getName + } + // First-occurrence-wins on key collision (groupBy semantics) so duplicate-className entries that + // passed the guardrail above don't shadow each other under `Map.toMap`'s last-wins behavior. + val directTypeMappings: Map[String, RW[? <: P]] = directPairs.foldLeft(Map.empty[String, RW[? <: P]]) { + case (acc, (k, v)) => if (acc.contains(k)) acc else acc + (k -> v) + } + + // Legacy-leaf fallback: case-insensitive index keyed by the leaf segment of each registered subtype's + // className. Unambiguous matches dispatch through this fallback so persisted records using the + // pre-1.29 wire format (`{"type": "Success"}`) continue to read. Ambiguous matches throw with a + // typeAliases pointer rather than silently picking a winner. + val legacyLeafIndex: Map[String, List[RW[? <: P]]] = types.toList.groupBy { rw => + val cn = rw.definition.className.getOrElse("") + val leaf = cn.split('.').filter(_.nonEmpty).lastOption.getOrElse(cn) + leaf.toLowerCase + } + + val aliasedTypeMappings = typeAliases.map { case (alias, primary) => + // Look up primary by exact match (new simpleClassName form) first; fall back to case-insensitive + // leaf-only match (legacy form) so aliases written against pre-1.29 fabric continue to work + // without modification when they're unambiguous. + val rw = directTypeMappings + .get(primary) + .orElse { + legacyLeafIndex.get(primary.toLowerCase) match { + case Some(single :: Nil) => Some(single) + case _ => None + } + } + .getOrElse( + throw new RuntimeException( + s"Unable to map alias '$alias' → '$primary': '$primary' not found in registered keys " + + s"[${directTypeMappings.keys.mkString(", ")}]" + ) + ) + alias -> rw + }.toMap + + val className: String = cleanClassName(implicitly[ClassTag[P]].runtimeClass.getName) val typeMap = directTypeMappings ++ aliasedTypeMappings + + def resolve(rawType: String): Option[RW[? <: P]] = typeMap + .get(rawType) + .orElse { + legacyLeafIndex.get(rawType.toLowerCase) match { + case Some(single :: Nil) => Some(single) + case Some(many) if many.size > 1 => + throw new RuntimeException( + s"Ambiguous legacy discriminator '$rawType' — matches ${many.size} registered types: " + + s"[${many.flatMap(_.definition.className).mkString(", ")}]. " + + s"Persisted records using leaf-only discriminators are ambiguous across these types; " + + s"add typeAliases to pin '$rawType' to one of them, or migrate the stored records to " + + s"the new wire form." + ) + case _ => None + } + } + .map(_.asInstanceOf[RW[? <: P]]) + + // Derive the instance's dispatch key. For Scala 3 parameterless enum cases (whose runtime class + // is an anonymous wrapper — `$$anon$N`) the cleaned getClass.getName produces a useless + // `.anon.N` chain. Recover the case name via `Product#productPrefix` combined with the + // enum's simpleClassName from the anon class's superclass. + def instanceKey(p: P): String = { + val raw = cleanClassName(p.getClass.getName) + if (raw.contains(".anon.")) { + val parent = Definition.simpleClassName(cleanClassName(p.getClass.getSuperclass.getName)) + p match { + case product: Product => s"$parent.${product.productPrefix}" + case _ => Definition.simpleClassName(raw) + } + } else Definition.simpleClassName(raw) + } + from( r = (p: P) => { - val `type` = classNameMapping(p.getClass.getName) - typeMap.get(`type`.toLowerCase) match { - case Some(rw) => rw.asInstanceOf[RW[P]].read(p).merge(obj("type" -> str(`type`))) + val `type` = instanceKey(p) + resolve(`type`) match { + case Some(rw) => rw.asInstanceOf[RW[P]].read(p).merge(obj(fieldName -> str(`type`))) case None => throw new RuntimeException( s"Type not found [${`type`}] converting from value $p. Available types are: [${typeMap.keySet.mkString(", ")}]" ) } }, w = (json: Json) => { - val `type` = json(fieldName).asString.toLowerCase - typeMap.get(`type`) match { + val rawType = json(fieldName).asString + resolve(rawType) match { case Some(rw) => rw.write(json) case None => throw new RuntimeException( - s"Type not found [${`type`}] converting from value $json. Available types are: [${typeMap.keySet.mkString(", ")}]" + s"Type not found [$rawType] converting from value $json. Available types are: [${typeMap.keySet.mkString(", ")}]" ) } }, @@ -217,14 +382,4 @@ object RW extends CompileRW { case s if s.endsWith(".") => s.substring(0, s.length - 1) case s => s } - - def defaultClassNameMapping(className: String): String = { - val cn = cleanClassName(className) - val index = cn.lastIndexOf('.') - if (index != -1) { - cn.substring(index + 1) - } else { - cn - } - }.replace("$", "") } diff --git a/core/shared/src/test/scala-3/spec/Scala3Spec.scala b/core/shared/src/test/scala-3/spec/Scala3Spec.scala index c4e674f1..40e779e4 100755 --- a/core/shared/src/test/scala-3/spec/Scala3Spec.scala +++ b/core/shared/src/test/scala-3/spec/Scala3Spec.scala @@ -51,13 +51,14 @@ class Scala3Spec extends AnyWordSpec with Matchers { str("Green").as[Color] should be(Color.Green) } "handle enums with derives RW" in { - Direction.North.json should be(str("North")) - str("South").as[Direction] should be(Direction.South) + Direction.North.json should be(str("Scala3Spec.Direction.North")) + str("Scala3Spec.Direction.South").as[Direction] should be(Direction.South) } "round-trip all enum values with derives RW" in { Direction.values.foreach { d => - str(d.toString).as[Direction] should be(d) - d.json should be(str(d.toString)) + val wire = s"Scala3Spec.Direction.${d.toString}" + str(wire).as[Direction] should be(d) + d.json should be(str(wire)) } } "handle union types (A | B) serialization" in { @@ -66,10 +67,10 @@ class Scala3Spec extends AnyWordSpec with Matchers { val dogVal: Cat | Dog = Dog("Rex", "Golden") val catJson = catVal.json - catJson should be(obj("type" -> "Cat", "name" -> "Whiskers", "age" -> 3)) + catJson should be(obj("type" -> "UnionTest.Cat", "name" -> "Whiskers", "age" -> 3)) val dogJson = dogVal.json - dogJson should be(obj("type" -> "Dog", "name" -> "Rex", "breed" -> "Golden")) + dogJson should be(obj("type" -> "UnionTest.Dog", "name" -> "Rex", "breed" -> "Golden")) } "handle union types (A | B) deserialization" in { import UnionTest._ @@ -94,10 +95,10 @@ class Scala3Spec extends AnyWordSpec with Matchers { val fish: Cat | Dog | Fish = Fish("Nemo", saltwater = true) val catJson = cat.json - catJson should be(obj("type" -> "Cat", "name" -> "Milo", "age" -> 2)) + catJson should be(obj("type" -> "UnionTest.Cat", "name" -> "Milo", "age" -> 2)) val fishJson = fish.json - fishJson should be(obj("type" -> "Fish", "name" -> "Nemo", "saltwater" -> true)) + fishJson should be(obj("type" -> "UnionTest.Fish", "name" -> "Nemo", "saltwater" -> true)) catJson.as[Cat | Dog | Fish] should be(Cat("Milo", 2)) dog.json.as[Cat | Dog | Fish] should be(Dog("Buddy", "Poodle")) @@ -121,8 +122,8 @@ class Scala3Spec extends AnyWordSpec with Matchers { intJson("_generic")("T")("type").asString should be("numeric") // Wrap with type field as the union would - val unionStr = strJson.asObj.merge(Obj("type" -> Str("Box"))).asObj - val unionInt = intJson.asObj.merge(Obj("type" -> Str("Box"))).asObj + val unionStr = strJson.asObj.merge(Obj("type" -> Str("GenericCollisionTest.Box"))).asObj + val unionInt = intJson.asObj.merge(Obj("type" -> Str("GenericCollisionTest.Box"))).asObj // Deserialize through union RW — _generic disambiguates val restored1 = unionStr.as[T] @@ -142,10 +143,10 @@ class Scala3Spec extends AnyWordSpec with Matchers { val rect: Shape = Shape.Rectangle(3.0, 4.0) val rgbJson = rgb.json - rgbJson should be(obj("type" -> "Circle", "radius" -> 5.0)) + rgbJson should be(obj("type" -> "ParameterizedEnumTest.Shape.Circle", "radius" -> 5.0)) val rectJson = rect.json - rectJson should be(obj("type" -> "Rectangle", "width" -> 3.0, "height" -> 4.0)) + rectJson should be(obj("type" -> "ParameterizedEnumTest.Shape.Rectangle", "width" -> 3.0, "height" -> 4.0)) rgbJson.as[Shape] should be(Shape.Circle(5.0)) rectJson.as[Shape] should be(Shape.Rectangle(3.0, 4.0)) @@ -172,7 +173,7 @@ class Scala3Spec extends AnyWordSpec with Matchers { import CustomFieldTest._ val circle: CustomShape = CustomShape.Round(5.0) val json = circle.json - json should be(obj("kind" -> "Round", "radius" -> 5.0)) + json should be(obj("kind" -> "CustomFieldTest.CustomShape.Round", "radius" -> 5.0)) json.as[CustomShape] should be(CustomShape.Round(5.0)) } "reject union type deserialization without type discriminator" in { @@ -186,10 +187,10 @@ class Scala3Spec extends AnyWordSpec with Matchers { val point: Shape = Shape.Point val circleJson = circle.json - circleJson should be(obj("type" -> "Circle", "radius" -> 5.0)) + circleJson should be(obj("type" -> "MixedEnumTest.Shape.Circle", "radius" -> 5.0)) val pointJson = point.json - pointJson should be(obj("type" -> "Point")) + pointJson should be(obj("type" -> "MixedEnumTest.Shape.Point")) circleJson.as[Shape] should be(Shape.Circle(5.0)) pointJson.as[Shape] should be(Shape.Point) @@ -197,6 +198,39 @@ class Scala3Spec extends AnyWordSpec with Matchers { // Round-trip (Shape.Rectangle(3.0, 4.0): Shape).json.as[Shape] should be(Shape.Rectangle(3.0, 4.0)) } + "expose stable className metadata for every enum case (no `.anon.N`)" in { + // Every variant of an `enum ... derives RW` — case class OR case + // object — must carry a `Definition.className` that names the + // case itself (e.g. `Shape.Point`), not the JVM-level anonymous + // wrapper Scala 3's mirror generates for parameterless enum + // cases. Case classes already produce a stable semantic name + // (`.`); case objects historically surfaced the + // raw `$$anon$N` JVM identifier, which fabric's + // `cleanClassName` turned into `..anon.N` — losing the + // case's real name and forcing every consumer that reads + // `className` for type-name derivation into ad-hoc fallback + // logic. Stable names across all cases let consumers use one + // path. + import MixedEnumTest._ + val defn = summon[RW[Shape]].definition + defn.defType match { + case DefType.Poly(values, _) => + val classNames = values.toList.map { case (k, v) => k -> v.className } + // Case classes — already work, included as a regression guard. + classNames.find(_._1 == "MixedEnumTest.Shape.Circle").flatMap(_._2) should be(Some("spec.MixedEnumTest.Shape.Circle")) + classNames.find(_._1 == "MixedEnumTest.Shape.Rectangle").flatMap(_._2) should be(Some("spec.MixedEnumTest.Shape.Rectangle")) + // The case object — the className must point at the enum case + // (`Shape.Point`) and must not contain `anon`. + val pointCn = classNames.find(_._1 == "MixedEnumTest.Shape.Point").flatMap(_._2) + pointCn should be(Some("spec.MixedEnumTest.Shape.Point")) + pointCn.foreach { cn => + withClue(s"className $cn must not leak the JVM-level anonymous wrapper: ") { + cn.contains("anon") should be(false) + } + } + case other => fail(s"Expected DefType.Poly, got: $other") + } + } "include @serialized members in JSON output" in { import SerializedTest._ val person = NamedPerson("Matt", "Hicks") diff --git a/core/shared/src/test/scala/spec/FabricDefinitionSpec.scala b/core/shared/src/test/scala/spec/FabricDefinitionSpec.scala index 5e148fce..98e61c22 100644 --- a/core/shared/src/test/scala/spec/FabricDefinitionSpec.scala +++ b/core/shared/src/test/scala/spec/FabricDefinitionSpec.scala @@ -262,14 +262,13 @@ class FabricDefinitionSpec extends AnyWordSpec with Matchers { ) ) } - "represent a proper optional for a definition with a default value" in { + "represent a default value on a non-optional Definition" in { User.rw.definition.json should be( obj( "type" -> "object", "values" -> obj( "name" -> obj( - "type" -> "optional", - "value" -> obj("type" -> "string"), + "type" -> "string", "default" -> "Unknown" ), "age" -> obj( @@ -345,7 +344,7 @@ class FabricDefinitionSpec extends AnyWordSpec with Matchers { val definition = oldEnumJson.as[Definition] definition.className should be(Some("spec.VehicleType")) definition.defType match { - case DefType.Poly(values) => + case DefType.Poly(values, _) => values.keySet should be(Set("Car", "SUV", "Truck")) values.values.foreach(_.defType should be(DefType.Null)) case other => fail(s"Expected DefType.Poly, got: $other") diff --git a/core/shared/src/test/scala/spec/FabricSpec.scala b/core/shared/src/test/scala/spec/FabricSpec.scala index 86bc142b..e951aa3e 100755 --- a/core/shared/src/test/scala/spec/FabricSpec.scala +++ b/core/shared/src/test/scala/spec/FabricSpec.scala @@ -167,8 +167,8 @@ class FabricSpec extends AnyWordSpec with Matchers { json.toString should be("{\"name\": null, \"age\": 21, \"data\": null}") } "use polymorphic values" in { - val json1 = obj("type" -> "Blank") - val json2 = obj("type" -> "PolyValue", "s" -> "Hello, World!") + val json1 = obj("type" -> "Polymorphic.Blank") + val json2 = obj("type" -> "Polymorphic.PolyValue", "s" -> "Hello, World!") val json3 = obj("type" -> "Empty") val p1 = json1.as[Polymorphic] @@ -187,12 +187,12 @@ class FabricSpec extends AnyWordSpec with Matchers { obj( "type" -> "poly", "values" -> obj( - "Blank" -> obj( + "Polymorphic.Blank" -> obj( "type" -> "object", "values" -> obj(), "className" -> "spec.Polymorphic.Blank" ), - "PolyValue" -> obj( + "Polymorphic.PolyValue" -> obj( "type" -> "object", "values" -> obj( "s" -> obj( diff --git a/core/shared/src/test/scala/spec/PolyTypeSpec.scala b/core/shared/src/test/scala/spec/PolyTypeSpec.scala new file mode 100644 index 00000000..74e75a89 --- /dev/null +++ b/core/shared/src/test/scala/spec/PolyTypeSpec.scala @@ -0,0 +1,210 @@ +/* + * Copyright (c) 2021 Typelevel + * + * 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 spec + +import fabric.define.DefType +import fabric.rw._ +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class PolyTypeSpec extends AnyWordSpec with Matchers { + import PolyTypeSpec._ + + "PolyType" should { + "register subtypes and serialize them" in { + // register in a fresh test-local PolyType to keep tests isolated + val poly = PolyType[Animal] + poly.register(RW.static(Cat), RW.static(Dog)) + val cat: Animal = Cat + val json = poly.rw.read(cat) + json("type").asString should be("PolyTypeSpec.Cat") + } + "round-trip via the registered RW" in { + val poly = PolyType[Animal] + poly.register(RW.static(Cat), RW.static(Dog)) + val asJson = poly.rw.read(Dog) + val back = poly.rw.write(asJson) + back should be(Dog) + } + "expose registered names via the name namespace" in { + val poly = PolyType[Animal] + poly.register(RW.static(Cat), RW.static(Dog)) + val names = poly.name.registered.map(_.name) + names should contain("Cat") + names should contain("Dog") + } + "validate name lookups against registered subtypes" in { + val poly = PolyType[Animal] + poly.register(RW.static(Cat)) + poly.name.from("Cat").map(_.name) should be(Some("Cat")) + poly.name.from("Unknown") should be(None) + } + "build PolyName from instance and from compile-time subtype" in { + val poly = PolyType[Animal] + poly.register(RW.static(Cat), RW.static(Dog)) + poly.name.of(Cat).name should be("Cat") + poly.name.of[Cat.type].name should be("Cat") + } + "reflect later registrations" in { + val poly = PolyType[Animal] + poly.register(RW.static(Cat)) + poly.name.registered.size should be(1) + poly.register(RW.static(Dog)) + poly.name.registered.size should be(2) + } + "round-trip PolyName through JSON" in { + val poly = PolyType[Animal] + poly.register(RW.static(Cat)) + val n: PolyName[Animal] = poly.name.of(Cat) + val json = n.json + json.asString should be("Cat") + json.as[PolyName[Animal]].name should be("Cat") + } + "expose definition as a Poly with all registered subtypes" in { + val poly = PolyType[Animal] + poly.register(RW.static(Cat), RW.static(Dog)) + poly.rw.definition.defType match { + case DefType.Poly(values, _) => + values.keySet should contain("PolyTypeSpec.Cat") + values.keySet should contain("PolyTypeSpec.Dog") + case other => fail(s"Expected DefType.Poly, got: $other") + } + } + "have empty registry before any register call" in { + val poly = PolyType[Animal] + poly.name.registered should be(Set.empty) + } + } + + "PolyType.commonFields" should { + + "be empty when no subtypes are registered" in { + val poly = PolyType[Shape] + poly.rw.definition.defType match { + case p: DefType.Poly => + p.commonFields should be(empty) + p.values should be(empty) + case other => fail(s"Expected DefType.Poly, got: $other") + } + } + + "include every field of a single registered subtype (intersection of one set is itself)" in { + val poly = PolyType[Shape] + poly.register(implicitly[RW[Circle]]) + poly.rw.definition.defType match { + case p: DefType.Poly => + // Single subtype: commonFields = the whole subtype's field map. + p.commonFields.keySet should be(Set("kind", "radius")) + case other => fail(s"Expected DefType.Poly, got: $other") + } + } + + "intersect across subtypes — keep names every subtype carries" in { + val poly = PolyType[Shape] + poly.register(implicitly[RW[Circle]], implicitly[RW[Square]]) + poly.rw.definition.defType match { + case p: DefType.Poly => + // Both Circle and Square declare `kind: String`. Circle's + // `radius` and Square's `side` are subtype-specific; they + // drop out of the intersection. + p.commonFields.keySet should be(Set("kind")) + case other => fail(s"Expected DefType.Poly, got: $other") + } + } + + "shrink commonFields when a new subtype registers without one of the previously-common fields" in { + val poly = PolyType[Shape] + poly.register(implicitly[RW[Circle]]) + // After Circle alone: kind + radius are both common (single-subtype). + poly.register(implicitly[RW[Square]]) + // After Square joins: only `kind` survives — radius is Circle-only. + poly.rw.definition.defType match { + case p: DefType.Poly => p.commonFields.keySet should be(Set("kind")) + case other => fail(s"Expected DefType.Poly, got: $other") + } + } + + "drop a name where subtypes disagree on the field's type" in { + val poly = PolyType[Mismatch] + poly.register(implicitly[RW[StringValued]], implicitly[RW[IntValued]]) + poly.rw.definition.defType match { + case p: DefType.Poly => + // Both subtypes have `value` but with different types — the + // intersection drops it because the abstract parent can't + // declare a single type for the field. + p.commonFields should not contain key("value") + case other => fail(s"Expected DefType.Poly, got: $other") + } + } + + "be empty when any subtype is non-Obj (e.g. case-object enum cases)" in { + // A poly mixing record-shaped and unit-shaped subtypes has no + // record-level intersection — the unit subtype contributes no + // fields. + val poly = PolyType[MixedShape] + poly.register(implicitly[RW[Triangle]], RW.static(MixedShape.Origin)) + poly.rw.definition.defType match { + case p: DefType.Poly => p.commonFields should be(empty) + case other => fail(s"Expected DefType.Poly, got: $other") + } + } + } +} + +object PolyTypeSpec { + sealed trait Animal + case object Cat extends Animal + case object Dog extends Animal + + // For commonFields tests — record-shaped subtypes with overlap. + trait Shape + case class Circle(kind: String, radius: Double) extends Shape + object Circle { + implicit val rw: RW[Circle] = RW.gen[Circle] + } + case class Square(kind: String, side: Double) extends Shape + object Square { + implicit val rw: RW[Square] = RW.gen[Square] + } + + // Same field name, different field type — intersection should drop it. + trait Mismatch + case class StringValued(value: String) extends Mismatch + object StringValued { + implicit val rw: RW[StringValued] = RW.gen[StringValued] + } + case class IntValued(value: Int) extends Mismatch + object IntValued { + implicit val rw: RW[IntValued] = RW.gen[IntValued] + } + + // Heterogeneous: one record-shaped, one case-object — no record-level + // intersection should be possible. + trait MixedShape + case class Triangle(sides: Int) extends MixedShape + object Triangle { + implicit val rw: RW[Triangle] = RW.gen[Triangle] + } + object MixedShape { + case object Origin extends MixedShape + } +} diff --git a/core/shared/src/test/scala/spec/RWEnumerationLegacyLeafSpec.scala b/core/shared/src/test/scala/spec/RWEnumerationLegacyLeafSpec.scala new file mode 100644 index 00000000..2b737234 --- /dev/null +++ b/core/shared/src/test/scala/spec/RWEnumerationLegacyLeafSpec.scala @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2021 Typelevel + * + * 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 spec + +import fabric._ +import fabric.rw._ +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +/** + * Pins the contract of [[fabric.rw.RW.enumeration]] around the parent-qualified + * wire form fabric adopted in v1.29.x and the legacy-leaf fallback that + * accompanies it. Documents both the read-side leniency (forward-compat for + * records persisted under the old leaf-only wire form) and the write-side + * asymmetry (writes only produce the new form — relevant for downstream + * consumers that match indexed string values literally). + */ +class RWEnumerationLegacyLeafSpec extends AnyWordSpec with Matchers { + + sealed trait Color + + object Color { + implicit val rw: RW[Color] = RW.enumeration(List(Red, Green, Blue)) + + case object Red extends Color + case object Green extends Color + case object Blue extends Color + } + + "RW.enumeration current wire form" should { + "write a value using the parent-qualified class chain" in { + // Definition.simpleClassName(getClass.getName) joins the class chain with + // dots, dropping lowercase (package) segments. The class chain for + // `Color.Red` here is RWEnumerationLegacyLeafSpec → Color → Red. + (Color.Red: Color).json should be(str("RWEnumerationLegacyLeafSpec.Color.Red")) + } + "round-trip the current wire form" in { + val json = (Color.Green: Color).json + json.as[Color] should be(Color.Green) + } + } + + "RW.enumeration legacy-leaf fallback" should { + "decode an old leaf-only wire value to the matching registered case" in { + // A persisted record written before fabric's class-chain change carried + // just the leaf name. The current RW resolves it via the in-memory + // legacy-leaf index so older records continue to deserialize. + str("Red").as[Color] should be(Color.Red) + } + "decode legacy leaf values case-insensitively" in { + // The legacy-leaf path is case-insensitive (lowercased before lookup) so + // hand-rolled tooling or older write paths that vary case still resolve. + str("green").as[Color] should be(Color.Green) + } + "throw a clear error for an unknown enumeration value" in { + a[RWException] should be thrownBy { + str("Purple").as[Color] + } + } + } + + "RW.enumeration write side" should { + "always emit the new parent-qualified form" in { + // Pinning the asymmetry: writes only produce the new form. Downstream + // consumers that index the literal wire string and then issue term + // queries against later writes must migrate stored values — fabric's + // legacy-leaf fallback covers DESERIALIZATION (read at `json.as[T]`), + // not server-side string matching in external indices. + val written = (Color.Blue: Color).json + written should be(str("RWEnumerationLegacyLeafSpec.Color.Blue")) + written should not be str("Blue") + } + } +} diff --git a/core/shared/src/test/scala/spec/RWMapTypeSpec.scala b/core/shared/src/test/scala/spec/RWMapTypeSpec.scala new file mode 100644 index 00000000..c2846229 --- /dev/null +++ b/core/shared/src/test/scala/spec/RWMapTypeSpec.scala @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2021 Typelevel + * + * 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 spec + +import fabric._ +import fabric.define.DefType +import fabric.rw._ +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec + +/** + * Verifies that `mapType`-derived transforms (`leaf`, `lowerCase`) apply consistently to BOTH the + * wire value AND the `definition` (the `DefType.Poly` variant keys), so the generated schema can't + * drift from what's actually written. Covered for the two `DefType.Poly` producers: `enumeration` + * (whole-value `Str` discriminator) and `poly` (a `"type"` field inside an `Obj`). + */ +class RWMapTypeSpec extends AnyWordSpec with Matchers { + sealed trait Color + object Color { + case object Red extends Color + case object Green extends Color + case object Blue extends Color + + val qualified: RW[Color] = RW.enumeration(List(Red, Green, Blue)) + val leaf: RW[Color] = qualified.leaf + val leafLower: RW[Color] = qualified.leaf.lowerCase + } + + sealed trait Shape + object Shape { + case class Circle(radius: Int) extends Shape + case class Square(side: Int) extends Shape + + val circleRW: RW[Circle] = RW.gen[Circle] + val squareRW: RW[Square] = RW.gen[Square] + val leaf: RW[Shape] = RW.poly[Shape]()(circleRW, squareRW).leaf + } + + private def polyKeys(rw: RW[_]): Set[String] = rw.definition.defType match { + case DefType.Poly(values, _) => values.keySet + case other => fail(s"expected a Poly definition, got: $other") + } + + "leaf on an enumeration" should { + "write the leaf form" in { + Color.leaf.read(Color.Red) should be(str("Red")) + } + "round-trip the leaf form" in { + Color.leaf.write(str("Green")) should be(Color.Green) + Color.leaf.write(Color.leaf.read(Color.Blue)) should be(Color.Blue) + } + "reflect the leaf names in the definition" in { + polyKeys(Color.leaf) should be(Set("Red", "Green", "Blue")) + } + } + + "leaf.lowerCase on an enumeration" should { + "write the lowercased leaf form" in { + Color.leafLower.read(Color.Red) should be(str("red")) + } + "round-trip the lowercased leaf form" in { + Color.leafLower.write(str("red")) should be(Color.Red) + Color.leafLower.write(Color.leafLower.read(Color.Green)) should be(Color.Green) + } + "reflect the lowercased leaf names in the definition" in { + polyKeys(Color.leafLower) should be(Set("red", "green", "blue")) + } + } + + "leaf on a poly/sealed trait" should { + "write the leaf discriminator in the type field" in { + Shape.leaf.read(Shape.Circle(3)).asObj.value("type") should be(str("Circle")) + } + "round-trip via the leaf discriminator" in { + val circle: Shape = Shape.Circle(3) + Shape.leaf.write(Shape.leaf.read(circle)) should be(circle) + } + "reflect the leaf names in the definition" in { + polyKeys(Shape.leaf) should be(Set("Circle", "Square")) + } + } +} diff --git a/core/shared/src/test/scala/spec/RWSpecAuto.scala b/core/shared/src/test/scala/spec/RWSpecAuto.scala index f198f459..cb7b7073 100755 --- a/core/shared/src/test/scala/spec/RWSpecAuto.scala +++ b/core/shared/src/test/scala/spec/RWSpecAuto.scala @@ -193,14 +193,14 @@ class RWSpecAuto extends AnyWordSpec with Matchers { } "supporting sealed traits" in { val car: VehicleType = VehicleType.Car - car.json should be(Str("Car")) - "SUV".json.as[VehicleType] should be(VehicleType.SUV) + car.json should be(Str("VehicleType.Car")) + "VehicleType.SUV".json.as[VehicleType] should be(VehicleType.SUV) VehicleType.rw.definition.defType.asInstanceOf[DefType.Poly].values.keySet should be( Set( - "Car", - "SUV", - "Truck", - "Mini Van" + "VehicleType.Car", + "VehicleType.SUV", + "VehicleType.Truck", + "VehicleType.Mini Van" ) ) } diff --git a/docs/README.md b/docs/README.md index cd8373e4..1596992a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,7 +32,7 @@ However, I was shocked to see how well my little library performed compared to t The focus of this project is minimalism and flexibility. To that end, the features are somewhat sparse: - Support for JVM, Scala.js, and Scala Native -- Support for Scala 2.12, 2.13, and 3.x +- Support for Scala 2.13 and 3.x - AST for representation of `Map`, `Array`, `Numeric`, `String`, `Boolean`, and `null` in a type-safe and immutable way - Clean DSL to create tree structures - Deep merging support @@ -261,6 +261,42 @@ given RW[Cat | Dog] = RW.gen[Cat | Dog] For collision unions where multiple variants share the same base class (e.g. `Id[String] | Id[Int]`), the `_generic` field is used to distinguish between them on the deserialization side. +### Open Polymorphism with PolyType + +When a hierarchy is *open* — a library defines a base trait and downstream applications add their own subtypes — +`RW.gen` and `RW.poly` can't see those subtypes at compile time. `PolyType[T]` is a runtime-registerable poly RW for +this case: + +```scala +import fabric.rw._ + +trait Mode { def name: String } +case object ConversationMode extends Mode { val name = "ConversationMode" } + +object Mode extends PolyType[Mode] { + register(RW.static(ConversationMode)) +} + +// Apps add their own at startup: +case object WorkflowMode extends Mode { val name = "WorkflowMode" } +Mode.register(RW.static(WorkflowMode)) +``` + +Each `PolyType` exposes a `name` namespace for typed-name construction: + +```scala +val n: PolyName[Mode] = Mode.name.of(ConversationMode) // PolyName("ConversationMode") +val all: Set[PolyName[Mode]] = Mode.name.registered // current live set +val maybe: Option[PolyName[Mode]] = Mode.name.from("X") // validated lookup +``` + +> ⚠️ **Mutability warning.** `PolyType` is the one place in fabric that uses mutable state. **Register subtypes at +> startup, before any serialization or `Definition` access.** Any `Definition` snapshot taken before registration +> will see an incomplete poly — most often visible as missing dispatchers in generated schemas. Centralize your +> registrations in one place (e.g. an application init block) to avoid order-dependence bugs. + +If your hierarchy is closed (all subtypes known at compile time), prefer `RW.poly` or `RW.gen` for sealed traits/unions. + ### Inspection & Generation ```scala diff --git a/io/jvm/src/test/resources/openapi-simple.yml b/io/jvm/src/test/resources/openapi-simple.yml index cbe6e655..45c1a15a 100644 --- a/io/jvm/src/test/resources/openapi-simple.yml +++ b/io/jvm/src/test/resources/openapi-simple.yml @@ -14,7 +14,7 @@ paths: summary: 'Returns a list of users.' description: 'Optional extended description in CommonMark or HTML.' responses: - 200: + '200': description: 'A JSON array of user names' content: application/json: diff --git a/io/jvm/src/test/resources/openapi-tictactoe.yml b/io/jvm/src/test/resources/openapi-tictactoe.yml index b9f410ad..51824cf8 100644 --- a/io/jvm/src/test/resources/openapi-tictactoe.yml +++ b/io/jvm/src/test/resources/openapi-tictactoe.yml @@ -14,7 +14,7 @@ paths: - 'Gameplay' operationId: 'get-board' responses: - 200: + '200': description: 'OK' content: application/json: @@ -31,13 +31,13 @@ paths: - 'Gameplay' operationId: 'get-square' responses: - 200: + '200': description: 'OK' content: application/json: schema: $ref: '#/components/schemas/mark' - 400: + '400': description: 'The provided parameters are incorrect' content: text/html: @@ -57,13 +57,13 @@ paths: schema: $ref: '#/components/schemas/mark' responses: - 200: + '200': description: 'OK' content: application/json: schema: $ref: '#/components/schemas/status' - 400: + '400': description: 'The provided parameters are incorrect' content: text/html: diff --git a/io/shared/src/main/scala/fabric/io/YamlFormatter.scala b/io/shared/src/main/scala/fabric/io/YamlFormatter.scala index 1bdf3da2..0b8697d3 100644 --- a/io/shared/src/main/scala/fabric/io/YamlFormatter.scala +++ b/io/shared/src/main/scala/fabric/io/YamlFormatter.scala @@ -28,23 +28,40 @@ object YamlFormatter extends Formatter { override def apply(json: Json): String = write(json, 0).trim + private val NumericKey = "^-?\\d+(?:\\.\\d+)?$".r + private val ReservedKeys = + Set("true", "false", "null", "yes", "no", "on", "off", "True", "False", "Null", "TRUE", "FALSE", "NULL") + + private def needsKeyQuoting(key: String): Boolean = key.isEmpty || + NumericKey.matches(key) || + ReservedKeys.contains(key) || + key.headOption.exists(c => + c == '-' || c == '?' || c == ':' || c == ',' || c == '[' || c == ']' || c == '{' || c == '}' || c == '#' || c == '&' || c == '*' || c == '!' || c == '|' || c == '>' || c == '\'' || c == '"' || c == '%' || c == '@' || c == '`' + ) || + key.contains(": ") || + key.contains(" #") + + private def renderKey(key: String): String = if (needsKeyQuoting(key)) s"'${key.replace("'", "''")}'" else key + private def write(json: Json, depth: Int): String = { def pad(adjust: Int = 0): String = "".padTo((depth + adjust) * 2, ' ') def fix(s: String): String = s.replace("'", "''") json match { + case Arr(v, _) if v.isEmpty => "[]" case Arr(v, _) => v.map(write(_, depth + 1)).map(s => s"${pad()}- ${s.dropWhile(_.isWhitespace)}").mkString("\n", "\n", "") case Bool(b, _) => b.toString case Null => "" case NumInt(n, _) => n.toString case NumDec(n, _) => n.toString() + case Obj(map) if map.isEmpty => "{}" case Obj(map) => map.toList .map { case (key, value) => val v = write(value, depth + 1) match { case s if s.headOption.contains('\n') => s case s => s" $s" } - s"${pad()}$key:$v" + s"${pad()}${renderKey(key)}:$v" } .mkString("\n", "\n", "") case Str(s, _) if s.contains("\n") => fix(s).split('\n').map(s => s"${pad()}$s").mkString("|-\n", "\n", "") diff --git a/project/build.properties b/project/build.properties index 7e437e91..c5e9f6ac 100755 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.12.9 \ No newline at end of file +sbt.version=1.12.11 \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt index 1205f048..01a33045 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -4,7 +4,7 @@ addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2") addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.21.0") -addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.11") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.12") addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.12.2") @@ -14,4 +14,4 @@ addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.8") addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.9.0") -addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.8.5") +addSbtPlugin("org.typelevel" % "sbt-typelevel" % "0.8.6")