Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set explicit default null values for nullable fields in generated schemas #294

Merged
merged 2 commits into from
Mar 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright 2019-2021 OVO Energy Limited
*
* SPDX-License-Identifier: Apache-2.0
*/

package vulcan.generic

import scala.annotation.StaticAnnotation

/**
* Annotation which can be used to enable/disable explicit default null values for nullable fields
* in derived schemas.
*
* The annotation can be used in the following situations.<br>
* - Annotate a `case class` to enable/disable explicit default null values in the schema
* for all nullable fields when using `Codec.derive` from the generic module.<br>
* - Annotate a `case class` parameter to enable/disable explicit default null value in the schema
* for this specific nullable field when using `Codec.derive` from the
* generic module.
*
* `Parameter` annotation takes precedence over `case class` one when both are used.
*/
final class AvroNullDefault(final val enabled: Boolean) extends StaticAnnotation {
override final def toString: String =
s"AvroNullDefault($enabled)"
}

object AvroNullDefault {
final def unapply(avroDefaultNulls: AvroNullDefault): Some[Boolean] =
Some(avroDefaultNulls.enabled)
}
15 changes: 14 additions & 1 deletion modules/generic/src/main/scala/vulcan/generic/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -120,15 +120,28 @@ package object generic {
caseClass.parameters.head.typeclass.schema
} else {
AvroError.catchNonFatal {
val nullDefaultBase = caseClass.annotations
.collectFirst { case AvroNullDefault(enabled) => enabled }
.getOrElse(false)

val fields =
caseClass.parameters.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
}.orNull,
if (schema.isNullable && nullDefaultField) Schema.Field.NULL_DEFAULT_VALUE
else null
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package vulcan.generic

import vulcan.BaseSpec

final class AvroNullDefaultSpec extends BaseSpec {
describe("AvroNullDefault") {
it("should provide null default flag via enabled") {
forAll { (b: Boolean) =>
assert(new AvroNullDefault(b).enabled == b)
}
}

it("should include enabled flag value in toString") {
forAll { (b: Boolean) =>
assert(new AvroNullDefault(b).toString.contains(b.toString))
}
}

it("should provide an extractor for enabled flag") {
forAll { (b1: Boolean) =>
assert(new AvroNullDefault(b1) match {
case AvroNullDefault(`b1`) => true
case AvroNullDefault(b2) => fail(b2.toString)
})
}
}
}
}
23 changes: 23 additions & 0 deletions modules/generic/src/test/scala/vulcan/generic/CodecSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,29 @@ final class CodecSpec extends AnyFunSpec with ScalaCheckPropertyChecks with Eith
"""org.apache.avro.SchemaParseException: Illegal initial character: -value"""
}
}

it(
"should support annotation for setting explicit default null values for all nullable fields"
) {
assertSchemaIs[CaseClassAvroNullDefault] {
"""{"type":"record","name":"CaseClassAvroNullDefault","namespace":"vulcan.generic.examples","fields":[{"name":"int","type":["null","int"],"default":null},{"name":"long","type":["null","long"],"default":null},{"name":"string","type":["null","string"],"default":null},{"name":"date","type":["null",{"type":"int","logicalType":"date"}],"default":null},{"name":"map","type":["null",{"type":"map","values":"string"}],"default":null},{"name":"caseClassValueClass","type":["null","int"],"default":null},{"name":"sealedTraitEnumDerived","type":["null",{"type":"enum","name":"SealedTraitEnumDerived","namespace":"com.example","symbols":["first","second"]}],"default":null}]}"""
}
}

it("should support annotation for setting explicit default null value for specific field") {
assertSchemaIs[CaseClassFieldAvroNullDefault] {
"""{"type":"record","name":"CaseClassFieldAvroNullDefault","namespace":"vulcan.generic.examples","fields":[{"name":"int","type":["null","int"],"default":null},{"name":"long","type":["null","long"]},{"name":"string","type":["null","string"],"default":null},{"name":"date","type":["null",{"type":"int","logicalType":"date"}]},{"name":"map","type":["null",{"type":"map","values":"string"}],"default":null},{"name":"caseClassValueClass","type":["null","int"]},{"name":"sealedTraitEnumDerived","type":["null",{"type":"enum","name":"SealedTraitEnumDerived","namespace":"com.example","symbols":["first","second"]}],"default":null}]}"""
}
}

it(
"should support parameter annotation overriding case class annotation when setting explicit default null value for specific field"
) {
assertSchemaIs[CaseClassAndFieldAvroNullDefault] {
"""{"type":"record","name":"CaseClassAndFieldAvroNullDefault","namespace":"vulcan.generic.examples","fields":[{"name":"int","type":["null","int"]},{"name":"long","type":["null","long"],"default":null},{"name":"string","type":["null","string"]},{"name":"date","type":["null",{"type":"int","logicalType":"date"}],"default":null},{"name":"map","type":["null",{"type":"map","values":"string"}]},{"name":"caseClassValueClass","type":["null","int"],"default":null},{"name":"sealedTraitEnumDerived","type":["null",{"type":"enum","name":"SealedTraitEnumDerived","namespace":"com.example","symbols":["first","second"]}]}]}"""
}
}

}

