From 973352ec9348850eaa159d7c6321d56addd2f9a9 Mon Sep 17 00:00:00 2001 From: Pierre Ricadat Date: Thu, 9 Jul 2026 12:36:57 +0900 Subject: [PATCH] Fix primitive default-value round-trip and preserve -0.0 in the codec Two encode/decode consistency bugs in the primitive codec: - Decode restored an absent field from the schema-captured default (which the deriver populates from the Scala case-class default parameter), while encode omits a field whenever it equals the type's zero. A primitive field with a non-zero declared default (e.g. Int = 5) therefore turned an encoded 0 into 5 on decode. For primitive fields, use the proto3 zero as the decode default so encode and decode agree; message/enum/option/collection defaults are unchanged. - Field presence for Double/Float used value == 0, and -0.0 == 0.0 in IEEE, so -0.0 was dropped and round-tripped to +0.0 (also diverging from protobuf-java, which gates on raw bits). Presence now checks raw bits, with a value == 0 short-circuit so non-zero values never call doubleToRawLongBits. --- .../main/scala/proteus/ProtobufCodec.scala | 16 ++++++++-------- .../main/scala/proteus/ProtobufDeriver.scala | 8 ++++++-- .../scala/proteus/ProtobufCodecSpec.scala | 19 +++++++++++++++++++ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/core/src/main/scala/proteus/ProtobufCodec.scala b/core/src/main/scala/proteus/ProtobufCodec.scala index edc8122..141a2f0 100644 --- a/core/src/main/scala/proteus/ProtobufCodec.scala +++ b/core/src/main/scala/proteus/ProtobufCodec.scala @@ -365,12 +365,12 @@ object ProtobufCodec { else CodedOutputStream.computeStringSize(id, value) case _: PrimitiveType.Double => val value = register.asInstanceOf[Register.Double].get(registers, offset) - if (value == 0d && !alwaysEncode) 0 + if (value == 0d && java.lang.Double.doubleToRawLongBits(value) == 0L && !alwaysEncode) 0 else if (id == -1) CodedOutputStream.computeDoubleSizeNoTag(value) else CodedOutputStream.computeDoubleSize(id, value) case _: PrimitiveType.Float => val value = register.asInstanceOf[Register.Float].get(registers, offset) - if (value == 0f && !alwaysEncode) 0 + if (value == 0f && java.lang.Float.floatToRawIntBits(value) == 0 && !alwaysEncode) 0 else if (id == -1) CodedOutputStream.computeFloatSizeNoTag(value) else CodedOutputStream.computeFloatSize(id, value) case _ => throw new ProteusException(s"Unsupported primitive type: $primitiveType") @@ -400,12 +400,12 @@ object ProtobufCodec { else CodedOutputStream.computeStringSize(id, value) case _: PrimitiveType.Double => val value: Double = a - if (value == 0d && !alwaysEncode) 0 + if (value == 0d && java.lang.Double.doubleToRawLongBits(value) == 0L && !alwaysEncode) 0 else if (id == -1) CodedOutputStream.computeDoubleSizeNoTag(value) else CodedOutputStream.computeDoubleSize(id, value) case _: PrimitiveType.Float => val value: Float = a - if (value == 0f && !alwaysEncode) 0 + if (value == 0f && java.lang.Float.floatToRawIntBits(value) == 0 && !alwaysEncode) 0 else if (id == -1) CodedOutputStream.computeFloatSizeNoTag(value) else CodedOutputStream.computeFloatSize(id, value) case _ => throw new ProteusException(s"Unsupported primitive type: $primitiveType") @@ -437,12 +437,12 @@ object ProtobufCodec { } case _: PrimitiveType.Double => val value = register.asInstanceOf[Register.Double].get(registers, offset) - if (value != 0d || alwaysEncode) { + if (value != 0d || java.lang.Double.doubleToRawLongBits(value) != 0L || alwaysEncode) { if (id == -1) output.writeDoubleNoTag(value) else output.writeDouble(id, value) } case _: PrimitiveType.Float => val value = register.asInstanceOf[Register.Float].get(registers, offset) - if (value != 0f || alwaysEncode) { + if (value != 0f || java.lang.Float.floatToRawIntBits(value) != 0 || alwaysEncode) { if (id == -1) output.writeFloatNoTag(value) else output.writeFloat(id, value) } case _ => throw new ProteusException(s"Unsupported primitive type: $primitiveType") @@ -472,12 +472,12 @@ object ProtobufCodec { } case _: PrimitiveType.Double => val value: Double = a - if (value != 0d || alwaysEncode) { + if (value != 0d || java.lang.Double.doubleToRawLongBits(value) != 0L || alwaysEncode) { if (id == -1) output.writeDoubleNoTag(value) else output.writeDouble(id, value) } case _: PrimitiveType.Float => val value: Float = a - if (value != 0f || alwaysEncode) { + if (value != 0f || java.lang.Float.floatToRawIntBits(value) != 0 || alwaysEncode) { if (id == -1) output.writeFloatNoTag(value) else output.writeFloat(id, value) } case _ => throw new ProteusException(s"Unsupported primitive type: $primitiveType") diff --git a/core/src/main/scala/proteus/ProtobufDeriver.scala b/core/src/main/scala/proteus/ProtobufDeriver.scala index c31033f..a02ab2a 100644 --- a/core/src/main/scala/proteus/ProtobufDeriver.scala +++ b/core/src/main/scala/proteus/ProtobufDeriver.scala @@ -271,7 +271,7 @@ case class ProtobufDeriver private ( getComment(field.modifiers) ) case instance => - val fieldId = getReservedIndex(field.modifiers) match { + val fieldId = getReservedIndex(field.modifiers) match { case Some(reservedIndex) => reservedIndex case None => getReservedFromIndex(field.modifiers) match { @@ -284,12 +284,16 @@ case class ProtobufDeriver private ( id } } + val fieldDefault = instance match { + case _: ProtobufCodec.Primitive[?] => getDefaultValue(using instance) + case _ => field.value.getDefaultValue.getOrElse(getDefaultValue(using instance)) + } builder += SimpleField( name, fieldId, instance, register, - field.value.getDefaultValue.getOrElse(getDefaultValue(using instance)), + fieldDefault, getComment(field.modifiers), isDeprecated(field.modifiers) ) diff --git a/core/src/test/scala/proteus/ProtobufCodecSpec.scala b/core/src/test/scala/proteus/ProtobufCodecSpec.scala index 6c4e752..f798856 100644 --- a/core/src/test/scala/proteus/ProtobufCodecSpec.scala +++ b/core/src/test/scala/proteus/ProtobufCodecSpec.scala @@ -75,6 +75,25 @@ object ProtobufCodecSpec extends ZIOSpecDefault { val decoded = codec.decode(encoded) assert(decoded)(equalTo(original)) + }, + test("primitive field with a non-zero default round-trips its zero value") { + case class WithDefault(count: Int = 5, label: String = "x") derives Schema + val codec = Schema[WithDefault].derive(deriver) + + val original = WithDefault(0, "") + val decoded = codec.decode(codec.encode(original)) + + assert(decoded)(equalTo(original)) + }, + test("negative-zero double and float fields preserve their sign bit") { + case class Zeros(d: Double, f: Float) derives Schema + val codec = Schema[Zeros].derive(deriver) + + val original = Zeros(-0.0, -0.0f) + val decoded = codec.decode(codec.encode(original)) + + assert(java.lang.Double.doubleToRawLongBits(decoded.d))(equalTo(java.lang.Double.doubleToRawLongBits(-0.0))) && + assert(java.lang.Float.floatToRawIntBits(decoded.f))(equalTo(java.lang.Float.floatToRawIntBits(-0.0f))) } ), suite("Enum Tests")(