diff --git a/php/src/Google/Protobuf/Internal/GPBJsonWire.php b/php/src/Google/Protobuf/Internal/GPBJsonWire.php index 1219578aae057..cec6c85772986 100644 --- a/php/src/Google/Protobuf/Internal/GPBJsonWire.php +++ b/php/src/Google/Protobuf/Internal/GPBJsonWire.php @@ -187,10 +187,21 @@ private static function serializeSingularFieldValueToStream( } $enum_value_desc = $enum_desc->getValueByNumber($value); if (!is_null($enum_value_desc)) { - $str_value = $enum_value_desc->getName(); - $output->writeRaw("\"", 1); - $output->writeRaw($str_value, strlen($str_value)); - $output->writeRaw("\"", 1); + $klass = $enum_desc->getClass(); + $custom_name = null; + if (method_exists($klass, 'customName')) { + $custom_name = $klass::customName($value); + } + if (!is_null($custom_name)) { + $str_value = json_encode( + $custom_name, JSON_UNESCAPED_UNICODE); + $output->writeRaw($str_value, strlen($str_value)); + } else { + $str_value = $enum_value_desc->getName(); + $output->writeRaw("\"", 1); + $output->writeRaw($str_value, strlen($str_value)); + $output->writeRaw("\"", 1); + } } else { $str_value = strval($value); $output->writeRaw($str_value, strlen($str_value)); diff --git a/php/src/Google/Protobuf/Internal/Message.php b/php/src/Google/Protobuf/Internal/Message.php index 892456d167bea..20b9e01a902ad 100644 --- a/php/src/Google/Protobuf/Internal/Message.php +++ b/php/src/Google/Protobuf/Internal/Message.php @@ -865,9 +865,20 @@ private function convertJsonValueToProtoValue( if (is_integer($value)) { return $value; } - $enum_value = $field->getEnumType()->getValueByName($value); + $enum_desc = $field->getEnumType(); + $klass = $enum_desc->getClass(); + $enum_value = null; + if (method_exists($klass, 'customValue')) { + $enum_value = $klass::customValue($value); + } + if (is_null($enum_value)) { + $enum_value_desc = $enum_desc->getValueByName($value); + if (!is_null($enum_value_desc)) { + $enum_value = $enum_value_desc->getNumber(); + } + } if (!is_null($enum_value)) { - return $enum_value->getNumber(); + return $enum_value; } else if ($ignore_unknown) { return $this->defaultValue($field); } else { @@ -1774,8 +1785,19 @@ private function fieldDataOnlyJsonByteSize($field, $value, $options = 0) } else { $enum_value_desc = $enum_desc->getValueByNumber($value); if (!is_null($enum_value_desc)) { - $size += 2; // size for "" - $size += strlen($enum_value_desc->getName()); + $klass = $enum_desc->getClass(); + $custom_name = null; + if (method_exists($klass, 'customName')) { + $custom_name = $klass::customName($value); + } + if (!is_null($custom_name)) { + $encoded = json_encode( + $custom_name, JSON_UNESCAPED_UNICODE); + $size += strlen($encoded); + } else { + $size += 2; // size for "" + $size += strlen($enum_value_desc->getName()); + } } else { $str_value = strval($value); $size += strlen($str_value); diff --git a/php/tests/EncodeDecodeTest.php b/php/tests/EncodeDecodeTest.php index edcc025347669..6fe228171dae8 100644 --- a/php/tests/EncodeDecodeTest.php +++ b/php/tests/EncodeDecodeTest.php @@ -20,6 +20,9 @@ use Foo\TestLargeFieldNumber; use Foo\TestMessage; use Foo\TestMessage\Sub; +use Custom_enum_json\Knight; +use Custom_enum_json\Armor; +use Google\Protobuf\Internal\GPBDecodeException; use Foo\TestPackedMessage; use Foo\TestRandomFieldOrder; use Foo\TestUnpackedMessage; @@ -2155,4 +2158,169 @@ public function testLargeMessageWithSubMessage() $this->assertEquals(42, $m2->getOptionalMessage()->getA()); $this->assertEquals(33 * 1024 * 1024, strlen($m2->getOptionalString())); } + + public function testSerializeGreatHelm() { + $message = new Knight(); + $message->setArmor(Armor::ARMOR_GREAT_HELM); + $this->assertEquals( + '{"armor":"gr8 helm"}', + $message->serializeToJsonString() + ); + } + + public function testParseGreatHelm() { + $message = new Knight(); + $message->mergeFromJsonString('{"armor":"gr8 helm"}'); + $this->assertEquals(Armor::ARMOR_GREAT_HELM, $message->getArmor()); + } + + public function testParseGorgetDefault() { + $message = new Knight(); + $message->mergeFromJsonString('{"armor":"ARMOR_GORGET"}'); + $this->assertEquals(Armor::ARMOR_GORGET, $message->getArmor()); + } + + public function testParseGorgetNumber() { + $message = new Knight(); + $message->mergeFromJsonString('{"armor":2}'); + $this->assertEquals(Armor::ARMOR_GORGET, $message->getArmor()); + } + + public function testSerializeGorget() { + $message = new Knight(); + $message->setArmor(Armor::ARMOR_GORGET); + $this->assertEquals( + '{"armor":"ARMOR_GORGET"}', + $message->serializeToJsonString() + ); + } + + public function testSerializeGauntlet() { + $message = new Knight(); + $message->setArmor(Armor::ARMOR_GAUNTLET); + $this->assertEquals( + '{"armor":"a\\\"b"}', + $message->serializeToJsonString() + ); + } + + public function testParseGauntlet() { + $message = new Knight(); + $message->mergeFromJsonString('{"armor":"a\"b"}'); + $this->assertEquals(Armor::ARMOR_GAUNTLET, $message->getArmor()); + } + + public function testParseGauntletRawName() { + $message = new Knight(); + $message->mergeFromJsonString('{"armor":"ARMOR_GAUNTLET"}'); + $this->assertEquals(Armor::ARMOR_GAUNTLET, $message->getArmor()); + } + + public function testSerializePlate() { + $message = new Knight(); + $message->setArmor(Armor::ARMOR_PLATE); + $this->assertEquals( + '{"armor":"\\\"plate\\\""}', + $message->serializeToJsonString() + ); + } + + public function testParsePlate() { + $message = new Knight(); + $message->mergeFromJsonString('{"armor":"\"plate\""}'); + $this->assertEquals(Armor::ARMOR_PLATE, $message->getArmor()); + } + + public function testSerializeCoif() { + $message = new Knight(); + $message->setArmor(Armor::ARMOR_COIF); + $this->assertEquals( + '{"armor":""}', + $message->serializeToJsonString() + ); + } + + public function testParseCoif() { + $message = new Knight(); + $message->mergeFromJsonString('{"armor":""}'); + $this->assertEquals(Armor::ARMOR_COIF, $message->getArmor()); + } + + public function testSerializePauldron() { + $message = new Knight(); + $message->setArmor(Armor::ARMOR_PAULDRON); + $this->assertEquals( + '{"armor":"p\\taul\\ndron"}', + $message->serializeToJsonString() + ); + } + + public function testParsePauldron() { + $message = new Knight(); + $message->mergeFromJsonString('{"armor":"p\\taul\\ndron"}'); + $this->assertEquals(Armor::ARMOR_PAULDRON, $message->getArmor()); + } + + public function testSerializeSabaton() { + $message = new Knight(); + $message->setArmor(Armor::ARMOR_SABATON); + $this->assertEquals( + '{"armor":"sabaton"}', + $message->serializeToJsonString() + ); + } + + public function testParseSabaton() { + $message = new Knight(); + $message->mergeFromJsonString('{"armor":"sabaton"}'); + $this->assertEquals(Armor::ARMOR_SABATON, $message->getArmor()); + } + + public function testSerializeSolleret() { + $message = new Knight(); + $message->setArmor(Armor::ARMOR_SOLLERET); + $this->assertEquals( + '{"armor":"sabaton"}', + $message->serializeToJsonString() + ); + } + + public function testParseSolleret() { + $message = new Knight(); + $message->mergeFromJsonString('{"armor":"sabaton"}'); + $this->assertEquals(Armor::ARMOR_SOLLERET, $message->getArmor()); + } + + public function testSerializeHachiMaiDo() { + $message = new Knight(); + $message->setArmor(Armor::ARMOR_HACHI_MAI_DO); + $this->assertEquals( + '{"armor":"8"}', + $message->serializeToJsonString() + ); + } + + public function testParseHachiMaiDo() { + $message = new Knight(); + $message->mergeFromJsonString('{"armor":"8"}'); + $this->assertEquals(Armor::ARMOR_HACHI_MAI_DO, $message->getArmor()); + } + + public function testParseHachiMaiDoNumber() { + $message = new Knight(); + $message->mergeFromJsonString('{"armor":8}'); + $this->assertEquals(Armor::ARMOR_HACHI_MAI_DO, $message->getArmor()); + } + + public function testCaseSensitiveGauntletParsingFails() { + $message = new Knight(); + $this->expectException(GPBDecodeException::class); + $message->mergeFromJsonString('{"armor":"A\"b"}'); + } + + public function testCaseSensitiveRawNameParsingFails() { + $message = new Knight(); + $this->expectException(GPBDecodeException::class); + $message->mergeFromJsonString('{"armor":"armor_GAUNtlet"}'); + } } diff --git a/php/tests/proto/test_custom_enum_json.proto b/php/tests/proto/test_custom_enum_json.proto new file mode 100644 index 0000000000000..a0cbb1e805ea4 --- /dev/null +++ b/php/tests/proto/test_custom_enum_json.proto @@ -0,0 +1,31 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2026 Google LLC. All rights reserved. +// +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file or at +// https://developers.google.com/open-source/licenses/bsd + +edition = "2026"; + +package custom_enum_json; + +import "google/protobuf/json_options.proto"; + +enum Armor { + option allow_alias = true; + + ARMOR_UNKNOWN = 0; + ARMOR_GREAT_HELM = 1 [(pb.enumvalue.json).string = "gr8 helm"]; + ARMOR_GORGET = 2; + ARMOR_GAUNTLET = 3 [(pb.enumvalue.json).string = "a\"b"]; + ARMOR_PLATE = 4 [(pb.enumvalue.json).string = "\"plate\""]; + ARMOR_COIF = 5 [(pb.enumvalue.json).string = ""]; + ARMOR_PAULDRON = 6 [(pb.enumvalue.json).string = "p\taul\ndron"]; + ARMOR_SABATON = 7 [(pb.enumvalue.json).string = "sabaton"]; + ARMOR_SOLLERET = 7 [(pb.enumvalue.json).string = "sabaton"]; + ARMOR_HACHI_MAI_DO = 8 [(pb.enumvalue.json).string = "8"]; +} + +message Knight { + Armor armor = 1; +} diff --git a/src/google/protobuf/compiler/php/generator_unittest.cc b/src/google/protobuf/compiler/php/generator_unittest.cc index 79d4b10faaeaf..a4382386ba6c9 100644 --- a/src/google/protobuf/compiler/php/generator_unittest.cc +++ b/src/google/protobuf/compiler/php/generator_unittest.cc @@ -137,6 +137,56 @@ TEST_F(PhpGeneratorTest, ImportPublic) { ExpectNoErrors(); } +TEST_F(PhpGeneratorTest, CustomEnumNames) { + CreateTempFile("google/protobuf/json_enumvalue_options.proto", + R"schema( + edition = "2024"; + package pb.enumvalue; + import "google/protobuf/descriptor.proto"; + message JsonEnumValueOptions { + string string = 1; + } + extend google.protobuf.EnumValueOptions { + JsonEnumValueOptions json = 998; + } + )schema"); + + CreateTempFile("foo.proto", + R"schema( + edition = "2026"; + package foo; + import "google/protobuf/json_enumvalue_options.proto"; + enum MyEnum { + MY_ENUM_UNKNOWN = 0; + MY_ENUM_BAR = 1 [(pb.enumvalue.json).string = "custom_bar"]; + MY_ENUM_BAZ = 2; + } + )schema"); + + RunProtoc( + "protocol_compiler --proto_path=$tmpdir --php_out=$tmpdir foo.proto"); + + ExpectNoErrors(); + + ExpectFileContentContainsSubstring( + "Foo/MyEnum.php", + "private static $valueToCustomName = [\n" + " self::MY_ENUM_BAR => \"custom_bar\",\n" + " ];"); + + ExpectFileContentContainsSubstring( + "Foo/MyEnum.php", + "private static $customNameToValue = [\n" + " \"custom_bar\" => self::MY_ENUM_BAR,\n" + " ];"); + + ExpectFileContentContainsSubstring( + "Foo/MyEnum.php", "public static function customName($value)"); + + ExpectFileContentContainsSubstring( + "Foo/MyEnum.php", "public static function customValue($name)"); +} + } // namespace } // namespace php } // namespace compiler diff --git a/src/google/protobuf/compiler/php/php_generator.cc b/src/google/protobuf/compiler/php/php_generator.cc index 4d85acf18bdf4..10b6e3cffcb56 100644 --- a/src/google/protobuf/compiler/php/php_generator.cc +++ b/src/google/protobuf/compiler/php/php_generator.cc @@ -28,9 +28,11 @@ #include "google/protobuf/descriptor.pb.h" #include "google/protobuf/io/printer.h" #include "google/protobuf/io/zero_copy_stream.h" +#include "google/protobuf/json_enumvalue_options.pb.h" constexpr absl::string_view kDescriptorFile = "google/protobuf/descriptor.proto"; + constexpr absl::string_view kEmptyFile = "google/protobuf/empty.proto"; constexpr absl::string_view kEmptyMetadataFile = "GPBMetadata/Google/Protobuf/GPBEmpty.php"; @@ -1410,6 +1412,67 @@ bool GenerateEnumFile(const FileDescriptor* file, const EnumDescriptor* en, Outdent(&printer); printer.Print("}\n"); + // Generate $valueToCustomName map if there are any custom names + bool has_custom_names = false; + for (int i = 0; i < en->value_count(); i++) { + const EnumValueDescriptor* value = en->value(i); + if (value->options().HasExtension(pb::enumvalue::json)) { + has_custom_names = true; + break; + } + } + + if (has_custom_names) { + printer.Print("\nprivate static $valueToCustomName = [\n"); + Indent(&printer); + for (int i = 0; i < en->value_count(); i++) { + const EnumValueDescriptor* value = en->value(i); + if (value->options().HasExtension(pb::enumvalue::json)) { + absl::string_view custom_name = + value->options().GetExtension(pb::enumvalue::json).string(); + std::string php_str = absl::CEscape(custom_name); + absl::StrReplaceAll({{"$", "\\$"}}, &php_str); + + printer.Print( + "self::^constant^ => \"^name^\",\n", "constant", + absl::StrCat(ConstantNamePrefix(value->name()), value->name()), + "name", php_str); + } + } + Outdent(&printer); + printer.Print("];\n"); + + printer.Print("\nprivate static $customNameToValue = [\n"); + Indent(&printer); + for (int i = 0; i < en->value_count(); i++) { + const EnumValueDescriptor* value = en->value(i); + if (value->options().HasExtension(pb::enumvalue::json)) { + absl::string_view custom_name = + value->options().GetExtension(pb::enumvalue::json).string(); + std::string php_str = absl::CEscape(custom_name); + absl::StrReplaceAll({{"$", "\\$"}}, &php_str); + + printer.Print( + "\"^name^\" => self::^constant^,\n", "name", php_str, "constant", + absl::StrCat(ConstantNamePrefix(value->name()), value->name())); + } + } + Outdent(&printer); + printer.Print("];\n"); + + printer.Print( + "\npublic static function customName($value)\n" + "{\n" + " return isset(self::$valueToCustomName[$value])\n" + " ? self::$valueToCustomName[$value] : null;\n" + "}\n" + "\npublic static function customValue($name)\n" + "{\n" + " return isset(self::$customNameToValue[$name])\n" + " ? self::$customNameToValue[$name] : null;\n" + "}\n"); + } + Outdent(&printer); printer.Print("}\n\n");