From cd39212c8fbf186e0c729009a16e1a220c3cb889 Mon Sep 17 00:00:00 2001 From: Matteo Merli Date: Tue, 17 Mar 2026 14:57:15 -0700 Subject: [PATCH] Return default values for unset optional fields instead of throwing Previously, accessing an unset optional field (proto2) would throw IllegalStateException. This breaks compatibility with standard Protobuf, which returns type-appropriate default values (0, false, "", empty bytes, first enum value, empty message instance). Now only required fields throw IllegalStateException when not set. Optional fields return defaults matching Protobuf behavior: - Numbers: 0 / 0L / 0.0 / 0.0f - Booleans: false - Strings: "" - Bytes: empty byte[] / empty ByteBuf - Enums: valueOf(0) - Messages: lazily-created empty instance Also fixed clear() to properly reset all field types to defaults. --- .../generator/LightProtoBooleanField.java | 4 +- .../generator/LightProtoBytesField.java | 14 +- .../generator/LightProtoEnumField.java | 14 +- .../generator/LightProtoMessageField.java | 12 +- .../generator/LightProtoNumberField.java | 4 +- .../generator/LightProtoStringField.java | 8 +- .../lightproto/tests/BytesTest.java | 28 +++ .../lightproto/tests/MessagesTest.java | 66 +++++++ .../lightproto/tests/NumbersTest.java | 164 +++++++++++------- .../lightproto/tests/Proto3Test.java | 43 +++++ .../lightproto/tests/StringsTest.java | 24 +++ 11 files changed, 294 insertions(+), 87 deletions(-) diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBooleanField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBooleanField.java index e1276f1..0f161e1 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBooleanField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBooleanField.java @@ -27,7 +27,7 @@ public LightProtoBooleanField(ProtoFieldDescriptor field, int index) { public void getter(PrintWriter w) { w.format(" /** Returns the value of the {@code %s} field. */\n", field.getName()); w.format(" public %s %s() {\n", field.getJavaType(), Util.camelCase("is", ccName)); - if (!field.hasImplicitPresence() && !field.isDefaultValueSet()) { + if (field.isRequired()) { w.format(" if (!%s()) {\n", Util.camelCase("has", ccName)); w.format(" throw new IllegalStateException(\"Field '%s' is not set\");\n", field.getName()); w.format(" }\n"); @@ -40,7 +40,7 @@ public void getter(PrintWriter w) { public void clear(PrintWriter w) { if (field.isDefaultValueSet()) { w.format("%s = %s;\n", ccName, field.getDefaultValueAsString()); - } else if (field.hasImplicitPresence()) { + } else { w.format("%s = false;\n", ccName); } } diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java index cc69452..14bbd80 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoBytesField.java @@ -65,19 +65,19 @@ public void setter(PrintWriter w, String enclosingType) { public void getter(PrintWriter w) { w.format("/** Returns the size in bytes of the {@code %s} field. */\n", field.getName()); w.format("public int %s() {\n", Util.camelCase("get", ccName, "size")); - if (field.hasImplicitPresence()) { - w.format(" if (_%sLen < 0) { return 0; }\n", ccName); - } else { + if (field.isRequired()) { w.format(" if (!%s()) {\n", Util.camelCase("has", ccName)); w.format(" throw new IllegalStateException(\"Field '%s' is not set\");\n", field.getName()); w.format(" }\n"); + } else { + w.format(" if (_%sLen < 0) { return 0; }\n", ccName); } w.format(" return _%sLen;\n", ccName); w.format("}\n"); w.format("/** Returns the {@code %s} field as a byte array. */\n", field.getName()); w.format("public byte[] %s() {\n", Util.camelCase("get", ccName)); - if (field.hasImplicitPresence()) { + if (!field.isRequired()) { w.format(" if (_%sLen < 0) { return new byte[0]; }\n", ccName); } w.format(" io.netty.buffer.ByteBuf _b = %s();\n", Util.camelCase("get", ccName, "slice")); @@ -88,12 +88,12 @@ public void getter(PrintWriter w) { w.format("/** Returns the {@code %s} field as a ByteBuf slice. */\n", field.getName()); w.format("public io.netty.buffer.ByteBuf %s() {\n", Util.camelCase("get", ccName, "slice")); - if (field.hasImplicitPresence()) { - w.format(" if (_%sLen < 0) { return io.netty.buffer.Unpooled.EMPTY_BUFFER; }\n", ccName); - } else { + if (field.isRequired()) { w.format(" if (!%s()) {\n", Util.camelCase("has", ccName)); w.format(" throw new IllegalStateException(\"Field '%s' is not set\");\n", field.getName()); w.format(" }\n"); + } else { + w.format(" if (_%sLen < 0) { return io.netty.buffer.Unpooled.EMPTY_BUFFER; }\n", ccName); } w.format(" if (%s == null) {\n", ccName); w.format(" return _parsedBuffer.slice(_%sIdx, _%sLen);\n", ccName, ccName); diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoEnumField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoEnumField.java index fbd1d2f..0ee5dfc 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoEnumField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoEnumField.java @@ -25,10 +25,10 @@ public LightProtoEnumField(ProtoFieldDescriptor field, int index) { @Override public void declaration(PrintWriter w) { - if (field.hasImplicitPresence()) { - w.format("private %s %s = %s.valueOf(0);\n", field.getJavaType(), ccName, field.getJavaType()); - } else { + if (field.isDefaultValueSet()) { super.declaration(w); + } else { + w.format("private %s %s = %s.valueOf(0);\n", field.getJavaType(), ccName, field.getJavaType()); } } @@ -36,7 +36,7 @@ public void declaration(PrintWriter w) { public void getter(PrintWriter w) { w.format(" /** Returns the value of the {@code %s} field. */\n", field.getName()); w.format(" public %s %s() {\n", field.getJavaType(), Util.camelCase("get", field.getName())); - if (!field.hasImplicitPresence() && !field.isDefaultValueSet()) { + if (field.isRequired()) { w.format(" if (!%s()) {\n", Util.camelCase("has", ccName)); w.format(" throw new IllegalStateException(\"Field '%s' is not set\");\n", field.getName()); w.format(" }\n"); @@ -47,10 +47,10 @@ public void getter(PrintWriter w) { @Override public void clear(PrintWriter w) { - if (field.hasImplicitPresence()) { - w.format("%s = %s.valueOf(0);\n", ccName, field.getJavaType()); + if (field.isDefaultValueSet()) { + w.format("%s = %s;\n", ccName, field.getDefaultValueAsString()); } else { - super.clear(w); + w.format("%s = %s.valueOf(0);\n", ccName, field.getJavaType()); } } diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java index ae6c8d7..daf182f 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoMessageField.java @@ -49,9 +49,15 @@ public void copy(PrintWriter w) { public void getter(PrintWriter w) { w.format("/** Returns the value of the {@code %s} field. */\n", field.getName()); w.format("public %s %s() {\n", field.getJavaType(), Util.camelCase("get", field.getName())); - w.format(" if (!%s()) {\n", Util.camelCase("has", ccName)); - w.format(" throw new IllegalStateException(\"Field '%s' is not set\");\n", field.getName()); - w.format(" }\n"); + if (field.isRequired()) { + w.format(" if (!%s()) {\n", Util.camelCase("has", ccName)); + w.format(" throw new IllegalStateException(\"Field '%s' is not set\");\n", field.getName()); + w.format(" }\n"); + } else { + w.format(" if (%s == null) {\n", ccName); + w.format(" %s = new %s();\n", ccName, field.getJavaType()); + w.format(" }\n"); + } w.format(" return %s;\n", ccName); w.format("}\n"); } diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoNumberField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoNumberField.java index e5cd9e6..9df60d0 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoNumberField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoNumberField.java @@ -169,7 +169,7 @@ public void tags(PrintWriter w) { public void getter(PrintWriter w) { w.format(" /** Returns the value of the {@code %s} field. */\n", field.getName()); w.format(" public %s %s() {\n", field.getJavaType(), Util.camelCase("get", field.getName())); - if (!field.hasImplicitPresence() && !field.isDefaultValueSet()) { + if (field.isRequired()) { w.format(" if (!%s()) {\n", Util.camelCase("has", ccName)); w.format(" throw new IllegalStateException(\"Field '%s' is not set\");\n", field.getName()); w.format(" }\n"); @@ -218,7 +218,7 @@ public void copy(PrintWriter w) { public void clear(PrintWriter w) { if (field.isDefaultValueSet()) { w.format("%s = %s;\n", ccName, field.getDefaultValueAsString()); - } else if (field.hasImplicitPresence()) { + } else { w.format("%s = 0;\n", ccName); } } diff --git a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java index f7c2831..c481ee3 100644 --- a/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java +++ b/code-generator/src/main/java/io/streamnative/lightproto/generator/LightProtoStringField.java @@ -58,13 +58,17 @@ public void copy(PrintWriter w) { public void getter(PrintWriter w) { w.format("/** Returns the value of the {@code %s} field. */\n", field.getName()); w.format("public %s %s() {\n", field.getJavaType(), Util.camelCase("get", field.getName())); - if (field.hasImplicitPresence()) { + if (field.isRequired()) { + w.format(" if (!%s()) {\n", Util.camelCase("has", ccName)); + w.format(" throw new IllegalStateException(\"Field '%s' is not set\");\n", field.getName()); + w.format(" }\n"); + } else if (field.hasImplicitPresence()) { w.format(" if (_%sBufferLen < 0) {\n", ccName); w.format(" return \"\";\n"); w.format(" }\n"); } else if (!field.isDefaultValueSet()) { w.format(" if (!%s()) {\n", Util.camelCase("has", ccName)); - w.format(" throw new IllegalStateException(\"Field '%s' is not set\");\n", field.getName()); + w.format(" return \"\";\n"); w.format(" }\n"); } w.format(" if (%s == null) {\n", camelCase(field.getName())); diff --git a/tests/src/test/java/io/streamnative/lightproto/tests/BytesTest.java b/tests/src/test/java/io/streamnative/lightproto/tests/BytesTest.java index 7231c2d..ae307b9 100644 --- a/tests/src/test/java/io/streamnative/lightproto/tests/BytesTest.java +++ b/tests/src/test/java/io/streamnative/lightproto/tests/BytesTest.java @@ -108,6 +108,34 @@ public void testBytesBuf() throws Exception { assertArrayEquals(b1, b2); } + @Test + public void testAccessUnsetOptionalBytes() { + B lpb = new B(); + assertFalse(lpb.hasPayload()); + + // Accessing unset optional bytes should return empty defaults, not throw + assertArrayEquals(new byte[0], lpb.getPayload()); + assertEquals(0, lpb.getPayloadSize()); + assertEquals(0, lpb.getPayloadSlice().readableBytes()); + } + + @Test + public void testClearResetsOptionalBytesToDefault() { + B lpb = new B(); + lpb.setPayload(new byte[]{1, 2, 3}); + + assertTrue(lpb.hasPayload()); + assertEquals(3, lpb.getPayloadSize()); + + lpb.clear(); + + assertFalse(lpb.hasPayload()); + assertArrayEquals(new byte[0], lpb.getPayload()); + assertEquals(0, lpb.getPayloadSize()); + assertEquals(0, lpb.getPayloadSlice().readableBytes()); + assertEquals(0, lpb.getSerializedSize()); + } + @Test public void testRepeatedBytes() throws Exception { B lpb = new B(); diff --git a/tests/src/test/java/io/streamnative/lightproto/tests/MessagesTest.java b/tests/src/test/java/io/streamnative/lightproto/tests/MessagesTest.java index 5cce311..0b83eb8 100644 --- a/tests/src/test/java/io/streamnative/lightproto/tests/MessagesTest.java +++ b/tests/src/test/java/io/streamnative/lightproto/tests/MessagesTest.java @@ -181,6 +181,72 @@ public void testClearNestedMessage() throws Exception { assertFalse(m.getX().hasB()); } + @Test + public void testAccessUnsetOptionalMessageReturnsDefault() { + M m = new M(); + assertFalse(m.hasX()); + + // Accessing an unset optional message field should return an empty instance, not throw + X x = m.getX(); + assertNotNull(x); + + // The sub-message fields should also return defaults + assertFalse(x.hasA()); + assertFalse(x.hasB()); + assertEquals("", x.getA()); + assertEquals("", x.getB()); + + // has() on the parent should still be false + assertFalse(m.hasX()); + + // Serialized size should be 0 (no fields set) + assertEquals(0, m.getSerializedSize()); + } + + @Test + public void testAccessUnsetNestedOptionalMessage() { + M m = new M(); + + // Add an item (with required k,v) but don't set the optional xx sub-message + m.addItem().setK("k1").setV("v1"); + + M.KV kv = m.getItemAt(0); + assertFalse(kv.hasXx()); + + // Accessing the unset optional nested message should return an empty instance + M.KV.XX xx = kv.getXx(); + assertNotNull(xx); + assertEquals(0, xx.getN()); + } + + @Test + public void testClearResetsSubMessageFields() throws Exception { + M m = new M(); + m.setX().setA("hello").setB("world"); + m.addItem().setK("k1").setV("v1").setXx().setN(42); + + assertTrue(m.hasX()); + assertEquals("hello", m.getX().getA()); + assertEquals(1, m.getItemsCount()); + + m.clear(); + + // After clear, optional message field should not be set + assertFalse(m.hasX()); + + // Accessing after clear should return an empty default instance + X x = m.getX(); + assertNotNull(x); + assertEquals("", x.getA()); + assertEquals("", x.getB()); + + // Repeated fields should be empty + assertEquals(0, m.getItemsCount()); + + // Serialized size should be 0 + assertEquals(0, m.getSerializedSize()); + } + @Test public void testByteArrays() throws Exception { M lp1 = new M(); diff --git a/tests/src/test/java/io/streamnative/lightproto/tests/NumbersTest.java b/tests/src/test/java/io/streamnative/lightproto/tests/NumbersTest.java index 09da2ca..b2dfb4d 100644 --- a/tests/src/test/java/io/streamnative/lightproto/tests/NumbersTest.java +++ b/tests/src/test/java/io/streamnative/lightproto/tests/NumbersTest.java @@ -33,15 +33,6 @@ public class NumbersTest { private byte[] b2 = new byte[4096]; private ByteBuf bb2 = Unpooled.wrappedBuffer(b2); - private static void assertException(Runnable r) { - try { - r.run(); - fail("Should raise exception"); - } catch (IllegalStateException e) { - // Expected - } - } - @BeforeEach public void setup() { bb1.clear(); @@ -92,45 +83,19 @@ private void verify(Numbers lpn, NumbersOuterClass.Numbers pbn) throws Exception assertEquals(pbn.hasXSint32(), parsed.hasXSint32()); assertEquals(pbn.hasXSint64(), parsed.hasXSint64()); - if (parsed.hasEnum1()) { - assertEquals(pbn.getEnum1().getNumber(), parsed.getEnum1().getValue()); - } - if (parsed.hasEnum2()) { - assertEquals(pbn.getEnum2().getNumber(), parsed.getEnum2().getValue()); - } - if (parsed.hasXBool()) { - assertEquals(pbn.getXBool(), parsed.isXBool()); - } - if (parsed.hasXDouble()) { - assertEquals(pbn.getXDouble(), parsed.getXDouble()); - } - if (parsed.hasXFixed32()) { - assertEquals(pbn.getXFixed32(), parsed.getXFixed32()); - } - if (parsed.hasXFixed64()) { - assertEquals(pbn.getXFixed64(), parsed.getXFixed64()); - } - if (parsed.hasXSfixed32()) { - assertEquals(pbn.getXSfixed32(), parsed.getXSfixed32()); - } - if (parsed.hasXSfixed64()) { - assertEquals(pbn.getXSfixed64(), parsed.getXSfixed64()); - } - if (parsed.hasXFloat()) { - assertEquals(pbn.getXFloat(), parsed.getXFloat()); - } - if (parsed.hasXInt32()) { - assertEquals(pbn.getXInt32(), parsed.getXInt32()); - } - if (parsed.hasXInt64()) { - assertEquals(pbn.getXInt64(), parsed.getXInt64()); - } - if (parsed.hasXSint32()) { - assertEquals(pbn.getXSint32(), parsed.getXSint32()); - } - if (parsed.hasXSint64()) { - assertEquals(pbn.getXSint64(), parsed.getXSint64()); - } + assertEquals(pbn.getEnum1().getNumber(), parsed.getEnum1().getValue()); + assertEquals(pbn.getEnum2().getNumber(), parsed.getEnum2().getValue()); + assertEquals(pbn.getXBool(), parsed.isXBool()); + assertEquals(pbn.getXDouble(), parsed.getXDouble()); + assertEquals(pbn.getXFixed32(), parsed.getXFixed32()); + assertEquals(pbn.getXFixed64(), parsed.getXFixed64()); + assertEquals(pbn.getXSfixed32(), parsed.getXSfixed32()); + assertEquals(pbn.getXSfixed64(), parsed.getXSfixed64()); + assertEquals(pbn.getXFloat(), parsed.getXFloat()); + assertEquals(pbn.getXInt32(), parsed.getXInt32()); + assertEquals(pbn.getXInt64(), parsed.getXInt64()); + assertEquals(pbn.getXSint32(), parsed.getXSint32()); + assertEquals(pbn.getXSint64(), parsed.getXSint64()); } @Test @@ -155,22 +120,23 @@ public void testNumberFields() throws Exception { assertFalse(lpn.hasXSint32()); assertFalse(lpn.hasXSint64()); - assertException(() -> lpn.getEnum1()); - assertException(() -> lpn.getEnum2()); - assertException(() -> lpn.isXBool()); - assertException(() -> lpn.getXDouble()); - assertException(() -> lpn.getXFixed32()); - assertException(() -> lpn.getXFixed64()); - assertException(() -> lpn.getXSfixed32()); - assertException(() -> lpn.getXSfixed64()); - assertException(() -> lpn.getXFloat()); - assertException(() -> lpn.getXInt32()); - assertException(() -> lpn.getXInt64()); - assertException(() -> lpn.getXInt32()); - assertException(() -> lpn.getXUint64()); - assertException(() -> lpn.getXUint32()); - assertException(() -> lpn.getXSint32()); - assertException(() -> lpn.getXSint64()); + // Optional fields should return default values when not set (matching Protobuf behavior) + assertEquals(Enum1.valueOf(0), lpn.getEnum1()); + assertEquals(Numbers.Enum2.valueOf(0), lpn.getEnum2()); + assertEquals(false, lpn.isXBool()); + assertEquals(0.0, lpn.getXDouble()); + assertEquals(0, lpn.getXFixed32()); + assertEquals(0L, lpn.getXFixed64()); + assertEquals(0, lpn.getXSfixed32()); + assertEquals(0L, lpn.getXSfixed64()); + assertEquals(0.0f, lpn.getXFloat()); + assertEquals(0, lpn.getXInt32()); + assertEquals(0L, lpn.getXInt64()); + assertEquals(0, lpn.getXInt32()); + assertEquals(0L, lpn.getXUint64()); + assertEquals(0, lpn.getXUint32()); + assertEquals(0, lpn.getXSint32()); + assertEquals(0L, lpn.getXSint64()); lpn.setEnum1(Enum1.X1_1); @@ -237,4 +203,74 @@ public void testNumberFields() throws Exception { verify(lpn, pbn.build()); } + + @Test + public void testClearResetsAllFieldsToDefaults() throws Exception { + Numbers lpn = new Numbers(); + + // Set all fields to non-default values + lpn.setEnum1(Enum1.X1_2); + lpn.setEnum2(Numbers.Enum2.X2_2); + lpn.setXBool(true); + lpn.setXDouble(3.14); + lpn.setXFixed32(100); + lpn.setXFixed64(200L); + lpn.setXSfixed32(-100); + lpn.setXSfixed64(-200L); + lpn.setXFloat(2.71f); + lpn.setXInt32(42); + lpn.setXInt64(84L); + lpn.setXUint32(55); + lpn.setXUint64(110L); + lpn.setXSint32(-33); + lpn.setXSint64(-66L); + + // Verify all fields are set + assertTrue(lpn.hasEnum1()); + assertTrue(lpn.hasXBool()); + assertTrue(lpn.hasXInt32()); + + lpn.clear(); + + // After clear, all has*() should return false + assertFalse(lpn.hasEnum1()); + assertFalse(lpn.hasEnum2()); + assertFalse(lpn.hasXBool()); + assertFalse(lpn.hasXDouble()); + assertFalse(lpn.hasXFixed32()); + assertFalse(lpn.hasXFixed64()); + assertFalse(lpn.hasXSfixed32()); + assertFalse(lpn.hasXSfixed64()); + assertFalse(lpn.hasXFloat()); + assertFalse(lpn.hasXInt32()); + assertFalse(lpn.hasXInt64()); + assertFalse(lpn.hasXUint32()); + assertFalse(lpn.hasXUint64()); + assertFalse(lpn.hasXSint32()); + assertFalse(lpn.hasXSint64()); + + // After clear, all getters should return default values (not old values) + assertEquals(Enum1.valueOf(0), lpn.getEnum1()); + assertEquals(Numbers.Enum2.valueOf(0), lpn.getEnum2()); + assertEquals(false, lpn.isXBool()); + assertEquals(0.0, lpn.getXDouble()); + assertEquals(0, lpn.getXFixed32()); + assertEquals(0L, lpn.getXFixed64()); + assertEquals(0, lpn.getXSfixed32()); + assertEquals(0L, lpn.getXSfixed64()); + assertEquals(0.0f, lpn.getXFloat()); + assertEquals(0, lpn.getXInt32()); + assertEquals(0L, lpn.getXInt64()); + assertEquals(0, lpn.getXUint32()); + assertEquals(0L, lpn.getXUint64()); + assertEquals(0, lpn.getXSint32()); + assertEquals(0L, lpn.getXSint64()); + + // Serialized size should be 0 after clear + assertEquals(0, lpn.getSerializedSize()); + + // Should serialize identically to a fresh empty protobuf message + NumbersOuterClass.Numbers pbn = NumbersOuterClass.Numbers.newBuilder().build(); + verify(lpn, pbn); + } } diff --git a/tests/src/test/java/io/streamnative/lightproto/tests/Proto3Test.java b/tests/src/test/java/io/streamnative/lightproto/tests/Proto3Test.java index 02220f0..cee277d 100644 --- a/tests/src/test/java/io/streamnative/lightproto/tests/Proto3Test.java +++ b/tests/src/test/java/io/streamnative/lightproto/tests/Proto3Test.java @@ -163,6 +163,49 @@ public void testMessageFieldPresence() { assertEquals(42, parsed.getNested().getValue()); } + @Test + public void testAccessUnsetMessageFieldReturnsDefault() { + Proto3Message msg = new Proto3Message(); + assertFalse(msg.hasNested()); + + // Accessing unset message field should return an empty instance, not throw + Proto3Nested nested = msg.getNested(); + assertNotNull(nested); + assertEquals("", nested.getLabel()); + assertEquals(0, nested.getValue()); + + // has() should still be false + assertFalse(msg.hasNested()); + } + + @Test + public void testClearResetsNestedMessageField() { + Proto3Message msg = new Proto3Message(); + msg.setNested().setLabel("test").setValue(42); + msg.setIntField(99); + msg.setStringField("hello"); + msg.setOptInt(7); + + assertTrue(msg.hasNested()); + assertTrue(msg.hasOptInt()); + + msg.clear(); + + // All fields should be reset + assertFalse(msg.hasNested()); + assertFalse(msg.hasOptInt()); + assertEquals(0, msg.getIntField()); + assertEquals("", msg.getStringField()); + + // Accessing nested after clear should return empty default + Proto3Nested nested = msg.getNested(); + assertNotNull(nested); + assertEquals("", nested.getLabel()); + assertEquals(0, nested.getValue()); + + assertEquals(0, msg.getSerializedSize()); + } + // --- Oneof --- @Test diff --git a/tests/src/test/java/io/streamnative/lightproto/tests/StringsTest.java b/tests/src/test/java/io/streamnative/lightproto/tests/StringsTest.java index b84e9a4..254deae 100644 --- a/tests/src/test/java/io/streamnative/lightproto/tests/StringsTest.java +++ b/tests/src/test/java/io/streamnative/lightproto/tests/StringsTest.java @@ -101,6 +101,15 @@ public void testAddAllStrings() throws Exception { assertEquals(new ArrayList<>(strings), lps.getNamesList()); } + @Test + public void testAccessUnsetOptionalString() { + S lps = new S(); + assertFalse(lps.hasId()); + + // Accessing an unset optional string should return empty string, not throw + assertEquals("", lps.getId()); + } + @Test public void testClearStrings() throws Exception { S lps = new S(); @@ -115,4 +124,19 @@ public void testClearStrings() throws Exception { assertEquals("d", lps.getNameAt(0)); assertEquals("e", lps.getNameAt(1)); } + + @Test + public void testClearResetsOptionalStringToDefault() throws Exception { + S lps = new S(); + lps.setId("hello"); + assertTrue(lps.hasId()); + assertEquals("hello", lps.getId()); + + lps.clear(); + + assertFalse(lps.hasId()); + assertEquals("", lps.getId()); + assertEquals(0, lps.getNamesCount()); + assertEquals(0, lps.getSerializedSize()); + } }