Skip to content

Commit 58a7fdf

Browse files
xerialclaude
andauthored
fix: Resolve Any to AnyWeaver in Weaver.fromSurface instead of the lossy empty-object fallback (#633)
## Summary Fixes #632. `Weaver.fromSurface` mapped the `Any` surface to the private `emptyObjectWeaver`, so any `Any`-typed value reached via the Surface-driven derivation path silently decoded to `null`. The visible symptom: a case class field like `properties: Map[String, Any]` inside a nested structure round-tripped every value in the map to `null` with no error, unless the caller hand-built a given chain down to `Weaver[Map[String, Any]]`. ## Fix Add an `anyFactory` to `Weaver.fromSurface`'s factory list, ahead of `emptyObjectFallbackFactory`, that resolves surfaces with `rawType == classOf[AnyRef]` to `AnyWeaver.default` — the generic codec already exposed as `given anyWeaver: Weaver[Any]` in `PrimitiveWeaver`. This covers the `scala.Any` alias, `AnyRef`/`java.lang.Object`, and the `AnyRefSurface` placeholders Surface uses for erased type parameters (per review feedback). Case classes and other concrete types are unaffected because `complexTypeFactory` matches first, and the empty-object fallback remains in place for genuinely open abstract types. As a consequence, `Weaver.fromSurfaceOpt` now returns `Some` for `Any`-containing types (e.g. `Map[String, Any]`), so `RouterHandler` routes returning such types use the weaver path (structural JSON) instead of the toString fallback. ## Tests - `fromSurface(Surface.of[Any])` round-trips primitives via `AnyWeaver` (was: decodes to `null`) - Same for `AnyRef` and `java.lang.Object` - The exact issue repro: `Map[String, Any]` field decoding from JSON - The nested variant (`List[ConfigWithAnyMap]`) that exercises the Surface-driven fallback path in the derivation macro, with no per-level givens (was: `Map("useSSL" -> null)`) - `fromSurfaceOpt` returns `Some` for `Any`-containing types All `uniJVM` and `netty` tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b6b72f2 commit 58a7fdf

2 files changed

Lines changed: 55 additions & 0 deletions

File tree

uni/src/main/scala/wvlet/uni/weaver/Weaver.scala

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,17 @@ object Weaver:
387387
CaseClassWeaver(s, fieldWeavers)
388388
}
389389

390+
// Any/AnyRef have a well-defined generic codec: AnyWeaver packs values by their runtime type
391+
// and unpacks to generic values (Long, Double, String, Seq, Map, ...). Resolve them ahead of
392+
// the lossy empty-object fallback so fields like Map[String, Any] round-trip out of the box.
393+
// The rawType check covers the `scala.Any` alias, `AnyRef`/`java.lang.Object`, and the
394+
// AnyRefSurface placeholders Surface uses for erased type parameters; case classes never
395+
// reach here because complexTypeFactory matches first.
396+
private val anyFactory: WeaverFactory = {
397+
case s if s.rawType == classOf[AnyRef] =>
398+
AnyWeaver.default
399+
}
400+
390401
// Fallback for surfaces with no objectFactory (open abstract types). Lossy by design:
391402
// a custom `given Weaver[A]` is required to preserve subtype data on round-trip.
392403
private val emptyObjectWeaver: Weaver[Any] =
@@ -415,6 +426,7 @@ object Weaver:
415426
collectionFactory,
416427
javaCollectionFactory,
417428
complexTypeFactory,
429+
anyFactory,
418430
emptyObjectFallbackFactory
419431
)
420432

uni/src/test/scala/wvlet/uni/weaver/WeaverTest.scala

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,47 @@ class WeaverTest extends UniTest:
636636
Weaver.fromSurfaceOpt(Surface.of[Map[String, WeaverTest.Greeting]]).isDefined shouldBe true
637637
}
638638

639+
// Regression coverage for issue #632: Any-typed values (e.g. Map[String, Any] fields) must
640+
// decode via AnyWeaver instead of the lossy empty-object fallback that silently yields null.
641+
test("fromSurface resolves Any to AnyWeaver") {
642+
import wvlet.uni.surface.Surface
643+
val w = Weaver.fromSurface(Surface.of[Any]).asInstanceOf[Weaver[Any]]
644+
val decoded = w.unweave(w.weave("hello"))
645+
decoded shouldBe "hello"
646+
w.unweave(w.weave(true)) shouldBe true
647+
w.unweave(w.weave(3)) shouldBe 3L
648+
}
649+
650+
test("fromSurface resolves AnyRef and java.lang.Object to AnyWeaver") {
651+
import wvlet.uni.surface.Surface
652+
for surface <- Seq(Surface.of[AnyRef], Surface.of[Object]) do
653+
val w = Weaver.fromSurface(surface).asInstanceOf[Weaver[Any]]
654+
w.unweave(w.weave("hello")) shouldBe "hello"
655+
w.unweave(w.weave(3)) shouldBe 3L
656+
}
657+
658+
test("case class with Map[String, Any] field decodes values from JSON") {
659+
val decoded = Weaver
660+
.of[WeaverTest.ConfigWithAnyMap]
661+
.fromJson("""{"name": "c", "properties": {"useSSL": false, "retries": 3}}""")
662+
decoded.name shouldBe "c"
663+
decoded.properties shouldBe Map("useSSL" -> false, "retries" -> 3L)
664+
}
665+
666+
test("nested case class with Map[String, Any] decodes without per-level givens") {
667+
val decoded = Weaver
668+
.of[WeaverTest.OuterConfig]
669+
.fromJson("""{"configs": [{"name": "c", "properties": {"useSSL": false}}]}""")
670+
decoded shouldBe
671+
WeaverTest.OuterConfig(List(WeaverTest.ConfigWithAnyMap("c", Map("useSSL" -> false))))
672+
}
673+
674+
test("fromSurfaceOpt returns Some for Any-containing types") {
675+
import wvlet.uni.surface.Surface
676+
Weaver.fromSurfaceOpt(Surface.of[Map[String, Any]]).isDefined shouldBe true
677+
Weaver.fromSurfaceOpt(Surface.of[WeaverTest.ConfigWithAnyMap]).isDefined shouldBe true
678+
}
679+
639680
test("fromSurfaceOpt returns None when fromSurface itself throws") {
640681
// java.math.BigInteger is marked primitive in Surface but has no primitiveFactory branch,
641682
// so fromSurface throws IllegalArgumentException. fromSurfaceOpt must convert that to None
@@ -651,3 +692,5 @@ end WeaverTest
651692
object WeaverTest:
652693
case class HasEither(e: Either[String, Int])
653694
case class Greeting(message: String)
695+
case class ConfigWithAnyMap(name: String, properties: Map[String, Any] = Map.empty)
696+
case class OuterConfig(configs: List[ConfigWithAnyMap])

0 commit comments

Comments
 (0)