smithy4s icon indicating copy to clipboard operation
smithy4s copied to clipboard

SUGGESTION: Scala Union -> ADT code generation custom names

Open andrewrigas opened this issue 2 years ago • 5 comments

There are 2 things I would like to see smithy4s supports:

  • Being able to create an 2 ADTs with similar schemas
  • Being able to create a union with actual case objects when structure contains empty fields

Smithy spec

@adt
union CustomerAction {
    @unionEntityName("Buy")
    buy: CustomerBuy 
}

@adt
union SellerAction {
    @unionEntityName("Buy")
    buy: SellerBuy 
    removeSellAccount: RemoveSellAccount 
}

structure CustomerBuy {
      quantity: Integer
}

structure SellerBuy {
      quantity: Integer
}

structure RemoveSellAccount {} // <- This will generate a case class instead of case object.

The reason why those features will be nice to see, its for the 1 to 1 transformations you can get for free with libraries like chimney to your domain after request passed validation.

andrewrigas avatar May 13 '23 18:05 andrewrigas

What's unionEntityName here?

I'm not sure if this would be such a significant improvement to have case objects just for the sake of easier conversions, but I do see the possible performance optimization in not having to instantiate empty case classes over and over (new RemoveSellAccount on every decode). ~~This might be worth special-casing in the StructureSchema constructors (using a singleton instance of the case class).~~ Looks like we already special-case that:

case class Hello()
object Hello extends ShapeTag.Companion[Hello] {
  val id: ShapeId = ShapeId("test", "Hello")

  val hints: Hints = Hints.empty

  implicit val schema: Schema[Hello] = constant(Hello()).withId(id).addHints(hints)
}

btw. to make a better case, it would be useful to see a concrete example of what your desired Scala code would look like.

kubukoz avatar May 13 '23 19:05 kubukoz

Sorry If I wasn't clear, the @unionEntityName trait doesn't exist its a suggestion. The limitation I see here is related to smithy and not necessarily the targeted language. Its possible to have 2 ADTs that are describing very similar behaviours of users but slightly different, often they can share more than one common data structure like in the example bellow its just the Buy action. But imagine a case where are the shared common structures are more. The practise most of the times I follow is to validate the request and transform the request to your domain. Naming my internal domain to be 1 to 1 with what smithy can generate I would like to avoid. A more detailed example is bellow:

Smithy spec


@adt 
union ClientAction { // <- A client action to describe all actions user can take, if he did an action as a seller or as customer
    customerAction: CustomerAction
    sellerAction: SellerAction 
}

@adt
union CustomerAction {
    buy: CustomerBuy 
}

@adt
union SellerAction {
    buy: SellerBuy 
    removeSellAccount: RemoveSellAccount 
}

structure CustomerBuy {
      quantity: Integer
}

structure SellerBuy {
      quantity: Integer
}

structure RemoveSellAccount {} // <- This will generate a case class instead of case object.

Generated CustomerAction

sealed trait CustomerAction extends scala.Product with scala.Serializable {
  @inline final def widen: CustomerAction = this
}
object CustomerAction extends ShapeTag.Companion[CustomerAction] {
  val id: ShapeId = ShapeId("com.pirum.fors.event_gateway.user_action.http", "CustomerAction")

  val hints: Hints = Hints.empty

  case class CustomerBuy(quantity: Option[Int] = None) extends CustomerAction
  object CustomerBuy extends ShapeTag.Companion[CustomerBuy] {
    val id: ShapeId = ShapeId("com.pirum.fors.event_gateway.user_action.http", "CustomerBuy")

    val hints: Hints = Hints.empty

    val schema: Schema[CustomerBuy] = struct(
      int.optional[CustomerBuy]("quantity", _.quantity),
    ){
      CustomerBuy.apply
    }.withId(id).addHints(hints)

    val alt = schema.oneOf[CustomerAction]("buy")
  }


  implicit val schema: Schema[CustomerAction] = union(
    CustomerBuy.alt,
  ){
    case c: CustomerBuy => CustomerBuy.alt(c)
  }.withId(id).addHints(hints)
}

Generated SellerAction

sealed trait SellerAction extends scala.Product with scala.Serializable {
  @inline final def widen: SellerAction = this
}
object SellerAction extends ShapeTag.Companion[SellerAction] {
  val id: ShapeId = ShapeId("com.pirum.fors.event_gateway.user_action.http", "SellerAction")

  val hints: Hints = Hints.empty

  case class SellerBuy(quantity: Option[Int] = None) extends SellerAction
  object SellerBuy extends ShapeTag.Companion[SellerBuy] {
    val id: ShapeId = ShapeId("com.pirum.fors.event_gateway.user_action.http", "SellerBuy")

    val hints: Hints = Hints.empty

    val schema: Schema[SellerBuy] = struct(
      int.optional[SellerBuy]("quantity", _.quantity),
    ){
      SellerBuy.apply
    }.withId(id).addHints(hints)

