diff --git a/schema/jvm/src/test/scala-2/zio/blocks/schema/structural/StructuralTypeSpec.scala b/schema/jvm/src/test/scala-2/zio/blocks/schema/structural/StructuralTypeSpec.scala new file mode 100644 index 000000000..8b48ba632 --- /dev/null +++ b/schema/jvm/src/test/scala-2/zio/blocks/schema/structural/StructuralTypeSpec.scala @@ -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" + } + } + ) +} diff --git a/schema/jvm/src/test/scala-2/zio/blocks/schema/structural/SumTypeErrorSpec.scala b/schema/jvm/src/test/scala-2/zio/blocks/schema/structural/SumTypeErrorSpec.scala new file mode 100644 index 000000000..c450cc7c3 --- /dev/null +++ b/schema/jvm/src/test/scala-2/zio/blocks/schema/structural/SumTypeErrorSpec.scala @@ -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) + } + } + ) + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/EnumStructuralSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/EnumStructuralSpec.scala new file mode 100644 index 000000000..c414120ac --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/EnumStructuralSpec.scala @@ -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) + } + ) + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/EnumToUnionSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/EnumToUnionSpec.scala new file mode 100644 index 000000000..1d7c385ec --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/EnumToUnionSpec.scala @@ -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 + }) + } + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/OpaqueTypeUnpackingSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/OpaqueTypeUnpackingSpec.scala new file mode 100644 index 000000000..6d0ecaf1d --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/OpaqueTypeUnpackingSpec.scala @@ -0,0 +1,134 @@ +package zio.blocks.schema.structural + +import zio.blocks.schema._ +import zio.test._ + +object OpaqueTypeUnpackingSpec extends ZIOSpecDefault { + + opaque type UserId <: String = String + object UserId { + def apply(value: String): UserId = value + extension (id: UserId) def value: String = id + } + + opaque type Age <: Int = Int + object Age { + def apply(value: Int): Age = value + extension (age: Age) def value: Int = age + } + + opaque type Score <: Double = Double + object Score { + def apply(value: Double): Score = value + extension (score: Score) def value: Double = score + } + + case class User(id: UserId, name: String) + case class Person(name: String, age: Age) + case class GameResult(player: String, score: Score) + case class NestedOpaque(user: User, score: Score) + case class ListOfOpaque(ids: List[UserId]) + case class OptionalOpaque(maybeId: Option[UserId]) + + def spec = suite("OpaqueTypeUnpackingSpec")( + suite("Simple opaque types are unpacked to primitives")( + test("UserId (opaque String) is unpacked to String in structural type") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.OpaqueTypeUnpackingSpec._ + val schema = Schema.derived[User] + val structural: Schema[{def id: String; def name: String}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("Age (opaque Int) is unpacked to Int in structural type") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.OpaqueTypeUnpackingSpec.{Person => PersonType, _} + val schema = Schema.derived[PersonType] + val structural: Schema[{def age: Int; def name: String}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("Score (opaque Double) is unpacked to Double in structural type") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.OpaqueTypeUnpackingSpec._ + val schema = Schema.derived[GameResult] + val structural: Schema[{def player: String; def score: Double}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ), + suite("Nested opaque types are unpacked recursively")( + test("nested case class with opaque fields is fully unpacked") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.OpaqueTypeUnpackingSpec._ + val schema = Schema.derived[NestedOpaque] + val structural: Schema[{def score: Double; def user: User}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ), + suite("Opaque types in collections are unpacked")( + test("List[UserId] field appears in structural type") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.OpaqueTypeUnpackingSpec._ + val schema = Schema.derived[ListOfOpaque] + val structural: Schema[{def ids: List[String]}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("Option[UserId] field appears in structural type") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.OpaqueTypeUnpackingSpec._ + val schema = Schema.derived[OptionalOpaque] + val structural: Schema[{def maybeId: Option[String]}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ), + suite("Structural schema round-trip with opaque types")( + test("User with opaque UserId round-trips through DynamicValue") { + val schema = Schema.derived[User] + val original = User(UserId("user-123"), "Alice") + + val dynamic = schema.toDynamicValue(original) + val roundTrip = schema.fromDynamicValue(dynamic) + + assertTrue(roundTrip == Right(original)) + }, + test("structural schema of User has correct type") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.OpaqueTypeUnpackingSpec._ + val schema = Schema.derived[User] + val structural: Schema[{def id: String; def name: String}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ), + suite("Type checking with explicit structural types")( + test("User unpacks UserId to String in structural type") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.OpaqueTypeUnpackingSpec._ + val schema = Schema.derived[User] + val structural: Schema[{def id: String; def name: String}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("Person unpacks Age to Int in structural type") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.OpaqueTypeUnpackingSpec.{Person => PersonType, _} + val schema = Schema.derived[PersonType] + val structural: Schema[{def age: Int; def name: String}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("GameResult unpacks Score to Double in structural type") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.OpaqueTypeUnpackingSpec._ + val schema = Schema.derived[GameResult] + val structural: Schema[{def player: String; def score: Double}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ) + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/PureStructuralTypeSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/PureStructuralTypeSpec.scala new file mode 100644 index 000000000..c3c1e5d29 --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/PureStructuralTypeSpec.scala @@ -0,0 +1,68 @@ +package zio.blocks.schema.structural + +import zio.blocks.schema._ +import zio.test._ + +/** Tests for pure structural type Schema derivation (JVM only). */ +object PureStructuralTypeSpec extends ZIOSpecDefault { + + type PersonLike = { def name: String; def age: Int } + + def spec: Spec[Any, Nothing] = suite("PureStructuralTypeSpec")( + test("pure structural type derives schema") { + val schema = Schema.derived[PersonLike] + assertTrue(schema != null) + }, + test("pure structural type schema has correct field names") { + val schema = Schema.derived[PersonLike] + val fieldNames = schema.reflect match { + case record: Reflect.Record[_, _] => record.fields.map(_.name).toSet + case _ => Set.empty[String] + } + assertTrue(fieldNames == Set("name", "age")) + }, + test("pure structural type converts to DynamicValue and back") { + val schema = Schema.derived[PersonLike] + + val person: PersonLike = new { + @scala.annotation.nowarn + def name: String = "Alice" + @scala.annotation.nowarn + def age: Int = 30 + } + + val dynamic = schema.toDynamicValue(person) + + assertTrue( + dynamic match { + case DynamicValue.Record(fields) => + val fieldMap = fields.toMap + fieldMap.get("name").contains(DynamicValue.Primitive(PrimitiveValue.String("Alice"))) && + fieldMap.get("age").contains(DynamicValue.Primitive(PrimitiveValue.Int(30))) + case _ => false + }, + schema.fromDynamicValue(dynamic).isRight + ) + }, + test("pure structural type encodes to correct DynamicValue structure") { + val schema = Schema.derived[PersonLike] + + val person: PersonLike = new { + @scala.annotation.nowarn + def name: String = "Bob" + @scala.annotation.nowarn + def age: Int = 25 + } + + val dynamic = schema.toDynamicValue(person) + + assertTrue(dynamic match { + case DynamicValue.Record(fields) => + val fieldMap = fields.toMap + fieldMap.get("name").contains(DynamicValue.Primitive(PrimitiveValue.String("Bob"))) && + fieldMap.get("age").contains(DynamicValue.Primitive(PrimitiveValue.Int(25))) + case _ => false + }) + } + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/SealedTraitToUnionSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/SealedTraitToUnionSpec.scala new file mode 100644 index 000000000..244c4e237 --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/SealedTraitToUnionSpec.scala @@ -0,0 +1,163 @@ +package zio.blocks.schema.structural + +import zio.blocks.schema._ +import zio.test._ + +/** + * Tests for sealed trait to structural union type conversion (Scala 3 only). + * + * Per issue #517: Sealed traits convert to union types with Tag discriminators. + * Example: sealed trait Result { Success, Failure } → Schema[{ type Tag = + * "Success"; def value: Int } | { type Tag = "Failure"; def error: String }] + */ +object SealedTraitToUnionSpec extends ZIOSpecDefault { + + sealed trait Result + object Result { + case class Success(value: Int) extends Result + case class Failure(error: String) extends Result + } + + sealed trait Status + object Status { + case object Active extends Status + case object Inactive extends Status + } + + sealed trait Animal + object Animal { + case class Dog(name: String, breed: String) extends Animal + case class Cat(name: String, indoor: Boolean) extends Animal + case class Bird(name: String, canFly: Boolean) extends Animal + } + + def spec = suite("SealedTraitToUnionSpec")( + test("sealed trait structural has exact union type name") { + typeCheck(""" + import zio.blocks.schema._ + sealed trait Result + object Result { + case class Success(value: Int) extends Result + case class Failure(error: String) extends Result + } + val schema = Schema.derived[Result] + val structural: Schema[{def Failure: {def error: String}} | {def Success: {def value: Int}}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("structural sealed trait schema is a Variant") { + val schema = Schema.derived[Result] + val structural = schema.structural + val isVariant = (structural.reflect: @unchecked) match { + case _: Reflect.Variant[_, _] => true + case _ => false + } + assertTrue(isVariant) + }, + test("structural sealed trait preserves case count") { + val schema = Schema.derived[Result] + val structural = schema.structural + val caseCount = (structural.reflect: @unchecked) match { + case v: Reflect.Variant[_, _] => v.cases.size + case _ => -1 + } + assertTrue(caseCount == 2) + }, + test("sealed trait with case objects has exact structural type name") { + typeCheck(""" + import zio.blocks.schema._ + sealed trait Status + object Status { + case object Active extends Status + case object Inactive extends Status + } + val schema = Schema.derived[Status] + val structural: Schema[{def Active: {}} | {def Inactive: {}}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("three variant sealed trait structural has all cases") { + val schema = Schema.derived[Animal] + val structural = schema.structural + val caseNames = (structural.reflect: @unchecked) match { + case v: Reflect.Variant[_, _] => v.cases.map(_.name).toSet + case _ => Set.empty[String] + } + assertTrue( + caseNames.size == 3, + caseNames.contains("Dog"), + caseNames.contains("Cat"), + caseNames.contains("Bird") + ) + }, + test("structural sealed trait encodes anonymous instance via toDynamicValue") { + val schema = Schema.derived[Result] + val structural = schema.structural + + val successInstance: { def Success: { def value: Int } } = new { + def Success: { def value: Int } = new { def value: Int = 42 } + } + val dynamic = structural.toDynamicValue(successInstance) + + assertTrue(dynamic match { + case DynamicValue.Variant("Success", DynamicValue.Record(fields)) => + fields.toMap.get("value").contains(DynamicValue.Primitive(PrimitiveValue.Int(42))) + case _ => false + }) + }, + test("structural sealed trait decodes from DynamicValue") { + val schema = Schema.derived[Result] + val structural = schema.structural + + val dynamic = DynamicValue.Variant( + "Success", + DynamicValue.Record("value" -> DynamicValue.Primitive(PrimitiveValue.Int(100))) + ) + + val result = structural.fromDynamicValue(dynamic) + assertTrue(result.isRight) + }, + test("structural animal variants preserve field information") { + val schema = Schema.derived[Animal] + val structural = schema.structural + + val dogFields = (structural.reflect: @unchecked) match { + case v: Reflect.Variant[_, _] => + v.cases.find(_.name == "Dog").flatMap { c => + (c.value: @unchecked) match { + case r: Reflect.Record[_, _] => Some(r.fields.map(_.name).toSet) + case _ => None + } + } + case _ => None + } + assertTrue( + dogFields.isDefined, + dogFields.get.contains("name"), + dogFields.get.contains("breed") + ) + }, + test("sealed trait converts to expected structural union type") { + typeCheck(""" + import zio.blocks.schema._ + sealed trait Result + object Result { + case class Success(value: Int) extends Result + case class Failure(error: String) extends Result + } + val schema = Schema.derived[Result] + val structural: Schema[{def Failure: {def error: String}} | {def Success: {def value: Int}}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("sealed trait with case objects converts to expected structural union type") { + typeCheck(""" + import zio.blocks.schema._ + sealed trait Status + object Status { + case object Active extends Status + case object Inactive extends Status + } + val schema = Schema.derived[Status] + val structural: Schema[{def Active: {}} | {def Inactive: {}}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/StructuralBindingSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/StructuralBindingSpec.scala new file mode 100644 index 000000000..1e08a991b --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/StructuralBindingSpec.scala @@ -0,0 +1,200 @@ +package zio.blocks.schema.structural + +import zio.blocks.schema._ +import zio.test._ + +object StructuralBindingSpec extends ZIOSpecDefault { + + case class Point(x: Int, y: Int) + case class Person(name: String, age: Int) + case class Triple(a: String, b: Int, c: Boolean) + + sealed trait Color + case object Red extends Color + case object Green extends Color + case object Blue extends Color + + sealed trait Shape + case class Circle(radius: Double) extends Shape + case class Rectangle(width: Double, h: Double) extends Shape + + def spec = suite("StructuralBindingSpec")( + suite("Product structural schema encoding")( + test("structural schema encodes via toDynamicValue") { + val schema = Schema.derived[Point] + val structural = schema.structural + val point = Point(10, 20) + + val structuralAny = structural.asInstanceOf[Schema[Any]] + val dynamic = structuralAny.toDynamicValue(point) + + assertTrue(dynamic match { + case DynamicValue.Record(fields) => + val map = fields.toMap + map.get("x").contains(DynamicValue.Primitive(PrimitiveValue.Int(10))) && + map.get("y").contains(DynamicValue.Primitive(PrimitiveValue.Int(20))) + case _ => false + }) + }, + test("structural schema decodes via fromDynamicValue") { + val schema = Schema.derived[Person] + val structural = schema.structural + + val dynamic = DynamicValue.Record( + "name" -> DynamicValue.Primitive(PrimitiveValue.String("Alice")), + "age" -> DynamicValue.Primitive(PrimitiveValue.Int(30)) + ) + + val structuralAny = structural.asInstanceOf[Schema[Any]] + val result = structuralAny.fromDynamicValue(dynamic) + + assertTrue(result match { + case Right(value) => + val p = value.asInstanceOf[Person] + p.name == "Alice" && p.age == 30 + case _ => false + }) + }, + test("structural schema round-trips through DynamicValue") { + val schema = Schema.derived[Triple] + val structural = schema.structural + val triple = Triple("test", 42, true) + + val structuralAny = structural.asInstanceOf[Schema[Any]] + val dynamic = structuralAny.toDynamicValue(triple) + val result = structuralAny.fromDynamicValue(dynamic) + + assertTrue(result match { + case Right(value) => + val t = value.asInstanceOf[Triple] + t == triple + case _ => false + }) + } + ), + suite("Sum type structural schema encoding")( + test("structural variant encodes case object") { + val schema = Schema.derived[Color] + val structural = schema.structural + val color: Color = Red + + val structuralAny = structural.asInstanceOf[Schema[Any]] + val dynamic = structuralAny.toDynamicValue(color) + + assertTrue(dynamic match { + case DynamicValue.Variant("Red", _) => true + case _ => false + }) + }, + test("structural variant encodes case class") { + val schema = Schema.derived[Shape] + val structural = schema.structural + val shape: Shape = Circle(5.0) + + val structuralAny = structural.asInstanceOf[Schema[Any]] + val dynamic = structuralAny.toDynamicValue(shape) + + assertTrue(dynamic match { + case DynamicValue.Variant("Circle", DynamicValue.Record(fields)) => + fields.toMap.get("radius").contains(DynamicValue.Primitive(PrimitiveValue.Double(5.0))) + case _ => false + }) + }, + test("structural variant decodes case class") { + val schema = Schema.derived[Shape] + val structural = schema.structural + + val dynamic = DynamicValue.Variant( + "Rectangle", + DynamicValue.Record( + "width" -> DynamicValue.Primitive(PrimitiveValue.Double(10.0)), + "h" -> DynamicValue.Primitive(PrimitiveValue.Double(20.0)) + ) + ) + + val structuralAny = structural.asInstanceOf[Schema[Any]] + val result = structuralAny.fromDynamicValue(dynamic) + + assertTrue(result match { + case Right(value) => + val s = value.asInstanceOf[Shape] + s == Rectangle(10.0, 20.0) + case _ => false + }) + }, + test("structural variant round-trips all cases") { + val schema = Schema.derived[Color] + val structural = schema.structural + + val structuralAny = structural.asInstanceOf[Schema[Any]] + + def roundTrip(c: Color): Boolean = { + val dynamic = structuralAny.toDynamicValue(c) + structuralAny.fromDynamicValue(dynamic) match { + case Right(value) => value.asInstanceOf[Color] == c + case _ => false + } + } + + assertTrue( + roundTrip(Red), + roundTrip(Green), + roundTrip(Blue) + ) + } + ), + suite("Tuple structural schema encoding")( + test("tuple structural schema encodes") { + val schema = Schema.derived[(String, Int)] + val structural = schema.structural + val tuple = ("hello", 42) + + val structuralAny = structural.asInstanceOf[Schema[Any]] + val dynamic = structuralAny.toDynamicValue(tuple) + + assertTrue(dynamic match { + case DynamicValue.Record(fields) => + val map = fields.toMap + map.get("_1").contains(DynamicValue.Primitive(PrimitiveValue.String("hello"))) && + map.get("_2").contains(DynamicValue.Primitive(PrimitiveValue.Int(42))) + case _ => false + }) + }, + test("tuple structural schema decodes") { + val schema = Schema.derived[(String, Int)] + val structural = schema.structural + + val dynamic = DynamicValue.Record( + "_1" -> DynamicValue.Primitive(PrimitiveValue.String("world")), + "_2" -> DynamicValue.Primitive(PrimitiveValue.Int(100)) + ) + + val structuralAny = structural.asInstanceOf[Schema[Any]] + val result = structuralAny.fromDynamicValue(dynamic) + + assertTrue(result match { + case Right(value) => + val t = value.asInstanceOf[(String, Int)] + t == ("world", 100) + case _ => false + }) + }, + test("tuple3 structural schema round-trips") { + val schema = Schema.derived[(Int, String, Boolean)] + val structural = schema.structural + val tuple = (1, "two", true) + + val structuralAny = structural.asInstanceOf[Schema[Any]] + val dynamic = structuralAny.toDynamicValue(tuple) + val result = structuralAny.fromDynamicValue(dynamic) + + assertTrue(result match { + case Right(value) => + val t = value.asInstanceOf[(Int, String, Boolean)] + t == tuple + case _ => false + }) + } + ) + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/StructuralRoundTripSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/StructuralRoundTripSpec.scala new file mode 100644 index 000000000..dd11b1a5b --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/StructuralRoundTripSpec.scala @@ -0,0 +1,177 @@ +package zio.blocks.schema.structural + +import zio.blocks.schema._ +import zio.test._ + +/** + * Round-trip tests for structural schemas through DynamicValue (JVM only). + * Tests both product and sum type structural conversions. + */ +object StructuralRoundTripSpec extends ZIOSpecDefault { + + case class Simple(x: Int, y: String) + case class Nested(inner: Simple, flag: Boolean) + case class WithOption(name: String, value: Option[Int]) + case class WithList(name: String, items: List[String]) + case class WithMap(name: String, data: Map[String, Int]) + + sealed trait Animal + case class Dog(name: String, breed: String) extends Animal + case class Cat(name: String, indoor: Boolean) extends Animal + case object Fish extends Animal + + def spec = suite("StructuralRoundTripSpec")( + suite("Product type round-trips")( + test("simple case class round-trip") { + val schema = Schema.derived[Simple] + val original = Simple(42, "test") + + val dynamic = schema.toDynamicValue(original) + val roundTrip = schema.fromDynamicValue(dynamic) + + assertTrue(roundTrip == Right(original)) + }, + test("nested case class round-trip") { + val schema = Schema.derived[Nested] + val original = Nested(Simple(1, "inner"), true) + + val dynamic = schema.toDynamicValue(original) + val roundTrip = schema.fromDynamicValue(dynamic) + + assertTrue(roundTrip == Right(original)) + }, + test("case class with Option round-trip") { + val schema = Schema.derived[WithOption] + val original1 = WithOption("test", Some(42)) + val original2 = WithOption("test", None) + + val roundTrip1 = schema.fromDynamicValue(schema.toDynamicValue(original1)) + val roundTrip2 = schema.fromDynamicValue(schema.toDynamicValue(original2)) + + assertTrue( + roundTrip1 == Right(original1), + roundTrip2 == Right(original2) + ) + }, + test("case class with List round-trip") { + val schema = Schema.derived[WithList] + val original = WithList("items", List("a", "b", "c")) + + val dynamic = schema.toDynamicValue(original) + val roundTrip = schema.fromDynamicValue(dynamic) + + assertTrue(roundTrip == Right(original)) + }, + test("case class with Map round-trip") { + val schema = Schema.derived[WithMap] + val original = WithMap("data", Map("a" -> 1, "b" -> 2)) + + val dynamic = schema.toDynamicValue(original) + val roundTrip = schema.fromDynamicValue(dynamic) + + assertTrue(roundTrip == Right(original)) + } + ), + suite("Sum type round-trips")( + test("sealed trait case class round-trip") { + val schema = Schema.derived[Animal] + val dog: Animal = Dog("Rex", "German Shepherd") + + val dynamic = schema.toDynamicValue(dog) + val roundTrip = schema.fromDynamicValue(dynamic) + + assertTrue(roundTrip == Right(dog)) + }, + test("sealed trait case object round-trip") { + val schema = Schema.derived[Animal] + val fish: Animal = Fish + + val dynamic = schema.toDynamicValue(fish) + val roundTrip = schema.fromDynamicValue(dynamic) + + assertTrue(roundTrip == Right(fish)) + }, + test("all sealed trait cases round-trip correctly") { + val schema = Schema.derived[Animal] + + val dog: Animal = Dog("Buddy", "Labrador") + val cat: Animal = Cat("Whiskers", true) + val fish: Animal = Fish + + val dogResult = schema.fromDynamicValue(schema.toDynamicValue(dog)) + val catResult = schema.fromDynamicValue(schema.toDynamicValue(cat)) + val fishResult = schema.fromDynamicValue(schema.toDynamicValue(fish)) + + assertTrue( + dogResult == Right(dog), + catResult == Right(cat), + fishResult == Right(fish) + ) + } + ), + suite("Type-level structural conversion")( + test("Simple product converts to structural type") { + typeCheck(""" + import zio.blocks.schema._ + case class Simple(x: Int, y: String) + val schema = Schema.derived[Simple] + val structural: Schema[{def x: Int; def y: String}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("Animal sealed trait converts to union structural type") { + typeCheck(""" + import zio.blocks.schema._ + sealed trait Animal + case class Dog(name: String, breed: String) extends Animal + case class Cat(name: String, indoor: Boolean) extends Animal + case object Fish extends Animal + val schema = Schema.derived[Animal] + val structural: Schema[{def Cat: {def indoor: Boolean; def name: String}} | {def Dog: {def breed: String; def name: String}} | {def Fish: {}}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("Nested product converts to structural type") { + typeCheck(""" + import zio.blocks.schema._ + case class Simple(x: Int, y: String) + case class Nested(inner: Simple, flag: Boolean) + val schema = Schema.derived[Nested] + val structural: Schema[{def flag: Boolean; def inner: Simple}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ), + suite("Structural schema preserves reflect structure")( + test("product structural schema is still a Record") { + val schema = Schema.derived[Simple] + val structural = schema.structural + val isRecord = (structural.reflect: @unchecked) match { + case _: Reflect.Record[_, _] => true + } + assertTrue(isRecord) + }, + test("sum type structural schema is still a Variant") { + val schema = Schema.derived[Animal] + val structural = schema.structural + val isVariant = (structural.reflect: @unchecked) match { + case _: Reflect.Variant[_, _] => true + } + assertTrue(isVariant) + }, + test("product structural schema preserves field count") { + val schema = Schema.derived[Nested] + val structural = schema.structural + val fieldCount = (structural.reflect: @unchecked) match { + case r: Reflect.Record[_, _] => r.fields.size + } + assertTrue(fieldCount == 2) + }, + test("sum type structural schema preserves case count") { + val schema = Schema.derived[Animal] + val structural = schema.structural + val caseCount = (structural.reflect: @unchecked) match { + case v: Reflect.Variant[_, _] => v.cases.size + } + assertTrue(caseCount == 3) + } + ) + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/SupportedTypesSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/SupportedTypesSpec.scala new file mode 100644 index 000000000..a2f93c6d1 --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/SupportedTypesSpec.scala @@ -0,0 +1,327 @@ +package zio.blocks.schema.structural + +import zio.test._ + +/** + * Tests for supported structural type conversions. + * + * ==Supported Types== + * - Case classes (product types) + * - Case objects + * - Tuples + * - Case classes with nested products + * - Case classes with collections (List, Set, Map, Vector, Option) + * - Large products (> 22 fields) + * + * ==Sum Types (Scala 2 vs Scala 3)== + * Sum type error tests are in scala-2 specific: SumTypeErrorSpec.scala In Scala + * 3, sealed traits/enums are supported via union types. + */ +object SupportedTypesSpec extends ZIOSpecDefault { + + def spec = suite("SupportedTypesSpec")( + suite("Supported Product Types")( + test("simple case class converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Simple(x: Int, y: String) + + val schema = Schema.derived[Simple] + val structural: Schema[{def x: Int; def y: String}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("case class with all primitives converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Primitives( + a: Int, b: Long, c: Float, d: Double, + e: Boolean, f: Byte, g: Short, h: Char, i: String + ) + + val schema = Schema.derived[Primitives] + val structural: Schema[{def a: Int; def b: Long; def c: Float; def d: Double; def e: Boolean; def f: Byte; def g: Short; def h: Char; def i: String}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("case class with Option converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class WithOption(required: String, optional: Option[Int]) + + val schema = Schema.derived[WithOption] + val structural: Schema[{def optional: Option[Int]; def required: String}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("case class with List converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class WithList(items: List[Int]) + + val schema = Schema.derived[WithList] + val structural: Schema[{def items: List[Int]}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("case class with Set converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class WithSet(items: Set[String]) + + val schema = Schema.derived[WithSet] + val structural: Schema[{def items: Set[String]}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("case class with Vector converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class WithVector(items: Vector[Double]) + + val schema = Schema.derived[WithVector] + val structural: Schema[{def items: Vector[Double]}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("case class with Map converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class WithMap(mapping: Map[String, Int]) + + val schema = Schema.derived[WithMap] + val structural: Schema[{def mapping: Map[String, Int]}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("nested case class converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Inner(value: Int) + case class Outer(name: String, inner: Inner) + + val schema = Schema.derived[Outer] + val structural: Schema[{def inner: Inner; def name: String}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("deeply nested case class converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Level3(value: Int) + case class Level2(level3: Level3) + case class Level1(level2: Level2) + case class Root(level1: Level1) + + val schema = Schema.derived[Root] + val structural: Schema[{def level1: Level1}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("empty case class converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Empty() + + val schema = Schema.derived[Empty] + val structural: Schema[{}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("case object converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case object Singleton + + val schema = Schema.derived[Singleton.type] + val structural: Schema[{}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + } + ), + suite("Large Products (More Than 22 Fields)")( + test("25 field case class converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Large25( + f1: Int, f2: Int, f3: Int, f4: Int, f5: Int, + f6: Int, f7: Int, f8: Int, f9: Int, f10: Int, + f11: Int, f12: Int, f13: Int, f14: Int, f15: Int, + f16: Int, f17: Int, f18: Int, f19: Int, f20: Int, + f21: Int, f22: Int, f23: Int, f24: Int, f25: Int + ) + + val schema = Schema.derived[Large25] + schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("30 field case class with mixed types converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Large30( + f1: Int, f2: String, f3: Long, f4: Double, f5: Boolean, + f6: Int, f7: String, f8: Long, f9: Double, f10: Boolean, + f11: Int, f12: String, f13: Long, f14: Double, f15: Boolean, + f16: Int, f17: String, f18: Long, f19: Double, f20: Boolean, + f21: Int, f22: String, f23: Long, f24: Double, f25: Boolean, + f26: Int, f27: String, f28: Long, f29: Double, f30: Boolean + ) + + val schema = Schema.derived[Large30] + schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + } + ), + suite("Tuple Types")( + test("tuple2 converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + val schema = Schema.derived[(Int, String)] + val structural: Schema[{def _1: Int; def _2: String}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("tuple3 converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + val schema = Schema.derived[(Int, String, Boolean)] + val structural: Schema[{def _1: Int; def _2: String; def _3: Boolean}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("nested tuple converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + val schema = Schema.derived[((Int, String), (Boolean, Double))] + val structural: Schema[{def _1: (Int, String); def _2: (Boolean, Double)}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("tuple with case class converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Point(x: Int, y: Int) + val schema = Schema.derived[(Point, String)] + val structural: Schema[{def _1: Point; def _2: String}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + } + ), +// Sum Types (Error Handling) tests are in Scala 2 specific: SumTypeErrorSpec.scala + // Scala 3 supports sum types via union types, so those tests are in the Scala 3 specs + suite("Either and Complex Nested Types")( + test("case class with Either field converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class WithEither(result: Either[String, Int]) + + val schema = Schema.derived[WithEither] + val structural: Schema[{def result: Either[String, Int]}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("case class with nested Option[List[T]] converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Complex(items: Option[List[Int]]) + + val schema = Schema.derived[Complex] + val structural: Schema[{def items: Option[List[Int]]}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("case class with Map[String, List[T]] converts to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class ComplexMap(data: Map[String, List[Int]]) + + val schema = Schema.derived[ComplexMap] + val structural: Schema[{def data: Map[String, List[Int]]}] = schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + } + ) + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/TupleStructuralSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/TupleStructuralSpec.scala new file mode 100644 index 000000000..42a0d909b --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/TupleStructuralSpec.scala @@ -0,0 +1,58 @@ +package zio.blocks.schema.structural + +import zio.blocks.schema._ +import zio.test._ + +/** + * Tests for tuple to structural type conversion (JVM only). + */ +object TupleStructuralSpec extends ZIOSpecDefault { + + def spec = suite("TupleStructuralSpec")( + suite("Tuple2 structural conversion")( + test("Tuple2 round-trips correctly") { + val schema = Schema.derived[(String, Int)] + val original = ("hello", 42) + + val dynamic = schema.toDynamicValue(original) + val roundTrip = schema.fromDynamicValue(dynamic) + + assertTrue(roundTrip == Right(original)) + } + ), + suite("Tuple structural schema is a Record")( + test("Tuple2 structural schema is a Record") { + val schema = Schema.derived[(String, Int)] + val structural = schema.structural + val isRecord = (structural.reflect: @unchecked) match { + case _: Reflect.Record[_, _] => true + } + assertTrue(isRecord) + }, + test("Tuple3 structural schema has correct field count") { + val schema = Schema.derived[(String, Int, Boolean)] + val structural = schema.structural + val fieldCount = (structural.reflect: @unchecked) match { + case r: Reflect.Record[_, _] => r.fields.size + } + assertTrue(fieldCount == 3) + } + ), + suite("Type-level structural conversion")( + test("Tuple2 converts to expected structural type") { + typeCheck(""" + import zio.blocks.schema._ + val schema = Schema.derived[(String, Int)] + val structural: Schema[{def _1: String; def _2: Int}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("Tuple3 converts to expected structural type") { + typeCheck(""" + import zio.blocks.schema._ + val schema = Schema.derived[(String, Int, Boolean)] + val structural: Schema[{def _1: String; def _2: Int; def _3: Boolean}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ) + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/UnionTypesSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/UnionTypesSpec.scala new file mode 100644 index 000000000..c4c3ebea1 --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/UnionTypesSpec.scala @@ -0,0 +1,51 @@ +package zio.blocks.schema.structural + +import zio.blocks.schema._ +import zio.test._ + +/** + * Tests for sealed trait to structural union type conversion (Scala 3 only). + */ +object UnionTypesSpec extends ZIOSpecDefault { + + sealed trait Result + object Result { + case class Success(value: Int) extends Result + case class Failure(error: String) extends Result + } + + sealed trait Status + object Status { + case object Active extends Status + case object Inactive extends Status + } + + def spec: Spec[Any, Nothing] = suite("UnionTypesSpec")( + test("sealed trait with case classes converts to structural") { + val schema = Schema.derived[Result] + val structural = schema.structural + assertTrue(structural != null) + }, + test("sealed trait with case objects converts to structural") { + val schema = Schema.derived[Status] + val structural = schema.structural + assertTrue(structural != null) + }, + test("Result sealed trait converts to union structural type") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.UnionTypesSpec._ + val schema = Schema.derived[Result] + val structural: Schema[{def Failure: {def error: String}} | {def Success: {def value: Int}}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("Status sealed trait converts to union structural type") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.UnionTypesSpec._ + val schema = Schema.derived[Status] + val structural: Schema[{def Active: {}} | {def Inactive: {}}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/AsIntegrationSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/AsIntegrationSpec.scala new file mode 100644 index 000000000..ac46829cd --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/AsIntegrationSpec.scala @@ -0,0 +1,27 @@ +package zio.blocks.schema.structural.common + +import zio.blocks.schema._ +import zio.test._ + +/** Tests for As integration with structural types. */ +object AsIntegrationSpec extends ZIOSpecDefault { + + case class Person(name: String, age: Int) + type PersonStructure = { def name: String; def age: Int } + + def spec: Spec[Any, Nothing] = suite("AsIntegrationSpec")( + test("Into from Person to structural works") { + val person = Person("Alice", 30) + val into = Into.derived[Person, PersonStructure] + val result = into.into(person) + assertTrue(result.isRight) + }, + test("structural schema round-trip via DynamicValue") { + val person = Person("Test", 42) + val schema = Schema.derived[Person] + val dynamic = schema.toDynamicValue(person) + val roundTrip = schema.fromDynamicValue(dynamic) + assertTrue(roundTrip == Right(person)) + } + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/CollectionsSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/CollectionsSpec.scala new file mode 100644 index 000000000..6af6a4b86 --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/CollectionsSpec.scala @@ -0,0 +1,37 @@ +package zio.blocks.schema.structural.common + +import zio.blocks.schema._ +import zio.test._ + +/** Tests for collections in structural types. */ +object CollectionsSpec extends ZIOSpecDefault { + + case class Team(name: String, members: List[String], leader: Option[String]) + case class Config(settings: Map[String, Int]) + + def spec: Spec[Any, Nothing] = suite("CollectionsSpec")( + test("List field round-trip preserves data") { + val team = Team("Engineering", List("Alice", "Bob"), Some("Alice")) + val schema = Schema.derived[Team] + val dynamic = schema.toDynamicValue(team) + val result = schema.fromDynamicValue(dynamic) + assertTrue(result == Right(team)) + }, + test("Team with List and Option converts to structural type") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.common.CollectionsSpec._ + val schema = Schema.derived[Team] + val structural: Schema[{def leader: Option[String]; def members: List[String]; def name: String}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("Config with Map converts to structural type") { + typeCheck(""" + import zio.blocks.schema._ + import zio.blocks.schema.structural.common.CollectionsSpec._ + val schema = Schema.derived[Config] + val structural: Schema[{def settings: Map[String, Int]}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/EmptyProductSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/EmptyProductSpec.scala new file mode 100644 index 000000000..da1d7c1c9 --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/EmptyProductSpec.scala @@ -0,0 +1,39 @@ +package zio.blocks.schema.structural.common + +import zio.blocks.schema._ +import zio.test._ + +/** Tests for empty product types to structural conversion. */ +object EmptyProductSpec extends ZIOSpecDefault { + + case class Empty() + case object Singleton + + def spec: Spec[Any, Nothing] = suite("EmptyProductSpec")( + test("empty structural has zero fields") { + val schema = Schema.derived[Empty] + val structural = schema.structural + val numFields = structural.reflect match { + case record: Reflect.Record[_, _] => record.fields.size + case _ => -1 + } + assertTrue(numFields == 0) + }, + test("empty case class converts to expected structural type") { + typeCheck(""" + import zio.blocks.schema._ + case class Empty() + val schema = Schema.derived[Empty] + val structural: Schema[{}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("case object converts to expected structural type") { + typeCheck(""" + import zio.blocks.schema._ + case object Singleton + val schema = Schema.derived[Singleton.type] + val structural: Schema[{}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/IntoIntegrationSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/IntoIntegrationSpec.scala new file mode 100644 index 000000000..28b2436eb --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/IntoIntegrationSpec.scala @@ -0,0 +1,34 @@ +package zio.blocks.schema.structural.common + +import zio.blocks.schema._ +import zio.test._ + +/** Tests for Into integration with structural types. */ +object IntoIntegrationSpec extends ZIOSpecDefault { + + case class Person(name: String, age: Int) + type PersonStructure = { def name: String; def age: Int } + + def spec: Spec[Any, Nothing] = suite("IntoIntegrationSpec")( + test("nominal to structural via Into") { + val person = Person("Alice", 30) + val into = Into.derived[Person, PersonStructure] + val result = into.into(person) + assertTrue(result.isRight) + }, + test("nominal to structural preserves data") { + val person = Person("Bob", 25) + val into = Into.derived[Person, PersonStructure] + val result = into.into(person) + + result match { + case Right(r) => + val nameMethod = r.getClass.getMethod("name") + val ageMethod = r.getClass.getMethod("age") + assertTrue(nameMethod.invoke(r) == "Bob", ageMethod.invoke(r) == 25) + case Left(err) => + assertTrue(false) ?? s"Conversion failed: $err" + } + } + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/LargeProductSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/LargeProductSpec.scala new file mode 100644 index 000000000..808ee05b4 --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/LargeProductSpec.scala @@ -0,0 +1,55 @@ +package zio.blocks.schema.structural.common + +import zio.blocks.schema._ +import zio.test._ + +/** Tests for large product types (>22 fields) to structural conversion. */ +object LargeProductSpec extends ZIOSpecDefault { + + case class Record25( + f1: Int, + f2: Int, + f3: Int, + f4: Int, + f5: Int, + f6: Int, + f7: Int, + f8: Int, + f9: Int, + f10: Int, + f11: Int, + f12: Int, + f13: Int, + f14: Int, + f15: Int, + f16: Int, + f17: Int, + f18: Int, + f19: Int, + f20: Int, + f21: Int, + f22: Int, + f23: Int, + f24: Int, + f25: Int + ) + + def spec: Spec[Any, Nothing] = suite("LargeProductSpec")( + test("25 field record converts with correct field count") { + val schema = Schema.derived[Record25] + val structural = schema.structural + val numFields = (structural.reflect: @unchecked) match { + case record: Reflect.Record[_, _] => record.fields.size + } + assertTrue(numFields == 25) + }, + test("25 field record has expected field names") { + val schema = Schema.derived[Record25] + val structural = schema.structural + val fieldNames = (structural.reflect: @unchecked) match { + case record: Reflect.Record[_, _] => record.fields.map(_.name).toSet + } + assertTrue(fieldNames.contains("f1"), fieldNames.contains("f25")) + } + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/NestedProductSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/NestedProductSpec.scala new file mode 100644 index 000000000..95d26143b --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/NestedProductSpec.scala @@ -0,0 +1,38 @@ +package zio.blocks.schema.structural.common + +import zio.blocks.schema._ +import zio.test._ + +/** Tests for nested product types to structural conversion. */ +object NestedProductSpec extends ZIOSpecDefault { + + case class Address(street: String, city: String, zip: Int) + case class Person(name: String, age: Int, address: Address) + + def spec: Spec[Any, Nothing] = suite("NestedProductSpec")( + test("structural schema preserves field names") { + val schema = Schema.derived[Person] + val structural = schema.structural + val fieldNames = (structural.reflect: @unchecked) match { + case record: Reflect.Record[_, _] => record.fields.map(_.name).toSet + } + assertTrue(fieldNames == Set("name", "age", "address")) + }, + test("nested case class round-trip preserves data") { + val person = Person("Alice", 30, Address("123 Main St", "Springfield", 12345)) + val schema = Schema.derived[Person] + val dynamic = schema.toDynamicValue(person) + val roundTrip = schema.fromDynamicValue(dynamic) + assertTrue(roundTrip == Right(person)) + }, + test("nested case class converts to expected structural type") { + typeCheck(""" + import zio.blocks.schema._ + case class Address(street: String, city: String, zip: Int) + case class Person(name: String, age: Int, address: Address) + val schema = Schema.derived[Person] + val structural: Schema[{def address: Address; def age: Int; def name: String}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/SimpleProductSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/SimpleProductSpec.scala new file mode 100644 index 000000000..86081d0c4 --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/SimpleProductSpec.scala @@ -0,0 +1,30 @@ +package zio.blocks.schema.structural.common + +import zio.blocks.schema._ +import zio.test._ + +/** Tests for simple product type to structural conversion. */ +object SimpleProductSpec extends ZIOSpecDefault { + + case class Person(name: String, age: Int) + type PersonLike = { def name: String; def age: Int } + + def spec: Spec[Any, Nothing] = suite("SimpleProductSpec")( + test("structural schema has correct field names") { + val schema = Schema.derived[Person] + val structural = schema.structural + val fieldNames = (structural.reflect: @unchecked) match { + case record: Reflect.Record[_, _] => record.fields.map(_.name).toSet + } + assertTrue(fieldNames == Set("name", "age")) + }, + test("case class converts to expected structural type") { + typeCheck(""" + import zio.blocks.schema._ + case class Person(name: String, age: Int) + val schema = Schema.derived[Person] + val structural: Schema[{def age: Int; def name: String}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/SingleFieldSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/SingleFieldSpec.scala new file mode 100644 index 000000000..76e1e412c --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/SingleFieldSpec.scala @@ -0,0 +1,38 @@ +package zio.blocks.schema.structural.common + +import zio.blocks.schema._ +import zio.test._ + +/** Tests for single-field product types to structural conversion. */ +object SingleFieldSpec extends ZIOSpecDefault { + + case class Id(value: String) + case class Count(n: Int) + + def spec: Spec[Any, Nothing] = suite("SingleFieldSpec")( + test("single field structural has one field") { + val schema = Schema.derived[Id] + val structural = schema.structural + val numFields = (structural.reflect: @unchecked) match { + case record: Reflect.Record[_, _] => record.fields.size + } + assertTrue(numFields == 1) + }, + test("single field case class converts to expected structural type") { + typeCheck(""" + import zio.blocks.schema._ + case class Id(value: String) + val schema = Schema.derived[Id] + val structural: Schema[{def value: String}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("single Int field case class converts to expected structural type") { + typeCheck(""" + import zio.blocks.schema._ + case class Count(n: Int) + val schema = Schema.derived[Count] + val structural: Schema[{def n: Int}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ) +} diff --git a/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/TuplesSpec.scala b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/TuplesSpec.scala new file mode 100644 index 000000000..bb0cf6b6c --- /dev/null +++ b/schema/jvm/src/test/scala-3/zio/blocks/schema/structural/common/TuplesSpec.scala @@ -0,0 +1,40 @@ +package zio.blocks.schema.structural.common + +import zio.blocks.schema._ +import zio.test._ + +/** Tests for tuple to structural type conversion. */ +object TuplesSpec extends ZIOSpecDefault { + + def spec: Spec[Any, Nothing] = suite("TuplesSpec")( + test("tuple3 has correct field names") { + val schema = Schema.derived[(String, Int, Boolean)] + val structural = schema.structural + val fieldNames = (structural.reflect: @unchecked) match { + case record: Reflect.Record[_, _] => record.fields.map(_.name).toList + } + assertTrue(fieldNames == List("_1", "_2", "_3")) + }, + test("tuple round-trip preserves data") { + val tuple = ("hello", 42) + val schema = Schema.derived[(String, Int)] + val dynamic = schema.toDynamicValue(tuple) + val roundTrip = schema.fromDynamicValue(dynamic) + assertTrue(roundTrip == Right(tuple)) + }, + test("tuple2 converts to expected structural type") { + typeCheck(""" + import zio.blocks.schema._ + val schema = Schema.derived[(String, Int)] + val structural: Schema[{def _1: String; def _2: Int}] = schema.structural + """).map(result => assertTrue(result.isRight)) + }, + test("tuple3 converts to expected structural type") { + typeCheck(""" + import zio.blocks.schema._ + val schema = Schema.derived[(String, Int, Boolean)] + val structural: Schema[{def _1: String; def _2: Int; def _3: Boolean}] = schema.structural + """).map(result => assertTrue(result.isRight)) + } + ) +} diff --git a/schema/jvm/src/test/scala/zio/blocks/schema/structural/errors/MutualRecursionErrorSpec.scala b/schema/jvm/src/test/scala/zio/blocks/schema/structural/errors/MutualRecursionErrorSpec.scala new file mode 100644 index 000000000..624f70d6e --- /dev/null +++ b/schema/jvm/src/test/scala/zio/blocks/schema/structural/errors/MutualRecursionErrorSpec.scala @@ -0,0 +1,204 @@ +package zio.blocks.schema.structural.errors + +import zio.test._ + +/** + * Tests that mutually recursive types produce compile-time errors when + * converting to structural. + * + * ==Overview== + * Mutual recursion occurs when: + * - Type A references type B and type B references type A (direct) + * - Type A → B → C → A (chain) + * + * ==Expected Error Format== + * {{{ + * Cannot generate structural type for recursive type MyType. + * + * Structural types cannot represent recursive structures. + * Scala's type system does not support infinite types. + * }}} + * + * Note: The error message for mutual recursion is the same as direct recursion + * because from the perspective of each type, it appears as a recursive + * reference. + */ +object MutualRecursionErrorSpec extends ZIOSpecDefault { + + def spec = suite("MutualRecursionErrorSpec")( + suite("Two-Way Mutual Recursion Detection")( + test("Node-Edge mutual recursion fails to convert to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Node(id: Int, edges: List[Edge]) + case class Edge(from: Int, to: Node) + + val schema = Schema.derived[Node] + schema.structural + """ + }.map { result => + assertTrue(result.isLeft) + } + }, + test("Parent-Child mutual recursion fails to convert to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Parent(name: String, children: List[Child]) + case class Child(name: String, parent: Option[Parent]) + + val schema = Schema.derived[Parent] + schema.structural + """ + }.map { result => + assertTrue(result.isLeft) + } + }, + test("both sides of mutual recursion fail - TypeA") { + typeCheck { + """ + import zio.blocks.schema._ + + case class TypeA(data: Int, ref: TypeB) + case class TypeB(data: String, ref: TypeA) + + val schemaA = Schema.derived[TypeA] + schemaA.structural + """ + }.map { result => + assertTrue(result.isLeft) + } + }, + test("both sides of mutual recursion fail - TypeB") { + typeCheck { + """ + import zio.blocks.schema._ + + case class TypeA(data: Int, ref: TypeB) + case class TypeB(data: String, ref: TypeA) + + val schemaB = Schema.derived[TypeB] + schemaB.structural + """ + }.map { result => + assertTrue(result.isLeft) + } + } + ), + suite("Three-Way Mutual Recursion Detection")( + test("A -> B -> C -> A mutual recursion fails") { + typeCheck { + """ + import zio.blocks.schema._ + + case class TypeA(b: TypeB) + case class TypeB(c: TypeC) + case class TypeC(a: Option[TypeA]) + + val schema = Schema.derived[TypeA] + schema.structural + """ + }.map { result => + assertTrue(result.isLeft) + } + }, + test("three-way with collections fails") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Company(departments: List[Department]) + case class Department(employees: List[Employee]) + case class Employee(company: Company) + + val schema = Schema.derived[Company] + schema.structural + """ + }.map { result => + assertTrue(result.isLeft) + } + } + ), + suite("Error Message Quality")( + test("error message mentions recursive nature") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Ping(next: Pong) + case class Pong(next: Ping) + + val schema = Schema.derived[Ping] + schema.structural + """ + }.map { result => + assertTrue( + result.isLeft, + // Accept either our custom error message OR Scala 3's generic macro failure message + result.left.exists { msg => + msg.contains("recursive") || + msg.contains("Recursive") || + msg.contains("infinite") || + msg.contains("Infinite") || + msg.contains("circular") || + msg.toLowerCase.contains("macro expansion was stopped") + } + ) + } + } + ), + suite("Non-Mutually-Recursive Types Still Work")( + test("independent case classes work") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Person(name: String, age: Int) + case class Address(street: String, city: String) + + val schema = Schema.derived[Person] + schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("nested but non-recursive types work") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Inner(value: Int) + case class Middle(inner: Inner) + case class Outer(middle: Middle) + + val schema = Schema.derived[Outer] + schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("diamond-shaped dependency (non-recursive) works") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Leaf(value: Int) + case class Left(leaf: Leaf) + case class Right(leaf: Leaf) + case class Root(left: Left, right: Right) + + val schema = Schema.derived[Root] + schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + } + ) + ) +} diff --git a/schema/jvm/src/test/scala/zio/blocks/schema/structural/errors/RecursiveTypeErrorSpec.scala b/schema/jvm/src/test/scala/zio/blocks/schema/structural/errors/RecursiveTypeErrorSpec.scala new file mode 100644 index 000000000..82607230a --- /dev/null +++ b/schema/jvm/src/test/scala/zio/blocks/schema/structural/errors/RecursiveTypeErrorSpec.scala @@ -0,0 +1,217 @@ +package zio.blocks.schema.structural.errors + +import zio.test._ + +/** + * Tests that recursive types produce compile-time errors when converting to + * structural types. + * + * ==Overview== + * Recursive types cannot be represented as structural types because Scala does + * not support infinite types. The error message should clearly indicate: + * - What type is recursive + * - Why it cannot be converted + * + * ==Expected Error Format== + * {{{ + * Cannot generate structural type for recursive type MyType. + * + * Structural types cannot represent recursive structures. + * Scala's type system does not support infinite types. + * }}} + */ +object RecursiveTypeErrorSpec extends ZIOSpecDefault { + + def spec = suite("RecursiveTypeErrorSpec")( + suite("Direct Recursion Detection")( + test("direct recursive type (LinkedList) fails to convert to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class LinkedList(head: Int, tail: Option[LinkedList]) + + val schema = Schema.derived[LinkedList] + schema.structural + """ + }.map { result => + assertTrue( + result.isLeft, + // Accept either our custom error message OR Scala 3's generic macro failure message + result.left.exists(msg => + msg.toLowerCase.contains("recursive") || + msg.toLowerCase.contains("infinite") || + msg.toLowerCase.contains("macro expansion was stopped") + ) + ) + } + }, + test("self-referencing type fails to convert to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class SelfRef(value: Int, next: SelfRef) + + val schema = Schema.derived[SelfRef] + schema.structural + """ + }.map { result => + assertTrue(result.isLeft) + } + } + ), + suite("Collection-Wrapped Recursion Detection")( + test("list-wrapped recursive type (Tree) fails to convert to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Tree(value: Int, children: List[Tree]) + + val schema = Schema.derived[Tree] + schema.structural + """ + }.map { result => + assertTrue( + result.isLeft, + // Accept either our custom error message OR Scala 3's generic macro failure message + result.left.exists(msg => + msg.toLowerCase.contains("recursive") || + msg.toLowerCase.contains("infinite") || + msg.toLowerCase.contains("macro expansion was stopped") + ) + ) + } + }, + test("option-wrapped recursive type fails to convert to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Node(id: Int, next: Option[Node]) + + val schema = Schema.derived[Node] + schema.structural + """ + }.map { result => + assertTrue(result.isLeft) + } + }, + test("set-wrapped recursive type fails to convert to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Graph(id: Int, connections: Set[Graph]) + + val schema = Schema.derived[Graph] + schema.structural + """ + }.map { result => + assertTrue(result.isLeft) + } + }, + test("vector-wrapped recursive type fails to convert to structural") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Sequence(value: Int, rest: Vector[Sequence]) + + val schema = Schema.derived[Sequence] + schema.structural + """ + }.map { result => + assertTrue(result.isLeft) + } + } + ), + suite("Error Message Quality")( + test("error message contains 'recursive' keyword") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Recursive(data: Int, self: List[Recursive]) + + val schema = Schema.derived[Recursive] + schema.structural + """ + }.map { result => + assertTrue( + result.isLeft, + result.left.exists { msg => + msg.contains("recursive") || + msg.contains("Recursive") || + msg.contains("infinite") || + msg.contains("Infinite") + } + ) + } + }, + test("error message mentions the type name") { + typeCheck { + """ + import zio.blocks.schema._ + + case class MyRecursiveType(value: Int, children: List[MyRecursiveType]) + + val schema = Schema.derived[MyRecursiveType] + schema.structural + """ + }.map { result => + assertTrue( + result.isLeft, + result.left.exists(msg => msg.contains("MyRecursiveType")) + ) + } + } + ), + suite("Non-Recursive Types Still Work")( + test("non-recursive case class converts to structural successfully") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Person(name: String, age: Int) + + val schema = Schema.derived[Person] + schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("case class with nested non-recursive type works") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Inner(value: Int) + case class Outer(name: String, inner: Inner) + + val schema = Schema.derived[Outer] + schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + }, + test("case class with collections works") { + typeCheck { + """ + import zio.blocks.schema._ + + case class Container(items: List[Int], mapping: Map[String, Int]) + + val schema = Schema.derived[Container] + schema.structural + """ + }.map { result => + assertTrue(result.isRight) + } + } + ) + ) +} diff --git a/schema/shared/src/main/scala-2/zio/blocks/schema/SchemaCompanionVersionSpecific.scala b/schema/shared/src/main/scala-2/zio/blocks/schema/SchemaCompanionVersionSpecific.scala index ea2f50ccb..f3b5906f8 100644 --- a/schema/shared/src/main/scala-2/zio/blocks/schema/SchemaCompanionVersionSpecific.scala +++ b/schema/shared/src/main/scala-2/zio/blocks/schema/SchemaCompanionVersionSpecific.scala @@ -30,6 +30,7 @@ private object SchemaCompanionVersionSpecific { def derived[A: c.WeakTypeTag](c: blackbox.Context): c.Expr[Schema[A]] = { import c.universe._ + import c.internal._ def fail(msg: String): Nothing = CommonMacroOps.fail(c)(msg) @@ -76,7 +77,20 @@ private object SchemaCompanionVersionSpecific { if (isZioPreludeNewtype(tpe)) zioPreludeNewtypeDealias(tpe) else tpe - def companion(tpe: Type): Symbol = CommonMacroOps.companion(c)(tpe) + def companion(tpe: Type): Symbol = { + val comp = tpe.typeSymbol.companion + if (comp.isModule) comp + else { + val ownerChainOf = (s: Symbol) => Iterator.iterate(s)(_.owner).takeWhile(_ != NoSymbol).toArray.reverseIterator + val path = ownerChainOf(tpe.typeSymbol) + .zipAll(ownerChainOf(enclosingOwner), NoSymbol, NoSymbol) + .dropWhile(x => x._1 == x._2) + .takeWhile(x => x._1 != NoSymbol) + .map(x => x._1.name.toTermName) + if (path.isEmpty) NoSymbol + else c.typecheck(path.foldLeft[Tree](Ident(path.next()))(Select(_, _)), silent = true).symbol + } + } def primaryConstructor(tpe: Type): MethodSymbol = tpe.decls.collectFirst { case m: MethodSymbol if m.isPrimaryConstructor => m @@ -115,9 +129,52 @@ private object SchemaCompanionVersionSpecific { val typeNameCache = new mutable.HashMap[Type, SchemaTypeName[?]] - def typeName(tpe: Type): SchemaTypeName[?] = CommonMacroOps.typeName(c)(typeNameCache, tpe) + def typeName(tpe: Type): SchemaTypeName[?] = { + def calculateTypeName(tpe: Type): SchemaTypeName[?] = + if (tpe =:= typeOf[java.lang.String]) SchemaTypeName.string + else { + var packages = List.empty[String] + var values = List.empty[String] + val tpeSymbol = tpe.typeSymbol + var name = NameTransformer.decode(tpeSymbol.name.toString) + val comp = companion(tpe) + var owner = + if (comp == null) tpeSymbol + else if (comp == NoSymbol) { + name += ".type" + tpeSymbol.asClass.module + } else comp + while ({ + owner = owner.owner + owner.owner != NoSymbol + }) { + val ownerName = NameTransformer.decode(owner.name.toString) + if (owner.isPackage || owner.isPackageClass) packages = ownerName :: packages + else values = ownerName :: values + } + new SchemaTypeName(new Namespace(packages, values), name, typeArgs(tpe).map(typeName)) + } - def toTree(tpeName: SchemaTypeName[?]): Tree = CommonMacroOps.toTree(c)(tpeName) + typeNameCache.getOrElseUpdate( + tpe, + tpe match { + case TypeRef(compTpe, typeSym, Nil) if typeSym.name.toString == "Type" => + var tpeName = calculateTypeName(compTpe) + if (tpeName.name.endsWith(".type")) tpeName = tpeName.copy(name = tpeName.name.stripSuffix(".type")) + tpeName + case _ => + calculateTypeName(tpe) + } + ) + } + + def toTree(tpeName: SchemaTypeName[?]): Tree = { + val packages = tpeName.namespace.packages.toList + val values = tpeName.namespace.values.toList + val name = tpeName.name + val params = tpeName.params.map(toTree).toList + q"new TypeName(new Namespace($packages, $values), $name, $params)" + } def modifiers(tpe: Type): List[Tree] = { val modifiers = new mutable.ListBuffer[Tree] @@ -313,6 +370,59 @@ private object SchemaCompanionVersionSpecific { }) } + // Check if a type is a structural (refinement) type + def isStructuralType(tpe: Type): Boolean = tpe.dealias match { + case RefinedType(_, _) => true + case t if t =:= definitions.AnyRefTpe => true // Empty structural type {} + case t if t.typeSymbol.fullName == "java.lang.Object" => true // java.lang.Object treated as empty structural + case _ => false + } + + // Get structural type members (def name: Type refinements) + def getStructuralMembers(tpe: Type): List[(String, Type)] = tpe.dealias match { + case RefinedType(_, scope) => + scope.collect { + case m: MethodSymbol if m.isAbstract && m.paramLists.isEmpty => + (m.name.toString, m.returnType) + }.toList.sortBy(_._1) + case _ => Nil + } + + // Normalize type name for structural types + def normalizeTypeForName(tpe: Type): String = { + val dealiased = tpe.dealias + if (dealiased =:= definitions.IntTpe) "Int" + else if (dealiased =:= definitions.LongTpe) "Long" + else if (dealiased =:= definitions.FloatTpe) "Float" + else if (dealiased =:= definitions.DoubleTpe) "Double" + else if (dealiased =:= definitions.BooleanTpe) "Boolean" + else if (dealiased =:= definitions.ByteTpe) "Byte" + else if (dealiased =:= definitions.CharTpe) "Char" + else if (dealiased =:= definitions.ShortTpe) "Short" + else if (dealiased =:= typeOf[String]) "String" + else if (dealiased =:= definitions.UnitTpe) "Unit" + else if (isStructuralType(dealiased)) { + val members = getStructuralMembers(dealiased) + val sorted = members.sortBy(_._1) + sorted.map { case (name, t) => s"$name:${normalizeTypeForName(t)}" }.mkString("{", ",", "}") + } else { + val tArgs = typeArgs(dealiased) + if (tArgs.isEmpty) dealiased.typeSymbol.name.toString + else s"${dealiased.typeSymbol.name}[${tArgs.map(normalizeTypeForName).mkString(",")}]" + } + } + + // Create structural type name + def structuralTypeName(members: List[(String, Type)]): Tree = { + val normalized = members + .sortBy(_._1) + .map { case (name, tpe) => s"$name:${normalizeTypeForName(tpe)}" } + .mkString("{", ",", "}") + val packages = List.empty[String] + val values = List.empty[String] + q"new TypeName(new Namespace($packages, $values), $normalized, Nil)" + } + def deriveSchema(tpe: Type): Tree = if (isEnumOrModuleValue(tpe)) { deriveSchemaForEnumOrModuleValue(tpe) @@ -381,6 +491,11 @@ private object SchemaCompanionVersionSpecific { } } else if (isSealedTraitOrAbstractClass(tpe)) { deriveSchemaForSealedTraitOrAbstractClass(tpe) + } else if (isStructuralType(tpe)) { + // Check for structural types BEFORE isNonAbstractScalaClass + // because refinement types like `Record { def name: String }` would otherwise + // be handled as the base class `Record` + deriveSchemaForStructuralType(tpe) } else if (isNonAbstractScalaClass(tpe)) { deriveSchemaForNonAbstractScalaClass(tpe) } else if (isZioPreludeNewtype(tpe)) { @@ -389,6 +504,158 @@ private object SchemaCompanionVersionSpecific { q"new Schema($schema.reflect.typeName($tpeName)).asInstanceOf[Schema[$tpe]]" } else cannotDeriveSchema(tpe) + def deriveSchemaForStructuralType(tpe: Type): Tree = { + // Pure structural types require runtime reflection (JVM only) + if (!Platform.supportsReflection) { + fail( + s"""Cannot derive Schema for structural type '$tpe' on ${Platform.name}. + | + |Structural types require reflection which is only available on JVM. + | + |Consider using a case class instead.""".stripMargin + ) + } + + val members = getStructuralMembers(tpe) + + if (members.isEmpty) { + // Empty structural type - derive as an empty record + val tpeName = structuralTypeName(members) + return q"""new Schema( + reflect = new Reflect.Record[Binding, $tpe]( + fields = _root_.scala.Vector.empty, + typeName = $tpeName, + recordBinding = new Binding.Record( + constructor = new Constructor[$tpe] { + def usedRegisters: RegisterOffset = RegisterOffset.Zero + + def construct(in: Registers, baseOffset: RegisterOffset): $tpe = { + (new _root_.java.lang.Object {}).asInstanceOf[$tpe] + } + }, + deconstructor = new Deconstructor[$tpe] { + def usedRegisters: RegisterOffset = RegisterOffset.Zero + + def deconstruct(out: Registers, baseOffset: RegisterOffset, in: $tpe): _root_.scala.Unit = () + } + ) + ) + )""" + } + + // Non-empty structural type - use reflection (JVM only) + deriveSchemaForPureStructuralType(tpe, members) + } + + def deriveSchemaForPureStructuralType(tpe: Type, members: List[(String, Type)]): Tree = { + // Pure structural types require runtime reflection for deconstruction (JVM only) + val tpeName = structuralTypeName(members) + + // Calculate register offsets for each field + var usedRegisters = RegisterOffset.Zero + val fieldInfos = members.map { case (name, fTpe) => + val sTpe = dealiasOnDemand(fTpe) + val offset = + if (sTpe <:< definitions.IntTpe) RegisterOffset(ints = 1) + else if (sTpe <:< definitions.FloatTpe) RegisterOffset(floats = 1) + else if (sTpe <:< definitions.LongTpe) RegisterOffset(longs = 1) + else if (sTpe <:< definitions.DoubleTpe) RegisterOffset(doubles = 1) + else if (sTpe <:< definitions.BooleanTpe) RegisterOffset(booleans = 1) + else if (sTpe <:< definitions.ByteTpe) RegisterOffset(bytes = 1) + else if (sTpe <:< definitions.CharTpe) RegisterOffset(chars = 1) + else if (sTpe <:< definitions.ShortTpe) RegisterOffset(shorts = 1) + else if (sTpe <:< definitions.UnitTpe) RegisterOffset.Zero + else RegisterOffset(objects = 1) + val info = (name, fTpe, usedRegisters) + usedRegisters = RegisterOffset.add(usedRegisters, offset) + info + } + + // Generate field terms + val fieldTerms = fieldInfos.map { case (name, fTpe, _) => + val schema = findImplicitOrDeriveSchema(fTpe) + val isNonRec = isNonRecursive(fTpe) + if (isNonRec) q"$schema.reflect.asTerm[$tpe]($name)" + else q"new Reflect.Deferred(() => $schema.reflect).asTerm[$tpe]($name)" + } + + // Generate map entries for constructor + val mapEntries = fieldInfos.map { case (name, fTpe, offset) => + val getter = + if (fTpe =:= definitions.IntTpe) q"in.getInt(baseOffset + $offset)" + else if (fTpe =:= definitions.FloatTpe) q"in.getFloat(baseOffset + $offset)" + else if (fTpe =:= definitions.LongTpe) q"in.getLong(baseOffset + $offset)" + else if (fTpe =:= definitions.DoubleTpe) q"in.getDouble(baseOffset + $offset)" + else if (fTpe =:= definitions.BooleanTpe) q"in.getBoolean(baseOffset + $offset)" + else if (fTpe =:= definitions.ByteTpe) q"in.getByte(baseOffset + $offset)" + else if (fTpe =:= definitions.CharTpe) q"in.getChar(baseOffset + $offset)" + else if (fTpe =:= definitions.ShortTpe) q"in.getShort(baseOffset + $offset)" + else if (fTpe =:= definitions.UnitTpe) q"()" + else q"in.getObject(baseOffset + $offset)" + q"($name, $getter: _root_.scala.Any)" + } + val mapExpr = q"_root_.scala.collection.immutable.Map[_root_.java.lang.String, _root_.scala.Any](..$mapEntries)" + + // Generate deconstructor statements - use reflection (JVM only) + val deconstructStatements = fieldInfos.map { case (name, fTpe, offset) => + val fieldAccessor = q"""in.getClass.getMethod($name).invoke(in)""" + if (fTpe <:< definitions.IntTpe) + q"out.setInt(baseOffset + $offset, $fieldAccessor.asInstanceOf[_root_.java.lang.Integer].intValue)" + else if (fTpe <:< definitions.FloatTpe) + q"out.setFloat(baseOffset + $offset, $fieldAccessor.asInstanceOf[_root_.java.lang.Float].floatValue)" + else if (fTpe <:< definitions.LongTpe) + q"out.setLong(baseOffset + $offset, $fieldAccessor.asInstanceOf[_root_.java.lang.Long].longValue)" + else if (fTpe <:< definitions.DoubleTpe) + q"out.setDouble(baseOffset + $offset, $fieldAccessor.asInstanceOf[_root_.java.lang.Double].doubleValue)" + else if (fTpe <:< definitions.BooleanTpe) + q"out.setBoolean(baseOffset + $offset, $fieldAccessor.asInstanceOf[_root_.java.lang.Boolean].booleanValue)" + else if (fTpe <:< definitions.ByteTpe) + q"out.setByte(baseOffset + $offset, $fieldAccessor.asInstanceOf[_root_.java.lang.Byte].byteValue)" + else if (fTpe <:< definitions.CharTpe) + q"out.setChar(baseOffset + $offset, $fieldAccessor.asInstanceOf[_root_.java.lang.Character].charValue)" + else if (fTpe <:< definitions.ShortTpe) + q"out.setShort(baseOffset + $offset, $fieldAccessor.asInstanceOf[_root_.java.lang.Short].shortValue)" + else if (fTpe <:< definitions.UnitTpe) q"()" + else q"out.setObject(baseOffset + $offset, $fieldAccessor.asInstanceOf[_root_.scala.AnyRef])" + } + + // Constructor creates an anonymous class with method implementations + val methodDefs = members.map { case (memberName, memberTpe) => + val methodName = TermName(memberName) + q"def $methodName: $memberTpe = _fields($memberName).asInstanceOf[$memberTpe]" + } + + val constructorExpr = q""" + { + (new { + private val _fields: _root_.scala.collection.immutable.Map[_root_.java.lang.String, _root_.scala.Any] = $mapExpr + ..$methodDefs + }).asInstanceOf[$tpe] + } + """ + + q"""new Schema( + reflect = new Reflect.Record[Binding, $tpe]( + fields = _root_.scala.Vector(..$fieldTerms), + typeName = $tpeName, + recordBinding = new Binding.Record( + constructor = new Constructor[$tpe] { + def usedRegisters: RegisterOffset = $usedRegisters + + def construct(in: Registers, baseOffset: RegisterOffset): $tpe = $constructorExpr + }, + deconstructor = new Deconstructor[$tpe] { + def usedRegisters: RegisterOffset = $usedRegisters + + def deconstruct(out: Registers, baseOffset: RegisterOffset, in: $tpe): _root_.scala.Unit = { + ..$deconstructStatements + } + } + ) + ) + )""" + } + def deriveSchemaForEnumOrModuleValue(tpe: Type): Tree = { val tpeName = toTree(typeName(tpe)) q"""new Schema( diff --git a/schema/shared/src/main/scala-2/zio/blocks/schema/SchemaVersionSpecific.scala b/schema/shared/src/main/scala-2/zio/blocks/schema/SchemaVersionSpecific.scala new file mode 100644 index 000000000..73298622c --- /dev/null +++ b/schema/shared/src/main/scala-2/zio/blocks/schema/SchemaVersionSpecific.scala @@ -0,0 +1,29 @@ +package zio.blocks.schema + +/** + * Scala 2 version-specific methods for Schema instances. + */ +trait SchemaVersionSpecific[A] { self: Schema[A] => + + /** + * Convert this schema to a structural type schema. + * + * The structural type represents the "shape" of A without its nominal + * identity. This enables duck typing and structural validation. + * + * Example: + * {{{ + * case class Person(name: String, age: Int) + * val structuralSchema: Schema[{ def name: String; def age: Int }] = + * Schema.derived[Person].structural + * }}} + * + * Note: This is JVM-only due to reflection requirements for structural types. + * + * @param ts + * Macro-generated conversion to structural representation + * @return + * Schema for the structural type corresponding to A + */ + def structural(implicit ts: ToStructural[A]): Schema[ts.StructuralType] = ts(this) +} diff --git a/schema/shared/src/main/scala-2/zio/blocks/schema/ToStructuralVersionSpecific.scala b/schema/shared/src/main/scala-2/zio/blocks/schema/ToStructuralVersionSpecific.scala new file mode 100644 index 000000000..f7c580caf --- /dev/null +++ b/schema/shared/src/main/scala-2/zio/blocks/schema/ToStructuralVersionSpecific.scala @@ -0,0 +1,289 @@ +package zio.blocks.schema + +import zio.blocks.schema.binding._ +import zio.blocks.schema.binding.RegisterOffset._ + +import scala.language.experimental.macros +import scala.reflect.macros.blackbox + +trait ToStructuralVersionSpecific { + implicit def toStructural[A]: ToStructural[A] = macro ToStructuralMacro.derived[A] +} + +object ToStructuralMacro { + def derived[A: c.WeakTypeTag](c: blackbox.Context): c.Expr[ToStructural[A]] = { + import c.universe._ + + val aTpe = weakTypeOf[A].dealias + + if (!Platform.supportsReflection) { + c.abort( + c.enclosingPosition, + s"""Cannot generate ToStructural[${aTpe}] on ${Platform.name}. + | + |Structural types require reflection which is only available on JVM. + |Consider using a case class instead.""".stripMargin + ) + } + + if (isRecursiveType(c)(aTpe)) { + c.abort( + c.enclosingPosition, + s"""Cannot generate structural type for recursive type ${aTpe}. + | + |Structural types cannot represent recursive structures. + |Scala's type system does not support infinite types.""".stripMargin + ) + } + + if (isProductType(c)(aTpe)) { + deriveForProduct[A](c)(aTpe) + } else if (isTupleType(c)(aTpe)) { + deriveForTuple[A](c)(aTpe) + } else { + c.abort( + c.enclosingPosition, + s"""Cannot generate ToStructural for ${aTpe}. + | + |Only product types (case classes) and tuples are currently supported. + |Sum types (sealed traits) are not supported in Scala 2.""".stripMargin + ) + } + } + + private def isProductType(c: blackbox.Context)(tpe: c.universe.Type): Boolean = + tpe.typeSymbol.isClass && tpe.typeSymbol.asClass.isCaseClass + + private def isTupleType(c: blackbox.Context)(tpe: c.universe.Type): Boolean = + tpe.typeSymbol.fullName.startsWith("scala.Tuple") + + private def isRecursiveType(c: blackbox.Context)(tpe: c.universe.Type): Boolean = { + import c.universe._ + + def containsType(searchIn: Type, searchFor: Type, visited: Set[Type]): Boolean = { + val dealiased = searchIn.dealias + if (visited.contains(dealiased)) return false + val newVisited = visited + dealiased + + if (dealiased =:= searchFor.dealias) return true + + val typeArgsContain = dealiased match { + case TypeRef(_, _, args) if args.nonEmpty => + args.exists(arg => containsType(arg, searchFor, newVisited)) + case _ => false + } + + if (typeArgsContain) return true + + val fields = dealiased.decls.collect { + case m: MethodSymbol if m.isCaseAccessor => m.returnType.asSeenFrom(dealiased, dealiased.typeSymbol) + } + fields.exists(fieldTpe => containsType(fieldTpe, searchFor, newVisited)) + } + + val fields = tpe.decls.collect { + case m: MethodSymbol if m.isCaseAccessor => m.returnType.asSeenFrom(tpe, tpe.typeSymbol) + } + fields.exists(fieldTpe => containsType(fieldTpe, tpe, Set.empty)) + } + + private def fullyUnpackType(c: blackbox.Context)(tpe: c.universe.Type): c.universe.Type = { + import c.universe._ + + val dealiased = tpe.dealias + + def isZioPreludeNewtype(t: Type): Boolean = t match { + case TypeRef(prefix, _, _) => + prefix.typeSymbol.fullName.contains("zio.prelude.Newtype") || + prefix.typeSymbol.fullName.contains("zio.prelude.Subtype") + case _ => false + } + + def getNewtypeUnderlying(t: Type): Type = t match { + case TypeRef(prefix, _, _) => + val baseTypes = prefix.baseClasses + baseTypes + .find(_.fullName.contains("zio.prelude.Newtype")) + .orElse( + baseTypes.find(_.fullName.contains("zio.prelude.Subtype")) + ) match { + case Some(baseSym) => + prefix.baseType(baseSym).typeArgs.headOption.map(_.dealias).getOrElse(t) + case None => t + } + case _ => t + } + + if (isZioPreludeNewtype(dealiased)) { + fullyUnpackType(c)(getNewtypeUnderlying(dealiased)) + } else { + dealiased match { + case TypeRef(_, sym, args) if args.nonEmpty => + val unpackedArgs = args.map(fullyUnpackType(c)(_)) + if (unpackedArgs != args) appliedType(sym, unpackedArgs) + else dealiased + case _ => dealiased + } + } + } + + private def deriveForProduct[A: c.WeakTypeTag]( + c: blackbox.Context + )(aTpe: c.universe.Type): c.Expr[ToStructural[A]] = { + import c.universe._ + + val fields: List[(String, Type)] = aTpe.decls.collect { + case m: MethodSymbol if m.isCaseAccessor => + (m.name.toString, m.returnType.asSeenFrom(aTpe, aTpe.typeSymbol)) + }.toList + + val structuralTpe = buildStructuralType(c)(fields) + + c.Expr[ToStructural[A]]( + q""" + new _root_.zio.blocks.schema.ToStructural[$aTpe] { + type StructuralType = $structuralTpe + def apply(schema: _root_.zio.blocks.schema.Schema[$aTpe]): _root_.zio.blocks.schema.Schema[$structuralTpe] = { + _root_.zio.blocks.schema.ToStructuralMacro.transformProductSchema[$aTpe, $structuralTpe](schema) + } + } + """ + ) + } + + private def deriveForTuple[A: c.WeakTypeTag](c: blackbox.Context)(aTpe: c.universe.Type): c.Expr[ToStructural[A]] = { + import c.universe._ + + val typeArgs = aTpe.typeArgs + + val fields: List[(String, Type)] = typeArgs.zipWithIndex.map { case (tpe, idx) => + (s"_${idx + 1}", tpe) + } + + if (fields.isEmpty) { + c.abort(c.enclosingPosition, "Cannot generate structural type for empty tuple") + } + + val structuralTpe = buildStructuralType(c)(fields) + + c.Expr[ToStructural[A]]( + q""" + new _root_.zio.blocks.schema.ToStructural[$aTpe] { + type StructuralType = $structuralTpe + def apply(schema: _root_.zio.blocks.schema.Schema[$aTpe]): _root_.zio.blocks.schema.Schema[$structuralTpe] = { + _root_.zio.blocks.schema.ToStructuralMacro.transformTupleSchema[$aTpe, $structuralTpe](schema) + } + } + """ + ) + } + + private def buildStructuralType(c: blackbox.Context)(fields: List[(String, c.universe.Type)]): c.universe.Type = { + import c.universe._ + + if (fields.isEmpty) { + return definitions.AnyRefTpe + } + + val sortedFields = fields.sortBy(_._1) + + // Unpack opaque/newtype field types to their underlying primitives + val unpackedFields = sortedFields.map { case (name, tpe) => + (name, fullyUnpackType(c)(tpe)) + } + + val refinedTypeTree = unpackedFields.foldLeft(tq"AnyRef": Tree) { case (parent, (name, tpe)) => + val methodName = TermName(name) + tq"$parent { def $methodName: $tpe }" + } + + c.typecheck(refinedTypeTree, c.TYPEmode).tpe.dealias + } + + /** + * Transform a product schema (case class) to its structural equivalent. This + * is called at runtime from the generated code. + */ + def transformProductSchema[A, S](schema: Schema[A]): Schema[S] = + schema.reflect match { + case record: Reflect.Record[Binding, A] @unchecked => + val binding = record.recordBinding.asInstanceOf[Binding.Record[A]] + + val fieldInfos = record.fields.map { field => + (field.name, field.value.asInstanceOf[Reflect.Bound[Any]]) + } + + val totalRegisters = binding.constructor.usedRegisters + + val typeName = normalizeTypeName(fieldInfos.toList.map { case (name, reflect) => + (name, reflect.typeName.name) + }) + + new Schema[S]( + new Reflect.Record[Binding, S]( + fields = record.fields.map { field => + field.value.asInstanceOf[Reflect.Bound[Any]].asTerm[S](field.name) + }, + typeName = new TypeName[S](new Namespace(Nil, Nil), typeName, Nil), + recordBinding = new Binding.Record[S]( + constructor = new Constructor[S] { + def usedRegisters: RegisterOffset = totalRegisters + + def construct(in: Registers, baseOffset: RegisterOffset): S = { + val nominal = binding.constructor.construct(in, baseOffset) + nominal.asInstanceOf[S] + } + }, + deconstructor = new Deconstructor[S] { + def usedRegisters: RegisterOffset = totalRegisters + + def deconstruct(out: Registers, baseOffset: RegisterOffset, in: S): Unit = + binding.deconstructor.deconstruct(out, baseOffset, in.asInstanceOf[A]) + } + ), + doc = record.doc, + modifiers = record.modifiers + ) + ) + + case _ => + throw new IllegalArgumentException( + s"Cannot transform non-record schema to structural type" + ) + } + + /** + * Transform a tuple schema to its structural equivalent. This is called at + * runtime from the generated code. + */ + def transformTupleSchema[A, S](schema: Schema[A]): Schema[S] = + schema.reflect match { + case _: Reflect.Record[Binding, A] @unchecked => + transformProductSchema[A, S](schema) + + case _ => + throw new IllegalArgumentException( + s"Cannot transform non-record schema to structural type" + ) + } + + /** + * Generate a normalized type name for a structural type. Fields are sorted + * alphabetically for deterministic naming. + */ + private def normalizeTypeName(fields: List[(String, String)]): String = { + val sorted = fields.sortBy(_._1) + sorted.map { case (name, typeName) => + s"$name:${simplifyTypeName(typeName)}" + }.mkString("{", ",", "}") + } + + /** + * Simplify type names for display (e.g., "scala.Int" -> "Int") + */ + private def simplifyTypeName(typeName: String): String = + typeName + .replace("scala.", "") + .replace("java.lang.", "") + .replace("Predef.", "") +} diff --git a/schema/shared/src/main/scala-3/zio/blocks/schema/SchemaCompanionVersionSpecific.scala b/schema/shared/src/main/scala-3/zio/blocks/schema/SchemaCompanionVersionSpecific.scala index 77dc4c408..b9b4b7944 100644 --- a/schema/shared/src/main/scala-3/zio/blocks/schema/SchemaCompanionVersionSpecific.scala +++ b/schema/shared/src/main/scala-3/zio/blocks/schema/SchemaCompanionVersionSpecific.scala @@ -78,6 +78,61 @@ private class SchemaCompanionVersionSpecificImpl(using Quotes) { private def isOpaque(tpe: TypeRepr): Boolean = tpe.typeSymbol.flags.is(Flags.Opaque) + // === Structural Type Support (JVM only) === + + private def isStructuralType(tpe: TypeRepr): Boolean = tpe.dealias match { + case Refinement(_, _, _) => true + // Empty structural type {} dealiases to java.lang.Object + case t if t =:= TypeRepr.of[AnyRef] => true + case _ => false + } + + private def getStructuralMembers(tpe: TypeRepr): List[(String, TypeRepr)] = { + def collectMembers(t: TypeRepr): List[(String, TypeRepr)] = t match { + case Refinement(parent, name, info) => + val memberType = info match { + case MethodType(_, _, returnType) => returnType + case ByNameType(underlying) => underlying + case other => other + } + (name, memberType) :: collectMembers(parent) + case _ => Nil + } + collectMembers(tpe.dealias).reverse + } + + private def normalizeStructuralTypeName[T](members: List[(String, TypeRepr)]): TypeName[T] = { + // Sort fields alphabetically for deterministic naming + val sorted = members.sortBy(_._1) + val nameString = sorted.map { case (name, tpe) => + s"$name:${normalizeTypeForName(tpe)}" + }.mkString("{", ",", "}") + new TypeName[T](new Namespace(Nil, Nil), nameString, Nil) + } + + private def normalizeTypeForName(tpe: TypeRepr): String = { + val dealiased = tpe.dealias + if (dealiased =:= intTpe) "Int" + else if (dealiased =:= longTpe) "Long" + else if (dealiased =:= floatTpe) "Float" + else if (dealiased =:= doubleTpe) "Double" + else if (dealiased =:= booleanTpe) "Boolean" + else if (dealiased =:= byteTpe) "Byte" + else if (dealiased =:= charTpe) "Char" + else if (dealiased =:= shortTpe) "Short" + else if (dealiased =:= stringTpe) "String" + else if (dealiased =:= unitTpe) "Unit" + else if (isStructuralType(dealiased)) { + val members = getStructuralMembers(dealiased) + val sorted = members.sortBy(_._1) + sorted.map { case (name, t) => s"$name:${normalizeTypeForName(t)}" }.mkString("{", ",", "}") + } else { + val typeArgs = CommonMacroOps.typeArgs(dealiased) + if (typeArgs.isEmpty) dealiased.typeSymbol.name + else s"${dealiased.typeSymbol.name}[${typeArgs.map(normalizeTypeForName).mkString(",")}]" + } + } + private def opaqueDealias(tpe: TypeRepr): TypeRepr = { @tailrec def loop(tpe: TypeRepr): TypeRepr = tpe match { @@ -155,7 +210,10 @@ private class SchemaCompanionVersionSpecificImpl(using Quotes) { private def normalizeGenericTuple(tpe: TypeRepr): TypeRepr = CommonMacroOps.normalizeGenericTuple(genericTupleTypeArgs(tpe)) - private def isNamedTuple(tpe: TypeRepr): Boolean = CommonMacroOps.isNamedTuple(tpe) + private def isNamedTuple(tpe: TypeRepr): Boolean = tpe match { + case AppliedType(ntTpe, _) => ntTpe.typeSymbol.fullName == "scala.NamedTuple$.NamedTuple" + case _ => false + } private def isJavaTime(tpe: TypeRepr): Boolean = tpe.typeSymbol.fullName.startsWith("java.time.") && (tpe <:< TypeRepr.of[java.time.temporal.Temporal] || tpe <:< TypeRepr.of[java.time.temporal.TemporalAmount]) @@ -209,9 +267,84 @@ private class SchemaCompanionVersionSpecificImpl(using Quotes) { private val typeNameCache = new mutable.HashMap[TypeRepr, TypeName[?]] - private def typeName[T: Type](tpe: TypeRepr): TypeName[T] = CommonMacroOps.typeName(typeNameCache, tpe) + private def typeName[T: Type](tpe: TypeRepr, nestedTpes: List[TypeRepr] = Nil): TypeName[T] = { + def calculateTypeName(tpe: TypeRepr): TypeName[?] = + if (tpe =:= TypeRepr.of[java.lang.String]) TypeName.string + else { + var packages: List[String] = Nil + var values: List[String] = Nil + var name: String = null + val isUnionTpe = isUnion(tpe) + if (isUnionTpe) name = "|" + else { + val tpeTypeSymbol = tpe.typeSymbol + name = tpeTypeSymbol.name + if (isEnumValue(tpe)) { + values = name :: values + name = tpe.termSymbol.name + } else if (tpeTypeSymbol.flags.is(Flags.Module)) name = name.substring(0, name.length - 1) + var owner = tpeTypeSymbol.owner + while (owner != defn.RootClass) { + val ownerName = owner.name + if (owner.flags.is(Flags.Package)) packages = ownerName :: packages + else if (owner.flags.is(Flags.Module)) values = ownerName.substring(0, ownerName.length - 1) :: values + else values = ownerName :: values + owner = owner.owner + } + } + val tpeTypeArgs = + if (isUnionTpe) allUnionTypes(tpe) + else if (isNamedTuple(tpe)) { + val tpeTypeArgs = typeArgs(tpe) + val nTpe = tpeTypeArgs.head + val tTpe = tpeTypeArgs.last + val nTypeArgs = + if (isGenericTuple(nTpe)) genericTupleTypeArgs(nTpe) + else typeArgs(nTpe) + var comma = false + val labels = new java.lang.StringBuilder(name) + labels.append('[') + nTypeArgs.foreach { case ConstantType(StringConstant(str)) => + if (comma) labels.append(',') + else comma = true + labels.append(str) + } + labels.append(']') + name = labels.toString + if (isGenericTuple(tTpe)) genericTupleTypeArgs(tTpe) + else typeArgs(tTpe) + } else if (isGenericTuple(tpe)) genericTupleTypeArgs(tpe) + else typeArgs(tpe) + new TypeName( + new Namespace(packages, values), + name, + tpeTypeArgs.map { x => + if (nestedTpes.contains(x)) typeName[Any](anyTpe) + else typeName(x, x :: nestedTpes) + } + ) + } - private def toExpr[T: Type](tpeName: TypeName[T])(using Quotes): Expr[TypeName[T]] = CommonMacroOps.toExpr(tpeName) + typeNameCache + .getOrElseUpdate( + tpe, + calculateTypeName(tpe match { + case TypeRef(compTpe, "Type") => compTpe + case _ => tpe + }) + ) + .asInstanceOf[TypeName[T]] + } + + private def toExpr[T: Type](tpeName: TypeName[T])(using Quotes): Expr[TypeName[T]] = { + val packages = Varargs(tpeName.namespace.packages.map(Expr(_))) + val vs = tpeName.namespace.values + val values = if (vs.isEmpty) '{ Nil } else Varargs(vs.map(Expr(_))) + val name = Expr(tpeName.name) + val ps = tpeName.params + val params = if (ps.isEmpty) '{ Nil } else Varargs(ps.map(param => toExpr(param.asInstanceOf[TypeName[T]]))) + '{ new TypeName[T](new Namespace($packages, $values), $name, $params) } + } private def doc(tpe: TypeRepr)(using Quotes): Expr[Doc] = { if (isEnumValue(tpe)) tpe.termSymbol @@ -748,6 +881,8 @@ private class SchemaCompanionVersionSpecificImpl(using Quotes) { ) } } + } else if (isStructuralType(tpe)) { + deriveSchemaForStructuralType(tpe) } else if (isNonAbstractScalaClass(tpe)) { deriveSchemaForNonAbstractScalaClass(tpe) } else if (isOpaque(tpe)) { @@ -880,6 +1015,203 @@ private class SchemaCompanionVersionSpecificImpl(using Quotes) { str.toString } + // === Structural Type Schema Derivation (JVM only) === + + private class StructuralFieldInfo( + val name: String, + val tpe: TypeRepr, + val usedRegisters: RegisterOffset + ) + + // NOTE: Empty structural types like `type Empty = {}` dealias to java.lang.Object. + // We now treat AnyRef as an empty structural type, which allows Schema.derived[{}] to work. + + private def deriveSchemaForStructuralType[T: Type](tpe: TypeRepr)(using Quotes): Expr[Schema[T]] = { + // Pure structural types require runtime reflection (JVM only) + if (!Platform.supportsReflection) { + fail( + s"""Cannot derive Schema for structural type '${tpe.show}' on ${Platform.name}. + | + |Structural types require reflection which is only available on JVM. + | + |Consider using a case class instead.""".stripMargin + ) + } + + val members = getStructuralMembers(tpe) + + // Handle empty structural type (no members) + if (members.isEmpty) { + val tpeName = toExpr(normalizeStructuralTypeName[T](members)) + '{ + new Schema( + reflect = new Reflect.Record[Binding, T]( + fields = Vector.empty, + typeName = $tpeName, + recordBinding = new Binding.Record( + constructor = new Constructor { + def usedRegisters: RegisterOffset = RegisterOffset.Zero + def construct(in: Registers, baseOffset: RegisterOffset): T = { + val emptyInstance = new Object {} + emptyInstance.asInstanceOf[T] + } + }, + deconstructor = new Deconstructor { + def usedRegisters: RegisterOffset = RegisterOffset.Zero + def deconstruct(out: Registers, baseOffset: RegisterOffset, in: T): Unit = () + } + ) + ) + ) + } + } else { + // Non-empty structural type - use reflection (JVM only) + deriveSchemaForPureStructuralType[T](tpe, members) + } + } + + @scala.annotation.nowarn("msg=unused explicit parameter") + private def deriveSchemaForPureStructuralType[T: Type](tpe: TypeRepr, members: List[(String, TypeRepr)])(using + Quotes + ): Expr[Schema[T]] = { + + var currentOffset = RegisterOffset.Zero + val fieldInfos: List[(StructuralFieldInfo, RegisterOffset)] = members.map { case (name, memberTpe) => + val offset = structuralFieldOffset(memberTpe) + val baseOffset = currentOffset + currentOffset = currentOffset + offset + (new StructuralFieldInfo(name, memberTpe, offset), baseOffset) + } + + val totalRegisters = currentOffset + val totalRegistersExpr = '{ + RegisterOffset( + objects = ${ Expr(RegisterOffset.getObjects(totalRegisters)) }, + bytes = ${ Expr(RegisterOffset.getBytes(totalRegisters)) } + ) + } + + val fieldTerms = Varargs(fieldInfos.map { case (fi, _) => + fi.tpe.asType match { + case '[ft] => + val fieldName = Expr(fi.name) + val fieldSchema = findImplicitOrDeriveSchema[ft](fi.tpe) + '{ $fieldSchema.reflect.asTerm[T]($fieldName).asInstanceOf[SchemaTerm[Binding, T, ?]] } + } + }) + + val tpeName = toExpr(normalizeStructuralTypeName[T](members)) + + val fieldInfoForRuntime: List[(String, Int, Int, Int)] = fieldInfos.map { case (fi, baseOffset) => + val typeIndicator = + if (fi.tpe.dealias <:< intTpe) 1 + else if (fi.tpe.dealias <:< longTpe) 2 + else if (fi.tpe.dealias <:< floatTpe) 3 + else if (fi.tpe.dealias <:< doubleTpe) 4 + else if (fi.tpe.dealias <:< booleanTpe) 5 + else if (fi.tpe.dealias <:< byteTpe) 6 + else if (fi.tpe.dealias <:< charTpe) 7 + else if (fi.tpe.dealias <:< shortTpe) 8 + else 0 // object + (fi.name, typeIndicator, RegisterOffset.getBytes(baseOffset), RegisterOffset.getObjects(baseOffset)) + } + + val fieldNamesExpr = Expr(fieldInfoForRuntime.map(_._1).toArray) + val fieldTypesExpr = Expr(fieldInfoForRuntime.map(_._2).toArray) + val fieldBytesExpr = Expr(fieldInfoForRuntime.map(_._3).toArray) + val fieldObjectsExpr = Expr(fieldInfoForRuntime.map(_._4).toArray) + + '{ + val _fieldNames: Array[String] = $fieldNamesExpr + val _fieldTypes: Array[Int] = $fieldTypesExpr + val _fieldBytes: Array[Int] = $fieldBytesExpr + val _fieldObjects: Array[Int] = $fieldObjectsExpr + + new Schema( + reflect = new Reflect.Record[Binding, T]( + fields = Vector($fieldTerms*), + typeName = $tpeName, + recordBinding = new Binding.Record( + constructor = new Constructor { + def usedRegisters: RegisterOffset = $totalRegistersExpr + + def construct(in: Registers, baseOffset: RegisterOffset): T = { + // Build a Map from registers and create a Selectable + val values = new scala.collection.mutable.HashMap[String, Any]() + var idx = 0 + val len = _fieldNames.length + while (idx < len) { + val fieldOffset = RegisterOffset(objects = _fieldObjects(idx), bytes = _fieldBytes(idx)) + val value: Any = _fieldTypes(idx) match { + case 1 => in.getInt(baseOffset + fieldOffset) + case 2 => in.getLong(baseOffset + fieldOffset) + case 3 => in.getFloat(baseOffset + fieldOffset) + case 4 => in.getDouble(baseOffset + fieldOffset) + case 5 => in.getBoolean(baseOffset + fieldOffset) + case 6 => in.getByte(baseOffset + fieldOffset) + case 7 => in.getChar(baseOffset + fieldOffset) + case 8 => in.getShort(baseOffset + fieldOffset) + case _ => in.getObject(baseOffset + fieldOffset) + } + values.put(_fieldNames(idx), value) + idx += 1 + } + (new scala.Selectable { + private val fields = values.toMap + @scala.annotation.nowarn("msg=unused private member") + def selectDynamic(name: String): Any = fields(name) + }).asInstanceOf[T] + } + }, + deconstructor = new Deconstructor { + def usedRegisters: RegisterOffset = $totalRegistersExpr + + def deconstruct(out: Registers, baseOffset: RegisterOffset, in: T): Unit = { + // Extract values from structural type using reflection + var idx = 0 + val len = _fieldNames.length + while (idx < len) { + val method = in.getClass.getMethod(_fieldNames(idx)) + val value = method.invoke(in) + val fieldOffset = RegisterOffset(objects = _fieldObjects(idx), bytes = _fieldBytes(idx)) + _fieldTypes(idx) match { + case 1 => out.setInt(baseOffset + fieldOffset, value.asInstanceOf[java.lang.Integer].intValue) + case 2 => out.setLong(baseOffset + fieldOffset, value.asInstanceOf[java.lang.Long].longValue) + case 3 => out.setFloat(baseOffset + fieldOffset, value.asInstanceOf[java.lang.Float].floatValue) + case 4 => + out.setDouble(baseOffset + fieldOffset, value.asInstanceOf[java.lang.Double].doubleValue) + case 5 => + out.setBoolean(baseOffset + fieldOffset, value.asInstanceOf[java.lang.Boolean].booleanValue) + case 6 => out.setByte(baseOffset + fieldOffset, value.asInstanceOf[java.lang.Byte].byteValue) + case 7 => + out.setChar(baseOffset + fieldOffset, value.asInstanceOf[java.lang.Character].charValue) + case 8 => out.setShort(baseOffset + fieldOffset, value.asInstanceOf[java.lang.Short].shortValue) + case _ => out.setObject(baseOffset + fieldOffset, value.asInstanceOf[AnyRef]) + } + idx += 1 + } + } + } + ) + ) + ) + } + } + + private def structuralFieldOffset(tpe: TypeRepr): RegisterOffset = { + val dealiased = tpe.dealias + if (dealiased <:< intTpe) RegisterOffset(ints = 1) + else if (dealiased <:< floatTpe) RegisterOffset(floats = 1) + else if (dealiased <:< longTpe) RegisterOffset(longs = 1) + else if (dealiased <:< doubleTpe) RegisterOffset(doubles = 1) + else if (dealiased <:< booleanTpe) RegisterOffset(booleans = 1) + else if (dealiased <:< byteTpe) RegisterOffset(bytes = 1) + else if (dealiased <:< charTpe) RegisterOffset(chars = 1) + else if (dealiased <:< shortTpe) RegisterOffset(shorts = 1) + else if (dealiased <:< unitTpe) RegisterOffset.Zero + else RegisterOffset(objects = 1) + } + private def cannotDeriveSchema(tpe: TypeRepr): Nothing = fail(s"Cannot derive schema for '${tpe.show}'.") def derived[A: Type]: Expr[Schema[A]] = { diff --git a/schema/shared/src/main/scala-3/zio/blocks/schema/SchemaVersionSpecific.scala b/schema/shared/src/main/scala-3/zio/blocks/schema/SchemaVersionSpecific.scala new file mode 100644 index 000000000..c2d37e20d --- /dev/null +++ b/schema/shared/src/main/scala-3/zio/blocks/schema/SchemaVersionSpecific.scala @@ -0,0 +1,29 @@ +package zio.blocks.schema + +/** + * Scala 3 version-specific methods for Schema instances. + */ +trait SchemaVersionSpecific[A] { self: Schema[A] => + + /** + * Convert this schema to a structural type schema. + * + * The structural type represents the "shape" of A without its nominal + * identity. This enables duck typing and structural validation. + * + * Example: + * {{{ + * case class Person(name: String, age: Int) + * val structuralSchema: Schema[{ def name: String; def age: Int }] = + * Schema.derived[Person].structural + * }}} + * + * Note: This is JVM-only due to reflection requirements for structural types. + * + * @param ts + * Macro-generated conversion to structural representation + * @return + * Schema for the structural type corresponding to A + */ + transparent inline def structural(using ts: ToStructural[A]): Schema[ts.StructuralType] = ts(this) +} diff --git a/schema/shared/src/main/scala-3/zio/blocks/schema/ToStructuralVersionSpecific.scala b/schema/shared/src/main/scala-3/zio/blocks/schema/ToStructuralVersionSpecific.scala new file mode 100644 index 000000000..631f64ca0 --- /dev/null +++ b/schema/shared/src/main/scala-3/zio/blocks/schema/ToStructuralVersionSpecific.scala @@ -0,0 +1,558 @@ +package zio.blocks.schema + +import zio.blocks.schema.binding.* +import zio.blocks.schema.binding.RegisterOffset.* + +import scala.quoted.* + +private[schema] class ReflectiveDeconstructor[S]( + val usedRegisters: RegisterOffset, + fieldMetadata: IndexedSeq[(String, Register[Any])] +) extends Deconstructor[S] { + + def deconstruct(out: Registers, baseOffset: RegisterOffset, in: S): Unit = { + val clazz = in.getClass + var idx = 0 + while (idx < fieldMetadata.length) { + val (fieldName, register) = fieldMetadata(idx) + val method = clazz.getMethod(fieldName) + val value = method.invoke(in) + register.set(out, baseOffset, value) + idx += 1 + } + } +} + +private[schema] class ReflectiveVariantCaseDeconstructor[S]( + val usedRegisters: RegisterOffset, + caseName: String, + fieldMetadata: IndexedSeq[(String, Register[Any])] +) extends Deconstructor[S] { + + def deconstruct(out: Registers, baseOffset: RegisterOffset, in: S): Unit = { + val outerClazz = in.getClass + // First, try to get the nested value via the case name method + val nested = try { + val caseMethod = outerClazz.getMethod(caseName) + caseMethod.invoke(in) + } catch { + case _: NoSuchMethodException => + // Fallback: input is already the case value (nominal type) + in + } + + val nestedClazz = nested.getClass + var idx = 0 + while (idx < fieldMetadata.length) { + val (fieldName, register) = fieldMetadata(idx) + val method = nestedClazz.getMethod(fieldName) + val value = method.invoke(nested) + register.set(out, baseOffset, value) + idx += 1 + } + } +} + +private[schema] class ReflectiveVariantDiscriminator[S]( + caseNames: IndexedSeq[String], + originalDiscriminator: Discriminator[?], + originalToSortedIndex: IndexedSeq[Int] +) extends Discriminator[S] { + + def discriminate(value: S): Int = { + val clazz = value.getClass + // Try to find which case name method exists on the structural instance + val idx = caseNames.indexWhere { caseName => + try { + clazz.getMethod(caseName) + true + } catch { + case _: NoSuchMethodException => false + } + } + if (idx >= 0) idx + else { + // Fallback to original discriminator for nominal types + val originalIdx = originalDiscriminator.asInstanceOf[Discriminator[S]].discriminate(value) + originalToSortedIndex(originalIdx) + } + } +} + +private[schema] class ReflectiveMatcher[S]( + expectedCaseName: String, + originalMatcher: Matcher[?] +) extends Matcher[S] { + + def downcastOrNull(any: Any): S = + if (any == null) null.asInstanceOf[S] + else { + val clazz = any.getClass + try { + val caseMethod = clazz.getMethod(expectedCaseName) + // The case method returns the nested structural value + caseMethod.invoke(any).asInstanceOf[S] + } catch { + case _: NoSuchMethodException => + originalMatcher.downcastOrNull(any).asInstanceOf[S] + } + } +} + +trait ToStructuralVersionSpecific { + transparent inline given [A]: ToStructural[A] = ${ ToStructuralMacro.derived[A] } +} + +private[schema] object ToStructuralMacro { + def derived[A: Type](using Quotes): Expr[ToStructural[A]] = { + import quotes.reflect.* + + val aTpe = TypeRepr.of[A].dealias + + if (!Platform.supportsReflection) { + report.errorAndAbort( + s"""Cannot generate ToStructural[${aTpe.show}] on ${Platform.name}. + | + |Structural types require reflection which is only available on JVM. + |Consider using a case class instead.""".stripMargin + ) + } + + if (isRecursiveType(aTpe)) { + report.errorAndAbort( + s"""Cannot generate structural type for recursive type ${aTpe.show}. + | + |Structural types cannot represent recursive structures. + |Scala's type system does not support infinite types.""".stripMargin + ) + } + + if (isProductType(aTpe)) { + deriveForProduct[A](aTpe) + } else if (isTupleType(aTpe)) { + deriveForTuple[A](aTpe) + } else if (isSumType(aTpe)) { + deriveForSumType[A](aTpe) + } else { + report.errorAndAbort( + s"""Cannot generate ToStructural for ${aTpe.show}. + | + |Only product types (case classes), tuples, and sum types (sealed traits/enums) are supported.""".stripMargin + ) + } + } + + private def isProductType(using Quotes)(tpe: quotes.reflect.TypeRepr): Boolean = { + import quotes.reflect.* + tpe.classSymbol.exists { sym => + val flags = sym.flags + val isCaseObject = flags.is(Flags.Module) && flags.is(Flags.Case) + val isCaseClass = !flags.is(Flags.Abstract) && !flags.is(Flags.Trait) && sym.primaryConstructor.exists + isCaseObject || isCaseClass + } + } + + private def isTupleType(using Quotes)(tpe: quotes.reflect.TypeRepr): Boolean = + tpe.typeSymbol.fullName.startsWith("scala.Tuple") + + private def isSumType(using Quotes)(tpe: quotes.reflect.TypeRepr): Boolean = { + import quotes.reflect.* + tpe.classSymbol.exists { sym => + val flags = sym.flags + val isSealedTrait = flags.is(Flags.Sealed) && flags.is(Flags.Trait) + val isSealedAbstract = flags.is(Flags.Sealed) && flags.is(Flags.Abstract) + val isEnum = flags.is(Flags.Enum) && !flags.is(Flags.Case) + isSealedTrait || isSealedAbstract || isEnum + } + } + + private def isRecursiveType(using Quotes)(tpe: quotes.reflect.TypeRepr): Boolean = { + import quotes.reflect.* + + def containsType(searchIn: TypeRepr, searchFor: TypeRepr, visited: Set[TypeRepr]): Boolean = { + val dealiased = searchIn.dealias + if (visited.contains(dealiased)) return false + val newVisited = visited + dealiased + + if (dealiased =:= searchFor.dealias) return true + + val typeArgsContain = dealiased match { + case AppliedType(_, args) if args.nonEmpty => + args.exists(arg => containsType(arg, searchFor, newVisited)) + case _ => false + } + + if (typeArgsContain) return true + + dealiased.classSymbol.toList.flatMap { sym => + sym.primaryConstructor.paramSymss.flatten.filter(!_.isTypeParam).map { param => + dealiased.memberType(param).dealias + } + }.exists(fieldTpe => containsType(fieldTpe, searchFor, newVisited)) + } + + tpe.classSymbol.toList.flatMap { sym => + sym.primaryConstructor.paramSymss.flatten.filter(!_.isTypeParam).map { param => + tpe.memberType(param).dealias + } + }.exists(fieldTpe => containsType(fieldTpe, tpe, Set.empty)) + } + + private def fullyUnpackType(using Quotes)(tpe: quotes.reflect.TypeRepr): quotes.reflect.TypeRepr = { + import quotes.reflect.* + + val dealiased = tpe.dealias + + dealiased match { + case trTpe: TypeRef if trTpe.isOpaqueAlias => + fullyUnpackType(trTpe.translucentSuperType.dealias) + case _ => + val unpacked = CommonMacroOps.dealiasOnDemand(dealiased) + if (unpacked != dealiased && !(unpacked =:= dealiased)) { + fullyUnpackType(unpacked) + } else { + dealiased match { + case AppliedType(tycon, args) if args.nonEmpty => + val unpackedArgs = args.map(fullyUnpackType) + if (unpackedArgs != args) tycon.appliedTo(unpackedArgs) + else dealiased + case _ => dealiased + } + } + } + } + + private def deriveForProduct[A: Type](using Quotes)(aTpe: quotes.reflect.TypeRepr): Expr[ToStructural[A]] = { + import quotes.reflect.* + + val classSymbol = aTpe.classSymbol.getOrElse( + report.errorAndAbort(s"${aTpe.show} is not a class type") + ) + + val fields: List[(String, TypeRepr)] = classSymbol.primaryConstructor.paramSymss.flatten + .filter(!_.isTypeParam) + .map { param => + val fieldName = param.name + val fieldType = aTpe.memberType(param).dealias + (fieldName, fieldType) + } + + val structuralTpe = buildStructuralType(fields) + + structuralTpe.asType match { + case '[s] => + '{ + new ToStructural[A] { + type StructuralType = s + def apply(schema: Schema[A]): Schema[s] = + transformProductSchema[A, s](schema) + } + } + } + } + + private def deriveForTuple[A: Type](using Quotes)(aTpe: quotes.reflect.TypeRepr): Expr[ToStructural[A]] = { + import quotes.reflect.* + + val typeArgs = aTpe match { + case AppliedType(_, args) => args + case _ => Nil + } + + val fields: List[(String, TypeRepr)] = typeArgs.zipWithIndex.map { case (tpe, idx) => + (s"_${idx + 1}", tpe) + } + + if (fields.isEmpty) { + report.errorAndAbort("Cannot generate structural type for empty tuple") + } + + val structuralTpe = buildStructuralType(fields) + + structuralTpe.asType match { + case '[s] => + '{ + new ToStructural[A] { + type StructuralType = s + def apply(schema: Schema[A]): Schema[s] = + transformTupleSchema[A, s](schema) + } + } + } + } + + private def buildStructuralType(using + Quotes + )(fields: List[(String, quotes.reflect.TypeRepr)]): quotes.reflect.TypeRepr = { + import quotes.reflect.* + + val baseTpe = TypeRepr.of[AnyRef] + + fields.foldLeft(baseTpe) { case (parent, (fieldName, fieldTpe)) => + val unpackedFieldTpe = fullyUnpackType(fieldTpe) + Refinement(parent, fieldName, ByNameType(unpackedFieldTpe)) + } + } + + private def buildNestedCaseType(using + Quotes + )(caseName: String, fields: List[(String, quotes.reflect.TypeRepr)]): quotes.reflect.TypeRepr = { + import quotes.reflect.* + + val innerType = buildStructuralType(fields) + val baseTpe = TypeRepr.of[AnyRef] + Refinement(baseTpe, caseName, ByNameType(innerType)) + } + + private def deriveForSumType[A: Type](using Quotes)(aTpe: quotes.reflect.TypeRepr): Expr[ToStructural[A]] = { + import quotes.reflect.* + + val classSymbol = aTpe.classSymbol.getOrElse( + report.errorAndAbort(s"${aTpe.show} is not a class type") + ) + + val children = classSymbol.children.toList.sortBy(_.name) + + if (children.isEmpty) { + report.errorAndAbort(s"Sum type ${aTpe.show} has no cases") + } + + val caseTypes: List[TypeRepr] = children.map { childSym => + val childTpe = if (childSym.flags.is(Flags.Module)) { + childSym.termRef + } else { + aTpe.memberType(childSym).dealias + } + + val caseName = childSym.name + + val fields: List[(String, TypeRepr)] = if (childSym.flags.is(Flags.Module)) { + Nil + } else { + childSym.primaryConstructor.paramSymss.flatten + .filter(!_.isTypeParam) + .map { param => + val fieldName = param.name + val fieldType = childTpe.memberType(param).dealias + (fieldName, fieldType) + } + } + + buildNestedCaseType(caseName, fields) + } + + val unionType = caseTypes.reduceLeft { (acc, tpe) => + OrType(acc, tpe) + } + + unionType.asType match { + case '[s] => + '{ + new ToStructural[A] { + type StructuralType = s + def apply(schema: Schema[A]): Schema[s] = + transformSumTypeSchema[A, s](schema) + } + } + } + } + + /** + * Transform a product schema (case class) to its structural equivalent. Uses + * reflection-based deconstruction to support anonymous structural instances. + */ + def transformProductSchema[A, S](schema: Schema[A]): Schema[S] = + schema.reflect match { + case record: Reflect.Record[Binding, A] @unchecked => + val binding = record.recordBinding.asInstanceOf[Binding.Record[A]] + + val fieldInfos = record.fields.map { field => + (field.name, field.value.asInstanceOf[Reflect.Bound[Any]]) + } + + val totalRegisters = binding.constructor.usedRegisters + + val typeName = normalizeTypeName(fieldInfos.toList.map { case (name, reflect) => + (name, reflect.typeName.name) + }) + + // Build field metadata for reflective deconstruction + val fieldMetadata: IndexedSeq[(String, Register[Any])] = record.fields.zipWithIndex.map { case (field, idx) => + (field.name, record.registers(idx).asInstanceOf[Register[Any]]) + }.toIndexedSeq + + new Schema[S]( + new Reflect.Record[Binding, S]( + fields = record.fields.map { field => + field.value.asInstanceOf[Reflect.Bound[Any]].asTerm[S](field.name) + }, + typeName = new TypeName[S](new Namespace(Nil, Nil), typeName, Nil), + recordBinding = new Binding.Record[S]( + constructor = new Constructor[S] { + def usedRegisters: RegisterOffset = totalRegisters + + def construct(in: Registers, baseOffset: RegisterOffset): S = { + val nominal = binding.constructor.construct(in, baseOffset) + nominal.asInstanceOf[S] + } + }, + deconstructor = new ReflectiveDeconstructor[S](totalRegisters, fieldMetadata) + ), + doc = record.doc, + modifiers = record.modifiers + ) + ) + + case _ => + throw new IllegalArgumentException( + s"Cannot transform non-record schema to structural type" + ) + } + + /** + * Transform a tuple schema to its structural equivalent. + */ + def transformTupleSchema[A, S](schema: Schema[A]): Schema[S] = + schema.reflect match { + case _: Reflect.Record[Binding, A] @unchecked => + transformProductSchema[A, S](schema) + + case _ => + throw new IllegalArgumentException( + s"Cannot transform non-record schema to structural type" + ) + } + + def transformSumTypeSchema[A, S](schema: Schema[A]): Schema[S] = + schema.reflect match { + case variant: Reflect.Variant[Binding, A] @unchecked => + val binding = variant.variantBinding.asInstanceOf[Binding.Variant[A]] + + val sortedCases = variant.cases.sortBy(_.name) + val caseNames: IndexedSeq[String] = sortedCases.map(_.name).toIndexedSeq + + val unionTypeName = sortedCases.map { case_ => + val caseName = case_.name + val caseFields = case_.value match { + case record: Reflect.Record[Binding, _] @unchecked => + record.fields.map { field => + (field.name, field.value.asInstanceOf[Reflect.Bound[Any]].typeName.name) + }.toList + case _ => Nil + } + normalizeUnionCaseTypeName(caseName, caseFields) + }.mkString("|") + + val sortedToOriginalIndex: IndexedSeq[Int] = sortedCases.map { case_ => + variant.cases.indexWhere(_.name == case_.name) + }.toIndexedSeq + + val originalToSortedIndex: IndexedSeq[Int] = { + val arr = new Array[Int](variant.cases.size) + sortedToOriginalIndex.zipWithIndex.foreach { case (origIdx, sortedIdx) => + arr(origIdx) = sortedIdx + } + arr.toIndexedSeq + } + + val reflectiveDiscriminator = + new ReflectiveVariantDiscriminator[S](caseNames, binding.discriminator, originalToSortedIndex) + + val newMatchers = Matchers[S]( + sortedCases.zipWithIndex.map { case (case_, sortedIdx) => + val originalIdx = sortedToOriginalIndex(sortedIdx) + val caseName = case_.name + val originalMatcher = binding.matchers(originalIdx) + new ReflectiveMatcher[S](caseName, originalMatcher) + }: _* + ) + + new Schema[S]( + new Reflect.Variant[Binding, S]( + cases = sortedCases.map { case_ => + transformVariantCase[S](case_) + }, + typeName = new TypeName[S](new Namespace(Nil, Nil), unionTypeName, Nil), + variantBinding = new Binding.Variant[S]( + discriminator = reflectiveDiscriminator, + matchers = newMatchers + ), + doc = variant.doc, + modifiers = variant.modifiers + ) + ) + + case _ => + throw new IllegalArgumentException( + s"Cannot transform non-variant schema to structural union type" + ) + } + + private def transformVariantCase[S](case_ : Term[Binding, ?, ?]): Term[Binding, S, ? <: S] = { + val caseName = case_.name + val caseValue = case_.value match { + case record: Reflect.Record[Binding, ?] @unchecked => + val binding = record.recordBinding.asInstanceOf[Binding.Record[Any]] + val fieldMetadata: IndexedSeq[(String, Register[Any])] = record.fields.zipWithIndex.map { case (field, idx) => + (field.name, record.registers(idx).asInstanceOf[Register[Any]]) + }.toIndexedSeq + val totalRegisters = binding.constructor.usedRegisters + + new Reflect.Record[Binding, Any]( + fields = record.fields.map { field => + field.value.asInstanceOf[Reflect.Bound[Any]].asTerm[Any](field.name) + }, + typeName = record.typeName.asInstanceOf[TypeName[Any]], + recordBinding = new Binding.Record[Any]( + constructor = binding.constructor.asInstanceOf[Constructor[Any]], + deconstructor = new ReflectiveVariantCaseDeconstructor[Any](totalRegisters, caseName, fieldMetadata) + ), + doc = record.doc, + modifiers = record.modifiers + ) + case other => other + } + new Term[Binding, S, Any]( + caseName, + caseValue.asInstanceOf[Reflect.Bound[Any]], + case_.doc, + case_.modifiers + ).asInstanceOf[Term[Binding, S, ? <: S]] + } + + /** + * Generate a normalized type name for a structural type. Fields are sorted + * alphabetically for deterministic naming. + */ + private def normalizeTypeName(fields: List[(String, String)]): String = { + val sorted = fields.sortBy(_._1) + sorted.map { case (name, typeName) => + s"$name:${simplifyTypeName(typeName)}" + }.mkString("{", ",", "}") + } + + /** + * Generate a normalized type name for a union case (includes Tag type + * member). + */ + private def normalizeUnionCaseTypeName(caseName: String, fields: List[(String, String)]): String = { + val innerTypeName = + if (fields.isEmpty) "{}" + else { + val sorted = fields.sortBy(_._1) + sorted.map { case (name, typeName) => + s"$name:${simplifyTypeName(typeName)}" + }.mkString("{", ",", "}") + } + s"{$caseName:$innerTypeName}" + } + + /** + * Simplify type names for display (e.g., "scala.Int" -> "Int") + */ + private def simplifyTypeName(typeName: String): String = + typeName + .replace("scala.", "") + .replace("java.lang.", "") + .replace("Predef.", "") +} diff --git a/schema/shared/src/main/scala/zio/blocks/schema/Schema.scala b/schema/shared/src/main/scala/zio/blocks/schema/Schema.scala index 7f099bb27..12f1a6fcc 100644 --- a/schema/shared/src/main/scala/zio/blocks/schema/Schema.scala +++ b/schema/shared/src/main/scala/zio/blocks/schema/Schema.scala @@ -12,7 +12,7 @@ import java.util.concurrent.ConcurrentHashMap * of a Scala data type, together with the ability to tear down and build up * values of that type. */ -final case class Schema[A](reflect: Reflect.Bound[A]) { +final case class Schema[A](reflect: Reflect.Bound[A]) extends SchemaVersionSpecific[A] { private[this] val cache: ConcurrentHashMap[codec.Format, ?] = new ConcurrentHashMap private[this] def getInstance[F <: codec.Format](format: F): format.TypeClass[A] = diff --git a/schema/shared/src/main/scala/zio/blocks/schema/ToStructural.scala b/schema/shared/src/main/scala/zio/blocks/schema/ToStructural.scala new file mode 100644 index 000000000..4d133283e --- /dev/null +++ b/schema/shared/src/main/scala/zio/blocks/schema/ToStructural.scala @@ -0,0 +1,26 @@ +package zio.blocks.schema + +/** + * Type class for converting nominal schemas to structural schemas. + * + * Given a Schema[A] for a nominal type like a case class, this type class + * generates the corresponding structural type and provides a conversion method. + * + * Example: + * {{{ + * case class Person(name: String, age: Int) + * + * val nominalSchema: Schema[Person] = Schema.derived[Person] + * val structuralSchema: Schema[{ def name: String; def age: Int }] = nominalSchema.structural + * }}} + * + * Note: This is JVM-only due to reflection requirements for structural types. + */ +trait ToStructural[A] { + type StructuralType + def apply(schema: Schema[A]): Schema[StructuralType] +} + +object ToStructural extends ToStructuralVersionSpecific { + type Aux[A, S] = ToStructural[A] { type StructuralType = S } +} diff --git a/schema/shared/src/test/scala/zio/blocks/schema/IntoIsCoercibleTest.scala b/schema/shared/src/test/scala/zio/blocks/schema/IntoIsCoercibleTest.scala index ceb29ef32..6333bb890 100644 --- a/schema/shared/src/test/scala/zio/blocks/schema/IntoIsCoercibleTest.scala +++ b/schema/shared/src/test/scala/zio/blocks/schema/IntoIsCoercibleTest.scala @@ -121,10 +121,10 @@ object IntoIsCoercibleTest extends SchemaBaseSpec { test("Long to Int in case class - overflow") { case class Source(value: Long) case class Target(value: Int) - locally { val _ = Target } val source = Source(Long.MaxValue) val result = Into.derived[Source, Target].into(source) + val _ = Target(0) // suppress unused warning assertTrue( result.isLeft, @@ -143,10 +143,10 @@ object IntoIsCoercibleTest extends SchemaBaseSpec { test("Double to Float in case class - overflow") { case class Source(value: Double) case class Target(value: Float) - locally { val _ = Target } val source = Source(Double.MaxValue) val result = Into.derived[Source, Target].into(source) + val _ = Target(0f) // suppress unused warning assertTrue( result.isLeft,