diff --git a/benchmarks/ComplexTypeBenchTrait.php b/benchmarks/ComplexTypeBenchTrait.php index fac22d7..5b9acc3 100644 --- a/benchmarks/ComplexTypeBenchTrait.php +++ b/benchmarks/ComplexTypeBenchTrait.php @@ -23,6 +23,7 @@ trait ComplexTypeBenchTrait /** @var list */ protected array $items; protected SoapResponse $literalResponse; + protected SoapResponse $literalSparseResponse; protected SoapResponse $encodedResponse; protected BenchSoapClient $extSoapLiteral; @@ -77,6 +78,13 @@ public function setUp(): void $this->encodedResponse = new SoapResponse( $this->encodedDriver->encode('test', [$this->singleItem])->getRequest() ); + // Real-world responses often leave most elements out: only 2 of the 12 elements are present here. + $this->literalSparseResponse = new SoapResponse( + '' + . '' + . '42
Springfield
' + . '
' + ); // ext-soap $this->extSoapLiteral = $this->buildExtSoapClient('literal'); diff --git a/benchmarks/DecodeBench.php b/benchmarks/DecodeBench.php index 1ace581..cfd113b 100644 --- a/benchmarks/DecodeBench.php +++ b/benchmarks/DecodeBench.php @@ -41,6 +41,22 @@ public function benchLiteral_x500(): void } } + #[Revs(100)] + #[Groups(['literal', 'php-soap', 'x1', 'sparse'])] + public function benchLiteralSparse_x1(): void + { + $this->literalDriver->decode('test', $this->literalSparseResponse); + } + + #[Groups(['literal', 'php-soap', 'x500', 'sparse'])] + public function benchLiteralSparse_x500(): void + { + $response = $this->literalSparseResponse; + foreach ($this->items as $item) { + $this->literalDriver->decode('test', $response); + } + } + // -- php-soap/encoding: encoded -- #[Revs(100)] diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 64ebd84..5b23bbf 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -13,9 +13,9 @@ use Soap\Engine\Metadata\Model\Type; use Soap\Engine\Metadata\Model\TypeMeta; use VeeWee\Reflecta\Iso\IsoInterface; +use VeeWee\Reflecta\Lens\Lens; use VeeWee\Reflecta\Lens\LensInterface; use function Psl\Vec\sort_by; -use function VeeWee\Reflecta\Lens\index; use function VeeWee\Reflecta\Lens\optional; use function VeeWee\Reflecta\Lens\property; @@ -137,12 +137,26 @@ private static function createDecoderLensForType( $lens = match(true) { $encoder instanceof Feature\DecoratingEncoder => self::createDecoderLensForType($shouldLensBeOptional, $name, $encoder->decoratedEncoder(), $type, $property), $encoder instanceof Feature\ProvidesObjectDecoderLens => $encoder::createObjectDecoderLens($type, $property), - default => index($name), + default => self::createLookupLens($name), }; return $shouldLensBeOptional ? optional($lens) : $lens; } + /** + * Resolves absent nodes to null instead of throwing like index() does: + * absent optional elements and attributes are common in large responses, + * and building + catching an exception for each of them dominates decoding time. + * + * @return LensInterface|null, array|null, mixed, mixed> + */ + private static function createLookupLens(string $name): LensInterface + { + return Lens::readonly( + static fn (?array $data): mixed => $data[$name] ?? null + ); + } + private static function shouldLensBeOptional(TypeMeta $meta): bool { if ($meta->isNullable()->unwrapOr(false)) { diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index 619cf32..d86da09 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -12,6 +12,7 @@ use Soap\Encoding\Xml\Writer\XsdTypeXmlElementWriter; use Soap\Encoding\Xml\Writer\XsiAttributeBuilder; use Soap\Engine\Metadata\Model\Property; +use stdClass; use VeeWee\Reflecta\Iso\Iso; use VeeWee\Reflecta\Lens\LensInterface; use function is_array; @@ -125,32 +126,39 @@ static function (string $normalizePropertyName, Property $property) use ($object private function from(Context $context, ObjectAccess $objectAccess, Element $data): object { $nodes = (new DocumentToLookupArrayReader())($data); + $values = map_with_key( + $objectAccess->properties, + static function (string $normalizePropertyName, Property $property) use ($objectAccess, $nodes): mixed { + $type = $property->getType(); + $meta = $type->getMeta(); + + /** @var string|null $value */ + $value = self::runLens( + $objectAccess->decoderLenses[$normalizePropertyName], + $nodes, + ); + $iso = $objectAccess->isos[$normalizePropertyName]; + $defaultValue = $meta->isList()->unwrapOr(false) ? [] : null; + + /** @psalm-suppress PossiblyNullArgument */ + return match(true) { + $meta->isAttribute()->unwrapOr(false) => $iso->from($value), + default => $value !== null ? $iso->from($value) : $defaultValue, + }; + }, + ); + + // stdClass has no declared properties: reflection-based hydration would build and catch + // an exception for every single property before falling back to a dynamic one. + if ($this->className === stdClass::class) { + /** @var TObj */ + return (object) $values; + } + /** @var Iso, array> $objectData */ $objectData = object_data($this->className); - return $objectData->from( - map_with_key( - $objectAccess->properties, - static function (string $normalizePropertyName, Property $property) use ($objectAccess, $nodes): mixed { - $type = $property->getType(); - $meta = $type->getMeta(); - - /** @var string|null $value */ - $value = self::runLens( - $objectAccess->decoderLenses[$normalizePropertyName], - $nodes, - ); - $iso = $objectAccess->isos[$normalizePropertyName]; - $defaultValue = $meta->isList()->unwrapOr(false) ? [] : null; - - /** @psalm-suppress PossiblyNullArgument */ - return match(true) { - $meta->isAttribute()->unwrapOr(false) => $iso->from($value), - default => $value !== null ? $iso->from($value) : $defaultValue, - }; - }, - ) - ); + return $objectData->from($values); } private static function runLens(LensInterface $lens, mixed $data, mixed $default = null): mixed diff --git a/tests/Unit/Encoder/ObjectEncoderTest.php b/tests/Unit/Encoder/ObjectEncoderTest.php index b6f9fc3..dcfab6f 100644 --- a/tests/Unit/Encoder/ObjectEncoderTest.php +++ b/tests/Unit/Encoder/ObjectEncoderTest.php @@ -212,6 +212,50 @@ public function test_it_can_decode_from_xml_item(): void static::assertEquals($expected, $actual); } + public function test_it_decodes_absent_elements_as_null(): void + { + $encoder = new ObjectEncoder(stdClass::class); + $xsdType = XsdType::create('user') + ->withXmlNamespace("https://test") + ->withXmlNamespaceName('test') + ->withXmlTargetNodeName('user') + ->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsQualified(true)); + $context = self::createContext($xsdType, self::buildTypes()); + + $actual = $encoder->iso($context)->from(Element::fromString('')); + + static::assertEquals( + (object)[ + 'active' => null, + 'hat' => (object)[ + 'color' => null, + ], + ], + $actual + ); + } + + public function test_it_decodes_absent_attributes_as_null(): void + { + $encoder = new ObjectEncoder(stdClass::class); + $xsdType = XsdType::create('user') + ->withXmlNamespace("https://test") + ->withXmlNamespaceName('test') + ->withXmlTargetNodeName('user') + ->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsQualified(true)); + $context = self::createContext($xsdType, self::buildTypes(activeAsAttribute: true)); + + $actual = $encoder->iso($context)->from(Element::fromString('')); + + static::assertEquals( + (object)[ + 'active' => null, + 'hat' => null, + ], + $actual + ); + } + public function test_it_can_encode_from_array(): void { $encoder = new ObjectEncoder(stdClass::class);