    val alt = schema.oneOf[SellerAction]("buy")
  }
  case class RemoveSellAccount() extends SellerAction
  object RemoveSellAccount extends ShapeTag.Companion[RemoveSellAccount] {
    val id: ShapeId = ShapeId("com.pirum.fors.event_gateway.user_action.http", "RemoveSellAccount")

    val hints: Hints = Hints.empty

    implicit val schema: Schema[RemoveSellAccount] = constant(RemoveSellAccount()).withId(id).addHints(hints)

    val alt = schema.oneOf[SellerAction]("removeSellAccount")
  }


  implicit val schema: Schema[SellerAction] = union(
    SellerBuy.alt,
    RemoveSellAccount.alt,
  ){
    case c: SellerBuy => SellerBuy.alt(c)
    case c: RemoveSellAccount => RemoveSellAccount.alt(c)
  }.withId(id).addHints(hints)
}

Desired CustomerAction

sealed trait CustomerAction extends scala.Product with scala.Serializable {
  @inline final def widen: CustomerAction = this
}
object CustomerAction extends ShapeTag.Companion[CustomerAction] {
  val id: ShapeId = ShapeId("com.pirum.fors.event_gateway.user_action.http", "CustomerAction")

  val hints: Hints = Hints.empty

  case class Buy(quantity: Option[Int] = None) extends CustomerAction // <- Notice the name of the class here
  object Buy extends ShapeTag.Companion[Buy] {
    val id: ShapeId = ShapeId("com.pirum.fors.event_gateway.user_action.http", "Buy")

    val hints: Hints = Hints.empty

    val schema: Schema[Buy] = struct(
      int.optional[Buy]("quantity", _.quantity),
    ){
      Buy.apply
    }.withId(id).addHints(hints)

    val alt = schema.oneOf[CustomerAction]("buy")
  }


  implicit val schema: Schema[CustomerAction] = union(
    Buy.alt,
  ){
    case c: Buy => Buy.alt(c)
  }.withId(id).addHints(hints)
}

Desired CustomerAction

sealed trait SellerAction extends scala.Product with scala.Serializable {
  @inline final def widen: SellerAction = this
}
object SellerAction extends ShapeTag.Companion[SellerAction] {
  val id: ShapeId = ShapeId("com.pirum.fors.event_gateway.user_action.http", "SellerAction")

  val hints: Hints = Hints.empty

  case class Buy(quantity: Option[Int] = None) extends SellerAction // <- Notice the name of the class here
  object Buy extends ShapeTag.Companion[Buy] {
    val id: ShapeId = ShapeId("com.pirum.fors.event_gateway.user_action.http", "Buy")

    val hints: Hints = Hints.empty

    val schema: Schema[Buy] = struct(
      int.optional[Buy]("quantity", _.quantity),
    ){
      Buy.apply
    }.withId(id).addHints(hints)

    val alt = schema.oneOf[SellerAction]("buy")
  }
  case class RemoveSellAccount() extends SellerAction
  object RemoveSellAccount extends ShapeTag.Companion[RemoveSellAccount] {
    val id: ShapeId = ShapeId("com.pirum.fors.event_gateway.user_action.http", "RemoveSellAccount")

    val hints: Hints = Hints.empty

    implicit val schema: Schema[RemoveSellAccount] = constant(RemoveSellAccount()).withId(id).addHints(hints)

    val alt = schema.oneOf[SellerAction]("removeSellAccount")
  }


  implicit val schema: Schema[SellerAction] = union(
    Buy.alt,
    RemoveSellAccount.alt,
  ){
    case c: Buy => Buy.alt(c)
    case c: RemoveSellAccount => RemoveSellAccount.alt(c)
  }.withId(id).addHints(hints)
}

andrewrigas avatar May 13 '23 19:05 andrewrigas

Naming my internal domain to be 1 to 1 with what smithy can generate I would like to avoid.

Maybe a preprocessor is better suited , https://disneystreaming.github.io/smithy4s/docs/guides/model-preprocessing

yisraelU avatar May 15 '23 19:05 yisraelU

Naming my internal domain to be 1 to 1 with what smithy can generate I would like to avoid.

Maybe a preprocessor is better suited , https://disneystreaming.github.io/smithy4s/docs/guides/model-preprocessing

Not sure if this is going to solve my problem here or is going to make it any easier since the transformation needs to be written explicitly.

andrewrigas avatar May 23 '23 23:05 andrewrigas

This issue should be split in two that could be handled separately :

  1. Generate empty structures as case objects (whether they're used in or out of unions)
  2. Allow for customising names of ADT members

Regarding 1 :

In the current state, Smithy4s will render as case objects union members that target the Unit shape provided by the Smithy standard library. This means you don't have to create empty structures yourself :

@adt
union SellerAction {
    removeSellAccount: Unit 
}

Regarding 2 :

I'm not fundamentally opposed to it, provided it's called @generatedName and constrained (via smithy selectors) union-members. A more generic name would open the door to re-using the same trait in other usecases later on.

Baccata avatar May 30 '23 10:05 Baccata