Skip to content

Commit

Permalink
Align series/2.x branch codec generation annotations with zio-schema (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
googley42 authored Feb 6, 2023
1 parent 01afd2e commit 307dd56
Show file tree
Hide file tree
Showing 10 changed files with 188 additions and 116 deletions.
22 changes: 11 additions & 11 deletions docs/codec-customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,39 +37,39 @@ Here an intermediate map is used to identify the member of `TraficLight` ie `Map
Note that the `Null` is used as in this case we do not care about the value.

# Customising encodings via annotations
Encodings can be customised through the use of the following annotations `@discriminator`, `@enumOfCaseObjects` and `@id`.
Encodings can be customised through the use of the following annotations `@discriminatorName`, `@enumOfCaseObjects` and `@fieldName`.
These annotations are useful when working with a legacy DynamoDB database.

The `@discriminator` encodings does not introduce another map for the purposes of identification but rather adds another
The `@discriminatorName` encodings does not introduce another map for the purposes of identification but rather adds another
discriminator field to the attribute Map.

Concrete examples of using the `@discriminator`, `@enumOfCaseObjects` and `@id` annotations can be seen below.
Concrete examples of using the `@discriminatorName`, `@enumOfCaseObjects` and `@field` annotations can be seen below.

## Sealed trait members that are case classes

```scala
@discriminator("light_type")
@discriminatorName("light_type")
sealed trait TrafficLight
final case class Green(rgb: Int) extends TrafficLight
@id("red_traffic_light")
@caseName("red_traffic_light")
final case class Red(rgb: Int) extends TrafficLight
final case class Amber(@id("red_green_blue") rgb: Int) extends TrafficLight
final case class Amber(@fieldName("red_green_blue") rgb: Int) extends TrafficLight
final case class Box(trafficLightColour: TrafficLight)
```

encoding for an instance of `Box(Green(42))` would be:

`Map(trafficLightColour -> Map(String(rgb) -> Number(42), String(light_type) -> String(Green)))`

We can specify the field name used to identify the case class through the `@discriminator` annotation. The discriminator
We can specify the field name used to identify the case class through the `@discriminatorName` annotation. The discriminator
encoding removes the intermediate map and inserts a new field with a name specified by discriminator annotation and a
value that identifies the member which defaults to the class name.

This can be further customised using the `@id` annotation - encoding for an instance of `Box(Red(42))` would be:
This can be further customised using the `@caseName` annotation - encoding for an instance of `Box(Red(42))` would be:

`Map(trafficLightColour -> Map(String(rgb) -> Number(42), String(light_type) -> String(red_traffic_light)))`

The encoding for case class field names can also be customised via `@id` - encoding for an instance of `Box(Amber(42))` would be:
The encoding for case class field names can also be customised via `@fieldName` - encoding for an instance of `Box(Amber(42))` would be:

`Map(trafficLightColour -> Map(String(red_green_blue) -> Number(42), String(light_type) -> String(Amber)))`

Expand All @@ -80,7 +80,7 @@ The encoding for case class field names can also be customised via `@id` - encod
@enumOfCaseObjects
sealed trait TrafficLight
case object GREEN extends TrafficLight
@id("red_traffic_light")
@caseName("red_traffic_light")
case object RED extends TrafficLight
final case class Box(trafficLightColour: TrafficLight)
```
Expand All @@ -90,7 +90,7 @@ annotation which encodes to just a value that is the member name. Encoding for a

`Map(trafficLightColour -> String(GREEN))`

This can be further customised by using the `@id` annotation again - encoding for `Box(RED)` would be
This can be further customised by using the `@caseName` annotation again - encoding for `Box(RED)` would be

`Map(trafficLightColour -> String(red_traffic_light))`

11 changes: 5 additions & 6 deletions dynamodb/src/main/scala/zio/dynamodb/Annotations.scala
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
package zio.dynamodb

import zio.Chunk
import zio.schema.annotation.{ caseName, discriminatorName }

object Annotations {
final case class discriminator(name: String) extends scala.annotation.Annotation
final case class enumOfCaseObjects() extends scala.annotation.Annotation
final case class id(value: String) extends scala.annotation.Annotation
final case class enumOfCaseObjects() extends scala.annotation.Annotation

def maybeId(annotations: Chunk[Any]): Option[String] =
annotations.collect { case id(name) => name }.headOption
def maybeCaseName(annotations: Chunk[Any]): Option[String] =
annotations.collect { case caseName(name) => name }.headOption

def maybeDiscriminator(annotations: Chunk[Any]): Option[String] =
annotations.collect { case discriminator(name) => name }.headOption
annotations.collect { case discriminatorName(name) => name }.headOption
}
96 changes: 62 additions & 34 deletions dynamodb/src/main/scala/zio/dynamodb/Codec.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package zio.dynamodb

import zio.dynamodb.Annotations.{ discriminator, enumOfCaseObjects, id, maybeDiscriminator, maybeId }
import zio.dynamodb.Annotations.{ enumOfCaseObjects, maybeCaseName, maybeDiscriminator }
import zio.dynamodb.DynamoDBError.DecodingError
import zio.schema.Schema.{ Optional, Primitive }
import zio.schema.annotation.{ caseName, discriminatorName }
import zio.schema.{ FieldSet, Schema, StandardType }
import zio.{ schema, Chunk }

Expand Down Expand Up @@ -177,7 +178,7 @@ private[dynamodb] object Codec {
val enc = encoder(s._1.schema)
val extractedFieldValue = s._1.get(a)
val av = enc(extractedFieldValue)
val k = maybeId(s._1.annotations).getOrElse(s._1.name)
val k = s._1.name

@tailrec
def appendToMap[B](schema: Schema[B]): AttributeValue.Map =
Expand Down Expand Up @@ -264,8 +265,12 @@ private[dynamodb] object Codec {
(col: Col) => AttributeValue.List(from(col).map(encoder))

private def enumEncoder[Z](annotations: Chunk[Any], cases: Schema.Case[Z, _]*): Encoder[Z] =
if (isEnumWithDiscriminatorOrCaseObjectAnnotationCodec(annotations))
enumWithDiscriminatorOrCaseObjectAnnotationEncoder(discriminatorWithDefault(annotations), cases: _*)
if (hasAnnotationAtClassLevel(annotations))
enumWithAnnotationAtClassLevelEncoder(
isCaseObjectAnnotation(annotations),
discriminatorWithDefault(annotations),
cases: _*
)
else
defaultEnumEncoder(cases: _*)

Expand All @@ -276,36 +281,42 @@ private[dynamodb] object Codec {
val case_ = cases(fieldIndex)
val enc = encoder(case_.schema.asInstanceOf[Schema[Any]])
val av = enc(a)
val id = maybeId(case_.annotations).getOrElse(case_.id)
val id = maybeCaseName(case_.annotations).getOrElse(case_.id)
AttributeValue.Map(Map.empty + (AttributeValue.String(id) -> av))
} else
AttributeValue.Null
}

private def enumWithDiscriminatorOrCaseObjectAnnotationEncoder[Z](
private def enumWithAnnotationAtClassLevelEncoder[Z](
hasEnumOfCaseObjectsAnnotation: Boolean,
discriminator: String,
cases: Schema.Case[Z, _]*
): Encoder[Z] =
(a: Z) => {
val fieldIndex = cases.indexWhere(c => c.deconstructOption(a).isDefined)
if (fieldIndex > -1) {
val case_ = cases(fieldIndex)
val enc = encoder(case_.schema.asInstanceOf[Schema[Any]])
lazy val id = maybeId(case_.annotations).getOrElse(case_.id)
val av = enc(a)
val case_ = cases(fieldIndex)
val enc = encoder(case_.schema.asInstanceOf[Schema[Any]])
val av = enc(a)
val id = maybeCaseName(case_.annotations).getOrElse(case_.id)
val av2 = AttributeValue.String(id)
av match { // TODO: review all pattern matches inside of a lambda
case AttributeValue.Map(map) =>
case AttributeValue.Map(map) =>
AttributeValue.Map(
map + (AttributeValue.String(discriminator) -> AttributeValue.String(id))
map + (AttributeValue.String(discriminator) -> av2)
)
case AttributeValue.Null =>
val av2 = AttributeValue.String(id)
case AttributeValue.Null
if (hasEnumOfCaseObjectsAnnotation && allCaseObjects(cases)) || !hasEnumOfCaseObjectsAnnotation =>
if (allCaseObjects(cases))
av2
else
// these are case objects and are a special case - they need to wrapped in an AttributeValue.Map
AttributeValue.Map(Map(AttributeValue.String(discriminator) -> av2))
case av => throw new IllegalStateException(s"unexpected state $av")
case _ if (hasEnumOfCaseObjectsAnnotation && !allCaseObjects(cases)) =>
throw new IllegalStateException(
s"Can not encode enum ${case_.id} - @enumOfCaseObjects annotation present when all instances are not case objects."
)
case av => throw new IllegalStateException(s"unexpected state $av")
}
} else
AttributeValue.Null
Expand Down Expand Up @@ -787,8 +798,12 @@ private[dynamodb] object Codec {
}

private def enumDecoder[Z](annotations: Chunk[Any], cases: Schema.Case[Z, _]*): Decoder[Z] =
if (isEnumWithDiscriminatorOrCaseObjectAnnotationCodec(annotations))
enumWithDisciminatorOrCaseObjectAnnotationDecoder(discriminatorWithDefault(annotations), cases: _*)
if (hasAnnotationAtClassLevel(annotations))
enumWithAnnotationAtClassLevelDecoder(
isCaseObjectAnnotation(annotations),
discriminatorWithDefault(annotations),
cases: _*
)
else
defaultEnumDecoder(cases: _*)

Expand All @@ -801,7 +816,7 @@ private[dynamodb] object Codec {
map.toList.headOption.fold[Either[DynamoDBError, Z]](Left(DecodingError(s"map $av is empty"))) {
case (AttributeValue.String(subtype), av) =>
cases.find { c =>
maybeId(c.annotations).fold(c.id == subtype)(_ == subtype)
maybeCaseName(c.annotations).fold(c.id == subtype)(_ == subtype)
} match {
case Some(c) =>
decoder(c.schema)(av).map(_.asInstanceOf[Z])
Expand All @@ -813,14 +828,15 @@ private[dynamodb] object Codec {
Left(DecodingError(s"invalid AttributeValue $av"))
}

private def enumWithDisciminatorOrCaseObjectAnnotationDecoder[Z](
private def enumWithAnnotationAtClassLevelDecoder[Z](
hasEnumOfCaseObjectsAnnotation: Boolean,
discriminator: String,
cases: Schema.Case[Z, _]*
): Decoder[Z] = { (av: AttributeValue) =>
def findCase(value: String): Either[DynamoDBError, Schema.Case[Z, _]] =
cases.find {
case Schema.Case(_, _, _, _, _, Chunk(id(const))) => const == value
case Schema.Case(id, _, _, _, _, _) => id == value
case Schema.Case(_, _, _, _, _, Chunk(caseName(const))) => const == value
case Schema.Case(id, _, _, _, _, _) => id == value
}.toRight(DecodingError(s"type name '$value' not found in schema cases"))

def decode(id: String): Either[DynamoDBError, Z] =
Expand All @@ -830,22 +846,28 @@ private[dynamodb] object Codec {
}

av match {
case AttributeValue.String(id) =>
if (allCaseObjects(cases))
decode(id)
else
Left(DecodingError(s"Error: not all enumeration elements are case objects. Found $cases"))
case AttributeValue.Map(map) =>
case AttributeValue.String(id)
if (hasEnumOfCaseObjectsAnnotation && allCaseObjects(cases)) || !hasEnumOfCaseObjectsAnnotation =>
decode(id)
case AttributeValue.Map(map) =>
map
.get(AttributeValue.String(discriminator))
.fold[Either[DynamoDBError, Z]](
Left(DecodingError(s"map $av does not contain discriminator field '$discriminator'"))
) {
case AttributeValue.String(typeName) =>
decode(typeName)
case av => Left(DecodingError(s"expected string type but found $av"))
case av =>
Left(DecodingError(s"expected string type but found $av"))
}
case _ => Left(DecodingError(s"unexpected AttributeValue type $av"))
case _ if hasEnumOfCaseObjectsAnnotation && !allCaseObjects(cases) =>
Left(
DecodingError(
s"Can not decode enum $av - @enumOfCaseObjects annotation present when all instances are not case objects."
)
)
case _ =>
Left(DecodingError(s"unexpected AttributeValue type $av"))
}
}

Expand All @@ -857,9 +879,9 @@ private[dynamodb] object Codec {
case AttributeValue.Map(map) =>
EitherUtil
.forEach(fields) {
case Schema.Field(key, schema, annotations, _, _, _) =>
case Schema.Field(key, schema, _, _, _, _) =>
val dec = decoder(schema)
val k = maybeId(annotations).getOrElse(key)
val k = key // @fieldName is respected by the zio-schema macro
val maybeValue = map.get(AttributeValue.String(k))
val maybeDecoder = maybeValue.map(dec).toRight(DecodingError(s"field '$k' not found in $av"))
val either: Either[DynamoDBError, Any] = for {
Expand Down Expand Up @@ -896,10 +918,16 @@ private[dynamodb] object Codec {
private def discriminatorWithDefault(annotations: Chunk[Any]): String =
maybeDiscriminator(annotations).getOrElse("discriminator")

private def isEnumWithDiscriminatorOrCaseObjectAnnotationCodec(annotations: Chunk[Any]): Boolean =
private def hasAnnotationAtClassLevel(annotations: Chunk[Any]): Boolean =
annotations.exists {
case discriminatorName(_) | enumOfCaseObjects() => true
case _ => false
}

private def isCaseObjectAnnotation(annotations: Chunk[Any]): Boolean =
annotations.exists {
case discriminator(_) | enumOfCaseObjects() => true
case _ => false
case enumOfCaseObjects() => true
case _ => false
}

} // end Codec
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package zio.dynamodb

import zio.Chunk
import zio.dynamodb.Annotations.{ maybeDiscriminator, maybeId }
import zio.dynamodb.Annotations.{ maybeCaseName, maybeDiscriminator }
import zio.dynamodb.ConditionExpression.Operand.ProjectionExpressionOperand
import zio.dynamodb.ProjectionExpression.{ ListElement, MapElement, Root }
import zio.dynamodb.UpdateExpression.SetOperand.{ IfNotExists, ListAppend, ListPrepend, PathOperand }
Expand Down Expand Up @@ -689,10 +689,10 @@ object ProjectionExpression extends ProjectionExpressionLowPriorityImplicits0 {
override type Prism[F, From, To] = ProjectionExpression[From, To]
override type Traversal[From, To] = Unit

// respects @id annotation
// respects @caseName annotation
override def makeLens[F, S, A](product: Schema.Record[S], term: Schema.Field[S, A]): Lens[F, S, A] = {

val label = maybeId(term.annotations).getOrElse(term.name)
val label = maybeCaseName(term.annotations).getOrElse(term.name)
ProjectionExpression.MapElement(Root, label)
}

Expand All @@ -702,7 +702,7 @@ object ProjectionExpression extends ProjectionExpressionLowPriorityImplicits0 {
ProjectionExpression.Root.asInstanceOf[Prism[F, S, A]]
case None =>
ProjectionExpression
.MapElement(Root, maybeId(term.annotations).getOrElse(term.id))
.MapElement(Root, maybeCaseName(term.annotations).getOrElse(term.id))
.asInstanceOf[Prism[F, S, A]]
}

Expand Down
Loading

0 comments on commit 307dd56

Please sign in to comment.