describe("encode") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ final class RoundtripSpec extends BaseSpec {
it("CaseClassField") { roundtrip[CaseClassField] }
it("SealedTraitCaseClassAvroNamespace") { roundtrip[SealedTraitCaseClassAvroNamespace] }
it("SealedTraitCaseClassCustom") { roundtrip[SealedTraitCaseClassCustom] }
it("CaseClassAvroNullDefault") { roundtrip[CaseClassAvroNullDefault] }
it("CaseClassFieldAvroNullDefault") { roundtrip[CaseClassFieldAvroNullDefault] }
it("CaseClassAndFieldAvroNullDefault") { roundtrip[CaseClassAndFieldAvroNullDefault] }
}

def roundtrip[A](
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package vulcan.generic.examples

import cats.Eq
import org.scalacheck.Arbitrary
import vulcan.Codec
import vulcan.generic._

import java.time.LocalDate

@AvroNullDefault(true)
final case class CaseClassAndFieldAvroNullDefault(
@AvroNullDefault(false) int: Option[Int],
long: Option[Long],
@AvroNullDefault(false) string: Option[String],
date: Option[LocalDate],
@AvroNullDefault(false) map: Option[Map[String, String]],
caseClassValueClass: Option[CaseClassValueClass],
@AvroNullDefault(false) sealedTraitEnumDerived: Option[SealedTraitEnumDerived]
)

object CaseClassAndFieldAvroNullDefault {
implicit val caseClassAvroNullDefaultArbitrary: Arbitrary[CaseClassAndFieldAvroNullDefault] =
Arbitrary(CaseClassNullableFields.genFieldProduct.map((apply _).tupled))

implicit val caseClassAvroNamespaceEq: Eq[CaseClassAndFieldAvroNullDefault] =
Eq.fromUniversalEquals

implicit val codec: Codec[CaseClassAndFieldAvroNullDefault] =
Codec.derive
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package vulcan.generic.examples

import cats.Eq
import org.scalacheck.Arbitrary
import vulcan.Codec
import vulcan.generic._

import java.time.LocalDate

@AvroNullDefault(true)
final case class CaseClassAvroNullDefault(
int: Option[Int],
long: Option[Long],
string: Option[String],
date: Option[LocalDate],
map: Option[Map[String, String]],
caseClassValueClass: Option[CaseClassValueClass],
sealedTraitEnumDerived: Option[SealedTraitEnumDerived]
)

object CaseClassAvroNullDefault {

implicit val caseClassAvroNullDefaultArbitrary: Arbitrary[CaseClassAvroNullDefault] =
Arbitrary(CaseClassNullableFields.genFieldProduct.map((apply _).tupled))

implicit val caseClassAvroNamespaceEq: Eq[CaseClassAvroNullDefault] =
Eq.fromUniversalEquals

implicit val codec: Codec[CaseClassAvroNullDefault] =
Codec.derive
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package vulcan.generic.examples

import cats.Eq
import org.scalacheck.Arbitrary
import vulcan.Codec
import vulcan.generic._

import java.time.LocalDate

final case class CaseClassFieldAvroNullDefault(
@AvroNullDefault(true) int: Option[Int],
long: Option[Long],
@AvroNullDefault(true) string: Option[String],
date: Option[LocalDate],
@AvroNullDefault(true) map: Option[Map[String, String]],
caseClassValueClass: Option[CaseClassValueClass],
@AvroNullDefault(true) sealedTraitEnumDerived: Option[SealedTraitEnumDerived]
)

object CaseClassFieldAvroNullDefault {
implicit val caseClassAvroNullDefaultArbitrary: Arbitrary[CaseClassFieldAvroNullDefault] =
Arbitrary(CaseClassNullableFields.genFieldProduct.map((apply _).tupled))

implicit val caseClassAvroNamespaceEq: Eq[CaseClassFieldAvroNullDefault] =
Eq.fromUniversalEquals

implicit val codec: Codec[CaseClassFieldAvroNullDefault] =
Codec.derive
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package vulcan.generic.examples

import org.scalacheck.Arbitrary.arbitrary
import org.scalacheck.{Arbitrary, Gen}

import java.time.LocalDate

object CaseClassNullableFields {
type FieldProduct = (
Option[Int],
Option[Long],
Option[String],
Option[LocalDate],
Option[Map[String, String]],
Option[CaseClassValueClass],
Option[SealedTraitEnumDerived]
)
val genFieldProduct: Gen[FieldProduct] = {
implicit val arbitraryLocalDate: Arbitrary[LocalDate] =
Arbitrary(Gen.posNum[Int].map(d => LocalDate.ofEpochDay(d.toLong)))

for {
int <- arbitrary[Option[Int]]
long <- arbitrary[Option[Long]]
string <- arbitrary[Option[String]]
date <- arbitrary[Option[LocalDate]]
map <- arbitrary[Option[Map[String, String]]]
caseClassValueClass <- arbitrary[Option[Int]].map(_.map(CaseClassValueClass.apply))
sealedTraitEnumDerived <- arbitrary[Option[SealedTraitEnumDerived]]
} yield (int, long, string, date, map, caseClassValueClass, sealedTraitEnumDerived)
}
}