From e70ffc5c333cc035b9e23d05490835752b73e4d7 Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Thu, 9 Apr 2026 11:24:11 +0300 Subject: [PATCH 1/6] chore: minor fixes in SerdeBool and SerdeString --- src/Internal/Serde/SerdeBool.php | 2 +- src/Internal/Serde/SerdeString.php | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Internal/Serde/SerdeBool.php b/src/Internal/Serde/SerdeBool.php index 3db8de3..d4ac8fe 100644 --- a/src/Internal/Serde/SerdeBool.php +++ b/src/Internal/Serde/SerdeBool.php @@ -30,6 +30,6 @@ public function deserialize(ReadBuffer $buffer): bool { $num = SerdeVarint::T->deserialize($buffer); - return (int) $num->value === 1; + return (int) $num->value !== 0; } } diff --git a/src/Internal/Serde/SerdeString.php b/src/Internal/Serde/SerdeString.php index fdd0b7e..ee4f5ce 100644 --- a/src/Internal/Serde/SerdeString.php +++ b/src/Internal/Serde/SerdeString.php @@ -33,7 +33,11 @@ public function serialize(WriteBuffer $buffer, mixed $value): void public function deserialize(ReadBuffer $buffer): string { $length = (int) SerdeVarint::T->deserialize($buffer)->value; - if ($length <= 0) { + if ($length < 0) { + throw new \UnexpectedValueException("String length must be positive or equal to 0, '{$length}' given."); + } + + if ($length === 0) { return ''; } From d501d39c9e139ae338e817870dbf9eae9fcd2e08 Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Thu, 9 Apr 2026 11:24:25 +0300 Subject: [PATCH 2/6] chore: add test for discardUnknown --- tests/DiscardUnknownFieldsTest.php | 59 ++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/DiscardUnknownFieldsTest.php diff --git a/tests/DiscardUnknownFieldsTest.php b/tests/DiscardUnknownFieldsTest.php new file mode 100644 index 0000000..01161ce --- /dev/null +++ b/tests/DiscardUnknownFieldsTest.php @@ -0,0 +1,59 @@ +assertUnknownFieldSkipped(fieldOf(2, int32Of(99))); + } + + public function testUnknownFixed32Field(): void + { + $this->assertUnknownFieldSkipped(fieldOf(2, fixed32Of(42))); + } + + public function testUnknownFixed64Field(): void + { + $this->assertUnknownFieldSkipped(fieldOf(2, fixed64Of(new Number(42)))); + } + + public function testUnknownBytesField(): void + { + $this->assertUnknownFieldSkipped(fieldOf(2, stringOf('unknown'))); + } + + /** + * @param FieldDescriptor<*> $unknownField + */ + private function assertUnknownFieldSkipped(FieldDescriptor $unknownField): void + { + $serializer = new Serializer(); + + $bytes = $serializer->serialize(message( + fieldOf(1, int32Of(42)), + $unknownField, + fieldOf(3, stringOf('hello')), + )); + + $message = $serializer->deserialize( + messageT( + fieldT(1, int32T), + fieldT(3, stringT), + ), + $bytes, + ); + + self::assertCount(2, $message); + self::assertSame(42, $message->fields[1]->value->value); // @phpstan-ignore offsetAccess.notFound + self::assertSame('hello', $message->fields[3]->value->value); // @phpstan-ignore offsetAccess.notFound + } +} From 3532a511144f64a39ba6ca47749a3d2a0624a2fb Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Thu, 9 Apr 2026 16:23:26 +0300 Subject: [PATCH 3/6] Allow to handle unknown fields --- README.md | 79 ++++++++++++++++ composer.json | 2 +- src/Decoder/Builder.php | 13 ++- src/Internal/Serde/DeserializeList.php | 2 +- src/Internal/Serde/DeserializeMessage.php | 6 +- src/Internal/Serde/SerializeList.php | 4 +- src/Internal/Serde/SerializeMessage.php | 2 +- src/Internal/Serde/SerializeTag.php | 2 +- src/Internal/Wire/wire.php | 61 +++++++++---- src/Message.php | 6 +- src/Reflection/Reflector.php | 7 ++ src/Serializer.php | 7 +- src/{Internal/Wire => }/Tag.php | 37 +------- src/Type/Visitor/DetermineWireType.php | 2 +- src/Type/Visitor/TypeDeserializerVisitor.php | 2 +- src/Type/Visitor/TypeSerializerVisitor.php | 2 +- src/UnknownField.php | 18 ++++ src/UnknownFieldHandler.php | 16 ++++ src/UnknownFieldHandler/OnUnknownFields.php | 32 +++++++ src/UnknownFieldHandler/UnknownFields.php | 42 +++++++++ src/{Internal/Wire => }/WireType.php | 4 +- src/constructors.php | 4 +- tests/DiscardUnknownFieldsTest.php | 59 ------------ tests/ScalarCardinalityTest.php | 1 - tests/TagTest.php | 2 - tests/UnknownHandlerTest.php | 94 ++++++++++++++++++++ 26 files changed, 372 insertions(+), 134 deletions(-) rename src/{Internal/Wire => }/Tag.php (52%) create mode 100644 src/UnknownField.php create mode 100644 src/UnknownFieldHandler.php create mode 100644 src/UnknownFieldHandler/OnUnknownFields.php create mode 100644 src/UnknownFieldHandler/UnknownFields.php rename src/{Internal/Wire => }/WireType.php (73%) delete mode 100644 tests/DiscardUnknownFieldsTest.php create mode 100644 tests/UnknownHandlerTest.php diff --git a/README.md b/README.md index 9d68422..010a860 100644 --- a/README.md +++ b/README.md @@ -89,3 +89,82 @@ $request = $decoder->decode(/** protobuf buffer here */, CreateUserRequest::clas echo $request->name; ``` + +### Unknown fields + +When a protobuf message is decoded, it may contain fields that are not defined in the target class. +This happens when the sender uses a newer version of the schema, but the receiver has not been updated yet. + +By default, unknown fields are silently skipped during decoding. However, you can configure the `Decoder` to capture them, +which is useful for logging, debugging, or forwarding messages without data loss. + +#### Storing unknown fields in memory + +The `UnknownFields` handler stores unknown fields in a `WeakMap` attached to the decoded object. +Once the object is garbage collected, the unknown fields are automatically cleaned up. + +```php +use Thesis\Protobuf\Decoder; +use Thesis\Protobuf\UnknownFieldHandler\UnknownFields; + +$decoder = new Decoder\Builder() + ->withUnknownHandler(UnknownFields::get()) + ->build(); + +$request = $decoder->decode($buffer, CreateUserRequest::class); + +// Get unknown fields for a specific object. +$unknowns = UnknownFields::of($request); + +foreach ($unknowns as $field) { + echo "Field #{$field->tag->num}, wire type: {$field->tag->type->name}\n"; +} +``` + +Each decoded object tracks its own unknown fields independently. +If `CreateUserRequest` has a nested message with unknown fields, you can inspect them separately: + +```php +$unknowns = UnknownFields::of($request->nested); +``` + +#### Using a callback + +The `OnUnknownFields` handler calls a user-defined function each time unknown fields are detected. +This is convenient for logging without keeping the data in memory: + +```php +use Thesis\Protobuf\Decoder; +use Thesis\Protobuf\UnknownFieldHandler\OnUnknownFields; + +$decoder = new Decoder\Builder() + ->withUnknownHandler(new OnUnknownFields( + static function (object $message, array $unknowns): void { + $logger->warning('Unknown fields detected', [ + 'class' => $message::class, + 'fields' => array_map( + static fn($f) => $f->tag->num, + $unknowns, + ), + ]); + }, + )) + ->build(); +``` + +#### Custom handler + +You can implement the `UnknownFieldHandler` interface to define your own strategy: + +```php +use Thesis\Protobuf\UnknownFieldHandler; + +final readonly class MyHandler implements UnknownFieldHandler +{ + #[\Override] + public function handle(object $message, array $unknowns): void + { + // your logic here + } +} +``` diff --git a/composer.json b/composer.json index 3c5958c..8aba2a9 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "files": [ "src/constructors.php", "src/Internal/Buffer/buffer.php", - "src/Internal/Wire/Tag.php", + "src/Tag.php", "src/Internal/Wire/wire.php", "src/Reflection/Internal/numeric.php" ] diff --git a/src/Decoder/Builder.php b/src/Decoder/Builder.php index 5d870db..80f1c8e 100644 --- a/src/Decoder/Builder.php +++ b/src/Decoder/Builder.php @@ -9,6 +9,7 @@ use Thesis\Protobuf\Decoder\Internal\ReflectionDecoder; use Thesis\Protobuf\Reflection\Reflector; use Thesis\Protobuf\Serializer; +use Thesis\Protobuf\UnknownFieldHandler; /** * @api @@ -17,6 +18,8 @@ final class Builder { private ?CacheInterface $cache = null; + private ?UnknownFieldHandler $unknowns = null; + public function withCache(CacheInterface $cache): self { $builder = clone $this; @@ -25,6 +28,14 @@ public function withCache(CacheInterface $cache): self return $builder; } + public function withUnknownHandler(UnknownFieldHandler $handler): self + { + $builder = clone $this; + $builder->unknowns = $handler; + + return $builder; + } + public static function buildDefault(): Decoder { return new self()->build(); @@ -34,7 +45,7 @@ public function build(): Decoder { return new ReflectionDecoder( new Serializer(), - Reflector::build($this->cache), + Reflector::build($this->cache, $this->unknowns), ); } } diff --git a/src/Internal/Serde/DeserializeList.php b/src/Internal/Serde/DeserializeList.php index 14d2814..aeb9ea0 100644 --- a/src/Internal/Serde/DeserializeList.php +++ b/src/Internal/Serde/DeserializeList.php @@ -7,7 +7,7 @@ use Thesis\Protobuf\Internal\Buffer; use Thesis\Protobuf\Internal\Buffer\ReadBuffer; use Thesis\Protobuf\Internal\Wire; -use Thesis\Protobuf\Internal\Wire\Tag; +use Thesis\Protobuf\Tag; /** * @internal diff --git a/src/Internal/Serde/DeserializeMessage.php b/src/Internal/Serde/DeserializeMessage.php index 120ca0c..6703fd9 100644 --- a/src/Internal/Serde/DeserializeMessage.php +++ b/src/Internal/Serde/DeserializeMessage.php @@ -31,12 +31,14 @@ public function deserialize(ReadBuffer $buffer): Message /** @var list> $descriptors */ $descriptors = []; + $unknowns = []; + while (\count($buffer) > 0) { $tag = Wire\readTag($buffer); $field = $this->messageT->fields[$tag->num] ?? null; if ($field === null) { - Wire\discardUnknown($buffer, $tag); + $unknowns[] = Wire\discardUnknown($buffer, $tag); continue; } @@ -54,6 +56,6 @@ public function deserialize(ReadBuffer $buffer): Message ); } - return new Message(...$descriptors); + return new Message($descriptors, $unknowns); } } diff --git a/src/Internal/Serde/SerializeList.php b/src/Internal/Serde/SerializeList.php index 61e94c2..4682c7d 100644 --- a/src/Internal/Serde/SerializeList.php +++ b/src/Internal/Serde/SerializeList.php @@ -8,8 +8,8 @@ use Thesis\Protobuf\Internal\Buffer\ByteBuffer; use Thesis\Protobuf\Internal\Buffer\WriteBuffer; use Thesis\Protobuf\Internal\Wire; -use Thesis\Protobuf\Internal\Wire\Tag; -use Thesis\Protobuf\Internal\Wire\WireType; +use Thesis\Protobuf\Tag; +use Thesis\Protobuf\WireType; /** * @internal diff --git a/src/Internal/Serde/SerializeMessage.php b/src/Internal/Serde/SerializeMessage.php index 3763f53..02577ef 100644 --- a/src/Internal/Serde/SerializeMessage.php +++ b/src/Internal/Serde/SerializeMessage.php @@ -8,8 +8,8 @@ use Thesis\Protobuf\Internal\Buffer; use Thesis\Protobuf\Internal\Buffer\ByteBuffer; use Thesis\Protobuf\Internal\Buffer\WriteBuffer; -use Thesis\Protobuf\Internal\Wire\Tag; use Thesis\Protobuf\Message; +use Thesis\Protobuf\Tag; use Thesis\Protobuf\Type\Visitor\DetermineWireType; use Thesis\Protobuf\Type\Visitor\TypeSerializerVisitor; diff --git a/src/Internal/Serde/SerializeTag.php b/src/Internal/Serde/SerializeTag.php index 2800ff8..b9656f2 100644 --- a/src/Internal/Serde/SerializeTag.php +++ b/src/Internal/Serde/SerializeTag.php @@ -6,7 +6,7 @@ use Thesis\Protobuf\Internal\Buffer\WriteBuffer; use Thesis\Protobuf\Internal\Wire; -use Thesis\Protobuf\Internal\Wire\Tag; +use Thesis\Protobuf\Tag; /** * @internal diff --git a/src/Internal/Wire/wire.php b/src/Internal/Wire/wire.php index e4cda5e..6c69e11 100644 --- a/src/Internal/Wire/wire.php +++ b/src/Internal/Wire/wire.php @@ -7,10 +7,15 @@ use BcMath\Number; use Thesis\Protobuf\Exception\BufferUnderflow; use Thesis\Protobuf\Internal\Buffer\ReadBuffer; +use Thesis\Protobuf\Internal\Buffer\WriteBuffer; +use Thesis\Protobuf\Internal\Serde\SerdeVarint; +use Thesis\Protobuf\Tag; +use Thesis\Protobuf\UnknownField; +use Thesis\Protobuf\WireType; use Thesis\Varint; /** - * Removes bytes from the buffer corresponding to the size of each type: + * Return bytes from the buffer corresponding to the size of each type: * fixed32 - 4, * fixed64 - 8, * varint - 1 ≤ size ≤ 10, @@ -19,25 +24,21 @@ * @internal * @throws BufferUnderflow */ -function discardUnknown(ReadBuffer $buffer, Tag $tag): void +function discardUnknown(ReadBuffer $buffer, Tag $tag): UnknownField { - switch ($tag->type) { - case WireType::FIXED32: - $buffer->read(4); - break; - case WireType::FIXED64: - $buffer->read(8); - break; - case WireType::VARINT: - readVarint($buffer); - break; - case WireType::BYTES: + return new UnknownField($tag, match ($tag->type) { + WireType::FIXED32 => $buffer->read(4), + WireType::FIXED64 => $buffer->read(8), + WireType::VARINT => readVarint($buffer), + WireType::BYTES => (static function () use ($buffer): string { $length = (int) readVarint($buffer)->value; if ($length > 0) { - $buffer->read($length); + return $buffer->read($length); } - break; - } + + return ''; + })(), + }); } /** @@ -51,3 +52,31 @@ function readVarint(ReadBuffer $buffer): Number return $number->value; } + +/** + * @internal + */ +function writeTag(WriteBuffer $buffer, Tag $tag): void +{ + SerdeVarint::T->serialize($buffer, $tag->number); +} + +/** + * @internal + * @throws BufferUnderflow + */ +function peekTag(ReadBuffer $buffer): Tag +{ + $number = Varint\BcMath::Codec->decodeVarintSized($buffer->peek(10)); + + return Tag::from($number->value); +} + +/** + * @internal + * @throws BufferUnderflow + */ +function readTag(ReadBuffer $buffer): Tag +{ + return Tag::from(readVarint($buffer)); +} diff --git a/src/Message.php b/src/Message.php index 41580ac..b1da833 100644 --- a/src/Message.php +++ b/src/Message.php @@ -17,10 +17,12 @@ /** * @no-named-arguments - * @param FieldDescriptor<*> ...$fields + * @param list> $fields + * @param list $unknowns */ public function __construct( - FieldDescriptor ...$fields, + array $fields = [], + public array $unknowns = [], ) { $map = []; diff --git a/src/Reflection/Reflector.php b/src/Reflection/Reflector.php index 1571ea1..6e3bd4d 100644 --- a/src/Reflection/Reflector.php +++ b/src/Reflection/Reflector.php @@ -38,16 +38,19 @@ final class Reflector public static function build( ?CacheInterface $cache = null, + ?Protobuf\UnknownFieldHandler $unknowns = null, ): self { return new self( cache: new Cache( $cache ?? new InMemoryPsr16Cache(), ), + unknowns: $unknowns, ); } private function __construct( private readonly Cache $cache, + private readonly ?Protobuf\UnknownFieldHandler $unknowns = null, ) { $this->typeVisitor = new ToProtobufTypeTypeVisitor($this); $this->valueVisitor = new ToProtobufValueTypeVisitor($this); @@ -164,6 +167,10 @@ public function map(Message $message, string $class): object } } + if ($message->unknowns !== []) { + $this->unknowns?->handle($object, $message->unknowns); + } + return $object; } diff --git a/src/Serializer.php b/src/Serializer.php index ee6c9cf..6b0d6fb 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -7,7 +7,6 @@ use Thesis\Protobuf\Exception\BufferUnderflow; use Thesis\Protobuf\Internal\Buffer\ByteBuffer; use Thesis\Protobuf\Internal\Wire; -use Thesis\Protobuf\Internal\Wire\Tag; use Thesis\Protobuf\Type\MessageT; use Thesis\Protobuf\Type\Visitor\DetermineWireType; use Thesis\Protobuf\Type\Visitor\TypeDeserializerVisitor; @@ -47,12 +46,14 @@ public function deserialize(MessageT $type, string $bytes): Message /** @var list> $descriptors */ $descriptors = []; + $unknowns = []; + while (\count($buffer) > 0) { $tag = Wire\readTag($buffer); $field = $type->fields[$tag->num] ?? null; if ($field === null) { - Wire\discardUnknown($buffer, $tag); + $unknowns[] = Wire\discardUnknown($buffer, $tag); continue; } @@ -70,6 +71,6 @@ public function deserialize(MessageT $type, string $bytes): Message ); } - return new Message(...$descriptors); + return new Message($descriptors, $unknowns); } } diff --git a/src/Internal/Wire/Tag.php b/src/Tag.php similarity index 52% rename from src/Internal/Wire/Tag.php rename to src/Tag.php index 8b97faa..ae87040 100644 --- a/src/Internal/Wire/Tag.php +++ b/src/Tag.php @@ -2,17 +2,12 @@ declare(strict_types=1); -namespace Thesis\Protobuf\Internal\Wire; +namespace Thesis\Protobuf; use BcMath\Number; -use Thesis\Protobuf\Exception\BufferUnderflow; -use Thesis\Protobuf\Internal\Buffer\ReadBuffer; -use Thesis\Protobuf\Internal\Buffer\WriteBuffer; -use Thesis\Protobuf\Internal\Serde\SerdeVarint; -use Thesis\Varint; /** - * @internal + * @api */ final class Tag { @@ -46,31 +41,3 @@ public function __construct( public readonly WireType $type, ) {} } - -/** - * @internal - */ -function writeTag(WriteBuffer $buffer, Tag $tag): void -{ - SerdeVarint::T->serialize($buffer, $tag->number); -} - -/** - * @internal - * @throws BufferUnderflow - */ -function peekTag(ReadBuffer $buffer): Tag -{ - $number = Varint\BcMath::Codec->decodeVarintSized($buffer->peek(10)); - - return Tag::from($number->value); -} - -/** - * @internal - * @throws BufferUnderflow - */ -function readTag(ReadBuffer $buffer): Tag -{ - return Tag::from(readVarint($buffer)); -} diff --git a/src/Type/Visitor/DetermineWireType.php b/src/Type/Visitor/DetermineWireType.php index 19d7379..fd14276 100644 --- a/src/Type/Visitor/DetermineWireType.php +++ b/src/Type/Visitor/DetermineWireType.php @@ -4,7 +4,6 @@ namespace Thesis\Protobuf\Type\Visitor; -use Thesis\Protobuf\Internal\Wire\WireType; use Thesis\Protobuf\Type\BoolT; use Thesis\Protobuf\Type\DoubleT; use Thesis\Protobuf\Type\EnumT; @@ -24,6 +23,7 @@ use Thesis\Protobuf\Type\Uint32T; use Thesis\Protobuf\Type\Uint64T; use Thesis\Protobuf\Type\Visitor; +use Thesis\Protobuf\WireType; /** * @internal diff --git a/src/Type/Visitor/TypeDeserializerVisitor.php b/src/Type/Visitor/TypeDeserializerVisitor.php index 01ad728..b30571f 100644 --- a/src/Type/Visitor/TypeDeserializerVisitor.php +++ b/src/Type/Visitor/TypeDeserializerVisitor.php @@ -23,7 +23,7 @@ use Thesis\Protobuf\Internal\Serde\SerdeString; use Thesis\Protobuf\Internal\Serde\SerdeUint32; use Thesis\Protobuf\Internal\Serde\SerdeUint64; -use Thesis\Protobuf\Internal\Wire\Tag; +use Thesis\Protobuf\Tag; use Thesis\Protobuf\Type\BoolT; use Thesis\Protobuf\Type\DoubleT; use Thesis\Protobuf\Type\EnumT; diff --git a/src/Type/Visitor/TypeSerializerVisitor.php b/src/Type/Visitor/TypeSerializerVisitor.php index c5214f0..6368d1c 100644 --- a/src/Type/Visitor/TypeSerializerVisitor.php +++ b/src/Type/Visitor/TypeSerializerVisitor.php @@ -26,7 +26,7 @@ use Thesis\Protobuf\Internal\Serde\SerializeMessage; use Thesis\Protobuf\Internal\Serde\SerializeTag; use Thesis\Protobuf\Internal\Serde\SerializeValue; -use Thesis\Protobuf\Internal\Wire\Tag; +use Thesis\Protobuf\Tag; use Thesis\Protobuf\Type\BoolT; use Thesis\Protobuf\Type\DoubleT; use Thesis\Protobuf\Type\EnumT; diff --git a/src/UnknownField.php b/src/UnknownField.php new file mode 100644 index 0000000..bd9d484 --- /dev/null +++ b/src/UnknownField.php @@ -0,0 +1,18 @@ + $unknowns + */ + public function handle(object $message, array $unknowns): void; +} diff --git a/src/UnknownFieldHandler/OnUnknownFields.php b/src/UnknownFieldHandler/OnUnknownFields.php new file mode 100644 index 0000000..6657018 --- /dev/null +++ b/src/UnknownFieldHandler/OnUnknownFields.php @@ -0,0 +1,32 @@ +): void */ + private \Closure $function; + + /** + * @param callable(object, non-empty-list): void $function + */ + public function __construct( + callable $function, + ) { + $this->function = $function(...); + } + + #[\Override] + public function handle(object $message, array $unknowns): void + { + ($this->function)($message, $unknowns); + } +} diff --git a/src/UnknownFieldHandler/UnknownFields.php b/src/UnknownFieldHandler/UnknownFields.php new file mode 100644 index 0000000..f008b5f --- /dev/null +++ b/src/UnknownFieldHandler/UnknownFields.php @@ -0,0 +1,42 @@ + + */ + public static function of(object $message): array + { + return self::get()->map[$message] ?? []; + } + + #[\Override] + public function handle(object $message, array $unknowns): void + { + $this->map[$message] = $unknowns; + } + + /** + * @param \WeakMap> $map + */ + private function __construct( + private \WeakMap $map = new \WeakMap(), + ) {} +} diff --git a/src/Internal/Wire/WireType.php b/src/WireType.php similarity index 73% rename from src/Internal/Wire/WireType.php rename to src/WireType.php index f93ee85..5c57f91 100644 --- a/src/Internal/Wire/WireType.php +++ b/src/WireType.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Thesis\Protobuf\Internal\Wire; +namespace Thesis\Protobuf; /** - * @internal + * @api */ enum WireType: int { diff --git a/src/constructors.php b/src/constructors.php index cd31a9e..31c0796 100644 --- a/src/constructors.php +++ b/src/constructors.php @@ -202,7 +202,7 @@ function fieldT(int $num, Type $type): Type\Field */ function message(FieldDescriptor ...$fields): Message { - return new Message(...$fields); + return new Message($fields); } /** @@ -214,7 +214,7 @@ function message(FieldDescriptor ...$fields): Message function messageOf(FieldDescriptor ...$fields): Value { return Value::message( - new Message(...$fields), + new Message($fields), ); } diff --git a/tests/DiscardUnknownFieldsTest.php b/tests/DiscardUnknownFieldsTest.php deleted file mode 100644 index 01161ce..0000000 --- a/tests/DiscardUnknownFieldsTest.php +++ /dev/null @@ -1,59 +0,0 @@ -assertUnknownFieldSkipped(fieldOf(2, int32Of(99))); - } - - public function testUnknownFixed32Field(): void - { - $this->assertUnknownFieldSkipped(fieldOf(2, fixed32Of(42))); - } - - public function testUnknownFixed64Field(): void - { - $this->assertUnknownFieldSkipped(fieldOf(2, fixed64Of(new Number(42)))); - } - - public function testUnknownBytesField(): void - { - $this->assertUnknownFieldSkipped(fieldOf(2, stringOf('unknown'))); - } - - /** - * @param FieldDescriptor<*> $unknownField - */ - private function assertUnknownFieldSkipped(FieldDescriptor $unknownField): void - { - $serializer = new Serializer(); - - $bytes = $serializer->serialize(message( - fieldOf(1, int32Of(42)), - $unknownField, - fieldOf(3, stringOf('hello')), - )); - - $message = $serializer->deserialize( - messageT( - fieldT(1, int32T), - fieldT(3, stringT), - ), - $bytes, - ); - - self::assertCount(2, $message); - self::assertSame(42, $message->fields[1]->value->value); // @phpstan-ignore offsetAccess.notFound - self::assertSame('hello', $message->fields[3]->value->value); // @phpstan-ignore offsetAccess.notFound - } -} diff --git a/tests/ScalarCardinalityTest.php b/tests/ScalarCardinalityTest.php index c75690d..cc87afe 100644 --- a/tests/ScalarCardinalityTest.php +++ b/tests/ScalarCardinalityTest.php @@ -24,7 +24,6 @@ use Thesis\Protobuf\Internal\Serde\SerdeUint32; use Thesis\Protobuf\Internal\Serde\SerdeUint64; use Thesis\Protobuf\Internal\Serde\SerializeTag; -use Thesis\Protobuf\Internal\Wire\Tag; use Thesis\Protobuf\Type\Visitor\DetermineWireType; use Thesis\Protobuf\Type\Visitor\TypeDeserializerVisitor; use Thesis\Protobuf\Type\Visitor\TypeSerializerVisitor; diff --git a/tests/TagTest.php b/tests/TagTest.php index 5caea89..5ce59c9 100644 --- a/tests/TagTest.php +++ b/tests/TagTest.php @@ -7,8 +7,6 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; -use Thesis\Protobuf\Internal\Wire\Tag; -use Thesis\Protobuf\Internal\Wire\WireType; #[CoversClass(Tag::class)] final class TagTest extends TestCase diff --git a/tests/UnknownHandlerTest.php b/tests/UnknownHandlerTest.php new file mode 100644 index 0000000..8b90df5 --- /dev/null +++ b/tests/UnknownHandlerTest.php @@ -0,0 +1,94 @@ +withUnknownHandler(UnknownFields::get()) + ->build(); + + $bytes = $encoder->encode(new UnknownHandlerTestFullMessage('kafkiansky', 30)); + $decoded = $decoder->decode($bytes, UnknownHandlerTestPartialMessage::class); + + self::assertSame('kafkiansky', $decoded->name); + + $unknowns = UnknownFields::of($decoded); + self::assertCount(1, $unknowns); + self::assertSame(2, $unknowns[0]->tag->num); + self::assertSame(WireType::VARINT, $unknowns[0]->tag->type); + } + + public function testOnUnknownFieldsHandler(): void + { + /** @var list}> $captured */ + $captured = []; + + $encoder = Encoder\Builder::buildDefault(); + $decoder = new Builder() + ->withUnknownHandler(new OnUnknownFields( + static function (object $message, array $unknowns) use (&$captured): void { + $captured[] = [$message, $unknowns]; + }, + )) + ->build(); + + $bytes = $encoder->encode(new UnknownHandlerTestFullMessage('kafkiansky', 42)); + $decoded = $decoder->decode($bytes, UnknownHandlerTestPartialMessage::class); + + self::assertSame('kafkiansky', $decoded->name); + self::assertCount(1, $captured); + self::assertSame($decoded, $captured[0][0]); + self::assertSame(2, $captured[0][1][0]->tag->num); + } + + public function testNoUnknownFieldsProducesEmptyResult(): void + { + $encoder = Encoder\Builder::buildDefault(); + $decoder = new Builder() + ->withUnknownHandler(UnknownFields::get()) + ->build(); + + $bytes = $encoder->encode(new UnknownHandlerTestPartialMessage('kafkiansky')); + $decoded = $decoder->decode($bytes, UnknownHandlerTestPartialMessage::class); + + self::assertSame([], UnknownFields::of($decoded)); + } +} + +/** + * @internal + */ +final readonly class UnknownHandlerTestFullMessage +{ + public function __construct( + #[Reflection\Field(1, Reflection\StringT::T)] + public string $name, + #[Reflection\Field(2, Reflection\Int32T::T)] + public int $age, + ) {} +} + +/** + * @internal + */ +final readonly class UnknownHandlerTestPartialMessage +{ + public function __construct( + #[Reflection\Field(1, Reflection\StringT::T)] + public string $name = '', + ) {} +} From 678cfc61c32fadfc9ec79a00772d8bfad32e0811 Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Thu, 9 Apr 2026 16:43:07 +0300 Subject: [PATCH 4/6] chore: fix test cs --- tests/UnknownHandlerTest.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/UnknownHandlerTest.php b/tests/UnknownHandlerTest.php index 8b90df5..60d3296 100644 --- a/tests/UnknownHandlerTest.php +++ b/tests/UnknownHandlerTest.php @@ -6,7 +6,6 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Thesis\Protobuf\Decoder\Builder; use Thesis\Protobuf\UnknownFieldHandler\OnUnknownFields; use Thesis\Protobuf\UnknownFieldHandler\UnknownFields; @@ -17,7 +16,7 @@ final class UnknownHandlerTest extends TestCase public function testUnknownFieldsHandler(): void { $encoder = Encoder\Builder::buildDefault(); - $decoder = new Builder() + $decoder = new Decoder\Builder() ->withUnknownHandler(UnknownFields::get()) ->build(); @@ -38,7 +37,7 @@ public function testOnUnknownFieldsHandler(): void $captured = []; $encoder = Encoder\Builder::buildDefault(); - $decoder = new Builder() + $decoder = new Decoder\Builder() ->withUnknownHandler(new OnUnknownFields( static function (object $message, array $unknowns) use (&$captured): void { $captured[] = [$message, $unknowns]; @@ -58,7 +57,7 @@ static function (object $message, array $unknowns) use (&$captured): void { public function testNoUnknownFieldsProducesEmptyResult(): void { $encoder = Encoder\Builder::buildDefault(); - $decoder = new Builder() + $decoder = new Decoder\Builder() ->withUnknownHandler(UnknownFields::get()) ->build(); From c86dc2f25e3dbedb894f36915d907d51e6b919f4 Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Thu, 9 Apr 2026 16:51:07 +0300 Subject: [PATCH 5/6] chore: fix README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 010a860..70eaf2a 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ This is convenient for logging without keeping the data in memory: ```php use Thesis\Protobuf\Decoder; +use Thesis\Protobuf\UnknownField; use Thesis\Protobuf\UnknownFieldHandler\OnUnknownFields; $decoder = new Decoder\Builder() @@ -143,7 +144,7 @@ $decoder = new Decoder\Builder() $logger->warning('Unknown fields detected', [ 'class' => $message::class, 'fields' => array_map( - static fn($f) => $f->tag->num, + static fn(UnknownField $f) => $f->tag->num, $unknowns, ), ]); From c6258bdb34d892b5e74342e72aa48dd94d0b7f35 Mon Sep 17 00:00:00 2001 From: kafkiansky Date: Thu, 9 Apr 2026 19:59:27 +0300 Subject: [PATCH 6/6] chore: fix naming --- README.md | 19 +++--- src/Decoder/Builder.php | 6 +- src/Internal/Wire/wire.php | 2 +- src/Message.php | 2 +- src/Reflection/Reflector.php | 5 +- src/UnknownFieldHandler/UnknownFields.php | 42 ------------ src/UnknownFields.php | 49 +++++++++++++ .../Handler.php} | 4 +- src/{ => UnknownFields}/UnknownField.php | 3 +- .../UnknownFieldsCallback.php} | 7 +- tests/UnknownHandlerTest.php | 68 +++++++++++++++++-- 11 files changed, 133 insertions(+), 74 deletions(-) delete mode 100644 src/UnknownFieldHandler/UnknownFields.php create mode 100644 src/UnknownFields.php rename src/{UnknownFieldHandler.php => UnknownFields/Handler.php} (76%) rename src/{ => UnknownFields}/UnknownField.php (76%) rename src/{UnknownFieldHandler/OnUnknownFields.php => UnknownFields/UnknownFieldsCallback.php} (72%) diff --git a/README.md b/README.md index 70eaf2a..acd0c28 100644 --- a/README.md +++ b/README.md @@ -105,10 +105,10 @@ Once the object is garbage collected, the unknown fields are automatically clean ```php use Thesis\Protobuf\Decoder; -use Thesis\Protobuf\UnknownFieldHandler\UnknownFields; +use Thesis\Protobuf\UnknownFields; $decoder = new Decoder\Builder() - ->withUnknownHandler(UnknownFields::get()) + ->withUnknownHandler(UnknownFields::handler()) ->build(); $request = $decoder->decode($buffer, CreateUserRequest::class); @@ -130,21 +130,20 @@ $unknowns = UnknownFields::of($request->nested); #### Using a callback -The `OnUnknownFields` handler calls a user-defined function each time unknown fields are detected. +The `UnknownFieldsCallback` handler calls a user-defined function each time unknown fields are detected. This is convenient for logging without keeping the data in memory: ```php use Thesis\Protobuf\Decoder; -use Thesis\Protobuf\UnknownField; -use Thesis\Protobuf\UnknownFieldHandler\OnUnknownFields; +use Thesis\Protobuf\UnknownFields; $decoder = new Decoder\Builder() - ->withUnknownHandler(new OnUnknownFields( + ->withUnknownHandler(new UnknownFields\UnknownFieldsCallback( static function (object $message, array $unknowns): void { $logger->warning('Unknown fields detected', [ 'class' => $message::class, 'fields' => array_map( - static fn(UnknownField $f) => $f->tag->num, + static fn(UnknownFields\UnknownField $f) => $f->tag->num, $unknowns, ), ]); @@ -155,12 +154,12 @@ $decoder = new Decoder\Builder() #### Custom handler -You can implement the `UnknownFieldHandler` interface to define your own strategy: +You can implement the `UnknownFields\Handler` interface to define your own strategy: ```php -use Thesis\Protobuf\UnknownFieldHandler; +use Thesis\Protobuf\UnknownFields; -final readonly class MyHandler implements UnknownFieldHandler +final readonly class MyHandler implements UnknownFields\Handler { #[\Override] public function handle(object $message, array $unknowns): void diff --git a/src/Decoder/Builder.php b/src/Decoder/Builder.php index 80f1c8e..3694b9c 100644 --- a/src/Decoder/Builder.php +++ b/src/Decoder/Builder.php @@ -9,7 +9,7 @@ use Thesis\Protobuf\Decoder\Internal\ReflectionDecoder; use Thesis\Protobuf\Reflection\Reflector; use Thesis\Protobuf\Serializer; -use Thesis\Protobuf\UnknownFieldHandler; +use Thesis\Protobuf\UnknownFields; /** * @api @@ -18,7 +18,7 @@ final class Builder { private ?CacheInterface $cache = null; - private ?UnknownFieldHandler $unknowns = null; + private ?UnknownFields\Handler $unknowns = null; public function withCache(CacheInterface $cache): self { @@ -28,7 +28,7 @@ public function withCache(CacheInterface $cache): self return $builder; } - public function withUnknownHandler(UnknownFieldHandler $handler): self + public function withUnknownHandler(UnknownFields\Handler $handler): self { $builder = clone $this; $builder->unknowns = $handler; diff --git a/src/Internal/Wire/wire.php b/src/Internal/Wire/wire.php index 6c69e11..df55594 100644 --- a/src/Internal/Wire/wire.php +++ b/src/Internal/Wire/wire.php @@ -10,7 +10,7 @@ use Thesis\Protobuf\Internal\Buffer\WriteBuffer; use Thesis\Protobuf\Internal\Serde\SerdeVarint; use Thesis\Protobuf\Tag; -use Thesis\Protobuf\UnknownField; +use Thesis\Protobuf\UnknownFields\UnknownField; use Thesis\Protobuf\WireType; use Thesis\Varint; diff --git a/src/Message.php b/src/Message.php index b1da833..ed0707a 100644 --- a/src/Message.php +++ b/src/Message.php @@ -18,7 +18,7 @@ /** * @no-named-arguments * @param list> $fields - * @param list $unknowns + * @param list $unknowns */ public function __construct( array $fields = [], diff --git a/src/Reflection/Reflector.php b/src/Reflection/Reflector.php index 6e3bd4d..9679ad2 100644 --- a/src/Reflection/Reflector.php +++ b/src/Reflection/Reflector.php @@ -18,6 +18,7 @@ use Thesis\Protobuf\Reflection\Internal\Visitor\ToProtobufValueTypeVisitor; use Thesis\Protobuf\Reflection\Internal\Visitor\ToValueTypeVisitor; use Thesis\Protobuf\Type; +use Thesis\Protobuf\UnknownFields; /** * @api @@ -38,7 +39,7 @@ final class Reflector public static function build( ?CacheInterface $cache = null, - ?Protobuf\UnknownFieldHandler $unknowns = null, + ?UnknownFields\Handler $unknowns = null, ): self { return new self( cache: new Cache( @@ -50,7 +51,7 @@ public static function build( private function __construct( private readonly Cache $cache, - private readonly ?Protobuf\UnknownFieldHandler $unknowns = null, + private readonly ?UnknownFields\Handler $unknowns = null, ) { $this->typeVisitor = new ToProtobufTypeTypeVisitor($this); $this->valueVisitor = new ToProtobufValueTypeVisitor($this); diff --git a/src/UnknownFieldHandler/UnknownFields.php b/src/UnknownFieldHandler/UnknownFields.php deleted file mode 100644 index f008b5f..0000000 --- a/src/UnknownFieldHandler/UnknownFields.php +++ /dev/null @@ -1,42 +0,0 @@ - - */ - public static function of(object $message): array - { - return self::get()->map[$message] ?? []; - } - - #[\Override] - public function handle(object $message, array $unknowns): void - { - $this->map[$message] = $unknowns; - } - - /** - * @param \WeakMap> $map - */ - private function __construct( - private \WeakMap $map = new \WeakMap(), - ) {} -} diff --git a/src/UnknownFields.php b/src/UnknownFields.php new file mode 100644 index 0000000..b2a1582 --- /dev/null +++ b/src/UnknownFields.php @@ -0,0 +1,49 @@ + + */ + public static function of(object $message): array + { + return self::handler()->map[$message] ?? []; + } + + /** + * @param callable(UnknownFields\UnknownField): void $function + */ + public static function each(object $message, callable $function): void + { + foreach (self::of($message) as $field) { + $function($field); + } + } + + #[\Override] + public function handle(object $message, array $unknowns): void + { + $this->map[$message] = $unknowns; + } + + /** + * @param \WeakMap> $map + */ + private function __construct( + private \WeakMap $map = new \WeakMap(), + ) {} +} diff --git a/src/UnknownFieldHandler.php b/src/UnknownFields/Handler.php similarity index 76% rename from src/UnknownFieldHandler.php rename to src/UnknownFields/Handler.php index 6e1af91..6573928 100644 --- a/src/UnknownFieldHandler.php +++ b/src/UnknownFields/Handler.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Thesis\Protobuf; +namespace Thesis\Protobuf\UnknownFields; /** * @api */ -interface UnknownFieldHandler +interface Handler { /** * @param non-empty-list $unknowns diff --git a/src/UnknownField.php b/src/UnknownFields/UnknownField.php similarity index 76% rename from src/UnknownField.php rename to src/UnknownFields/UnknownField.php index bd9d484..8c9e8df 100644 --- a/src/UnknownField.php +++ b/src/UnknownFields/UnknownField.php @@ -2,9 +2,10 @@ declare(strict_types=1); -namespace Thesis\Protobuf; +namespace Thesis\Protobuf\UnknownFields; use BcMath\Number; +use Thesis\Protobuf\Tag; /** * @api diff --git a/src/UnknownFieldHandler/OnUnknownFields.php b/src/UnknownFields/UnknownFieldsCallback.php similarity index 72% rename from src/UnknownFieldHandler/OnUnknownFields.php rename to src/UnknownFields/UnknownFieldsCallback.php index 6657018..e8bb57c 100644 --- a/src/UnknownFieldHandler/OnUnknownFields.php +++ b/src/UnknownFields/UnknownFieldsCallback.php @@ -2,15 +2,12 @@ declare(strict_types=1); -namespace Thesis\Protobuf\UnknownFieldHandler; - -use Thesis\Protobuf\UnknownField; -use Thesis\Protobuf\UnknownFieldHandler; +namespace Thesis\Protobuf\UnknownFields; /** * @api */ -final readonly class OnUnknownFields implements UnknownFieldHandler +final readonly class UnknownFieldsCallback implements Handler { /** @var \Closure(object, non-empty-list): void */ private \Closure $function; diff --git a/tests/UnknownHandlerTest.php b/tests/UnknownHandlerTest.php index 60d3296..65985b8 100644 --- a/tests/UnknownHandlerTest.php +++ b/tests/UnknownHandlerTest.php @@ -6,18 +6,16 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use Thesis\Protobuf\UnknownFieldHandler\OnUnknownFields; -use Thesis\Protobuf\UnknownFieldHandler\UnknownFields; #[CoversClass(UnknownFields::class)] -#[CoversClass(OnUnknownFields::class)] +#[CoversClass(UnknownFields\UnknownFieldsCallback::class)] final class UnknownHandlerTest extends TestCase { public function testUnknownFieldsHandler(): void { $encoder = Encoder\Builder::buildDefault(); $decoder = new Decoder\Builder() - ->withUnknownHandler(UnknownFields::get()) + ->withUnknownHandler(UnknownFields::handler()) ->build(); $bytes = $encoder->encode(new UnknownHandlerTestFullMessage('kafkiansky', 30)); @@ -33,12 +31,12 @@ public function testUnknownFieldsHandler(): void public function testOnUnknownFieldsHandler(): void { - /** @var list}> $captured */ + /** @var list}> $captured */ $captured = []; $encoder = Encoder\Builder::buildDefault(); $decoder = new Decoder\Builder() - ->withUnknownHandler(new OnUnknownFields( + ->withUnknownHandler(new UnknownFields\UnknownFieldsCallback( static function (object $message, array $unknowns) use (&$captured): void { $captured[] = [$message, $unknowns]; }, @@ -54,11 +52,39 @@ static function (object $message, array $unknowns) use (&$captured): void { self::assertSame(2, $captured[0][1][0]->tag->num); } + public function testUnknownFieldsHandlerWithNestedMessage(): void + { + $encoder = Encoder\Builder::buildDefault(); + $decoder = new Decoder\Builder() + ->withUnknownHandler(UnknownFields::handler()) + ->build(); + + $bytes = $encoder->encode(new UnknownHandlerTestFullParent( + name: 'kafkiansky', + age: 30, + nested: new UnknownHandlerTestFullMessage('nested', 42), + )); + $decoded = $decoder->decode($bytes, UnknownHandlerTestPartialParent::class); + + self::assertSame('kafkiansky', $decoded->name); + + $parentUnknowns = UnknownFields::of($decoded); + self::assertCount(1, $parentUnknowns); + self::assertSame(2, $parentUnknowns[0]->tag->num); + self::assertSame(WireType::VARINT, $parentUnknowns[0]->tag->type); + + self::assertNotNull($decoded->nested); + $nestedUnknowns = UnknownFields::of($decoded->nested); + self::assertCount(1, $nestedUnknowns); + self::assertSame(2, $nestedUnknowns[0]->tag->num); + self::assertSame(WireType::VARINT, $nestedUnknowns[0]->tag->type); + } + public function testNoUnknownFieldsProducesEmptyResult(): void { $encoder = Encoder\Builder::buildDefault(); $decoder = new Decoder\Builder() - ->withUnknownHandler(UnknownFields::get()) + ->withUnknownHandler(UnknownFields::handler()) ->build(); $bytes = $encoder->encode(new UnknownHandlerTestPartialMessage('kafkiansky')); @@ -91,3 +117,31 @@ public function __construct( public string $name = '', ) {} } + +/** + * @internal + */ +final readonly class UnknownHandlerTestFullParent +{ + public function __construct( + #[Reflection\Field(1, Reflection\StringT::T)] + public string $name, + #[Reflection\Field(2, Reflection\Int32T::T)] + public int $age, + #[Reflection\Field(3, new Reflection\ObjectT(UnknownHandlerTestFullMessage::class))] + public UnknownHandlerTestFullMessage $nested, + ) {} +} + +/** + * @internal + */ +final readonly class UnknownHandlerTestPartialParent +{ + public function __construct( + #[Reflection\Field(1, Reflection\StringT::T)] + public string $name = '', + #[Reflection\Field(3, new Reflection\ObjectT(UnknownHandlerTestPartialMessage::class))] + public ?UnknownHandlerTestPartialMessage $nested = null, + ) {} +}