Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
38cd0f8
Add structural schema support (#517)
987Nabil Jan 25, 2026
14fb16a
Add opaque type unpacking and enum structural conversion tests
987Nabil Jan 25, 2026
7e65b76
Move structural tests to JVM-only and add coverage tests
987Nabil Jan 25, 2026
4440ecf
Restore original test files and add StructuralBindingSpec for coverage
987Nabil Jan 26, 2026
c6e7078
Add coverage tests for As/Into primitives and Json edge cases
987Nabil Jan 26, 2026
a2bb441
Remove unrelated tests from PR #614 per reviewer feedback
987Nabil Jan 26, 2026
1e2f48a
Rewrite structural tests to actually test .structural method
987Nabil Jan 26, 2026
dfb9e9b
Use exact type name matches in structural tests
987Nabil Jan 26, 2026
de4074f
Use reflection-based structural schemas to support anonymous instances
987Nabil Jan 26, 2026
57bee45
Restore test files incorrectly removed during rebase
987Nabil Jan 26, 2026
b709c0e
Move structural enum/sealed trait tests to JVM-only
987Nabil Jan 26, 2026
836fac2
Address JDG review feedback on PR #614
987Nabil Jan 27, 2026
d724f97
Add typeCheck tests with explicit structural types per JDG review
987Nabil Jan 27, 2026
9d14e28
Restore TypeNameNormalizationSpec per user request
987Nabil Jan 27, 2026
31293af
Remove .sisyphus plan file from branch
987Nabil Jan 27, 2026
ac86a0b
refactor(schema): migrate structural tests from typeName to typeCheck
987Nabil Jan 27, 2026
e6c5e70
refactor(schema): use nested structural encoding for sum types
987Nabil Jan 28, 2026
333a641
Merge branch 'main' into structural-schemas-517
jdegoes Jan 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package zio.blocks.schema.structural

import scala.language.reflectiveCalls

import zio.blocks.schema._
import zio.test._

/**
* Tests for Scala 2 pure structural type derivation (JVM only).
*/
object StructuralTypeSpec extends ZIOSpecDefault {

type PersonLike = { def name: String; def age: Int }
type PointLike = { def x: Int; def y: Int }

def spec = suite("StructuralTypeSpec")(
test("structural type round-trips through DynamicValue") {
val schema = Schema.derived[PersonLike]
val dynamic = DynamicValue.Record(
"name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")),
"age" -> DynamicValue.Primitive(PrimitiveValue.Int(30))
)
val result = schema.fromDynamicValue(dynamic)
result match {
case Right(person) =>
val backToDynamic = schema.toDynamicValue(person)
backToDynamic match {
case rec: DynamicValue.Record =>
val expected = dynamic.asInstanceOf[DynamicValue.Record]
assertTrue(
rec.fields.toSet == expected.fields.toSet,
person.name == "Alice",
person.age == 30
)
case _ => assertTrue(false) ?? "Expected DynamicValue.Record"
}
case Left(err) =>
assertTrue(false) ?? s"fromDynamicValue failed: $err"
}
},
test("structural type with primitives round-trips") {
val schema = Schema.derived[PointLike]
val dynamic = DynamicValue.Record(
"x" -> DynamicValue.Primitive(PrimitiveValue.Int(100)),
"y" -> DynamicValue.Primitive(PrimitiveValue.Int(200))
)
val result = schema.fromDynamicValue(dynamic)
result match {
case Right(point) =>
val backToDynamic = schema.toDynamicValue(point)
backToDynamic match {
case rec: DynamicValue.Record =>
val expected = dynamic.asInstanceOf[DynamicValue.Record]
assertTrue(
rec.fields.toSet == expected.fields.toSet,
point.x == 100,
point.y == 200
)
case _ => assertTrue(false) ?? "Expected DynamicValue.Record"
}
case Left(err) =>
assertTrue(false) ?? s"fromDynamicValue failed: $err"
}
}
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package zio.blocks.schema.structural

import zio.test._

/**
* Tests that sum types (sealed traits) produce compile-time errors in Scala 2.
*
* Sum types cannot be converted to structural types in Scala 2 because they
* require union types, which are only available in Scala 3.
*/
object SumTypeErrorSpec extends ZIOSpecDefault {

def spec = suite("SumTypeErrorSpec")(
suite("Sealed Trait Structural Conversion")(
test("sealed trait with case classes fails to convert to structural") {
typeCheck {
"""
import zio.blocks.schema._
sealed trait Result
case class Success(value: Int) extends Result
case class Failure(error: String) extends Result
val schema = Schema.derived[Result]
schema.structural
"""
}.map { result =>
assertTrue(
result.isLeft,
result.left.exists(msg => msg.toLowerCase.contains("sum type") || msg.toLowerCase.contains("structural"))
)
}
},
test("sealed trait with case objects fails to convert to structural") {
typeCheck {
"""
import zio.blocks.schema._
sealed trait Status
case object Active extends Status
case object Inactive extends Status
val schema = Schema.derived[Status]
schema.structural
"""
}.map { result =>
assertTrue(result.isLeft)
}
},
test("nested sealed trait fails to convert to structural") {
typeCheck {
"""
import zio.blocks.schema._
sealed trait Outer
case class Inner(value: Int) extends Outer
sealed trait NestedSum extends Outer
case object A extends NestedSum
case object B extends NestedSum
val schema = Schema.derived[Outer]
schema.structural
"""
}.map { result =>
assertTrue(result.isLeft)
}
}
),
suite("Error Message Quality")(
test("error message mentions sum types or union types") {
typeCheck {
"""
import zio.blocks.schema._
sealed trait MySum
case class CaseA(x: Int) extends MySum
case class CaseB(y: String) extends MySum
val schema = Schema.derived[MySum]
schema.structural
"""
}.map { result =>
// The error should mention that sum types require Scala 3 union types
assertTrue(
result.isLeft,
result.left.exists { msg =>
msg.contains("sum") ||
msg.contains("Sum") ||
msg.contains("union") ||
msg.contains("sealed") ||
msg.contains("Scala 3")
}
)
}
}
),
suite("Product Types Still Work")(
test("case class converts to structural successfully") {
typeCheck {
"""
import zio.blocks.schema._
case class Person(name: String, age: Int)
val schema = Schema.derived[Person]
val structural = schema.structural
structural != null
"""
}.map { result =>
assertTrue(result.isRight)
}
},
test("tuple converts to structural successfully") {
typeCheck {
"""
import zio.blocks.schema._
val schema = Schema.derived[(String, Int)]
val structural = schema.structural
structural != null
"""
}.map { result =>
assertTrue(result.isRight)
}
}
)
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package zio.blocks.schema.structural

import zio.blocks.schema._
import zio.test._

/**
* Tests for Scala 3 enum structural conversion (JVM only, requires
* ToStructural).
*/
object EnumStructuralSpec extends ZIOSpecDefault {

enum Color {
case Red, Green, Blue
}

enum Status {
case Active, Inactive, Suspended
}

enum Shape {
case Circle(radius: Double)
case Rectangle(width: Double, height: Double)
case Triangle(base: Double, height: Double)
}

def spec = suite("EnumStructuralSpec")(
suite("Structural Conversion")(
test("simple enum converts to structural union type") {
typeCheck("""
import zio.blocks.schema._
enum Color { case Red, Green, Blue }
val schema = Schema.derived[Color]
val structural: Schema[{def Blue: {}} | {def Green: {}} | {def Red: {}}] = schema.structural
""").map(result => assertTrue(result.isRight))
},
test("parameterized enum converts to structural union type") {
typeCheck("""
import zio.blocks.schema._
enum Shape {
case Circle(radius: Double)
case Rectangle(width: Double, height: Double)
case Triangle(base: Double, height: Double)
}
val schema = Schema.derived[Shape]
val structural: Schema[
{def Circle: {def radius: Double}} |
{def Rectangle: {def height: Double; def width: Double}} |
{def Triangle: {def base: Double; def height: Double}}
] = schema.structural
""").map(result => assertTrue(result.isRight))
},
test("structural enum schema is still a Variant") {
val schema = Schema.derived[Status]
val structural = schema.structural
val isVariant = (structural.reflect: @unchecked) match {
case _: Reflect.Variant[_, _] => true
}
assertTrue(isVariant)
},
test("structural enum preserves case count") {
val schema = Schema.derived[Status]
val structural = schema.structural
val caseCount = (structural.reflect: @unchecked) match {
case v: Reflect.Variant[_, _] => v.cases.size
}
assertTrue(caseCount == 3)
}
)
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package zio.blocks.schema.structural

import zio.blocks.schema._
import zio.test._

/**
* Tests for Scala 3 enum to structural union type conversion.
*
* Per issue #517: Enums convert to union types with Tag discriminators.
* Example: enum Color { Red, Green, Blue } → Schema[{type Tag = "Red"} | {type
* Tag = "Green"} | {type Tag = "Blue"}]
*/
object EnumToUnionSpec extends ZIOSpecDefault {

enum Color {
case Red, Green, Blue
}

enum Status {
case Active, Inactive, Suspended
}

enum Shape {
case Circle(radius: Double)
case Rectangle(width: Double, height: Double)
case Triangle(base: Double, height: Double)
}

def spec = suite("EnumToUnionSpec")(
test("simple enum converts to structural union type") {
typeCheck("""
import zio.blocks.schema._
enum Color { case Red, Green, Blue }
val schema = Schema.derived[Color]
val structural: Schema[{def Blue: {}} | {def Green: {}} | {def Red: {}}] = schema.structural
""").map(result => assertTrue(result.isRight))
},
test("parameterized enum converts to structural union type with fields") {
typeCheck("""
import zio.blocks.schema._
enum Shape {
case Circle(radius: Double)
case Rectangle(width: Double, height: Double)
case Triangle(base: Double, height: Double)
}
val schema = Schema.derived[Shape]
val structural: Schema[{def Circle: {def radius: Double}} | {def Rectangle: {def height: Double; def width: Double}} | {def Triangle: {def base: Double; def height: Double}}] = schema.structural
""").map(result => assertTrue(result.isRight))
},
test("structural enum schema is a Variant") {
val schema = Schema.derived[Color]
val structural = schema.structural
val isVariant = (structural.reflect: @unchecked) match {
case _: Reflect.Variant[_, _] => true
case _ => false
}
assertTrue(isVariant)
},
test("structural enum preserves case count") {
val schema = Schema.derived[Status]
val structural = schema.structural
val caseCount = (structural.reflect: @unchecked) match {
case v: Reflect.Variant[_, _] => v.cases.size
case _ => -1
}
assertTrue(caseCount == 3)
},
test("structural enum encodes anonymous instance via DynamicValue") {
val schema = Schema.derived[Color]
val structural = schema.structural

val redInstance: { def Red: {} } = new { def Red: {} = new {} }
val dynamic = structural.toDynamicValue(redInstance)

assertTrue(dynamic match {
case DynamicValue.Variant("Red", _) => true
case _ => false
})
},
test("parameterized enum structural encodes anonymous instance with fields") {
val schema = Schema.derived[Shape]
val structural = schema.structural

val circleInstance: { def Circle: { def radius: Double } } = new {
def Circle: { def radius: Double } = new { def radius: Double = 5.0 }
}

val dynamic = structural.toDynamicValue(circleInstance)

assertTrue(dynamic match {
case DynamicValue.Variant("Circle", DynamicValue.Record(fields)) =>
fields.toMap.get("radius").contains(DynamicValue.Primitive(PrimitiveValue.Double(5.0)))
case _ => false
})
}
)
}
Loading