Open nkgm opened 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?
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.
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)
}
@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 => ???
////////////////////////////////////////////////////////////////////////////////////////////////////////
}
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?
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.
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?
Here's a simplified scenario. Any insight would be greatly appreciated.