com-lihaoyi / upickle

uPickle: a simple, fast, dependency-free JSON & Binary (MessagePack) serialization library for Scala
https://com-lihaoyi.github.io/upickle
MIT License
707 stars 158 forks source link

Support customizing `$type` tag with `key` annotation #579

Closed mrdziuban closed 1 month ago

mrdziuban commented 1 month ago

Closes #578

Adds support for customizing the key used a discriminator for sealed hierarchies. The default is still $type, but can be customized by annotating the sealed trait with @key("customKey").

The code changes necessary to support this in Writer were:

The code changes necessary to support this in Reader were:

I tried instead to pass the tagKey through to findReader, but findReader is called in a number of contexts where it wasn't possible to get the tagKey value, for example in TaggedReader#visitString and the visitValue method within the ObjVisitor returned by AttributeTagged#taggedObjectContext.

The one limitation of the customization is when a member of a sealed hierarchy inherits from multiple parents that would result in different discriminators, e.g. if two parents have @key annotations with different arguments, or if one parent has a @key annotation and another doesn't. In these cases, the macros will fail with an error telling the user what went wrong and suggesting ways to fix it:

@key("customKey1")
sealed trait MultiKeyedADT1
@key("customKey2")
sealed trait MultiKeyedADT2
case object MultiKeyedObj extends MultiKeyedADT1 with MultiKeyedADT2 {
  implicit val rw: ReadWriter[MultiKeyedObj.type] = macroRW
}
-- Error: ----------------------------------------------------------------------
 6 |  implicit val rw: ReadWriter[MultiKeyedObj.type] = macroRW
   |                                                    ^^^^^^^
   |Type MultiKeyedObj.type inherits from multiple parent types with different discriminator keys:
   |
   |  parents: MultiKeyedADT1, MultiKeyedADT2
   |  keys: customKey1, customKey2
   |
   |To resolve this, either remove the `@key` annotations from all parents of the type,
   |or make sure all the parents pass the same value to `@key`
lihaoyi commented 1 month ago

@mrdziuban I think the approach looks great.

Determine how to handle cases where a type inherits from multiple sealed traits

In the current codebase, I suppose it doesn't matter, because the parent sealed trait that a case class inherits from does not change the serialization format. With this change, that is no longer true.

I think a reasonably first pass would be to just fail loudly if a case class inherits from multiple sealed traits with different @keys configured (or some configured and others not configured). The "different parent sealed traits have different discriminator fields" use case is probably obscure enough most people won't notice.

Another alternative would be to make the case class have multiple duplicate discriminator fields if its parents have inconsistent @keys, e.g. {"$type": "foo.Bar", "$type2": "foo.Baz", "data", "blah", ...}. But I think that can be left to a follow PR, and for now just erroring out gives us the flexibility to adjust the design in future if we learn more

Consider if there's a more ergonomic way to get tagKey in taggedObjectContext

Could you describe what you think is un-ergonomic about the current approach? That would help me give a proper answer

mrdziuban commented 1 month ago

fail loudly if a case class inherits from multiple sealed traits with different @keys configured (or some configured and others not configured)

Good idea, updated to do so.

Could you describe what you think is un-ergonomic about the current approach?

I wasn't sure if adding def tagKey to TaggedReader was the best approach, since it proliferates to everywhere that constructs one, like Reader.merge and Writer.merge. That said, I tried some other approaches, like passing the tagKey to findReader, but ran into limitations where I didn't have the value available where findReader was called.

mrdziuban commented 1 month ago

Re: binary compatibility, I've made progress on it but am still having trouble with 5 problems:

 * abstract method annotate(upickle.core.Types#Reader,java.lang.String,java.lang.String)upickle.core.Types#TaggedReader in interface upickle.core.Annotator is present only in current version
   filter with: ProblemFilter.exclude[ReversedMissingMethodProblem]("upickle.core.Annotator.annotate")

 * abstract method annotate(upickle.core.Types#ObjectWriter,java.lang.String,java.lang.String,upickle.core.Annotator#Checker)upickle.core.Types#TaggedWriter in interface upickle.core.Annotator is present only in current version
   filter with: ProblemFilter.exclude[ReversedMissingMethodProblem]("upickle.core.Annotator.annotate")

 * abstract method taggedWrite(upickle.core.Types#ObjectWriter,java.lang.String,java.lang.String,upickle.core.Visitor,java.lang.Object)java.lang.Object in interface upickle.core.Types is present only in current version
   filter with: ProblemFilter.exclude[ReversedMissingMethodProblem]("upickle.core.Types.taggedWrite")

 * private[..] abstract method tagKey()java.lang.String in interface upickle.core.Types#TaggedReader is present only in current version
   filter with: ProblemFilter.exclude[ReversedMissingMethodProblem]("upickle.core.Types#TaggedReader.tagKey")

 * abstract method findWriterWithKey(java.lang.Object)scala.Tuple3 in interface upickle.core.Types#TaggedWriter is present only in current version
   filter with: ProblemFilter.exclude[ReversedMissingMethodProblem]("upickle.core.Types#TaggedWriter.findWriterWithKey")

I can fix the first two by implementing the annotate methods in Types.scala to construct the TaggedReader.Leaf and TaggedWriter.Leaf instances like they do in the LegacyApi and AttributeTagged in Api.scala, but I'm not sure if there's any drawback to this and/or if they need to stay abstract in Types.scala. These are the methods I'm talking about: https://github.com/com-lihaoyi/upickle/blob/d050f729197a96db7bf8fcde9a6d8645e70126c4/upickle/core/src/upickle/core/Types.scala#L287-L288

I'm stumped on the others. Do you have any idea how to handle them?

lihaoyi commented 1 month ago

The abstracy method present only in current version errors generally can be resolved by giving the method some default implementation. e.g.

mrdziuban commented 1 month ago

Thanks for the tips, all the changes are binary compatible now 🎉

I'll hopefully be able to add some tests over the next couple of days

mrdziuban commented 1 month ago

Added a couple tests, I think this is ready for review now

lihaoyi commented 1 month ago

Left one code comment. One more request, could you update the documentation site to include the new functionality? You can use sbt upickleReadme/run to re-generate it in the upickleReadme/target/scalatex folder

lihaoyi commented 1 month ago

Also, could you update the PR description? A few lines summarizing what code changes are necessary, any alternatives you considered, and any other concerns you thought about, should be enough

mrdziuban commented 1 month ago

could you update the documentation site to include the new functionality?

👍 done

could you update the PR description?

For sure, updated to add these details

lihaoyi commented 1 month ago

Looks good to me. I kicked off CI and will merge it when green. Send me your bank details via email to haoyi.sg@gmail.com and i will close out the bounty

mrdziuban commented 1 month ago

Whoops sorry about the test failures, fixing in a minute

lihaoyi commented 1 month ago

Thanks!