From fa1fb77420a07bd746b17364f27f4b303288ff82 Mon Sep 17 00:00:00 2001 From: Ben Plommer Date: Wed, 9 Mar 2022 12:04:50 +0000 Subject: [PATCH 01/10] Update gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) 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 From f27192f5a6db2c8d05528d702fa7e25f9229c351 Mon Sep 17 00:00:00 2001 From: Ben Plommer Date: Wed, 9 Mar 2022 12:47:30 +0000 Subject: [PATCH 02/10] Update magnolia versions --- build.sbt | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/build.sbt b/build.sbt index 99bff4ca..89c01395 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,21 @@ 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,7 +207,9 @@ lazy val mdocSettings = Seq( lazy val buildInfoSettings = Seq( buildInfoPackage := "vulcan.build", buildInfoObject := "info", - buildInfoKeys := Seq[BuildInfoKey]( + buildInfoKeys := { + val magnolia: String = if (scalaVersion.value.startsWith("3")) magnolia3Version else magnolia2Version + Seq[BuildInfoKey]( scalaVersion, scalacOptions, sourceDirectory, @@ -239,10 +246,10 @@ lazy val buildInfoSettings = Seq( BuildInfoKey("avroVersion" -> avroVersion), BuildInfoKey("catsVersion" -> catsVersion), BuildInfoKey("enumeratumVersion" -> enumeratumVersion), - BuildInfoKey("magnoliaVersion" -> magnoliaVersion), + BuildInfoKey("magnoliaVersion" -> magnolia), BuildInfoKey("refinedVersion" -> refinedVersion), BuildInfoKey("shapelessVersion" -> shapelessVersion) - ) + )} ) lazy val metadataSettings = Seq( From d68b01f2cf972105c5bce5fcbecdcf0dc6f942be Mon Sep 17 00:00:00 2001 From: Ben Plommer Date: Wed, 9 Mar 2022 12:47:47 +0000 Subject: [PATCH 03/10] Implement generic derivation in Scala 3 --- .../main/scala-3/vulcan/generic/package.scala | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) 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 987773ff..62428cff 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,223 @@ 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 typeName = + s"$namespace.${caseClass.typeInfo.short}" + 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( + caseClass.typeInfo.short, + 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 From cc2709ea9248b90082f360807d24ff5eee03097c Mon Sep 17 00:00:00 2001 From: Ben Plommer Date: Wed, 9 Mar 2022 12:58:40 +0000 Subject: [PATCH 04/10] Test generic derivation in Scala 3 --- .../vulcan/generic/CoproductCodecSpec.scala | 181 ++++++++++++++ .../generic/CoproductRoundtripSpec.scala | 40 +++ .../examples/CaseClassValueClass.scala | 3 +- .../examples/CaseClassValueClass.scala | 11 + .../vulcan/generic/AvroDocSpec.scala | 0 .../vulcan/generic/AvroNamespaceSpec.scala | 0 .../vulcan/generic/AvroNullDefaultSpec.scala | 0 .../test/scala/vulcan/generic/CodecBase.scala | 60 +++++ .../generic/GenericDerivationCodecSpec.scala} | 230 +----------------- .../GenericDerivationRoundtripSpec.scala} | 34 +-- .../scala/vulcan/generic/RoundtripBase.scala | 79 ++++++ .../CaseClassAndFieldAvroNullDefault.scala | 0 .../generic/examples/CaseClassAvroDoc.scala | 0 .../examples/CaseClassAvroNamespace.scala | 0 .../examples/CaseClassAvroNullDefault.scala | 0 .../generic/examples/CaseClassField.scala | 0 .../examples/CaseClassFieldAvroDoc.scala | 0 .../CaseClassFieldAvroNullDefault.scala | 0 .../examples/CaseClassFieldInvalidName.scala | 0 .../examples/CaseClassNullableFields.scala | 0 .../generic/examples/CaseClassTwoFields.scala | 0 .../examples/SealedTraitCaseClass.scala | 0 .../SealedTraitCaseClassAvroNamespace.scala | 11 +- .../examples/SealedTraitCaseClassCustom.scala | 0 .../SealedTraitCaseClassIncomplete.scala | 0 .../examples/SealedTraitCaseObject.scala | 0 .../examples/SealedTraitNestedUnion.scala | 0 27 files changed, 384 insertions(+), 265 deletions(-) create mode 100644 modules/generic/src/test/scala-2/vulcan/generic/CoproductCodecSpec.scala create mode 100644 modules/generic/src/test/scala-2/vulcan/generic/CoproductRoundtripSpec.scala create mode 100644 modules/generic/src/test/scala-3/vulcan/generic/examples/CaseClassValueClass.scala rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/AvroDocSpec.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/AvroNamespaceSpec.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/AvroNullDefaultSpec.scala (100%) create mode 100644 modules/generic/src/test/scala/vulcan/generic/CodecBase.scala rename modules/generic/src/test/{scala-2/vulcan/generic/CodecSpec.scala => scala/vulcan/generic/GenericDerivationCodecSpec.scala} (60%) rename modules/generic/src/test/{scala-2/vulcan/generic/RoundtripSpec.scala => scala/vulcan/generic/GenericDerivationRoundtripSpec.scala} (73%) create mode 100644 modules/generic/src/test/scala/vulcan/generic/RoundtripBase.scala rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/CaseClassAndFieldAvroNullDefault.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/CaseClassAvroDoc.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/CaseClassAvroNamespace.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/CaseClassAvroNullDefault.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/CaseClassField.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/CaseClassFieldAvroDoc.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/CaseClassFieldAvroNullDefault.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/CaseClassFieldInvalidName.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/CaseClassNullableFields.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/CaseClassTwoFields.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/SealedTraitCaseClass.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala (95%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/SealedTraitCaseClassCustom.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/SealedTraitCaseClassIncomplete.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/SealedTraitCaseObject.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/SealedTraitNestedUnion.scala (100%) 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/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 60% rename from modules/generic/src/test/scala-2/vulcan/generic/CodecSpec.scala rename to modules/generic/src/test/scala/vulcan/generic/GenericDerivationCodecSpec.scala index fd6227fe..4fe9700f 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 CodecSpec 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") { @@ -420,54 +244,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/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 95% 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 f2ec85f8..7cb0948f 100644 --- a/modules/generic/src/test/scala-2/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala +++ b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala @@ -5,15 +5,20 @@ import org.scalacheck.{Arbitrary, Gen} import org.scalacheck.Arbitrary.arbitrary import vulcan.Codec import vulcan.generic._ +import A._ +import B._ sealed trait SealedTraitCaseClassAvroNamespace -final case class FirstInSealedTraitCaseClassAvroNamespace(value: Int) - extends SealedTraitCaseClassAvroNamespace - +object A { @AvroNamespace("com.example") final case class SecondInSealedTraitCaseClassAvroNamespace(value: String) extends SealedTraitCaseClassAvroNamespace +} +object B { +final case class FirstInSealedTraitCaseClassAvroNamespace(value: Int) + extends SealedTraitCaseClassAvroNamespace +} object SealedTraitCaseClassAvroNamespace { implicit val sealedTraitCaseClassAvroNamespaceArbitrary 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 From de135dbfda178191d9c3de9c082e8761e76c3c56 Mon Sep 17 00:00:00 2001 From: Ben Plommer Date: Wed, 9 Mar 2022 13:00:13 +0000 Subject: [PATCH 05/10] Revert object wrappers --- .../examples/SealedTraitCaseClassAvroNamespace.scala | 6 ------ 1 file changed, 6 deletions(-) diff --git a/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala index 7cb0948f..49a998b8 100644 --- a/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala +++ b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala @@ -5,20 +5,14 @@ import org.scalacheck.{Arbitrary, Gen} import org.scalacheck.Arbitrary.arbitrary import vulcan.Codec import vulcan.generic._ -import A._ -import B._ sealed trait SealedTraitCaseClassAvroNamespace -object A { @AvroNamespace("com.example") final case class SecondInSealedTraitCaseClassAvroNamespace(value: String) extends SealedTraitCaseClassAvroNamespace -} -object B { final case class FirstInSealedTraitCaseClassAvroNamespace(value: Int) extends SealedTraitCaseClassAvroNamespace -} object SealedTraitCaseClassAvroNamespace { implicit val sealedTraitCaseClassAvroNamespaceArbitrary From 7e4432930c40946cbab8883aae58b8424c7628c3 Mon Sep 17 00:00:00 2001 From: Ben Plommer Date: Wed, 9 Mar 2022 13:05:08 +0000 Subject: [PATCH 06/10] Test order of derived sealed trait schemas --- .../vulcan/generic/GenericDerivationCodecSpec.scala | 9 ++++++++- .../examples/SealedTraitCaseClassAvroNamespace.scala | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/modules/generic/src/test/scala/vulcan/generic/GenericDerivationCodecSpec.scala b/modules/generic/src/test/scala/vulcan/generic/GenericDerivationCodecSpec.scala index 4fe9700f..cf1d7795 100644 --- a/modules/generic/src/test/scala/vulcan/generic/GenericDerivationCodecSpec.scala +++ b/modules/generic/src/test/scala/vulcan/generic/GenericDerivationCodecSpec.scala @@ -5,7 +5,7 @@ import org.apache.avro.generic.GenericData import vulcan.generic.examples._ import vulcan.internal.converters.collection._ -final class CodecSpec extends CodecBase { +final class GenericDerivationCodecSpec extends CodecBase { describe("Codec") { describe("derive") { @@ -169,6 +169,13 @@ final class CodecSpec extends CodecBase { } } + // 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"]]""" diff --git a/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala index 49a998b8..b62aff82 100644 --- a/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala +++ b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala @@ -8,6 +8,7 @@ import vulcan.generic._ sealed trait 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 From 0f47ae8fab464b30ec9714aee64e7b7018515f4e Mon Sep 17 00:00:00 2001 From: Ben Plommer Date: Wed, 9 Mar 2022 13:17:35 +0000 Subject: [PATCH 07/10] Fix mima settings --- build.sbt | 1 + 1 file changed, 1 insertion(+) diff --git a/build.sbt b/build.sbt index 89c01395..48b20841 100644 --- a/build.sbt +++ b/build.sbt @@ -93,6 +93,7 @@ lazy val generic = project ), scalatestSettings, publishSettings, + mimaSettings(), scalaSettings ++ Seq( crossScalaVersions += scala3_1 ), From 1a63e54df2c4a1293c03760b5fde626f18b99133 Mon Sep 17 00:00:00 2001 From: Ben Plommer Date: Wed, 9 Mar 2022 13:24:16 +0000 Subject: [PATCH 08/10] Oh, we never released vulcan-generic for scala 3 --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 48b20841..b6cd71e8 100644 --- a/build.sbt +++ b/build.sbt @@ -93,7 +93,7 @@ lazy val generic = project ), scalatestSettings, publishSettings, - mimaSettings(), + mimaSettings(excludeScala3 = true), // re-include scala 3 after publishing scalaSettings ++ Seq( crossScalaVersions += scala3_1 ), From 27e047f7e569b8d031ec2ae6bc12769399c53edc Mon Sep 17 00:00:00 2001 From: Ben Plommer Date: Wed, 9 Mar 2022 13:26:16 +0000 Subject: [PATCH 09/10] Update generic doc --- docs/src/main/mdoc/modules.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 0114a25252827ba9629cb6c70558480eb4590108 Mon Sep 17 00:00:00 2001 From: Ben Plommer Date: Wed, 9 Mar 2022 13:43:14 +0000 Subject: [PATCH 10/10] Test AvroName annotation in scala 3, fix --- build.sbt | 86 ++++++++++--------- .../main/scala-2/vulcan/generic/package.scala | 8 +- .../main/scala-3/vulcan/generic/package.scala | 12 ++- .../vulcan/generic/AvroNameSpec.scala | 0 .../generic/examples/CaseClassAvroName.scala | 0 .../SealedTraitCaseClassAvroNamespace.scala | 4 +- 6 files changed, 62 insertions(+), 48 deletions(-) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/AvroNameSpec.scala (100%) rename modules/generic/src/test/{scala-2 => scala}/vulcan/generic/examples/CaseClassAvroName.scala (100%) diff --git a/build.sbt b/build.sbt index b6cd71e8..f3eb4a09 100644 --- a/build.sbt +++ b/build.sbt @@ -209,48 +209,50 @@ lazy val buildInfoSettings = Seq( buildInfoPackage := "vulcan.build", buildInfoObject := "info", 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) - )} + 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/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 4b0fff1b..89b7d575 100644 --- a/modules/generic/src/main/scala-3/vulcan/generic/package.scala +++ b/modules/generic/src/main/scala-3/vulcan/generic/package.scala @@ -32,8 +32,14 @@ package object generic { .collectFirst { case AvroNamespace(namespace) => namespace } .getOrElse(caseClass.typeInfo.owner) - val typeName = - s"$namespace.${caseClass.typeInfo.short}" + 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 @@ -67,7 +73,7 @@ package object generic { fields.map { fields => Schema.createRecord( - caseClass.typeInfo.short, + shortName, caseClass.annotations.collectFirst { case AvroDoc(doc) => doc }.orNull, 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/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/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala index 140b6a57..b62aff82 100644 --- a/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala +++ b/modules/generic/src/test/scala/vulcan/generic/examples/SealedTraitCaseClassAvroNamespace.scala @@ -20,8 +20,8 @@ object SealedTraitCaseClassAvroNamespace { : Arbitrary[SealedTraitCaseClassAvroNamespace] = Arbitrary( Gen.oneOf( - arbitrary[Int].map(FirstInSealedTraitCaseClassAvroNamespace), - arbitrary[String].map(SecondInSealedTraitCaseClassAvroNamespace) + arbitrary[Int].map(FirstInSealedTraitCaseClassAvroNamespace(_)), + arbitrary[String].map(SecondInSealedTraitCaseClassAvroNamespace(_)) ) )