diff --git a/.gitignore b/.gitignore index 621f6623..8fc2c65b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ /website/variables.js /website/yarn.lock target/ +.metals/ +.vscode/ +.bloop/ +metals.sbt \ No newline at end of file diff --git a/build.sbt b/build.sbt index 99bff4ca..f3eb4a09 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,9 @@ val catsVersion = "2.7.0" val enumeratumVersion = "1.7.0" -val magnoliaVersion = "0.17.0" +val magnolia2Version = "0.17.0" + +val magnolia3Version = "1.1.0" val refinedVersion = "0.9.27" @@ -12,11 +14,12 @@ val shapelessVersion = "2.3.8" val shapeless3Version = "3.0.4" -val scala212 = "2.12.14" +val scala212 = "2.12.15" val scala213 = "2.13.8" val scala3 = "3.0.2" +val scala3_1 = "3.1.1" // used in generic module as requiried for Magnolia lazy val vulcan = project .in(file(".")) @@ -77,19 +80,22 @@ lazy val generic = project libraryDependencies ++= { if (scalaVersion.value.startsWith("2")) Seq( - "com.propensive" %% "magnolia" % magnoliaVersion, + "com.propensive" %% "magnolia" % magnolia2Version, "com.chuusai" %% "shapeless" % shapelessVersion, "org.scala-lang" % "scala-reflect" % scalaVersion.value % Provided ) else - Seq("org.typelevel" %% "shapeless3-deriving" % shapeless3Version) + Seq( + "com.softwaremill.magnolia1_3" %% "magnolia" % magnolia3Version, + "org.typelevel" %% "shapeless3-deriving" % shapeless3Version + ) } ), scalatestSettings, publishSettings, mimaSettings(excludeScala3 = true), // re-include scala 3 after publishing scalaSettings ++ Seq( - crossScalaVersions += scala3 + crossScalaVersions += scala3_1 ), testSettings ) @@ -202,47 +208,51 @@ lazy val mdocSettings = Seq( lazy val buildInfoSettings = Seq( buildInfoPackage := "vulcan.build", buildInfoObject := "info", - buildInfoKeys := Seq[BuildInfoKey]( - scalaVersion, - scalacOptions, - sourceDirectory, - ThisBuild / latestVersion, - BuildInfoKey.map(ThisBuild / version) { - case (_, v) => "latestSnapshotVersion" -> v - }, - BuildInfoKey.map(core / moduleName) { - case (k, v) => "core" ++ k.capitalize -> v - }, - BuildInfoKey.map(core / crossScalaVersions) { - case (k, v) => "core" ++ k.capitalize -> v - }, - BuildInfoKey.map(enumeratum / moduleName) { - case (k, v) => "enumeratum" ++ k.capitalize -> v - }, - BuildInfoKey.map(enumeratum / crossScalaVersions) { - case (k, v) => "enumeratum" ++ k.capitalize -> v - }, - BuildInfoKey.map(generic / moduleName) { - case (k, v) => "generic" ++ k.capitalize -> v - }, - BuildInfoKey.map(generic / crossScalaVersions) { - case (k, v) => "generic" ++ k.capitalize -> v - }, - BuildInfoKey.map(refined / moduleName) { - case (k, v) => "refined" ++ k.capitalize -> v - }, - BuildInfoKey.map(refined / crossScalaVersions) { - case (k, v) => "refined" ++ k.capitalize -> v - }, - LocalRootProject / organization, - core / crossScalaVersions, - BuildInfoKey("avroVersion" -> avroVersion), - BuildInfoKey("catsVersion" -> catsVersion), - BuildInfoKey("enumeratumVersion" -> enumeratumVersion), - BuildInfoKey("magnoliaVersion" -> magnoliaVersion), - BuildInfoKey("refinedVersion" -> refinedVersion), - BuildInfoKey("shapelessVersion" -> shapelessVersion) - ) + buildInfoKeys := { + val magnolia: String = + if (scalaVersion.value.startsWith("3")) magnolia3Version else magnolia2Version + Seq[BuildInfoKey]( + scalaVersion, + scalacOptions, + sourceDirectory, + ThisBuild / latestVersion, + BuildInfoKey.map(ThisBuild / version) { + case (_, v) => "latestSnapshotVersion" -> v + }, + BuildInfoKey.map(core / moduleName) { + case (k, v) => "core" ++ k.capitalize -> v + }, + BuildInfoKey.map(core / crossScalaVersions) { + case (k, v) => "core" ++ k.capitalize -> v + }, + BuildInfoKey.map(enumeratum / moduleName) { + case (k, v) => "enumeratum" ++ k.capitalize -> v + }, + BuildInfoKey.map(enumeratum / crossScalaVersions) { + case (k, v) => "enumeratum" ++ k.capitalize -> v + }, + BuildInfoKey.map(generic / moduleName) { + case (k, v) => "generic" ++ k.capitalize -> v + }, + BuildInfoKey.map(generic / crossScalaVersions) { + case (k, v) => "generic" ++ k.capitalize -> v + }, + BuildInfoKey.map(refined / moduleName) { + case (k, v) => "refined" ++ k.capitalize -> v + }, + BuildInfoKey.map(refined / crossScalaVersions) { + case (k, v) => "refined" ++ k.capitalize -> v + }, + LocalRootProject / organization, + core / crossScalaVersions, + BuildInfoKey("avroVersion" -> avroVersion), + BuildInfoKey("catsVersion" -> catsVersion), + BuildInfoKey("enumeratumVersion" -> enumeratumVersion), + BuildInfoKey("magnoliaVersion" -> magnolia), + BuildInfoKey("refinedVersion" -> refinedVersion), + BuildInfoKey("shapelessVersion" -> shapelessVersion) + ) + } ) lazy val metadataSettings = Seq( diff --git a/docs/src/main/mdoc/modules.md b/docs/src/main/mdoc/modules.md index 0c88d840..68d0950b 100644 --- a/docs/src/main/mdoc/modules.md +++ b/docs/src/main/mdoc/modules.md @@ -79,7 +79,7 @@ Codec[Day] ## Generic -The `@GENERIC_MODULE_NAME@` module provides generic derivation of [`Codec`][codec]s using [Magnolia](https://github.com/propensive/magnolia) for records and unions (currently not supported in Scala 3), and reflection for enumerations and fixed types. +The `@GENERIC_MODULE_NAME@` module provides generic derivation of [`Codec`][codec]s using [Magnolia](https://github.com/softwaremill/magnolia) for records and unions, and reflection for enumerations and fixed types. To derive [`Codec`][codec]s for `case class`es or `sealed trait`s, we can use `Codec.derive`. Annotations like `@AvroDoc` and `@AvroNamespace` can be used to customize the documentation and namespace during derivation. diff --git a/modules/generic/src/main/scala-2/vulcan/generic/package.scala b/modules/generic/src/main/scala-2/vulcan/generic/package.scala index c25113f7..1a1fb857 100644 --- a/modules/generic/src/main/scala-2/vulcan/generic/package.scala +++ b/modules/generic/src/main/scala-2/vulcan/generic/package.scala @@ -118,8 +118,14 @@ package object generic { .collectFirst { case AvroNamespace(namespace) => namespace } .getOrElse(caseClass.typeName.owner) + val shortName = + caseClass.annotations + .collectFirst { case AvroName(namespace) => namespace } + .getOrElse(caseClass.typeName.short) + val typeName = - s"$namespace.${caseClass.typeName.short}" + s"$namespace.$shortName" + val schema = if (caseClass.isValueClass) { caseClass.parameters.head.typeclass.schema diff --git a/modules/generic/src/main/scala-3/vulcan/generic/package.scala b/modules/generic/src/main/scala-3/vulcan/generic/package.scala index 182b4c40..89b7d575 100644 --- a/modules/generic/src/main/scala-3/vulcan/generic/package.scala +++ b/modules/generic/src/main/scala-3/vulcan/generic/package.scala @@ -6,14 +6,229 @@ package vulcan + import org.apache.avro.generic._ import org.apache.avro.Schema import shapeless3.deriving._ import scala.compiletime._ import scala.reflect.ClassTag +import scala.deriving.Mirror +import cats.implicits._ +import magnolia1._ +import org.apache.avro.generic._ +import org.apache.avro.Schema +import vulcan.internal.converters.collection._ package object generic { + implicit final class MagnoliaCodec private[generic] ( + private val codec: Codec.type + ) extends Derivation[Codec] { + inline def derive[A](using Mirror.Of[A]): Codec[A] = derived[A] + + final def join[A](caseClass: CaseClass[Codec, A]): Codec[A] = { + val namespace = + caseClass.annotations + .collectFirst { case AvroNamespace(namespace) => namespace } + .getOrElse(caseClass.typeInfo.owner) + + val shortName = + caseClass.annotations + .collectFirst { case AvroName(namespace) => namespace } + .getOrElse(caseClass.typeInfo.short) + + val typeName = + s"$namespace.$shortName" + + val schema = + if (caseClass.isValueClass) { + caseClass.params.head.typeclass.schema + } else { + AvroError.catchNonFatal { + val nullDefaultBase = caseClass.annotations + .collectFirst { case AvroNullDefault(enabled) => enabled } + .getOrElse(false) + + val fields = + caseClass.params.toList.traverse { param => + param.typeclass.schema.map { schema => + def nullDefaultField = + param.annotations + .collectFirst { + case AvroNullDefault(nullDefault) => nullDefault + } + .getOrElse(nullDefaultBase) + + new Schema.Field( + param.label, + schema, + param.annotations.collectFirst { + case AvroDoc(doc) => doc + }.orNull, + if (schema.isNullable && nullDefaultField) Schema.Field.NULL_DEFAULT_VALUE + else null + ) + } + } + + fields.map { fields => + Schema.createRecord( + shortName, + caseClass.annotations.collectFirst { + case AvroDoc(doc) => doc + }.orNull, + namespace, + false, + fields.asJava + ) + } + } + } + Codec + .instance[Any, A]( + schema, + if (caseClass.isValueClass) { a => + val param = caseClass.params.head + param.typeclass.encode(param.deref(a)) + } else + (a: A) => + schema.flatMap { schema => + val fields = + caseClass.params.toList.traverse { param => + param.typeclass + .encode(param.deref(a)) + .tupleLeft(param.label) + } + + fields.map { values => + val record = new GenericData.Record(schema) + values.foreach { + case (label, value) => + record.put(label, value) + } + + record + } + }, + if (caseClass.isValueClass) { (value, schema) => + caseClass.params.head.typeclass + .decode(value, schema) + .map(decoded => caseClass.rawConstruct(List(decoded))) + } else + (value, writerSchema) => { + writerSchema.getType() match { + case Schema.Type.RECORD => + value match { + case record: IndexedRecord => + caseClass.params.toList + .traverse { + param => + val field = record.getSchema.getField(param.label) + if (field != null) { + val value = record.get(field.pos) + param.typeclass.decode(value, field.schema()) + } else { + schema.flatMap { readerSchema => + readerSchema.getFields.asScala + .find(_.name == param.label) + .filter(_.hasDefaultValue) + .toRight(AvroError.decodeMissingRecordField(param.label)) + .flatMap( + readerField => param.typeclass.decode(null, readerField.schema) + ) + } + } + } + .map(caseClass.rawConstruct) + + case other => + Left(AvroError.decodeUnexpectedType(other, "IndexedRecord")) + } + + case schemaType => + Left { + AvroError + .decodeUnexpectedSchemaType( + schemaType, + Schema.Type.RECORD + ) + } + } + } + ) + .withTypeName(typeName) + } + + final def split[A](sealedTrait: SealedTrait[Codec, A]): Codec.Aux[Any, A] = { + val typeName = sealedTrait.typeInfo.full + Codec + .instance[Any, A]( + AvroError.catchNonFatal { + sealedTrait.subtypes.toList + .sortBy(_.typeInfo.full) + .traverse(_.typeclass.schema) + .map(schemas => Schema.createUnion(schemas.asJava)) + }, + a => + sealedTrait.choose(a) { subtype => + subtype.typeclass.encode(subtype.cast(a)) + }, + (value, schema) => { + val schemaTypes = + schema.getType() match { + case Schema.Type.UNION => schema.getTypes.asScala + case _ => Seq(schema) + } + + value match { + case container: GenericContainer => + val subtypeName = + container.getSchema.getName + + val subtypeUnionSchema = + schemaTypes + .find(_.getName == subtypeName) + .toRight(AvroError.decodeMissingUnionSchema(subtypeName)) + + def subtypeMatching = + sealedTrait.subtypes + .find(_.typeclass.schema.exists(_.getName == subtypeName)) + .toRight(AvroError.decodeMissingUnionAlternative(subtypeName)) + + subtypeUnionSchema.flatMap { subtypeSchema => + subtypeMatching.flatMap { subtype => + subtype.typeclass.decode(container, subtypeSchema) + } + } + + case other => + sealedTrait.subtypes.toList + .collectFirstSome { subtype => + subtype.typeclass.schema + .traverse { subtypeSchema => + val subtypeName = subtypeSchema.getName + schemaTypes + .find(_.getName == subtypeName) + .flatMap { schema => + subtype.typeclass + .decode(other, schema) + .toOption + } + } + } + .getOrElse { + Left(AvroError.decodeExhaustedAlternatives(other)) + } + } + } + ) + .withTypeName(typeName) + } + + final type Typeclass[A] = Codec[A] + } + + /** * Returns an enum `Codec` for type `A`, deriving details * like the name, namespace, and [[AvroDoc]] documentation diff --git a/modules/generic/src/test/scala-2/vulcan/generic/CoproductCodecSpec.scala b/modules/generic/src/test/scala-2/vulcan/generic/CoproductCodecSpec.scala new file mode 100644 index 00000000..6294d874 --- /dev/null +++ b/modules/generic/src/test/scala-2/vulcan/generic/CoproductCodecSpec.scala @@ -0,0 +1,181 @@ +package vulcan.generic + +import org.apache.avro.{Schema, SchemaBuilder} +import shapeless.{:+:, CNil, Coproduct} +import vulcan._ +import vulcan.generic.examples._ + +final class CoproductCodecSpec extends CodecBase { + describe("Codec") { + describe("cnil") { + describe("schema") { + it("should be encoded as empty union") { + assertSchemaIs[CNil] { + """[]""" + } + } + } + + describe("encode") { + it("should error") { + assertEncodeError[CNil]( + null, + "Error encoding Coproduct: Exhausted alternatives for type null" + ) + } + } + + describe("decode") { + it("should error") { + assertDecodeError[CNil]( + null, + unsafeSchema[CNil], + "Error decoding Coproduct: Exhausted alternatives for type null" + ) + } + } + } + + describe("coproduct") { + describe("schema") { + it("should be encoded as union") { + assertSchemaIs[Int :+: String :+: CNil] { + """["int","string"]""" + } + } + + it("should capture errors on nested unions") { + assertSchemaError[Int :+: Option[String] :+: CNil] { + """org.apache.avro.AvroRuntimeException: Nested union: [["null","string"]]""" + } + } + + it("should fail if CNil schema is not union") { + val codec: Codec[Int :+: CNil] = + coproductCodec[Int, CNil]( + Codec.int, + shapeless.Lazy { + Codec.instance[Null, CNil]( + Right(SchemaBuilder.builder().nullType()), + _ => Left(AvroError("encode")), + (_, _) => Left(AvroError("decode")) + ) + } + ) + + assertSchemaError[Int :+: CNil] { + """Unexpected schema type NULL in Coproduct""" + }(codec) + } + } + + describe("encode") { + it("should encode first in coproduct using first type") { + type A = Int :+: String :+: CNil + assertEncodeIs[A]( + Coproduct[A](123), + Right(unsafeEncode(123)) + ) + } + + it("should encode second in coproduct using second type") { + type A = Int :+: String :+: CNil + assertEncodeIs[A]( + Coproduct[A]("abc"), + Right(unsafeEncode("abc")) + ) + } + } + + describe("decode") { + it("should error if schema is not in union") { + type A = Int :+: String :+: CNil + assertDecodeError[A]( + unsafeEncode(Coproduct[A](123)), + unsafeSchema[String], + "Error decoding Coproduct: Exhausted alternatives for type java.lang.Integer" + ) + } + + it("should decode if schema is part of union") { + type A = Int :+: String :+: CNil + assertDecodeIs[A]( + unsafeEncode(Coproduct[A](123)), + Right(Coproduct[A](123)), + Some(unsafeSchema[Int]) + ) + } + + it("should error on empty union schema") { + type A = Int :+: String :+: CNil + assertDecodeError[A]( + unsafeEncode(Coproduct[A](123)), + Schema.createUnion(), + "Error decoding Coproduct: Exhausted alternatives for type java.lang.Integer" + ) + } + + it("should decode first in coproduct using first type") { + type A = Int :+: String :+: CNil + assertDecodeIs[A]( + unsafeEncode(Coproduct[A](123)), + Right(Coproduct[A](123)) + ) + } + + it("should decode second in coproduct using second type") { + type A = Int :+: String :+: CNil + assertDecodeIs[A]( + unsafeEncode(Coproduct[A]("abc")), + Right(Coproduct[A]("abc")) + ) + } + + it("should decode coproduct with records") { + type A = CaseClassField :+: CaseClassTwoFields :+: Int :+: CNil + assertDecodeIs[A]( + unsafeEncode(Coproduct[A](CaseClassField(10))), + Right(Coproduct[A](CaseClassField(10))) + ) + + assertDecodeIs[A]( + unsafeEncode(Coproduct[A](CaseClassTwoFields("name", 10))), + Right(Coproduct[A](CaseClassTwoFields("name", 10))) + ) + + assertDecodeIs[A]( + unsafeEncode(Coproduct[A](123)), + Right(Coproduct[A](123)) + ) + } + + it("should error if no schema in union with container name") { + type A = Int :+: CaseClassField :+: CNil + assertDecodeError[A]( + unsafeEncode(Coproduct[A](CaseClassField(10))), + unsafeSchema[Int :+: String :+: CNil], + "Error decoding Coproduct: Missing schema CaseClassField in union" + ) + } + + it("should error when not enough union schemas") { + type A = Int :+: String :+: CNil + assertDecodeError[A]( + unsafeEncode(Coproduct[A]("abc")), + Schema.createUnion(), + "Error decoding Coproduct: Exhausted alternatives for type org.apache.avro.util.Utf8" + ) + } + + it("should error when not enough union schemas when decoding record") { + type A = Int :+: CaseClassField :+: CNil + assertDecodeError[A]( + unsafeEncode(Coproduct[A](CaseClassField(10))), + unsafeSchema[CNil], + "Error decoding Coproduct: Missing schema CaseClassField in union" + ) + } + } + } + } +} diff --git a/modules/generic/src/test/scala-2/vulcan/generic/CoproductRoundtripSpec.scala b/modules/generic/src/test/scala-2/vulcan/generic/CoproductRoundtripSpec.scala new file mode 100644 index 00000000..b6c0ab4c --- /dev/null +++ b/modules/generic/src/test/scala-2/vulcan/generic/CoproductRoundtripSpec.scala @@ -0,0 +1,40 @@ +package vulcan.generic + +import cats.Eq +import cats.implicits._ +import org.scalacheck.{Arbitrary, Gen} +import org.scalacheck.Arbitrary.arbitrary +import shapeless.{:+:, CNil, Coproduct} +import vulcan._ +import vulcan.generic.examples._ + +final class CoproductRoundtripSpec extends RoundtripBase { + describe("coproduct") { + type Types = CaseClassField :+: Int :+: CaseClassAvroDoc :+: CNil + + implicit val arbitraryTypes: Arbitrary[Types] = + Arbitrary { + Gen.oneOf( + arbitrary[Int].map(n => Coproduct[Types](CaseClassField(n))), + arbitrary[Int].map(n => Coproduct[Types](n)), + arbitrary[Option[String]].map(os => Coproduct[Types](CaseClassAvroDoc(os))) + ) + } + + implicit val eqTypes: Eq[Types] = + Eq.fromUniversalEquals + + it("roundtrip.derived") { + roundtrip[Types] + } + + it("roundtrip.union") { + implicit val codec: Codec[Types] = + Codec.union { alt => + alt[CaseClassField] |+| alt[Int] |+| alt[CaseClassAvroDoc] + } + + roundtrip[Types] + } + } +} diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassValueClass.scala b/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassValueClass.scala index 7e70c43d..afcb0c37 100644 --- a/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassValueClass.scala +++ b/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassValueClass.scala @@ -6,6 +6,5 @@ import vulcan.generic._ final case class CaseClassValueClass(value: Int) extends AnyVal object CaseClassValueClass { - implicit val codec: Codec[CaseClassValueClass] = - Codec.derive + implicit val codec: Codec[CaseClassValueClass] = Codec.derive } diff --git a/modules/generic/src/test/scala-3/vulcan/generic/examples/CaseClassValueClass.scala b/modules/generic/src/test/scala-3/vulcan/generic/examples/CaseClassValueClass.scala new file mode 100644 index 00000000..c7606d98 --- /dev/null +++ b/modules/generic/src/test/scala-3/vulcan/generic/examples/CaseClassValueClass.scala @@ -0,0 +1,11 @@ +package vulcan.generic.examples + +import vulcan.Codec +import vulcan.generic._ + +final case class CaseClassValueClass(value: Int) extends AnyVal + +object CaseClassValueClass { + // we don't support autoderivation for value classes in Scala 3 + implicit val codec: Codec[CaseClassValueClass] = Codec[Int].imap(apply)(_.value) +} diff --git a/modules/generic/src/test/scala-2/vulcan/generic/AvroDocSpec.scala b/modules/generic/src/test/scala/vulcan/generic/AvroDocSpec.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/AvroDocSpec.scala rename to modules/generic/src/test/scala/vulcan/generic/AvroDocSpec.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/AvroNameSpec.scala b/modules/generic/src/test/scala/vulcan/generic/AvroNameSpec.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/AvroNameSpec.scala rename to modules/generic/src/test/scala/vulcan/generic/AvroNameSpec.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/AvroNamespaceSpec.scala b/modules/generic/src/test/scala/vulcan/generic/AvroNamespaceSpec.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/AvroNamespaceSpec.scala rename to modules/generic/src/test/scala/vulcan/generic/AvroNamespaceSpec.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/AvroNullDefaultSpec.scala b/modules/generic/src/test/scala/vulcan/generic/AvroNullDefaultSpec.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/AvroNullDefaultSpec.scala rename to modules/generic/src/test/scala/vulcan/generic/AvroNullDefaultSpec.scala diff --git a/modules/generic/src/test/scala/vulcan/generic/CodecBase.scala b/modules/generic/src/test/scala/vulcan/generic/CodecBase.scala new file mode 100644 index 00000000..1aba99d7 --- /dev/null +++ b/modules/generic/src/test/scala/vulcan/generic/CodecBase.scala @@ -0,0 +1,60 @@ +package vulcan.generic + +import cats.implicits._ +import org.scalatest.Assertion +import org.scalatest.funspec.AnyFunSpec +import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks +import vulcan._ +import org.apache.avro.Schema + +class CodecBase extends AnyFunSpec with ScalaCheckPropertyChecks with EitherValues { + def unsafeSchema[A](implicit codec: Codec[A]): Schema = + codec.schema.value + + def unsafeEncode[A](a: A)(implicit codec: Codec[A]): Any = + codec.encode(a).value + + def unsafeDecode[A](value: Any)(implicit codec: Codec[A]): A = + codec.schema.flatMap(codec.decode(value, _)).value + + def assertSchemaIs[A](expectedSchema: String)(implicit codec: Codec[A]): Assertion = + assert(codec.schema.value.toString == expectedSchema) + + def assertEncodeIs[A]( + a: A, + encoded: Either[AvroError, Any] + )(implicit codec: Codec[A]): Assertion = + assert(unsafeEncode(a) === encoded.value) + + def assertDecodeIs[A]( + value: Any, + decoded: Either[AvroError, A], + schema: Option[Schema] = None + )(implicit codec: Codec[A]): Assertion = + assert { + val decode = + schema + .map(codec.decode(value, _).value) + .getOrElse(unsafeDecode[A](value)) + + decode === decoded.value + } + + def assertSchemaError[A]( + expectedErrorMessage: String + )(implicit codec: Codec[A]): Assertion = + assert(codec.schema.swap.value.message == expectedErrorMessage) + + def assertDecodeError[A]( + value: Any, + schema: Schema, + expectedErrorMessage: String + )(implicit codec: Codec[A]): Assertion = + assert(codec.decode(value, schema).swap.value.message == expectedErrorMessage) + + def assertEncodeError[A]( + a: A, + expectedErrorMessage: String + )(implicit codec: Codec[A]): Assertion = + assert(codec.encode(a).swap.value.message == expectedErrorMessage) +} diff --git a/modules/generic/src/test/scala-2/vulcan/generic/CodecSpec.scala b/modules/generic/src/test/scala/vulcan/generic/GenericDerivationCodecSpec.scala similarity index 61% rename from modules/generic/src/test/scala-2/vulcan/generic/CodecSpec.scala rename to modules/generic/src/test/scala/vulcan/generic/GenericDerivationCodecSpec.scala index 5bcc5186..27342bf8 100644 --- a/modules/generic/src/test/scala-2/vulcan/generic/CodecSpec.scala +++ b/modules/generic/src/test/scala/vulcan/generic/GenericDerivationCodecSpec.scala @@ -1,188 +1,12 @@ package vulcan.generic -import cats.implicits._ -import org.apache.avro.{Schema, SchemaBuilder} +import org.apache.avro.{Schema} import org.apache.avro.generic.GenericData -import org.scalatest.Assertion -import org.scalatest.funspec.AnyFunSpec -import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks -import shapeless.{:+:, CNil, Coproduct} -import vulcan._ import vulcan.generic.examples._ import vulcan.internal.converters.collection._ -final class CodecSpec extends AnyFunSpec with ScalaCheckPropertyChecks with EitherValues { +final class GenericDerivationCodecSpec extends CodecBase { describe("Codec") { - describe("cnil") { - describe("schema") { - it("should be encoded as empty union") { - assertSchemaIs[CNil] { - """[]""" - } - } - } - - describe("encode") { - it("should error") { - assertEncodeError[CNil]( - null, - "Error encoding Coproduct: Exhausted alternatives for type null" - ) - } - } - - describe("decode") { - it("should error") { - assertDecodeError[CNil]( - null, - unsafeSchema[CNil], - "Error decoding Coproduct: Exhausted alternatives for type null" - ) - } - } - } - - describe("coproduct") { - describe("schema") { - it("should be encoded as union") { - assertSchemaIs[Int :+: String :+: CNil] { - """["int","string"]""" - } - } - - it("should capture errors on nested unions") { - assertSchemaError[Int :+: Option[String] :+: CNil] { - """org.apache.avro.AvroRuntimeException: Nested union: [["null","string"]]""" - } - } - - it("should fail if CNil schema is not union") { - val codec: Codec[Int :+: CNil] = - coproductCodec[Int, CNil]( - Codec.int, - shapeless.Lazy { - Codec.instance[Null, CNil]( - Right(SchemaBuilder.builder().nullType()), - _ => Left(AvroError("encode")), - (_, _) => Left(AvroError("decode")) - ) - } - ) - - assertSchemaError[Int :+: CNil] { - """Unexpected schema type NULL in Coproduct""" - }(codec) - } - } - - describe("encode") { - it("should encode first in coproduct using first type") { - type A = Int :+: String :+: CNil - assertEncodeIs[A]( - Coproduct[A](123), - Right(unsafeEncode(123)) - ) - } - - it("should encode second in coproduct using second type") { - type A = Int :+: String :+: CNil - assertEncodeIs[A]( - Coproduct[A]("abc"), - Right(unsafeEncode("abc")) - ) - } - } - - describe("decode") { - it("should error if schema is not in union") { - type A = Int :+: String :+: CNil - assertDecodeError[A]( - unsafeEncode(Coproduct[A](123)), - unsafeSchema[String], - "Error decoding Coproduct: Exhausted alternatives for type java.lang.Integer" - ) - } - - it("should decode if schema is part of union") { - type A = Int :+: String :+: CNil - assertDecodeIs[A]( - unsafeEncode(Coproduct[A](123)), - Right(Coproduct[A](123)), - Some(unsafeSchema[Int]) - ) - } - - it("should error on empty union schema") { - type A = Int :+: String :+: CNil - assertDecodeError[A]( - unsafeEncode(Coproduct[A](123)), - Schema.createUnion(), - "Error decoding Coproduct: Exhausted alternatives for type java.lang.Integer" - ) - } - - it("should decode first in coproduct using first type") { - type A = Int :+: String :+: CNil - assertDecodeIs[A]( - unsafeEncode(Coproduct[A](123)), - Right(Coproduct[A](123)) - ) - } - - it("should decode second in coproduct using second type") { - type A = Int :+: String :+: CNil - assertDecodeIs[A]( - unsafeEncode(Coproduct[A]("abc")), - Right(Coproduct[A]("abc")) - ) - } - - it("should decode coproduct with records") { - type A = CaseClassField :+: CaseClassTwoFields :+: Int :+: CNil - assertDecodeIs[A]( - unsafeEncode(Coproduct[A](CaseClassField(10))), - Right(Coproduct[A](CaseClassField(10))) - ) - - assertDecodeIs[A]( - unsafeEncode(Coproduct[A](CaseClassTwoFields("name", 10))), - Right(Coproduct[A](CaseClassTwoFields("name", 10))) - ) - - assertDecodeIs[A]( - unsafeEncode(Coproduct[A](123)), - Right(Coproduct[A](123)) - ) - } - - it("should error if no schema in union with container name") { - type A = Int :+: CaseClassField :+: CNil - assertDecodeError[A]( - unsafeEncode(Coproduct[A](CaseClassField(10))), - unsafeSchema[Int :+: String :+: CNil], - "Error decoding Coproduct: Missing schema CaseClassField in union" - ) - } - - it("should error when not enough union schemas") { - type A = Int :+: String :+: CNil - assertDecodeError[A]( - unsafeEncode(Coproduct[A]("abc")), - Schema.createUnion(), - "Error decoding Coproduct: Exhausted alternatives for type org.apache.avro.util.Utf8" - ) - } - - it("should error when not enough union schemas when decoding record") { - type A = Int :+: CaseClassField :+: CNil - assertDecodeError[A]( - unsafeEncode(Coproduct[A](CaseClassField(10))), - unsafeSchema[CNil], - "Error decoding Coproduct: Missing schema CaseClassField in union" - ) - } - } - } describe("derive") { describe("caseClass") { @@ -351,6 +175,13 @@ final class CodecSpec extends AnyFunSpec with ScalaCheckPropertyChecks with Eith } } + // Preserving existing behaviour from Magnolia for scala 2 + it("should order alternatives alphabetically by class name") { + assertSchemaIs[SealedTraitCaseClassAvroNamespace]( + """[{"type":"record","name":"FirstInSealedTraitCaseClassAvroNamespace","namespace":"vulcan.generic.examples","fields":[{"name":"value","type":"int"}]},{"type":"record","name":"SecondInSealedTraitCaseClassAvroNamespace","namespace":"com.example","fields":[{"name":"value","type":"string"}]}]""" + ) + } + it("should capture errors on nested unions") { assertSchemaError[SealedTraitNestedUnion] { """org.apache.avro.AvroRuntimeException: Nested union: [["null","int"]]""" @@ -426,54 +257,4 @@ final class CodecSpec extends AnyFunSpec with ScalaCheckPropertyChecks with Eith } } } - - def unsafeSchema[A](implicit codec: Codec[A]): Schema = - codec.schema.value - - def unsafeEncode[A](a: A)(implicit codec: Codec[A]): Any = - codec.encode(a).value - - def unsafeDecode[A](value: Any)(implicit codec: Codec[A]): A = - codec.schema.flatMap(codec.decode(value, _)).value - - def assertSchemaIs[A](expectedSchema: String)(implicit codec: Codec[A]): Assertion = - assert(codec.schema.value.toString == expectedSchema) - - def assertEncodeIs[A]( - a: A, - encoded: Either[AvroError, Any] - )(implicit codec: Codec[A]): Assertion = - assert(unsafeEncode(a) === encoded.value) - - def assertDecodeIs[A]( - value: Any, - decoded: Either[AvroError, A], - schema: Option[Schema] = None - )(implicit codec: Codec[A]): Assertion = - assert { - val decode = - schema - .map(codec.decode(value, _).value) - .getOrElse(unsafeDecode[A](value)) - - decode === decoded.value - } - - def assertSchemaError[A]( - expectedErrorMessage: String - )(implicit codec: Codec[A]): Assertion = - assert(codec.schema.swap.value.message == expectedErrorMessage) - - def assertDecodeError[A]( - value: Any, - schema: Schema, - expectedErrorMessage: String - )(implicit codec: Codec[A]): Assertion = - assert(codec.decode(value, schema).swap.value.message == expectedErrorMessage) - - def assertEncodeError[A]( - a: A, - expectedErrorMessage: String - )(implicit codec: Codec[A]): Assertion = - assert(codec.encode(a).swap.value.message == expectedErrorMessage) } diff --git a/modules/generic/src/test/scala-2/vulcan/generic/RoundtripSpec.scala b/modules/generic/src/test/scala/vulcan/generic/GenericDerivationRoundtripSpec.scala similarity index 73% rename from modules/generic/src/test/scala-2/vulcan/generic/RoundtripSpec.scala rename to modules/generic/src/test/scala/vulcan/generic/GenericDerivationRoundtripSpec.scala index 823df7ab..0a7d62dd 100644 --- a/modules/generic/src/test/scala-2/vulcan/generic/RoundtripSpec.scala +++ b/modules/generic/src/test/scala/vulcan/generic/GenericDerivationRoundtripSpec.scala @@ -5,42 +5,12 @@ import cats.implicits._ import java.io.{ByteArrayInputStream, ByteArrayOutputStream} import org.apache.avro.generic.{GenericData, GenericDatumReader, GenericDatumWriter} import org.apache.avro.io.{DecoderFactory, EncoderFactory} -import org.scalacheck.{Arbitrary, Gen} -import org.scalacheck.Arbitrary.arbitrary +import org.scalacheck.{Arbitrary} import org.scalatest.Assertion -import shapeless.{:+:, CNil, Coproduct} import vulcan._ import vulcan.generic.examples._ -final class RoundtripSpec extends BaseSpec { - describe("coproduct") { - type Types = CaseClassField :+: Int :+: CaseClassAvroDoc :+: CNil - - implicit val arbitraryTypes: Arbitrary[Types] = - Arbitrary { - Gen.oneOf( - arbitrary[Int].map(n => Coproduct[Types](CaseClassField(n))), - arbitrary[Int].map(n => Coproduct[Types](n)), - arbitrary[Option[String]].map(os => Coproduct[Types](CaseClassAvroDoc(os))) - ) - } - - implicit val eqTypes: Eq[Types] = - Eq.fromUniversalEquals - - it("roundtrip.derived") { - roundtrip[Types] - } - - it("roundtrip.union") { - implicit val codec: Codec[Types] = - Codec.union { alt => - alt[CaseClassField] |+| alt[Int] |+| alt[CaseClassAvroDoc] - } - - roundtrip[Types] - } - } +final class GenericDerivationRoundtripSpec extends BaseSpec { describe("derive") { it("CaseClassAvroNamespace") { roundtrip[CaseClassAvroNamespace] } diff --git a/modules/generic/src/test/scala/vulcan/generic/RoundtripBase.scala b/modules/generic/src/test/scala/vulcan/generic/RoundtripBase.scala new file mode 100644 index 00000000..18cb216e --- /dev/null +++ b/modules/generic/src/test/scala/vulcan/generic/RoundtripBase.scala @@ -0,0 +1,79 @@ +package vulcan.generic + +import cats.Eq +import cats.implicits._ +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} +import org.apache.avro.generic.{GenericData, GenericDatumReader, GenericDatumWriter} +import org.apache.avro.io.{DecoderFactory, EncoderFactory} +import org.scalacheck.Arbitrary +import org.scalatest.Assertion +import vulcan._ + +class RoundtripBase extends BaseSpec { + + def roundtrip[A]( + implicit codec: Codec[A], + arbitrary: Arbitrary[A], + eq: Eq[A] + ): Assertion = { + forAll { (a: A) => + roundtrip(a) + binaryRoundtrip(a) + } + } + + def roundtrip[A](a: A)( + implicit codec: Codec[A], + eq: Eq[A] + ): Assertion = { + val avroSchema = codec.schema + assert(avroSchema.isRight) + + val encoded = codec.encode(a) + assert(encoded.isRight) + + val decoded = codec.decode(encoded.value, avroSchema.value) + assert(decoded === Right(a)) + } + + def binaryRoundtrip[A](a: A)( + implicit codec: Codec[A], + eq: Eq[A] + ): Assertion = { + val binary = toBinary(a) + assert(binary.isRight) + + val decoded = fromBinary[A](binary.value) + assert(decoded === Right(a)) + } + + def toBinary[A](a: A)( + implicit codec: Codec[A] + ): Either[AvroError, Array[Byte]] = + codec.schema.flatMap { schema => + codec.encode(a).map { encoded => + val baos = new ByteArrayOutputStream() + val serializer = EncoderFactory.get().binaryEncoder(baos, null) + new GenericDatumWriter[Any](schema) + .write(encoded, serializer) + serializer.flush() + baos.toByteArray() + } + } + + def fromBinary[A](bytes: Array[Byte])( + implicit codec: Codec[A] + ): Either[AvroError, A] = + codec.schema.flatMap { schema => + val bais = new ByteArrayInputStream(bytes) + val deserializer = DecoderFactory.get().binaryDecoder(bais, null) + val read = + new GenericDatumReader[Any]( + schema, + schema, + new GenericData + ).read(null, deserializer) + + codec.decode(read, schema) + } +} diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassAndFieldAvroNullDefault.scala b/modules/generic/src/test/scala/vulcan/generic/examples/CaseClassAndFieldAvroNullDefault.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassAndFieldAvroNullDefault.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/CaseClassAndFieldAvroNullDefault.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassAvroDoc.scala b/modules/generic/src/test/scala/vulcan/generic/examples/CaseClassAvroDoc.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassAvroDoc.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/CaseClassAvroDoc.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassAvroName.scala b/modules/generic/src/test/scala/vulcan/generic/examples/CaseClassAvroName.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassAvroName.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/CaseClassAvroName.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassAvroNamespace.scala b/modules/generic/src/test/scala/vulcan/generic/examples/CaseClassAvroNamespace.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassAvroNamespace.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/CaseClassAvroNamespace.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassAvroNullDefault.scala b/modules/generic/src/test/scala/vulcan/generic/examples/CaseClassAvroNullDefault.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassAvroNullDefault.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/CaseClassAvroNullDefault.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassField.scala b/modules/generic/src/test/scala/vulcan/generic/examples/CaseClassField.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassField.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/CaseClassField.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassFieldAvroDoc.scala b/modules/generic/src/test/scala/vulcan/generic/examples/CaseClassFieldAvroDoc.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassFieldAvroDoc.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/CaseClassFieldAvroDoc.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassFieldAvroNullDefault.scala b/modules/generic/src/test/scala/vulcan/generic/examples/CaseClassFieldAvroNullDefault.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassFieldAvroNullDefault.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/CaseClassFieldAvroNullDefault.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassFieldInvalidName.scala b/modules/generic/src/test/scala/vulcan/generic/examples/CaseClassFieldInvalidName.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassFieldInvalidName.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/CaseClassFieldInvalidName.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassNullableFields.scala b/modules/generic/src/test/scala/vulcan/generic/examples/CaseClassNullableFields.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassNullableFields.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/CaseClassNullableFields.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassTwoFields.scala b/modules/generic/src/test/scala/vulcan/generic/examples/CaseClassTwoFields.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/CaseClassTwoFields.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/CaseClassTwoFields.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitCaseClass.scala b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClass.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitCaseClass.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClass.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala similarity index 91% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala index 2faaa903..b62aff82 100644 --- a/modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala +++ b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala @@ -8,20 +8,20 @@ import vulcan.generic._ sealed trait SealedTraitCaseClassAvroNamespace -final case class FirstInSealedTraitCaseClassAvroNamespace(value: Int) - extends SealedTraitCaseClassAvroNamespace - +// out of order to verify that ordering in derived schema is by name @AvroNamespace("com.example") final case class SecondInSealedTraitCaseClassAvroNamespace(value: String) extends SealedTraitCaseClassAvroNamespace +final case class FirstInSealedTraitCaseClassAvroNamespace(value: Int) + extends SealedTraitCaseClassAvroNamespace object SealedTraitCaseClassAvroNamespace { implicit val sealedTraitCaseClassAvroNamespaceArbitrary : Arbitrary[SealedTraitCaseClassAvroNamespace] = Arbitrary( Gen.oneOf( - arbitrary[Int].map(FirstInSealedTraitCaseClassAvroNamespace), - arbitrary[String].map(SecondInSealedTraitCaseClassAvroNamespace) + arbitrary[Int].map(FirstInSealedTraitCaseClassAvroNamespace(_)), + arbitrary[String].map(SecondInSealedTraitCaseClassAvroNamespace(_)) ) ) diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitCaseClassCustom.scala b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassCustom.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitCaseClassCustom.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassCustom.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitCaseClassIncomplete.scala b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassIncomplete.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitCaseClassIncomplete.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassIncomplete.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitCaseObject.scala b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseObject.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitCaseObject.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseObject.scala diff --git a/modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitNestedUnion.scala b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitNestedUnion.scala similarity index 100% rename from modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitNestedUnion.scala rename to modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitNestedUnion.scala