From b514714d6acf974624a0dbff549bbe3b484bd1e8 Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Sat, 14 Feb 2026 17:15:52 +0300 Subject: [PATCH 1/3] Implement `Descriptor Pool` --- src/Pool/Descriptor.php | 39 ++++++++++ src/Pool/EnumMetadata.php | 18 +++++ src/Pool/MessageMetadata.php | 18 +++++ src/Pool/OnceRegistrar.php | 29 ++++++++ src/Pool/Registrar.php | 17 +++++ src/Pool/Registry.php | 139 +++++++++++++++++++++++++++++++++++ src/Pool/ServiceMetadata.php | 22 ++++++ tests/Pool/RegistryTest.php | 70 ++++++++++++++++++ 8 files changed, 352 insertions(+) create mode 100644 src/Pool/Descriptor.php create mode 100644 src/Pool/EnumMetadata.php create mode 100644 src/Pool/MessageMetadata.php create mode 100644 src/Pool/OnceRegistrar.php create mode 100644 src/Pool/Registrar.php create mode 100644 src/Pool/Registry.php create mode 100644 src/Pool/ServiceMetadata.php create mode 100644 tests/Pool/RegistryTest.php diff --git a/src/Pool/Descriptor.php b/src/Pool/Descriptor.php new file mode 100644 index 0000000..74bd111 --- /dev/null +++ b/src/Pool/Descriptor.php @@ -0,0 +1,39 @@ + $this->bytes ??= ($this->decode)($this->buffer); } + + /** + * @param non-empty-string $buffer + */ + public static function base64(string $buffer): self + { + return new self($buffer, \base64_decode(...)); // @phpstan-ignore argument.type + } + + /** + * @param non-empty-string $buffer + */ + public static function raw(string $buffer): self + { + return new self($buffer, static fn(string $buffer) => $buffer); + } + + /** + * @param non-empty-string $buffer + * @param \Closure(non-empty-string): non-empty-string $decode + */ + private function __construct( + private readonly string $buffer, + private readonly \Closure $decode, + ) {} +} diff --git a/src/Pool/EnumMetadata.php b/src/Pool/EnumMetadata.php new file mode 100644 index 0000000..8822560 --- /dev/null +++ b/src/Pool/EnumMetadata.php @@ -0,0 +1,18 @@ +, true> $registered */ + static $registered = []; + + $fqcn = $this->registrar::class; + + if (!isset($registered[$fqcn])) { + $pool->register($this->registrar); + $registered[$fqcn] = true; + } + } +} diff --git a/src/Pool/Registrar.php b/src/Pool/Registrar.php new file mode 100644 index 0000000..8dc5fb7 --- /dev/null +++ b/src/Pool/Registrar.php @@ -0,0 +1,17 @@ + */ + public private(set) array $descriptors = []; + + /** @var array */ + public private(set) array $messageTypes = []; + + /** @var array */ + private array $messageTypeToDescriptorIndex = []; + + /** @var array */ + public private(set) array $enumTypes = []; + + /** @var array */ + private array $enumTypeToDescriptorIndex = []; + + /** @var array */ + public private(set) array $serviceTypes = []; + + /** @var array */ + private array $serviceTypeToDescriptorIndex = []; + + /** + * @param non-empty-string $type + */ + public function messageByType(string $type): MessageMetadata + { + return $this->messageTypes[$type] ?? throw new \RuntimeException("No message metadata for type '{$type}'"); + } + + /** + * @param non-empty-string $messageType + */ + public function descriptorByMessage(string $messageType): Descriptor + { + $descriptorIdx = $this->messageTypeToDescriptorIndex[$messageType] ?? throw new \RuntimeException("Message type '{$messageType}' was not registered"); + + return $this->descriptors[$descriptorIdx] ?? throw new \RuntimeException("No descriptor for message type '{$messageType}'"); + } + + /** + * @param non-empty-string $type + */ + public function enumByType(string $type): EnumMetadata + { + return $this->enumTypes[$type] ?? throw new \RuntimeException("No enum metadata for type '{$type}'"); + } + + /** + * @param non-empty-string $enumType + */ + public function descriptorByEnum(string $enumType): Descriptor + { + $descriptorIdx = $this->enumTypeToDescriptorIndex[$enumType] ?? throw new \RuntimeException("Enum type '{$enumType}' was not registered"); + + return $this->descriptors[$descriptorIdx] ?? throw new \RuntimeException("No descriptor for enum type '{$enumType}'"); + } + + /** + * @param non-empty-string $type + */ + public function serviceByType(string $type): ServiceMetadata + { + return $this->serviceTypes[$type] ?? throw new \RuntimeException("No service metadata for type '{$type}'"); + } + + /** + * @param non-empty-string $serviceType + */ + public function descriptorByService(string $serviceType): Descriptor + { + $descriptorIdx = $this->serviceTypeToDescriptorIndex[$serviceType] ?? throw new \RuntimeException("Service type '{$serviceType}' was not registered"); + + return $this->descriptors[$descriptorIdx] ?? throw new \RuntimeException("No descriptor for service type '{$serviceType}'"); + } + + public function register(Registrar $registry): self + { + $pool = self::get(); + $registry->register($pool); + + return $pool; + } + + /** + * @param array $types + */ + public function add(Descriptor $descriptor, array $types): self + { + $pool = self::get(); + + $idx = \count($pool->descriptors); + $pool->descriptors[] = $descriptor; + + foreach ($types as $type => $md) { + if ($md instanceof MessageMetadata) { + if (isset($this->messageTypes[$type])) { + throw new \RuntimeException("Message type '{$type}' is already registered"); + } + + $pool->messageTypeToDescriptorIndex[$type] = $idx; + $pool->messageTypes[$type] = $md; + } elseif ($md instanceof EnumMetadata) { + if (isset($this->enumTypes[$type])) { + throw new \RuntimeException("Enum type '{$type}' is already registered"); + } + + $pool->enumTypeToDescriptorIndex[$type] = $idx; + $pool->enumTypes[$type] = $md; + } elseif ($md instanceof ServiceMetadata) { // @phpstan-ignore instanceof.alwaysTrue + if (isset($this->serviceTypes[$type])) { + throw new \RuntimeException("Service type '{$type}' is already registered"); + } + + $pool->serviceTypeToDescriptorIndex[$type] = $idx; + $pool->serviceTypes[$type] = $md; + } + } + + return $pool; + } +} diff --git a/src/Pool/ServiceMetadata.php b/src/Pool/ServiceMetadata.php new file mode 100644 index 0000000..7803f25 --- /dev/null +++ b/src/Pool/ServiceMetadata.php @@ -0,0 +1,22 @@ +add($descriptor = Descriptor::raw('xyz'), [ + 'thesis.api.Request' => $md = new MessageMetadata('Thesis\Api\Request'), + ]); + + self::assertEquals($md, $pool->messageByType('thesis.api.Request')); + self::assertEquals($descriptor, $pool->descriptorByMessage('thesis.api.Request')); + + self::expectExceptionObject(new \RuntimeException("Message type 'thesis.api.Request' is already registered")); + $pool->add($descriptor, ['thesis.api.Request' => $md]); + } + + public function testEnumRegistered(): void + { + $pool = Registry::get(); + $pool->add($descriptor = Descriptor::raw('xyz'), [ + 'thesis.api.RequestType' => $md = new EnumMetadata('Thesis\Api\RequestType'), + ]); + + self::assertEquals($md, $pool->enumByType('thesis.api.RequestType')); + self::assertEquals($descriptor, $pool->descriptorByEnum('thesis.api.RequestType')); + + self::expectExceptionObject(new \RuntimeException("Enum type 'thesis.api.RequestType' is already registered")); + $pool->add($descriptor, ['thesis.api.RequestType' => $md]); + } + + public function testServiceRegistered(): void + { + $pool = Registry::get(); + $pool->add($descriptor = Descriptor::raw('xyz'), [ + 'thesis.api.RequestService' => $md = new ServiceMetadata('Thesis\Api\RequestServiceClient'), + ]); + + self::assertEquals($md, $pool->serviceByType('thesis.api.RequestService')); + self::assertEquals($descriptor, $pool->descriptorByService('thesis.api.RequestService')); + + self::expectExceptionObject(new \RuntimeException("Service type 'thesis.api.RequestService' is already registered")); + $pool->add($descriptor, ['thesis.api.RequestService' => $md]); + } + + public function testRegister(): void + { + $pool = Registry::get(); + $pool->register(new class implements Registrar { + #[\Override] + public function register(Registry $pool): void + { + $pool->add(Descriptor::raw('xyz'), [ + 'thesis.api.OtherType' => new EnumMetadata('Thesis\Api\OtherType'), + ]); + } + }); + + self::assertEquals(new EnumMetadata('Thesis\Api\OtherType'), $pool->enumByType('thesis.api.OtherType')); + } +} From bec8ee873c7b9a19f6a82390629904454959f56d Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Sat, 14 Feb 2026 17:26:03 +0300 Subject: [PATCH 2/3] chore: registry refactoring --- src/Pool/Registry.php | 70 ++++++++++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/src/Pool/Registry.php b/src/Pool/Registry.php index 5fc15e5..f850cfc 100644 --- a/src/Pool/Registry.php +++ b/src/Pool/Registry.php @@ -91,10 +91,13 @@ public function descriptorByService(string $serviceType): Descriptor return $this->descriptors[$descriptorIdx] ?? throw new \RuntimeException("No descriptor for service type '{$serviceType}'"); } - public function register(Registrar $registry): self + public function register(Registrar ...$registries): self { $pool = self::get(); - $registry->register($pool); + + foreach ($registries as $registry) { + $registry->register($pool); + } return $pool; } @@ -111,29 +114,56 @@ public function add(Descriptor $descriptor, array $types): self foreach ($types as $type => $md) { if ($md instanceof MessageMetadata) { - if (isset($this->messageTypes[$type])) { - throw new \RuntimeException("Message type '{$type}' is already registered"); - } - - $pool->messageTypeToDescriptorIndex[$type] = $idx; - $pool->messageTypes[$type] = $md; + $pool->doAddMessageType($type, $md, $idx); } elseif ($md instanceof EnumMetadata) { - if (isset($this->enumTypes[$type])) { - throw new \RuntimeException("Enum type '{$type}' is already registered"); - } - - $pool->enumTypeToDescriptorIndex[$type] = $idx; - $pool->enumTypes[$type] = $md; + $pool->doAddEnumType($type, $md, $idx); } elseif ($md instanceof ServiceMetadata) { // @phpstan-ignore instanceof.alwaysTrue - if (isset($this->serviceTypes[$type])) { - throw new \RuntimeException("Service type '{$type}' is already registered"); - } - - $pool->serviceTypeToDescriptorIndex[$type] = $idx; - $pool->serviceTypes[$type] = $md; + $pool->doAddServiceType($type, $md, $idx); } } return $pool; } + + /** + * @param non-empty-string $type + * @param non-negative-int $descriptorIdx + */ + private function doAddMessageType(string $type, MessageMetadata $md, int $descriptorIdx): void + { + if (isset($this->messageTypes[$type])) { + throw new \RuntimeException("Message type '{$type}' is already registered"); + } + + $this->messageTypeToDescriptorIndex[$type] = $descriptorIdx; + $this->messageTypes[$type] = $md; + } + + /** + * @param non-empty-string $type + * @param non-negative-int $descriptorIdx + */ + private function doAddEnumType(string $type, EnumMetadata $md, int $descriptorIdx): void + { + if (isset($this->enumTypes[$type])) { + throw new \RuntimeException("Enum type '{$type}' is already registered"); + } + + $this->enumTypeToDescriptorIndex[$type] = $descriptorIdx; + $this->enumTypes[$type] = $md; + } + + /** + * @param non-empty-string $type + * @param non-negative-int $descriptorIdx + */ + private function doAddServiceType(string $type, ServiceMetadata $md, int $descriptorIdx): void + { + if (isset($this->serviceTypes[$type])) { + throw new \RuntimeException("Service type '{$type}' is already registered"); + } + + $this->serviceTypeToDescriptorIndex[$type] = $descriptorIdx; + $this->serviceTypes[$type] = $md; + } } From dc39eb3435b63db7099c4e59dbb3e26ec6ba10a0 Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Sun, 15 Feb 2026 17:30:11 +0300 Subject: [PATCH 3/3] chore: add class+enum type index --- Makefile | 4 +- src/Pool/Registry.php | 93 +++++++++++++++++++++++++++++++------ tests/Pool/RegistryTest.php | 25 ++++++++-- 3 files changed, 101 insertions(+), 21 deletions(-) diff --git a/Makefile b/Makefile index 74a3576..04cdd75 100644 --- a/Makefile +++ b/Makefile @@ -95,8 +95,8 @@ phpstan: var vendor ## Analyze code using PHPStan $(RUN) phpstan analyze --memory-limit=1G $(ARGS) .PHONY: phpstan -test: var vendor up ## Run tests using PHPUnit - $(RUN) vendor/bin/phpunit $(ARGS) +test: var vendor ## Run tests using PHPUnit + $(RUN) vendor/bin/phpunit $(ARGS) --colors .PHONY: test infect: var vendor up ## Run mutation tests using Infection diff --git a/src/Pool/Registry.php b/src/Pool/Registry.php index f850cfc..3888947 100644 --- a/src/Pool/Registry.php +++ b/src/Pool/Registry.php @@ -22,12 +22,18 @@ public static function get(): self /** @var array */ public private(set) array $messageTypes = []; + /** @var array */ + private array $classToTypeIndex = []; + /** @var array */ private array $messageTypeToDescriptorIndex = []; /** @var array */ public private(set) array $enumTypes = []; + /** @var array */ + private array $enumToTypeIndex = []; + /** @var array */ private array $enumTypeToDescriptorIndex = []; @@ -42,7 +48,16 @@ public static function get(): self */ public function messageByType(string $type): MessageMetadata { - return $this->messageTypes[$type] ?? throw new \RuntimeException("No message metadata for type '{$type}'"); + return $this->messageTypes[$type] ?? self::throwTypeNotFound($type); + } + + /** + * @param class-string $fqcn + * @return non-empty-string + */ + public function classType(string $fqcn): string + { + return $this->classToTypeIndex[$fqcn] ?? self::throwClassTypeNotFound($fqcn); } /** @@ -50,9 +65,8 @@ public function messageByType(string $type): MessageMetadata */ public function descriptorByMessage(string $messageType): Descriptor { - $descriptorIdx = $this->messageTypeToDescriptorIndex[$messageType] ?? throw new \RuntimeException("Message type '{$messageType}' was not registered"); - - return $this->descriptors[$descriptorIdx] ?? throw new \RuntimeException("No descriptor for message type '{$messageType}'"); + return $this->descriptors[$this->messageTypeToDescriptorIndex[$messageType] ?? self::throwTypeNotFound($messageType)] + ?? self::throwDescriptorNotFound($messageType); } /** @@ -60,7 +74,16 @@ public function descriptorByMessage(string $messageType): Descriptor */ public function enumByType(string $type): EnumMetadata { - return $this->enumTypes[$type] ?? throw new \RuntimeException("No enum metadata for type '{$type}'"); + return $this->enumTypes[$type] ?? self::throwTypeNotFound($type); + } + + /** + * @param class-string $fqcn + * @return non-empty-string + */ + public function enumType(string $fqcn): string + { + return $this->enumToTypeIndex[$fqcn] ?? self::throwEnumTypeNotFound($fqcn); } /** @@ -68,9 +91,8 @@ public function enumByType(string $type): EnumMetadata */ public function descriptorByEnum(string $enumType): Descriptor { - $descriptorIdx = $this->enumTypeToDescriptorIndex[$enumType] ?? throw new \RuntimeException("Enum type '{$enumType}' was not registered"); - - return $this->descriptors[$descriptorIdx] ?? throw new \RuntimeException("No descriptor for enum type '{$enumType}'"); + return $this->descriptors[$this->enumTypeToDescriptorIndex[$enumType] ?? self::throwTypeNotFound($enumType)] + ?? self::throwDescriptorNotFound($enumType); } /** @@ -78,7 +100,7 @@ public function descriptorByEnum(string $enumType): Descriptor */ public function serviceByType(string $type): ServiceMetadata { - return $this->serviceTypes[$type] ?? throw new \RuntimeException("No service metadata for type '{$type}'"); + return $this->serviceTypes[$type] ?? self::throwTypeNotFound($type); } /** @@ -86,9 +108,8 @@ public function serviceByType(string $type): ServiceMetadata */ public function descriptorByService(string $serviceType): Descriptor { - $descriptorIdx = $this->serviceTypeToDescriptorIndex[$serviceType] ?? throw new \RuntimeException("Service type '{$serviceType}' was not registered"); - - return $this->descriptors[$descriptorIdx] ?? throw new \RuntimeException("No descriptor for service type '{$serviceType}'"); + return $this->descriptors[$this->serviceTypeToDescriptorIndex[$serviceType] ?? self::throwTypeNotFound($serviceType)] + ?? self::throwDescriptorNotFound($serviceType); } public function register(Registrar ...$registries): self @@ -132,11 +153,12 @@ public function add(Descriptor $descriptor, array $types): self private function doAddMessageType(string $type, MessageMetadata $md, int $descriptorIdx): void { if (isset($this->messageTypes[$type])) { - throw new \RuntimeException("Message type '{$type}' is already registered"); + self::throwTypeAlreadyRegistered($type); } $this->messageTypeToDescriptorIndex[$type] = $descriptorIdx; $this->messageTypes[$type] = $md; + $this->classToTypeIndex[$md->fqcn] = $type; } /** @@ -146,11 +168,12 @@ private function doAddMessageType(string $type, MessageMetadata $md, int $descri private function doAddEnumType(string $type, EnumMetadata $md, int $descriptorIdx): void { if (isset($this->enumTypes[$type])) { - throw new \RuntimeException("Enum type '{$type}' is already registered"); + self::throwTypeAlreadyRegistered($type); } $this->enumTypeToDescriptorIndex[$type] = $descriptorIdx; $this->enumTypes[$type] = $md; + $this->enumToTypeIndex[$md->fqcn] = $type; } /** @@ -160,10 +183,50 @@ private function doAddEnumType(string $type, EnumMetadata $md, int $descriptorId private function doAddServiceType(string $type, ServiceMetadata $md, int $descriptorIdx): void { if (isset($this->serviceTypes[$type])) { - throw new \RuntimeException("Service type '{$type}' is already registered"); + self::throwTypeAlreadyRegistered($type); } $this->serviceTypeToDescriptorIndex[$type] = $descriptorIdx; $this->serviceTypes[$type] = $md; } + + /** + * @param non-empty-string $type + */ + private static function throwTypeAlreadyRegistered(string $type): never + { + throw new \RuntimeException(\sprintf('Type "%s" is already registered in the \Thesis\Protobuf\Pool\Registry. Ensure that you are using protobuf compiler correctly, or use \Thesis\Protobuf\Pool\OnceRegistrar to prevent duplicate registration of types in the pool', $type)); + } + + /** + * @param class-string $fqcn + */ + private static function throwClassTypeNotFound(string $fqcn): never + { + throw new \RuntimeException(\sprintf('Associated with class "%s" metadata not found in the \Thesis\Protobuf\Pool\Registry. Perhaps you forgot to include autoload.metadata.php in composer.json or did not call the appropriate descriptor registrar to register types in the pool?', $fqcn)); + } + + /** + * @param class-string $fqcn + */ + private static function throwEnumTypeNotFound(string $fqcn): never + { + throw new \RuntimeException(\sprintf('Associated with enum "%s" metadata not found in the \Thesis\Protobuf\Pool\Registry. Perhaps you forgot to include autoload.metadata.php in composer.json or did not call the appropriate descriptor registrar to register types in the pool?', $fqcn)); + } + + /** + * @param non-empty-string $type + */ + private static function throwDescriptorNotFound(string $type): never + { + throw new \RuntimeException(\sprintf('Descriptor for type "%s" not found in the \Thesis\Protobuf\Pool\Registry. Perhaps you forgot to include autoload.metadata.php in composer.json or did not call the appropriate descriptor registrar to register types in the pool?', $type)); + } + + /** + * @param non-empty-string $type + */ + private static function throwTypeNotFound(string $type): never + { + throw new \RuntimeException(\sprintf('Type metadata "%s" not found in the \Thesis\Protobuf\Pool\Registry. Perhaps you forgot to include autoload.metadata.php in composer.json or did not call the appropriate descriptor registrar to register types in the pool?', $type)); + } } diff --git a/tests/Pool/RegistryTest.php b/tests/Pool/RegistryTest.php index 3e107bf..d1bfaa7 100644 --- a/tests/Pool/RegistryTest.php +++ b/tests/Pool/RegistryTest.php @@ -14,13 +14,14 @@ public function testMessageRegistered(): void { $pool = Registry::get(); $pool->add($descriptor = Descriptor::raw('xyz'), [ - 'thesis.api.Request' => $md = new MessageMetadata('Thesis\Api\Request'), + 'thesis.api.Request' => $md = new MessageMetadata(\stdClass::class), ]); self::assertEquals($md, $pool->messageByType('thesis.api.Request')); self::assertEquals($descriptor, $pool->descriptorByMessage('thesis.api.Request')); + self::assertSame('thesis.api.Request', $pool->classType(\stdClass::class)); - self::expectExceptionObject(new \RuntimeException("Message type 'thesis.api.Request' is already registered")); + self::expectExceptionObject(new \RuntimeException('Type "thesis.api.Request" is already registered in the \Thesis\Protobuf\Pool\Registry. Ensure that you are using protobuf compiler correctly, or use \Thesis\Protobuf\Pool\OnceRegistrar to prevent duplicate registration of types in the pool')); $pool->add($descriptor, ['thesis.api.Request' => $md]); } @@ -34,7 +35,7 @@ public function testEnumRegistered(): void self::assertEquals($md, $pool->enumByType('thesis.api.RequestType')); self::assertEquals($descriptor, $pool->descriptorByEnum('thesis.api.RequestType')); - self::expectExceptionObject(new \RuntimeException("Enum type 'thesis.api.RequestType' is already registered")); + self::expectExceptionObject(new \RuntimeException('Type "thesis.api.RequestType" is already registered in the \Thesis\Protobuf\Pool\Registry. Ensure that you are using protobuf compiler correctly, or use \Thesis\Protobuf\Pool\OnceRegistrar to prevent duplicate registration of types in the pool')); $pool->add($descriptor, ['thesis.api.RequestType' => $md]); } @@ -48,7 +49,7 @@ public function testServiceRegistered(): void self::assertEquals($md, $pool->serviceByType('thesis.api.RequestService')); self::assertEquals($descriptor, $pool->descriptorByService('thesis.api.RequestService')); - self::expectExceptionObject(new \RuntimeException("Service type 'thesis.api.RequestService' is already registered")); + self::expectExceptionObject(new \RuntimeException('Type "thesis.api.RequestService" is already registered in the \Thesis\Protobuf\Pool\Registry. Ensure that you are using protobuf compiler correctly, or use \Thesis\Protobuf\Pool\OnceRegistrar to prevent duplicate registration of types in the pool')); $pool->add($descriptor, ['thesis.api.RequestService' => $md]); } @@ -67,4 +68,20 @@ public function register(Registry $pool): void self::assertEquals(new EnumMetadata('Thesis\Api\OtherType'), $pool->enumByType('thesis.api.OtherType')); } + + public function testTypeNotFound(): void + { + $pool = Registry::get(); + + self::expectExceptionObject(new \RuntimeException('Type metadata "thesis.api.OtherRequest" not found in the \Thesis\Protobuf\Pool\Registry. Perhaps you forgot to include autoload.metadata.php in composer.json or did not call the appropriate descriptor registrar to register types in the pool?')); + $pool->messageByType('thesis.api.OtherRequest'); + } + + public function testClassNotFound(): void + { + $pool = Registry::get(); + + self::expectExceptionObject(new \RuntimeException('Associated with class "Thesis\Protobuf\Pool\RegistryTest" metadata not found in the \Thesis\Protobuf\Pool\Registry. Perhaps you forgot to include autoload.metadata.php in composer.json or did not call the appropriate descriptor registrar to register types in the pool?')); + $pool->classType(self::class); + } }