plokhotnyuk / jsoniter-scala

Scala macros for compile-time generation of safe and ultra-fast JSON codecs + circe booster
MIT License
744 stars 99 forks source link

JsonCodecMaker fails in self-type scenario #1188

Open nkgm opened 1 month ago

nkgm commented 1 month ago

Here's a simplified scenario. Any insight would be greatly appreciated.

import com.github.plokhotnyuk.jsoniter_scala.core.*
import com.github.plokhotnyuk.jsoniter_scala.macros.*

trait Aggregate {
  type Props

  def propsCodec: JsonValueCodec[Props]

  given JsonValueCodec[Props] = propsCodec
}

trait Events { self: Aggregate =>

  case class MyEvent(props: Props)

  // works here
  // val myEventCodec: JsonValueCodec[MyEvent] = JsonCodecMaker.make
}

object Person extends Aggregate with Events {

  case class Props(name: String, age: Int)

  val propsCodec: JsonValueCodec[Props] = JsonCodecMaker.make

  // No implicit 'com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec[_ >: scala.Nothing <: scala.Any]' 
  // defined for 'Events.this.Props'.
  val myEventCodec: JsonValueCodec[MyEvent] = JsonCodecMaker.make
}
plokhotnyuk commented 1 month ago

@nkgm Thanks for the question!

What are you going to do with that codecs?

Could you please update your code snippet with lines for creation of MyEvent instances and parsing/serialization of them?

Which JSON representation are you expecting for them?

nkgm commented 1 month ago

Hey @plokhotnyuk, thanks for looking! This is still largely experimental, so feel free to poke holes in it if you must :)

It's a micro framework for DDD/ES that attempts to eliminate as much boilerplate as possible by use of Scala3 macro annotations (@aggregate) and other modern metaprogramming facilities.

I tried to keep it as short as possible without leaving out key bits of the motivation behind it. I have added **STYLE** comments to the parts most relevant to this **ISSUE**.

I'm still figuring out a few things, so implementation will be incomplete.

Framework code

type AggregateID[AT <: Aggregate] = FlakeID @@ AT

trait AggregateState[AT <: Aggregate]

trait AggregateRoot[AT <: Aggregate] {
  def version: AggregateVersion[AT]
  def props: Aggregate.GetIdProps[AT]
}

trait AggregateVersion[AT <: Aggregate] {
  def aid: Aggregate.GetID[AT]
  def eid: FlakeID
}

type JsonString = String
type EventName = String

trait Aggregate { self =>
  type ID = FlakeID @@ self.type
  inline given (using ev: JsonValueCodec[FlakeID]): JsonValueCodec[ID] = ev.asInstanceOf[JsonValueCodec[ID]]

  type Props[F[_]]

  type EntityProps = Props[Id]
  type OptionProps = Props[Option]

  case class Entity(version: EntityVersion, props: EntityProps) extends AggregateRoot[self.type]

  case class EntityVersion(aid: ID, eid: FlakeID) extends AggregateVersion[self.type]

  trait EntityState extends AggregateState[self.type]
  trait EntityEvent extends AggregateEvent[self.type]

  case object Initial extends EntityState

  inline def eventName[E <: EntityEvent]: EventName = ${eventNameImpl}

  // autogenerated by @aggregate **SERIALIZATION**
  def encodeEvent[E <: EntityEvent](event: E): (EventName, JsonString)

  // autogenerated by @aggregate **PARSING**
  def decodeEvent(eventName: EventName, json: JsonString): EntityEvent

  // autogenerated by @aggregate
  def updateProps(props: EntityProps, optionProps: OptionProps): EntityProps

  // autogenerated by @aggregate
  def handleEvents(state: EntityState, event: EntityEvent): UnionStates

  def processEvent(event: UnionEvents, state: Option[UnionStates] = None): UnionStates = 
    handleEvents(state.getOrElse(Initial), event)

  def processEvents(events: List[UnionEvents], state: Option[UnionStates] = None): UnionStates =
    events.foldLeft(processEvent(events.head, state))((s, e) => handleEvents(s, e))

  type UnionStates <: EntityState
  type UnionEvents <: EntityEvent

  // autogenerated by @aggregate
  def entityPropsJsonCodec: JsonValueCodec[EntityProps]
  def optionPropsJsonCodec: JsonValueCodec[OptionProps]

  val entityVersionJsonCodec: JsonValueCodec[EntityVersion] =
    JsonCodecMaker.make[EntityVersion]

  inline given JsonValueCodec[EntityProps] = entityPropsJsonCodec
  inline given JsonValueCodec[OptionProps]   = optionPropsJsonCodec
  inline given JsonValueCodec[EntityVersion] = entityVersionJsonCodec

  inline given self.type = self
}

trait AggregateEvent[AT <: Aggregate]

trait CrudEvents { self: Aggregate =>

  type CrudUnionStates = Initial.type | Active | Inactive
  type CrudUnionEvents = CreatedEvent | UpdatedEvent | DeletedEvent.type

  case class Active(props: EntityProps)   extends EntityState
  case class Inactive(props: EntityProps) extends EntityState

  case class CreatedEvent(props: EntityProps) extends EntityEvent

  case class UpdatedEvent(optionProps: OptionProps) extends EntityEvent

  case object DeletedEvent extends EntityEvent

  // **ISSUE** assume I had to let @aggregate generate this for me for whatever reason
  // given createdEventCodec: JsonValueCodec[CreatedEvent]      = JsonCodecMaker.make[CreatedEvent]

