Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow Output to ignore @transientDefault #633

Merged
merged 8 commits into from
Sep 30, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -312,11 +312,13 @@ object GenCodec extends RecursiveAutoCodecs with TupleGenCodecs {
}

trait SizedCodec[T] extends GenCodec[T] {
def size(value: T): Int
def size(value: T): Int = size(value, Opt.Empty)

def size(value: T, output: Opt[SequentialOutput]): Int
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we'd like to have output: OptArg[SequentialOutput]?


protected final def declareSizeFor(output: SequentialOutput, value: T): Unit =
if (output.sizePolicy != SizePolicy.Ignored) {
output.declareSize(size(value))
output.declareSize(size(value, output.opt))
}
}

Expand All @@ -336,8 +338,8 @@ object GenCodec extends RecursiveAutoCodecs with TupleGenCodecs {
object OOOFieldsObjectCodec {
// this was introduced so that transparent wrapper cases are possible in flat sealed hierarchies
final class Transformed[A, B](val wrapped: OOOFieldsObjectCodec[B], onWrite: A => B, onRead: B => A) extends OOOFieldsObjectCodec[A] {
def size(value: A): Int =
wrapped.size(onWrite(value))
def size(value: A, output: Opt[SequentialOutput]): Int =
wrapped.size(onWrite(value), output)

def readObject(input: ObjectInput, outOfOrderFields: FieldValues): A =
onRead(wrapped.readObject(input, outOfOrderFields))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.avsystem.commons
package serialization

/**
* Instructs [[GenCodec]] to <b>ignore</b> the [[transientDefault]] annotation when serializing a case class.
* This ensures that even if a field's value is the same as its default, it will be <b>included</b> in the serialized
* representation. Deserialization behavior remains <b>unchanged</b>. If a field is missing from the input, the default
* value will be used as usual.
*
* This marker can be helpful when using the same model class in multiple contexts with different serialization
* formats that have conflicting requirements for handling default values.
*
* @see [[CustomMarkersOutputWrapper]] for an easy way to add markers to existing [[Output]] implementations
*/
object IgnoreTransientDefaultMarker extends CustomEventMarker[Unit]
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class OOOFieldCborRawKeysCodec[T](stdObjectCodec: OOOFieldsObjectCodec[T], keyCo
stdObjectCodec.writeFields(output, value)
}

def size(value: T): Int = stdObjectCodec.size(value)
def size(value: T, output: Opt[SequentialOutput]): Int = stdObjectCodec.size(value, output)
def nullable: Boolean = stdObjectCodec.nullable
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.avsystem.commons
package serialization

trait AcceptsAdditionalCustomMarkers extends AcceptsCustomEvents {

protected def markers: Set[CustomEventMarker[_]]

override def customEvent[T](marker: CustomEventMarker[T], event: T): Boolean =
marker match {
case marker if markers(marker) => true
case _ => super.customEvent(marker, event)
}
}

/**
* [[Input]] implementation that adds additional markers [[CustomEventMarker]] to the provided [[Input]] instance
*/
final class CustomMarkersInputWrapper(
override protected val wrapped: Input,
override protected val markers: Set[CustomEventMarker[_]],
) extends InputWrapper with AcceptsAdditionalCustomMarkers {

override def readList(): ListInput =
new CustomMarkersInputWrapper.AdjustedListInput(super.readList(), markers)

override def readObject(): ObjectInput =
new CustomMarkersInputWrapper.AdjustedObjectInput(super.readObject(), markers)
}
object CustomMarkersInputWrapper {
def apply(input: Input, markers: CustomEventMarker[_]*): CustomMarkersInputWrapper =
new CustomMarkersInputWrapper(input, markers.toSet)

private final class AdjustedListInput(
override protected val wrapped: ListInput,
override protected val markers: Set[CustomEventMarker[_]],
) extends ListInputWrapper with AcceptsAdditionalCustomMarkers {
override def nextElement(): Input = new CustomMarkersInputWrapper(super.nextElement(), markers)
}

private final class AdjustedFieldInput(
override protected val wrapped: FieldInput,
override protected val markers: Set[CustomEventMarker[_]],
) extends FieldInputWrapper with AcceptsAdditionalCustomMarkers {

override def readList(): ListInput = new AdjustedListInput(super.readList(), markers)
override def readObject(): ObjectInput = new AdjustedObjectInput(super.readObject(), markers)
}

private final class AdjustedObjectInput(
override protected val wrapped: ObjectInput,
override protected val markers: Set[CustomEventMarker[_]],
) extends ObjectInputWrapper with AcceptsAdditionalCustomMarkers {

override def nextField(): FieldInput = new AdjustedFieldInput(super.nextField(), markers)
override def peekField(name: String): Opt[FieldInput] =
super.peekField(name).map(new AdjustedFieldInput(_, markers))
}
}

/**
* [[Output]] implementation that adds additional markers [[CustomEventMarker]] to the provided [[Output]] instance
*/
final class CustomMarkersOutputWrapper(
override protected val wrapped: Output,
override protected val markers: Set[CustomEventMarker[_]],
) extends OutputWrapper with AcceptsAdditionalCustomMarkers {

override def writeSimple(): SimpleOutput =
new CustomMarkersOutputWrapper.AdjustedSimpleOutput(super.writeSimple(), markers)

override def writeList(): ListOutput =
new CustomMarkersOutputWrapper.AdjustedListOutput(super.writeList(), markers)

override def writeObject(): ObjectOutput =
new CustomMarkersOutputWrapper.AdjustedObjectOutput(super.writeObject(), markers)
}

object CustomMarkersOutputWrapper {
def apply(output: Output, markers: CustomEventMarker[_]*): CustomMarkersOutputWrapper =
new CustomMarkersOutputWrapper(output, markers.toSet)

private final class AdjustedSimpleOutput(
override protected val wrapped: SimpleOutput,
override protected val markers: Set[CustomEventMarker[_]],
) extends SimpleOutputWrapper with AcceptsAdditionalCustomMarkers

private final class AdjustedListOutput(
override protected val wrapped: ListOutput,
override protected val markers: Set[CustomEventMarker[_]],
) extends ListOutputWrapper with AcceptsAdditionalCustomMarkers {

override def writeElement(): Output =
new CustomMarkersOutputWrapper(super.writeElement(), markers)
}

private final class AdjustedObjectOutput(
override protected val wrapped: ObjectOutput,
override protected val markers: Set[CustomEventMarker[_]],
) extends ObjectOutputWrapper with AcceptsAdditionalCustomMarkers {

override def writeField(key: String): Output =
new CustomMarkersOutputWrapper(super.writeField(key), markers)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class SingletonCodec[T <: Singleton](
) extends ErrorReportingCodec[T] with OOOFieldsObjectCodec[T] {
final def nullable = true
final def readObject(input: ObjectInput, outOfOrderFields: FieldValues): T = singletonValue
def size(value: T): Int = 0
def size(value: T, output: Opt[SequentialOutput]): Int = 0
def writeFields(output: ObjectOutput, value: T): Unit = ()
}

Expand Down Expand Up @@ -109,7 +109,7 @@ abstract class ProductCodec[T <: Product](
nullable: Boolean,
fieldNames: Array[String]
) extends ApplyUnapplyCodec[T](typeRepr, nullable, fieldNames) {
def size(value: T): Int = value.productArity
def size(value: T, output: Opt[SequentialOutput]): Int = value.productArity

final def writeFields(output: ObjectOutput, value: T): Unit = {
val size = value.productArity
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.avsystem.commons
package serialization

import com.avsystem.commons.serialization.CodecTestData.HasDefaults

object IgnoreTransientDefaultMarkerTest {
final case class NestedHasDefaults(
@transientDefault flag: Boolean = false,
obj: HasDefaults,
list: Seq[HasDefaults],
@transientDefault defaultObj: HasDefaults = HasDefaults(),
)
object NestedHasDefaults extends HasGenCodec[NestedHasDefaults]

final case class HasOptParam(
@transientDefault flag: Boolean = false,
@optionalParam str: Opt[String] = Opt.Empty,
)
object HasOptParam extends HasGenCodec[HasOptParam]
}

class IgnoreTransientDefaultMarkerTest extends AbstractCodecTest {
import IgnoreTransientDefaultMarkerTest._

override type Raw = Any

def writeToOutput(write: Output => Unit): Any = {
var result: Any = null
write(CustomMarkersOutputWrapper(new SimpleValueOutput(result = _), IgnoreTransientDefaultMarker))
result
}

def createInput(raw: Any): Input =
CustomMarkersInputWrapper(new SimpleValueInput(raw), IgnoreTransientDefaultMarker)

test("write case class with default values") {
testWrite(HasDefaults(str = "lol"), Map("str" -> "lol", "int" -> 42))
testWrite(HasDefaults(43, "lol"), Map("int" -> 43, "str" -> "lol"))
testWrite(HasDefaults(str = null), Map("str" -> null, "int" -> 42))
testWrite(HasDefaults(str = "dafuq"), Map("str" -> "dafuq", "int" -> 42))
}

test("read case class with default values") {
testRead(Map("str" -> "lol", "int" -> 42), HasDefaults(str = "lol", int = 42))
testRead(Map("str" -> "lol"), HasDefaults(str = "lol", int = 42))
testRead(Map("int" -> 43, "str" -> "lol"), HasDefaults(int = 43, str = "lol"))
testRead(Map("str" -> null, "int" -> 42), HasDefaults(str = null, int = 42))
testRead(Map("str" -> null), HasDefaults(str = null, int = 42))
testRead(Map(), HasDefaults(str = "dafuq", int = 42))
}

test("write case class with opt values") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about NOpt?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assumed all option-like types are treated the same by com.avsystem.commons.serialization.optionalParam, so there should be no need to write test for every type.

testWrite(HasOptParam(str = "lol".opt), Map("flag" -> false, "str" -> "lol"))
testWrite(HasOptParam(), Map("flag" -> false))
}

test("write nested case class with default values") {
testWrite(
value = NestedHasDefaults(
flag = false,
obj = HasDefaults(str = "lol"),
list = Seq(HasDefaults(int = 43)),
defaultObj = HasDefaults(),
),
expectedRepr = Map(
"flag" -> false,
"defaultObj" -> Map[String, Any]("str" -> "kek", "int" -> 42),
"obj" -> Map[String, Any]("str" -> "lol", "int" -> 42),
"list" -> List(Map[String, Any]("str" -> "kek", "int" -> 43)),
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,55 @@ object CustomWrapper extends HasApplyUnapplyCodec[CustomWrapper] {
def unapply(cw: CustomWrapper): Opt[String] = Opt(cw.a)
}

case class RecordWithOpts(
@optionalParam abc: Opt[String] = Opt.Empty,
@transientDefault flag: Opt[Boolean] = Opt.Empty,
b: Int = 42,
)
object RecordWithOpts extends HasApplyUnapplyCodec[RecordWithOpts]

case class SingleFieldRecordWithOpts(@optionalParam abc: Opt[String] = Opt.Empty)
object SingleFieldRecordWithOpts extends HasApplyUnapplyCodec[SingleFieldRecordWithOpts]

case class SingleFieldRecordWithTD(@transientDefault abc: String = "abc")
object SingleFieldRecordWithTD extends HasApplyUnapplyCodec[SingleFieldRecordWithTD]

class ObjectSizeTest extends AnyFunSuite {
test("computing object size") {
assert(RecordWithDefaults.codec.size(RecordWithDefaults()) == 2)
assert(RecordWithDefaults.codec.size(RecordWithDefaults("fuu")) == 3)
assert(RecordWithOpts.codec.size(RecordWithOpts("abc".opt)) == 2)
assert(RecordWithOpts.codec.size(RecordWithOpts("abc".opt, true.opt)) == 3)
assert(RecordWithOpts.codec.size(RecordWithOpts()) == 1)
assert(SingleFieldRecordWithOpts.codec.size(SingleFieldRecordWithOpts()) == 0)
assert(SingleFieldRecordWithOpts.codec.size(SingleFieldRecordWithOpts("abc".opt)) == 1)
assert(SingleFieldRecordWithTD.codec.size(SingleFieldRecordWithTD()) == 0)
assert(SingleFieldRecordWithTD.codec.size(SingleFieldRecordWithTD("haha")) == 1)
assert(CustomRecordWithDefaults.codec.size(CustomRecordWithDefaults()) == 1)
assert(CustomRecordWithDefaults.codec.size(CustomRecordWithDefaults("fuu")) == 2)
assert(CustomWrapper.codec.size(CustomWrapper()) == 0)
assert(CustomWrapper.codec.size(CustomWrapper("fuu")) == 1)
}

test("computing object size with custom output") {
val defaultIgnoringOutput = new SequentialOutput {
override def customEvent[T](marker: CustomEventMarker[T], event: T): Boolean =
marker match {
case IgnoreTransientDefaultMarker => true
case _ => super.customEvent(marker, event)
}
override def finish(): Unit = ()
}
assert(RecordWithDefaults.codec.size(RecordWithDefaults(), defaultIgnoringOutput.opt) == 3)
assert(RecordWithDefaults.codec.size(RecordWithDefaults("fuu"), defaultIgnoringOutput.opt) == 3)
assert(RecordWithOpts.codec.size(RecordWithOpts("abc".opt), defaultIgnoringOutput.opt) == 3)
assert(RecordWithOpts.codec.size(RecordWithOpts("abc".opt, true.opt), defaultIgnoringOutput.opt) == 3)
assert(RecordWithOpts.codec.size(RecordWithOpts(), defaultIgnoringOutput.opt) == 2)
assert(SingleFieldRecordWithOpts.codec.size(SingleFieldRecordWithOpts(), defaultIgnoringOutput.opt) == 0) // @optionalParam field should NOT be counted
assert(SingleFieldRecordWithOpts.codec.size(SingleFieldRecordWithOpts("abc".opt), defaultIgnoringOutput.opt) == 1)
assert(SingleFieldRecordWithTD.codec.size(SingleFieldRecordWithTD(), defaultIgnoringOutput.opt) == 1) // @transientDefault field should be counted
assert(SingleFieldRecordWithTD.codec.size(SingleFieldRecordWithTD("haha"), defaultIgnoringOutput.opt) == 1)
assert(CustomRecordWithDefaults.codec.size(CustomRecordWithDefaults(), defaultIgnoringOutput.opt) == 2)
assert(CustomRecordWithDefaults.codec.size(CustomRecordWithDefaults("fuu"), defaultIgnoringOutput.opt) == 2)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ case class CustomKeysRecord(
)
object CustomKeysRecord extends HasCborCodec[CustomKeysRecord]

case class CustomKeysRecordWithDefaults(
@transientDefault @cborKey(1) first: Int = 0,
@cborKey(true) second: Boolean,
)
object CustomKeysRecordWithDefaults extends HasCborCodec[CustomKeysRecordWithDefaults]

case class CustomKeysRecordWithNoDefaults(
@cborKey(1) first: Int = 0,
@cborKey(true) second: Boolean,
)
object CustomKeysRecordWithNoDefaults extends HasCborCodec[CustomKeysRecordWithNoDefaults]

@cborDiscriminator(0)
sealed trait GenericSealedTrait[+T]
object GenericSealedTrait extends HasPolyCborCodec[GenericSealedTrait] {
Expand Down Expand Up @@ -61,14 +73,22 @@ class CborInputOutputTest extends AnyFunSuite {
keyCodec: CborKeyCodec = CborKeyCodec.Default
)(implicit pos: Position): Unit =
test(s"${pos.lineNumber}: $value") {
val baos = new ByteArrayOutputStream
val output = new CborOutput(new DataOutputStream(baos), keyCodec, SizePolicy.Optional)
GenCodec.write[T](output, value)
val bytes = baos.toByteArray
assert(Bytes(bytes).toString == binary)
assert(RawCbor(bytes).readAs[T](keyCodec) == value)
assertRoundtrip(value, binary, keyCodec)
}

private def assertRoundtrip[T: GenCodec](
value: T,
binary: String,
keyCodec: CborKeyCodec = CborKeyCodec.Default
)(implicit pos: Position): Unit = {
val baos = new ByteArrayOutputStream
val output = new CborOutput(new DataOutputStream(baos), keyCodec, SizePolicy.Optional)
GenCodec.write[T](output, value)
val bytes = baos.toByteArray
assert(Bytes(bytes).toString == binary)
assert(RawCbor(bytes).readAs[T](keyCodec) == value)
}

// binary representation from cbor.me

roundtrip(null, "F6")
Expand Down Expand Up @@ -213,6 +233,22 @@ class CborInputOutputTest extends AnyFunSuite {
"""{"first":42,"second":true,"third":"foo","strMap":{"foo":1},"intMap":{"1":"foo"}}""")
}

test("writing with IgnoreTransientDefaultMarker to CBOR output") {
val baos = new ByteArrayOutputStream
val output = CustomMarkersOutputWrapper(
new CborOutput(new DataOutputStream(baos), keyCodec, SizePolicy.Optional),
IgnoreTransientDefaultMarker,
)
val value = CustomKeysRecordWithDefaults(first = 0, second = true)
GenCodec.write(output, value)
val bytes = Bytes(baos.toByteArray)
assert(bytes.toString == "A20100F5F5")
assert(RawCbor(bytes.bytes).readAs[CustomKeysRecordWithDefaults](keyCodec) == value)

// should be the same as model with @transientDefault and serialization ignoring it
assertRoundtrip(CustomKeysRecordWithNoDefaults(first = 0, second = true), "A20100F5F5")
}

test("chunked text string") {
assert(CborInput.readRawCbor[String](RawCbor.fromHex("7F626162626162626162FF")) == "ababab")
}
Expand Down
Loading
Loading