From 6d8bc7405641c2e46ada64e3cab3fb68d2229160 Mon Sep 17 00:00:00 2001 From: SpencerMalone Date: Tue, 14 Oct 2025 22:01:04 -0700 Subject: [PATCH] Add option for PHP to emit default values. --- php/ext/google/protobuf/message.c | 3 + php/ext/google/protobuf/print_options.c | 3 + php/ext/google/protobuf/print_options.h | 1 + php/src/Google/Protobuf/Internal/Message.php | 96 +++++--- php/src/Google/Protobuf/PrintOptions.php | 3 +- php/tests/EncodeDecodeTest.php | 235 +++++++++++++++++++ 6 files changed, 303 insertions(+), 38 deletions(-) diff --git a/php/ext/google/protobuf/message.c b/php/ext/google/protobuf/message.c index b54d6fe451f0e..bc43d34431b2a 100644 --- a/php/ext/google/protobuf/message.c +++ b/php/ext/google/protobuf/message.c @@ -773,6 +773,9 @@ PHP_METHOD(Message, serializeToJsonString) { if (Z_LVAL_P(flags) & PRESERVE_PROTO_FIELD_NAMES) { options |= upb_JsonEncode_UseProtoNames; } + if (Z_LVAL_P(flags) & EMIT_DEFAULTS) { + options |= upb_JsonEncode_EmitDefaults; + } } upb_Status_Clear(&status); diff --git a/php/ext/google/protobuf/print_options.c b/php/ext/google/protobuf/print_options.c index d94f44f4b20d2..8a624c1e74c67 100644 --- a/php/ext/google/protobuf/print_options.c +++ b/php/ext/google/protobuf/print_options.c @@ -24,4 +24,7 @@ void PrintOptions_ModuleInit() { zend_declare_class_constant_long(options_ce, "ALWAYS_PRINT_ENUMS_AS_INTS", sizeof("ALWAYS_PRINT_ENUMS_AS_INTS") - 1, ALWAYS_PRINT_ENUMS_AS_INTS); + zend_declare_class_constant_long(options_ce, "EMIT_DEFAULTS", + sizeof("EMIT_DEFAULTS") - 1, + EMIT_DEFAULTS); } diff --git a/php/ext/google/protobuf/print_options.h b/php/ext/google/protobuf/print_options.h index f6605776b7f78..5d73ed30b6283 100644 --- a/php/ext/google/protobuf/print_options.h +++ b/php/ext/google/protobuf/print_options.h @@ -10,6 +10,7 @@ #define PRESERVE_PROTO_FIELD_NAMES (1 << 0) #define ALWAYS_PRINT_ENUMS_AS_INTS (1 << 1) +#define EMIT_DEFAULTS (1 << 2) // Registers the PHP PrintOptions class. void PrintOptions_ModuleInit(); diff --git a/php/src/Google/Protobuf/Internal/Message.php b/php/src/Google/Protobuf/Internal/Message.php index 81419d8e62512..6e85546d91307 100644 --- a/php/src/Google/Protobuf/Internal/Message.php +++ b/php/src/Google/Protobuf/Internal/Message.php @@ -239,6 +239,14 @@ protected function whichOneof($oneof_name) return $field->getName(); } + /** + * @ignore + */ + private function shouldEmitDefaults($options): bool + { + return ($options & \Google\Protobuf\PrintOptions::EMIT_DEFAULTS) !== 0; + } + /** * @ignore */ @@ -1469,6 +1477,7 @@ public function serializeToStream(&$output) */ public function serializeToJsonStream(&$output) { + $options = $output->getOptions(); if (is_a($this, 'Google\Protobuf\Any')) { $output->writeRaw("{", 1); $type_field = $this->desc->getFieldByNumber(1); @@ -1487,7 +1496,7 @@ public function serializeToJsonStream(&$output) } else { $value_fields = $value_msg->desc->getField(); foreach ($value_fields as $field) { - if ($value_msg->existField($field)) { + if ($value_msg->existField($field, $options)) { $output->writeRaw(",", 1); if (!$value_msg->serializeFieldToJsonStream($output, $field)) { return false; @@ -1513,7 +1522,7 @@ public function serializeToJsonStream(&$output) $output->writeRaw($timestamp, strlen($timestamp)); } elseif (get_class($this) === 'Google\Protobuf\ListValue') { $field = $this->desc->getField()[1]; - if (!$this->existField($field)) { + if (!$this->existField($field, $options)) { $output->writeRaw("[]", 2); } else { if (!$this->serializeFieldToJsonStream($output, $field)) { @@ -1522,7 +1531,7 @@ public function serializeToJsonStream(&$output) } } elseif (get_class($this) === 'Google\Protobuf\Struct') { $field = $this->desc->getField()[1]; - if (!$this->existField($field)) { + if (!$this->existField($field, $options)) { $output->writeRaw("{}", 2); } else { if (!$this->serializeFieldToJsonStream($output, $field)) { @@ -1536,7 +1545,7 @@ public function serializeToJsonStream(&$output) $fields = $this->desc->getField(); $first = true; foreach ($fields as $field) { - if ($this->existField($field) || + if ($this->existField($field, $options) || GPBUtil::hasJsonValue($this)) { if ($first) { $first = false; @@ -1580,7 +1589,7 @@ public function serializeToJsonString($options = 0) /** * @ignore */ - private function existField($field) + private function existField($field, $options = 0) { $getter = $field->getGetter(); $hazzer = "has" . substr($getter, 3); @@ -1597,10 +1606,19 @@ private function existField($field) $values = $this->$getter(); if ($field->isMap()) { + if ($this->shouldEmitDefaults($options)) { + return true; + } return count($values) !== 0; } elseif ($field->isRepeated()) { + if ($this->shouldEmitDefaults($options)) { + return true; + } return count($values) !== 0; } else { + if ($this->shouldEmitDefaults($options)) { + return true; + } return $values !== $this->defaultValue($field); } } @@ -1863,7 +1881,7 @@ private function fieldJsonByteSize($field, $options = 0) $getter = $field->getGetter(); $values = $this->$getter(); $count = count($values); - if ($count !== 0) { + if ($count !== 0 || $this->shouldEmitDefaults($options)) { if (!GPBUtil::hasSpecialJsonMapping($this)) { $size += 3; // size for "\"\":". if ($options & PrintOptions::PRESERVE_PROTO_FIELD_NAMES) { @@ -1873,37 +1891,39 @@ private function fieldJsonByteSize($field, $options = 0) } // size for field name } $size += 2; // size for "{}". - $size += $count - 1; // size for commas - $getter = $field->getGetter(); - $map_entry = $field->getMessageType(); - $key_field = $map_entry->getFieldByNumber(1); - $value_field = $map_entry->getFieldByNumber(2); - switch ($key_field->getType()) { - case GPBType::STRING: - case GPBType::SFIXED64: - case GPBType::INT64: - case GPBType::SINT64: - case GPBType::FIXED64: - case GPBType::UINT64: - $additional_quote = false; - break; - default: - $additional_quote = true; - } - foreach ($values as $key => $value) { - if ($additional_quote) { - $size += 2; // size for "" + if ($count > 0) { + $size += $count - 1; // size for commas + $getter = $field->getGetter(); + $map_entry = $field->getMessageType(); + $key_field = $map_entry->getFieldByNumber(1); + $value_field = $map_entry->getFieldByNumber(2); + switch ($key_field->getType()) { + case GPBType::STRING: + case GPBType::SFIXED64: + case GPBType::INT64: + case GPBType::SINT64: + case GPBType::FIXED64: + case GPBType::UINT64: + $additional_quote = false; + break; + default: + $additional_quote = true; + } + foreach ($values as $key => $value) { + if ($additional_quote) { + $size += 2; // size for "" + } + $size += $this->fieldDataOnlyJsonByteSize($key_field, $key, $options); + $size += $this->fieldDataOnlyJsonByteSize($value_field, $value, $options); + $size += 1; // size for : } - $size += $this->fieldDataOnlyJsonByteSize($key_field, $key, $options); - $size += $this->fieldDataOnlyJsonByteSize($value_field, $value, $options); - $size += 1; // size for : } } } elseif ($field->isRepeated()) { $getter = $field->getGetter(); $values = $this->$getter(); $count = count($values); - if ($count !== 0) { + if ($count !== 0 || $this->shouldEmitDefaults($options)) { if (!GPBUtil::hasSpecialJsonMapping($this)) { $size += 3; // size for "\"\":". if ($options & PrintOptions::PRESERVE_PROTO_FIELD_NAMES) { @@ -1913,13 +1933,15 @@ private function fieldJsonByteSize($field, $options = 0) } // size for field name } $size += 2; // size for "[]". - $size += $count - 1; // size for commas - $getter = $field->getGetter(); - foreach ($values as $value) { - $size += $this->fieldDataOnlyJsonByteSize($field, $value, $options); + if ($count > 0) { + $size += $count - 1; // size for commas + $getter = $field->getGetter(); + foreach ($values as $value) { + $size += $this->fieldDataOnlyJsonByteSize($field, $value, $options); + } } } - } elseif ($this->existField($field) || GPBUtil::hasJsonValue($this)) { + } elseif ($this->existField($field, $options) || GPBUtil::hasJsonValue($this)) { if (!GPBUtil::hasSpecialJsonMapping($this)) { $size += 3; // size for "\"\":". if ($options & PrintOptions::PRESERVE_PROTO_FIELD_NAMES) { @@ -2017,7 +2039,7 @@ public function jsonByteSize($options = 0) $size += strlen($timestamp); } elseif (get_class($this) === 'Google\Protobuf\ListValue') { $field = $this->desc->getField()[1]; - if ($this->existField($field)) { + if ($this->existField($field, $options)) { $field_size = $this->fieldJsonByteSize($field, $options); $size += $field_size; } else { @@ -2026,7 +2048,7 @@ public function jsonByteSize($options = 0) } } elseif (get_class($this) === 'Google\Protobuf\Struct') { $field = $this->desc->getField()[1]; - if ($this->existField($field)) { + if ($this->existField($field, $options)) { $field_size = $this->fieldJsonByteSize($field, $options); $size += $field_size; } else { diff --git a/php/src/Google/Protobuf/PrintOptions.php b/php/src/Google/Protobuf/PrintOptions.php index e85f5344c2e66..36231c4ade940 100644 --- a/php/src/Google/Protobuf/PrintOptions.php +++ b/php/src/Google/Protobuf/PrintOptions.php @@ -13,4 +13,5 @@ class PrintOptions { const PRESERVE_PROTO_FIELD_NAMES = 1 << 0; const ALWAYS_PRINT_ENUMS_AS_INTS = 1 << 1; -} \ No newline at end of file + const EMIT_DEFAULTS = 1 << 2; +} diff --git a/php/tests/EncodeDecodeTest.php b/php/tests/EncodeDecodeTest.php index 36e4ed3175602..01be3a726315a 100644 --- a/php/tests/EncodeDecodeTest.php +++ b/php/tests/EncodeDecodeTest.php @@ -1707,4 +1707,239 @@ public static function preserveProtoFieldNamesProvider(): array [false, '{"oneofEnum":"ONE"}'], ]; } + + public function testEmitDefaultsInt32() + { + $m = new TestMessage(); + // Without EMIT_DEFAULTS, default values should not be included + $this->assertSame("{}", $m->serializeToJsonString()); + + // With EMIT_DEFAULTS, default values should be included + $json = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertStringContainsString('"optionalInt32":0', $json); + $this->assertStringContainsString('"optionalUint32":0', $json); + $this->assertStringContainsString('"optionalSint32":0', $json); + } + + public function testEmitDefaultsInt64() + { + $m = new TestMessage(); + $json = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertStringContainsString('"optionalInt64":"0"', $json); + $this->assertStringContainsString('"optionalUint64":"0"', $json); + $this->assertStringContainsString('"optionalSint64":"0"', $json); + } + + public function testEmitDefaultsFloat() + { + $m = new TestMessage(); + $json = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertStringContainsString('"optionalFloat":0', $json); + $this->assertStringContainsString('"optionalDouble":0', $json); + } + + public function testEmitDefaultsBool() + { + $m = new TestMessage(); + $json = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertStringContainsString('"optionalBool":false', $json); + } + + public function testEmitDefaultsString() + { + $m = new TestMessage(); + $json = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertStringContainsString('"optionalString":""', $json); + $this->assertStringContainsString('"optionalBytes":""', $json); + } + + public function testEmitDefaultsEnum() + { + $m = new TestMessage(); + $json = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertStringContainsString('"optionalEnum":"ZERO"', $json); + } + + public function testEmitDefaultsMessage() + { + $m = new TestMessage(); + // Messages are not included even with EMIT_DEFAULTS when they are null + $json = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertThat($json, $this->logicalNot($this->stringContains('"optionalMessage"'))); + } + + public function testEmitDefaultsRepeatedFields() + { + $m = new TestMessage(); + $json = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + + // Empty repeated fields should be included as [] with EMIT_DEFAULTS + $this->assertStringContainsString('"repeatedInt32":[]', $json); + $this->assertStringContainsString('"repeatedString":[]', $json); + + // Non-empty repeated fields are always included + $m->setRepeatedInt32([1, 2, 3]); + $json2 = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertStringContainsString('"repeatedInt32":[1,2,3]', $json2); + } + + public function testEmitDefaultsMaps() + { + $m = new TestMessage(); + $json = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + + // Empty maps should be included as {} with EMIT_DEFAULTS + $this->assertStringContainsString('"mapInt32Int32":{}', $json); + $this->assertStringContainsString('"mapStringString":{}', $json); + + // Non-empty maps are always included + $m->getMapInt32Int32()[1] = 100; + $json2 = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertStringContainsString('"mapInt32Int32":{"1":100}', $json2); + } + + public function testEmitDefaultsWithNonDefaultValues() + { + $m = new TestMessage(); + $m->setOptionalInt32(42); + $m->setOptionalString("test"); + + // Non-default values should always be included + $json = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertStringContainsString('"optionalInt32":42', $json); + $this->assertStringContainsString('"optionalString":"test"', $json); + + // Default values should also be included + $this->assertStringContainsString('"optionalBool":false', $json); + $this->assertStringContainsString('"optionalDouble":0', $json); + } + + public function testEmitDefaultsWithPreserveProtoFieldNames() + { + $m = new TestMessage(); + $json = $m->serializeToJsonString( + PrintOptions::EMIT_DEFAULTS | PrintOptions::PRESERVE_PROTO_FIELD_NAMES + ); + + // Should use proto field names (with underscores) + $this->assertStringContainsString('"optional_int32":0', $json); + $this->assertStringContainsString('"optional_string":""', $json); + $this->assertStringContainsString('"optional_bool":false', $json); + } + + public function testEmitDefaultsWithAlwaysPrintEnumsAsInts() + { + $m = new TestMessage(); + $json = $m->serializeToJsonString( + PrintOptions::EMIT_DEFAULTS | PrintOptions::ALWAYS_PRINT_ENUMS_AS_INTS + ); + + // Enum should be printed as integer + $this->assertStringContainsString('"optionalEnum":0', $json); + } + + public function testEmitDefaultsAllFlags() + { + $m = new TestMessage(); + $json = $m->serializeToJsonString( + PrintOptions::EMIT_DEFAULTS | + PrintOptions::PRESERVE_PROTO_FIELD_NAMES | + PrintOptions::ALWAYS_PRINT_ENUMS_AS_INTS + ); + + // Should have all three flag behaviors + $this->assertStringContainsString('"optional_int32":0', $json); + $this->assertStringContainsString('"optional_enum":0', $json); + $this->assertStringContainsString('"optional_bool":false', $json); + } + + public function testEmitDefaultsDoesNotAffectParsing() + { + // Create a message with default values using EMIT_DEFAULTS + $m1 = new TestMessage(); + $json = $m1->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + + // Parse it back + $m2 = new TestMessage(); + $m2->mergeFromJsonString($json); + + // All fields should have default values + $this->assertSame(0, $m2->getOptionalInt32()); + $this->assertSame(false, $m2->getOptionalBool()); + $this->assertSame('', $m2->getOptionalString()); + $this->assertSame(TestEnum::ZERO, $m2->getOptionalEnum()); + } + + public function testEmitDefaultsWithSetThenClearedField() + { + $m = new TestMessage(); + $m->setOptionalInt32(42); + $m->setOptionalInt32(0); // Set back to default + + // Without EMIT_DEFAULTS, should not be in JSON + $json1 = $m->serializeToJsonString(); + $this->assertSame("{}", $json1); + + // With EMIT_DEFAULTS, should be in JSON + $json2 = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertStringContainsString('"optionalInt32":0', $json2); + } + + public function testEmitDefaultsTrueOptionalFieldsWithHazzer() + { + // True optional fields (proto3 optional) should not be included + // even with EMIT_DEFAULTS if they haven't been set + $m = new TestMessage(); + $json = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + + // Regular optional (implicit presence) should be included + $this->assertStringContainsString('"optionalInt32":0', $json); + + // True optional (explicit presence) should NOT be included if not set + $this->assertThat($json, $this->logicalNot($this->stringContains('"trueOptionalInt32"'))); + + // But if we set the true optional field, it should be included + $m->setTrueOptionalInt32(0); + $json2 = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertStringContainsString('"trueOptionalInt32":0', $json2); + } + + public function testEmitDefaultsWellKnownTypes() + { + // Test with wrapper types + $m = new Int32Value(); + + // Without setting value + $json1 = $m->serializeToJsonString(); + $this->assertSame("0", $json1); + + // With EMIT_DEFAULTS (should behave the same for wrapper types) + $json2 = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertSame("0", $json2); + } + + public function testEmitDefaultsStructAndListValue() + { + // Test with ListValue + $m = new ListValue(); + + // Empty ListValue + $json1 = $m->serializeToJsonString(); + $this->assertSame("[]", $json1); + + // With EMIT_DEFAULTS + $json2 = $m->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertSame("[]", $json2); + + // Test with Struct + $s = new Struct(); + + // Empty Struct + $json3 = $s->serializeToJsonString(); + $this->assertSame("{}", $json3); + + // With EMIT_DEFAULTS + $json4 = $s->serializeToJsonString(PrintOptions::EMIT_DEFAULTS); + $this->assertSame("{}", $json4); + } }