  given updatedEventCodec: JsonValueCodec[UpdatedEvent]      = JsonCodecMaker.make[UpdatedEvent]
  given deletedEventCodec: JsonValueCodec[DeletedEvent.type] = JsonCodecMaker.make[DeletedEvent.type]

  def handleEvent(state: Initial.type, event: CreatedEvent) = Active(event.props)
  def handleEvent(state: Active, event: UpdatedEvent)       = Active(updateProps(state.props, event.optionProps))
  def handleEvent(state: Active, event: DeletedEvent.type)  = Inactive(state.props)
}

User code

@aggregate
object Person extends Aggregate with CrudEvents {

  // Higher-Kinded Data FTW!
  case class Props[F[_]](name: F[String], age: F[Int])

  object Props {
    // Props("John", 33) gives Props[Id]("John", 33)
    // Props[Option]("John".some) gives Props[Option]("John", None)
    // Any F[_] with a MonoidK instance gets auto default params
    // If no MonoidK and params omitted -> compiletime error
    inline def apply[F[_]](using Infer[F])(
        inline name: F[String] = autop[F[String]],
        inline age: F[Int] = autop[F[Int]]
    ): Props[F] = new Props[F](name, age)
  }

  type UnionStates = CrudUnionStates

  type UnionEvents = CrudUnionEvents | MyEvent

  case class MyEvent(name: String) extends EntityEvent

  def handleEvent(state: EntityState, event: MyEvent) = ???

  //////////////////////// AUTOGENERATED BY @aggregate ////////////////////////

  val entityPropsJsonCodec: JsonValueCodec[EntityProps] = JsonCodecMaker.make
  val optionPropsJsonCodec: JsonValueCodec[OptionProps] = JsonCodecMaker.make

  // only generate codecs that can't be found in scope
  val _myEventCodec: JsonValueCodec[MyEvent]           = JsonCodecMaker.make

  // Trying to generate the codec for `CrudEvents.CreatedEvent` here will fail
  // This is the very reason for this **ISSUE**
  val _createdEventCodec: JsonValueCodec[CreatedEvent] = JsonCodecMaker.make
  // No implicit 'com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec[_ >: scala.Nothing <: scala.Any]' 
  // defined for 'CrudEvents.this.Props[cats.Id]'.

  // codec already found in `CrudEvents` mixin, so macro will use that instead
  // val _updatedEventCodec: JsonValueCodec[UpdatedEvent] = JsonCodecMaker.make

  // @aggregate will first check for the existence of `handleEvent(s <: EntityState, e in UnionEvents)` 
  // for all UnionEvents and give a compile-time error if any events go unhandled.
  // nb: the implementation auto-sorts cases by specificity so the more specific ones come first
  def handleEvents(state: EntityState, event: EntityEvent): UnionStates =
    (state, event) match {
      case (s: Initial.type, e: CreatedEvent) => handleEvent(s, e)
      case (s: Active, e: UpdatedEvent)       => handleEvent(s, e)
      case (s: Active, e: DeletedEvent.type)  => handleEvent(s, e)
      case (s: EntityState, e: MyEvent)       => handleEvent(s, e)
      case (s: EntityState, e: PersonEvent)   => handleEvent(s, e)
      case default$macro$1 =>
        throw IllegalStateException(
          StringBuilder("No handler for (")
          .append(state)
          .append(", ")
          .append(event)
          .append(")")
          .toString()
        )
    }

  def updateProps(props: EntityProps, optionProps: OptionProps): EntityProps = props.patchUsing(optionProps)

  // **SERIALIZATION**
  def encodeEvent[E <: EntityEvent](event: E): (EventName, JsonString) = event match
      case e: CreatedEvent => ("CreatedEvent", writeToString(e)(using _createdEventCodec))
      case e: UpdatedEvent => ("UpdatedEvent", writeToString(e)(using updatedEventCodec)) // codec can reside in any of the mixed in traits
      case e: MyEvent => ("MyEvent", writeToString(e)(using _myEventCodec)) 
      case whatever => ???

  // **PARSING**
  def decodeEvent(eventName: EventName, json: JsonString): EntityEvent = eventName match
      case "CreatedEvent" => readFromString(json)(using _createdEventCodec)
      case "UpdatedEvent" => readFromString(json)(using updatedEventCodec) // codec can reside in any of the mixed in traits
      case "MyEvent" => readFromString(json)(using _myEventCodec)
      case whatever => ???

  ////////////////////////////////////////////////////////////////////////////////////////////////////////

}
nkgm commented 1 month ago

Hey @plokhotnyuk, I think I found a fix but it would require a refactoring of JsonCodecMaker (which I believe it could benefit from anyway due to its large size). Would you consider a PR?

plokhotnyuk commented 1 month ago

Hey @plokhotnyuk, I think I found a fix but it would require a refactoring of JsonCodecMaker (which I believe it could benefit from anyway due to its large size). Would you consider a PR?

@nkgm Thanks for the help!

I cannot promise that your refactoring will be merged as is. Mostly because 2.x versions are too mature and some features could be not documented yet in tests for macros while used in some code that works in production. But your ideas for large refactoring could be game changing for the next 3.x versions that should not keep backward binary/source/feature compatibility.

nkgm commented 1 month ago

Mostly because 2.x versions are too mature and some features could be not documented yet in tests for macros while used in some code that works in production

Hey @plokhotnyuk , fully agree. Since I'll need to use the new experimental codec maker alongside 2.x, maybe the best course of action for now is a standalone artifact like "io.nkgm" %%% "jsoniter2-scala3-macros" % "0.1.0" or whatever naming scheme you deem appropriate to avoid confusion. That way, anyone can see it in action, and maybe in due time, (some of) it finds its way into 3.x.

What do you think?