From 755bfc1f533ef94cc8c75e1929349366d05d1dd7 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 20:35:28 +0200 Subject: [PATCH 1/8] Read the xsi namespace once instead of per decoded element Xmlns::xsi() builds a new object on every call. OptionalElementEncoder now resolves it once per iso, and DocumentToLookupArrayReader compares against a class constant. --- src/Encoder/OptionalElementEncoder.php | 6 ++++-- src/Xml/Reader/DocumentToLookupArrayReader.php | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Encoder/OptionalElementEncoder.php b/src/Encoder/OptionalElementEncoder.php index 78078f8..6201aac 100644 --- a/src/Encoder/OptionalElementEncoder.php +++ b/src/Encoder/OptionalElementEncoder.php @@ -42,6 +42,8 @@ public function iso(Context $context): IsoInterface return $elementIso; } + $xsiNamespace = Xmlns::xsi()->value(); + return new Iso( /** * @param T|null $raw @@ -54,7 +56,7 @@ public function iso(Context $context): IsoInterface /** * @return T|null */ - static function (ElementList|Element|string $xml) use ($elementIso, $isList) : mixed { + static function (ElementList|Element|string $xml) use ($elementIso, $isList, $xsiNamespace) : mixed { if ($xml === '') { return null; } @@ -70,7 +72,7 @@ static function (ElementList|Element|string $xml) use ($elementIso, $isList) : m $parsedXml instanceof Element => $parsedXml->element(), default => null }; - if ($documentElement && $documentElement->getAttributeNS(Xmlns::xsi()->value(), 'nil') === 'true') { + if ($documentElement && $documentElement->getAttributeNS($xsiNamespace, 'nil') === 'true') { return null; } diff --git a/src/Xml/Reader/DocumentToLookupArrayReader.php b/src/Xml/Reader/DocumentToLookupArrayReader.php index dd6b7b1..044e18b 100644 --- a/src/Xml/Reader/DocumentToLookupArrayReader.php +++ b/src/Xml/Reader/DocumentToLookupArrayReader.php @@ -7,7 +7,6 @@ use Dom\Node; use Soap\Encoding\Xml\Node\Element; use Soap\Encoding\Xml\Node\ElementList; -use VeeWee\Xml\Xmlns\Xmlns; use function VeeWee\Xml\Dom\Predicate\is_element; /** @@ -16,6 +15,8 @@ */ final class DocumentToLookupArrayReader { + private const XSI_NAMESPACE = 'http://www.w3.org/2001/XMLSchema-instance'; + /** * @return LookupArray */ @@ -52,7 +53,7 @@ public function __invoke(Element $xml): array // It might be possible that the child is a regular textNode. // In that case, we use '_' as the key and the value of the textNode as value. - if (!$nodes && $root->getAttributeNS(Xmlns::xsi()->value(), 'nil') !== 'true') { + if (!$nodes && $root->getAttributeNS(self::XSI_NAMESPACE, 'nil') !== 'true') { $nodes['_'] = $root->textContent ?? ''; } From 353a61c68fcbbc7fe2c390d5035138897a782673 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 20:36:06 +0200 Subject: [PATCH 2/8] Read element values from the node directly Locator\Node\value() reads substitutedNodeValue and coerces it to a string, which it already is on a Dom\Element. Reading the property skips the function call and the Psl coercion. --- src/Xml/Reader/ElementValueReader.php | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/Xml/Reader/ElementValueReader.php b/src/Xml/Reader/ElementValueReader.php index e5865c3..48c2210 100644 --- a/src/Xml/Reader/ElementValueReader.php +++ b/src/Xml/Reader/ElementValueReader.php @@ -7,8 +7,6 @@ use Soap\Encoding\Encoder\Context; use Soap\Encoding\Encoder\XmlEncoder; use VeeWee\Reflecta\Iso\IsoInterface; -use function Psl\Type\string; -use function VeeWee\Xml\Dom\Locator\Node\value as readValue; final class ElementValueReader { @@ -30,9 +28,7 @@ public function __invoke( */ public static function forEncoder(Context $context, XmlEncoder $encoder, Element $element): mixed { - return $encoder->iso($context)->from( - readValue($element, string()) - ); + return $encoder->iso($context)->from($element->substitutedNodeValue); } /** @@ -41,8 +37,6 @@ public static function forEncoder(Context $context, XmlEncoder $encoder, Element */ public static function forIso(IsoInterface $iso, Element $element): mixed { - return $iso->from( - readValue($element, string()) - ); + return $iso->from($element->substitutedNodeValue); } } From 85c702f395b307d830847e02a4c0397669dfc742 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 18:34:29 +0200 Subject: [PATCH 3/8] Read built in decoder properties from ElementLookup directly Every property of a decoded object went through Lens::readonly, its closure and the try/catch in runLens, only to call element(), attribute() or text() on the lookup. ObjectAccess now records per property how the built in lens reads it, and ObjectEncoder::from makes that call itself. Those three calls cannot throw, so the try/catch had nothing to catch for them. Custom ProvidesObjectDecoderLens lenses keep going through runLens. The built in lenses stay in decoderLenses and are built from the same record, so they read exactly what the direct call reads. --- src/Encoder/ObjectAccess.php | 46 ++++++-- src/Encoder/ObjectEncoder.php | 15 ++- .../Encoder/ObjectAccessDecodeReadTest.php | 110 ++++++++++++++++++ 3 files changed, 155 insertions(+), 16 deletions(-) create mode 100644 tests/Unit/Encoder/ObjectAccessDecodeReadTest.php diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 5195c88..5bfef5d 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -23,6 +23,10 @@ final class ObjectAccess { + public const DECODE_ELEMENT = 0; + public const DECODE_ATTRIBUTE = 1; + public const DECODE_TEXT = 2; + /** * Everything the object encoder needs per property of one type, worked out once and cached per type. * Every array is keyed by the normalized PHP property name. @@ -35,6 +39,7 @@ final class ObjectAccess * @param array $isAttribute Whether the property is an XML attribute instead of a child element. * @param array|null> $decodeDefaultValues The value a property decodes to when its element is missing from the XML. * @param array $omitWhenAbsent Whether encoding leaves the element out, instead of writing xsi:nil, when the property is absent from the PHP value. + * @param array $decodeReads How the built in decoder lens reads the property from the ElementLookup: what, local name and namespace. Null for a custom decoder lens. */ public function __construct( public readonly array $properties, @@ -45,6 +50,7 @@ public function __construct( public readonly array $isAttribute, public readonly array $decodeDefaultValues, public readonly array $omitWhenAbsent, + public readonly array $decodeReads, ) { } @@ -91,6 +97,7 @@ private static function build(Context $context): self $isAttribute = []; $decodeDefaultValues = []; $omitWhenAbsent = []; + $decodeReads = []; $isAnyPropertyQualified = false; foreach ($sortedProperties as $property) { @@ -106,8 +113,10 @@ private static function build(Context $context): self $customEncoderLens = self::createCustomEncoderLens($shouldLensBeOptional, $encoder, $type, $property); $encoderLenses[$normalizedName] = $customEncoderLens ?? optional_property($normalizedName); - $decoderLenses[$normalizedName] = self::createCustomDecoderLens($shouldLensBeOptional, $encoder, $type, $property) - ?? self::createDecoderLens($name, $propertyType); + $customDecoderLens = self::createCustomDecoderLens($shouldLensBeOptional, $encoder, $type, $property); + $decodeRead = self::decodeRead($name, $propertyType); + $decoderLenses[$normalizedName] = $customDecoderLens ?? self::createDecoderLens($decodeRead); + $decodeReads[$normalizedName] = $customDecoderLens === null ? $decodeRead : null; $isos[$normalizedName] = $encoder->iso($propertyContext); $isAttribute[$normalizedName] = $propertyTypeMeta->isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; @@ -125,6 +134,7 @@ private static function build(Context $context): self $isAttribute, $decodeDefaultValues, $omitWhenAbsent, + $decodeReads, ); } @@ -163,22 +173,36 @@ private static function createCustomEncoderLens( } /** - * @return LensInterface + * @return array{self::DECODE_*, string, string} */ - private static function createDecoderLens(string $name, XsdType $type): LensInterface + private static function decodeRead(string $name, XsdType $type): array { $meta = $type->getMeta(); if ($meta->isAttribute()->unwrapOr(false)) { - $namespace = $meta->isQualified()->unwrapOr(false) ? $type->getXmlTargetNamespace() : ''; - - return Lens::readonly(static fn (ElementLookup $lookup): ?string => $lookup->attribute($name, $namespace)); + return [ + self::DECODE_ATTRIBUTE, + $name, + $meta->isQualified()->unwrapOr(false) ? $type->getXmlTargetNamespace() : '', + ]; } - if ($name === '_') { - return Lens::readonly(static fn (ElementLookup $lookup): ?string => $lookup->text()); - } + return [$name === '_' ? self::DECODE_TEXT : self::DECODE_ELEMENT, $name, '']; + } + + /** + * @param array{self::DECODE_*, string, string} $read + * + * @return LensInterface + */ + private static function createDecoderLens(array $read): LensInterface + { + [$kind, $name, $namespace] = $read; - return Lens::readonly(static fn (ElementLookup $lookup): Element|ElementList|null => $lookup->element($name)); + return match ($kind) { + self::DECODE_ATTRIBUTE => Lens::readonly(static fn (ElementLookup $lookup): ?string => $lookup->attribute($name, $namespace)), + self::DECODE_TEXT => Lens::readonly(static fn (ElementLookup $lookup): ?string => $lookup->text()), + self::DECODE_ELEMENT => Lens::readonly(static fn (ElementLookup $lookup): Element|ElementList|null => $lookup->element($name)), + }; } /** diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index c8ae806..97e846a 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -5,6 +5,7 @@ use Exception; use Soap\Encoding\Xml\Node\Element; +use Soap\Encoding\Xml\Node\ElementList; use Soap\Encoding\Xml\Node\ElementLookup; use Soap\Encoding\Xml\Writer\AttributeBuilder; use Soap\Encoding\Xml\Writer\NilAttributeBuilder; @@ -127,11 +128,15 @@ private function from(Context $context, ObjectAccess $objectAccess, Element $dat $result = []; foreach ($objectAccess->isos as $normalizePropertyName => $iso) { - /** @var string|null $value */ - $value = self::runLens( - $objectAccess->decoderLenses[$normalizePropertyName], - $lookup, - ); + $read = $objectAccess->decodeReads[$normalizePropertyName]; + /** @var Element|ElementList|string|null $value */ + $value = $read === null + ? self::runLens($objectAccess->decoderLenses[$normalizePropertyName], $lookup) + : match ($read[0]) { + ObjectAccess::DECODE_ELEMENT => $lookup->element($read[1]), + ObjectAccess::DECODE_ATTRIBUTE => $lookup->attribute($read[1], $read[2]), + ObjectAccess::DECODE_TEXT => $lookup->text(), + }; /** * @var mixed diff --git a/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php b/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php new file mode 100644 index 0000000..8d3548b --- /dev/null +++ b/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + XML; + + #[Test] + public function built_in_properties_decode_by_element_attribute_or_text_and_custom_lenses_by_their_lens(): void + { + static::assertSame( + [ + 'plain' => [ObjectAccess::DECODE_ATTRIBUTE, 'plain', ''], + 'qualified' => [ObjectAccess::DECODE_ATTRIBUTE, 'qualified', 'https://test'], + 'lang' => [ObjectAccess::DECODE_ATTRIBUTE, 'lang', 'http://www.w3.org/XML/1998/namespace'], + 'element' => [ObjectAccess::DECODE_ELEMENT, 'element', ''], + 'list' => [ObjectAccess::DECODE_ELEMENT, 'list', ''], + 'custom' => null, + ], + ObjectAccess::forContext(self::context('mixedType'))->decodeReads + ); + static::assertSame( + ['plain' => [ObjectAccess::DECODE_ATTRIBUTE, 'plain', ''], '_' => [ObjectAccess::DECODE_TEXT, '_', '']], + ObjectAccess::forContext(self::context('textType'))->decodeReads + ); + } + + #[Test] + public function built_in_decoder_lenses_read_the_same_value_as_the_direct_read(): void + { + $mixed = ObjectAccess::forContext(self::context('mixedType')); + $mixedLookup = ElementLookup::fromElement(Element::fromString( + '' + . 'e12c' + )); + $text = ObjectAccess::forContext(self::context('textType')); + $textLookup = ElementLookup::fromElement(Element::fromString('content')); + + foreach ([[$mixed, $mixedLookup], [$text, $textLookup]] as [$access, $lookup]) { + foreach ($access->decodeReads as $property => $read) { + if ($read === null) { + continue; + } + + $direct = match ($read[0]) { + ObjectAccess::DECODE_ELEMENT => $lookup->element($read[1]), + ObjectAccess::DECODE_ATTRIBUTE => $lookup->attribute($read[1], $read[2]), + ObjectAccess::DECODE_TEXT => $lookup->text(), + }; + + static::assertNotNull($direct, $property); + static::assertSame($direct, $access->decoderLenses[$property]->get($lookup), $property); + } + } + } + + private static function context(string $typeName): Context + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:'.$typeName.'"'); + $registry = EncoderRegistry::default() + ->addComplexTypeConverter('https://test', 'customType', new ThrowingLensEncoder()); + + return new Context( + $metadata->getTypes()->fetchByNameAndXmlNamespace($typeName, 'https://test')->getXsdType(), + $metadata, + $registry, + self::buildNamespaces(), + BindingUse::LITERAL, + ); + } +} From d74891b53f37dd2a849b1f7b26e318498bcf5b5b Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 18:37:44 +0200 Subject: [PATCH 4/8] Compare built in decoder lenses with the direct read on absent values too Missing elements, attributes in another namespace and nil or mixed text all read null, and those are the reads where a lens and the direct call could differ. --- .../Encoder/ObjectAccessDecodeReadTest.php | 56 ++++++++++++------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php b/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php index 8d3548b..fdd716d 100644 --- a/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php +++ b/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php @@ -4,6 +4,7 @@ namespace Soap\Encoding\Test\Unit\Encoder; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Soap\Encoding\Encoder\Context; @@ -64,32 +65,45 @@ public function built_in_properties_decode_by_element_attribute_or_text_and_cust ); } - #[Test] - public function built_in_decoder_lenses_read_the_same_value_as_the_direct_read(): void + /** @return iterable */ + public static function lookups(): iterable { - $mixed = ObjectAccess::forContext(self::context('mixedType')); - $mixedLookup = ElementLookup::fromElement(Element::fromString( + yield 'every property present' => [ + 'mixedType', '' - . 'e12c' - )); - $text = ObjectAccess::forContext(self::context('textType')); - $textLookup = ElementLookup::fromElement(Element::fromString('content')); - - foreach ([[$mixed, $mixedLookup], [$text, $textLookup]] as [$access, $lookup]) { - foreach ($access->decodeReads as $property => $read) { - if ($read === null) { - continue; - } + . 'e12c', + ]; + yield 'every property absent' => ['mixedType', '']; + yield 'attributes in the wrong namespace' => [ + 'mixedType', + '', + ]; + yield 'text present' => ['textType', 'content']; + yield 'text nil' => [ + 'textType', + '', + ]; + yield 'text with child elements' => ['textType', '']; + } - $direct = match ($read[0]) { - ObjectAccess::DECODE_ELEMENT => $lookup->element($read[1]), - ObjectAccess::DECODE_ATTRIBUTE => $lookup->attribute($read[1], $read[2]), - ObjectAccess::DECODE_TEXT => $lookup->text(), - }; + #[Test, DataProvider('lookups')] + public function built_in_decoder_lenses_read_the_same_value_as_the_direct_read(string $typeName, string $xml): void + { + $access = ObjectAccess::forContext(self::context($typeName)); + $lookup = ElementLookup::fromElement(Element::fromString($xml)); - static::assertNotNull($direct, $property); - static::assertSame($direct, $access->decoderLenses[$property]->get($lookup), $property); + foreach ($access->decodeReads as $property => $read) { + if ($read === null) { + continue; } + + $direct = match ($read[0]) { + ObjectAccess::DECODE_ELEMENT => $lookup->element($read[1]), + ObjectAccess::DECODE_ATTRIBUTE => $lookup->attribute($read[1], $read[2]), + ObjectAccess::DECODE_TEXT => $lookup->text(), + }; + + static::assertSame($direct, $access->decoderLenses[$property]->get($lookup), $property); } } From 0caac8162eed6089514d7ea5d99f6beb2740bd98 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 08:26:23 +0200 Subject: [PATCH 5/8] Read every property through a plain closure instead of runLens --- src/Encoder/ObjectAccess.php | 89 ++++++++++--------- src/Encoder/ObjectEncoder.php | 29 +----- tests/Support/Throws/allowed-throws.php | 10 +-- .../Encoder/ObjectAccessDecodeReadTest.php | 53 ++++++----- 4 files changed, 79 insertions(+), 102 deletions(-) diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 5bfef5d..b655cf4 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -3,6 +3,8 @@ namespace Soap\Encoding\Encoder; +use Closure; +use Exception; use Soap\Encoding\Cache\ScopedCache; use Soap\Encoding\EncoderRegistry; use Soap\Encoding\Normalizer\PhpPropertyNameNormalizer; @@ -15,7 +17,6 @@ use Soap\Engine\Metadata\Model\TypeMeta; use Soap\Engine\Metadata\Model\XsdType; 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\optional; @@ -23,34 +24,28 @@ final class ObjectAccess { - public const DECODE_ELEMENT = 0; - public const DECODE_ATTRIBUTE = 1; - public const DECODE_TEXT = 2; - /** * Everything the object encoder needs per property of one type, worked out once and cached per type. * Every array is keyed by the normalized PHP property name. * * @param array $properties The metadata properties, attributes first. - * @param array> $encoderLenses Reads the property value from the PHP object on encode. - * @param array> $decoderLenses Reads the raw XML value from the element on decode. + * @param array $encodeReaders Reads the property value from the PHP object on encode, null when the lens fails. + * @param array $decodeReaders Reads the raw XML value from the element on decode, null when a custom decoder lens fails. * @param array> $isos Converts between the PHP value and its XML form. * @param bool $isAnyPropertyQualified Whether at least one property is namespace qualified. * @param array $isAttribute Whether the property is an XML attribute instead of a child element. * @param array|null> $decodeDefaultValues The value a property decodes to when its element is missing from the XML. * @param array $omitWhenAbsent Whether encoding leaves the element out, instead of writing xsi:nil, when the property is absent from the PHP value. - * @param array $decodeReads How the built in decoder lens reads the property from the ElementLookup: what, local name and namespace. Null for a custom decoder lens. */ public function __construct( public readonly array $properties, - public readonly array $encoderLenses, - public readonly array $decoderLenses, + public readonly array $encodeReaders, + public readonly array $decodeReaders, public readonly array $isos, public readonly bool $isAnyPropertyQualified, public readonly array $isAttribute, public readonly array $decodeDefaultValues, public readonly array $omitWhenAbsent, - public readonly array $decodeReads, ) { } @@ -91,13 +86,12 @@ private static function build(Context $context): self ); $normalizedProperties = []; - $encoderLenses = []; - $decoderLenses = []; + $encodeReaders = []; + $decodeReaders = []; $isos = []; $isAttribute = []; $decodeDefaultValues = []; $omitWhenAbsent = []; - $decodeReads = []; $isAnyPropertyQualified = false; foreach ($sortedProperties as $property) { @@ -112,11 +106,15 @@ private static function build(Context $context): self $normalizedProperties[$normalizedName] = $property; $customEncoderLens = self::createCustomEncoderLens($shouldLensBeOptional, $encoder, $type, $property); - $encoderLenses[$normalizedName] = $customEncoderLens ?? optional_property($normalizedName); + /** @var Closure(object): mixed $encodeReader */ + $encodeReader = $customEncoderLens !== null + ? self::readOrNull($customEncoderLens) + : optional_property($normalizedName)->get(...); + $encodeReaders[$normalizedName] = $encodeReader; $customDecoderLens = self::createCustomDecoderLens($shouldLensBeOptional, $encoder, $type, $property); - $decodeRead = self::decodeRead($name, $propertyType); - $decoderLenses[$normalizedName] = $customDecoderLens ?? self::createDecoderLens($decodeRead); - $decodeReads[$normalizedName] = $customDecoderLens === null ? $decodeRead : null; + $decodeReaders[$normalizedName] = $customDecoderLens !== null + ? self::readOrNull($customDecoderLens) + : self::createDecoderReader($name, $propertyType); $isos[$normalizedName] = $encoder->iso($propertyContext); $isAttribute[$normalizedName] = $propertyTypeMeta->isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; @@ -127,14 +125,13 @@ private static function build(Context $context): self return new self( $normalizedProperties, - $encoderLenses, - $decoderLenses, + $encodeReaders, + $decodeReaders, $isos, $isAnyPropertyQualified, $isAttribute, $decodeDefaultValues, $omitWhenAbsent, - $decodeReads, ); } @@ -173,36 +170,42 @@ private static function createCustomEncoderLens( } /** - * @return array{self::DECODE_*, string, string} + * Custom lenses may throw on a missing value, the encoder treats that as null. + * + * @template S + * + * @param LensInterface $lens + * + * @return Closure(S): mixed */ - private static function decodeRead(string $name, XsdType $type): array + private static function readOrNull(LensInterface $lens): Closure { - $meta = $type->getMeta(); - if ($meta->isAttribute()->unwrapOr(false)) { - return [ - self::DECODE_ATTRIBUTE, - $name, - $meta->isQualified()->unwrapOr(false) ? $type->getXmlTargetNamespace() : '', - ]; - } - - return [$name === '_' ? self::DECODE_TEXT : self::DECODE_ELEMENT, $name, '']; + return static function (mixed $data) use ($lens): mixed { + try { + return $lens->get($data); + } catch (Exception) { + return null; + } + }; } /** - * @param array{self::DECODE_*, string, string} $read - * - * @return LensInterface + * @return Closure(ElementLookup): mixed */ - private static function createDecoderLens(array $read): LensInterface + private static function createDecoderReader(string $name, XsdType $type): Closure { - [$kind, $name, $namespace] = $read; + $meta = $type->getMeta(); + if ($meta->isAttribute()->unwrapOr(false)) { + $namespace = $meta->isQualified()->unwrapOr(false) ? $type->getXmlTargetNamespace() : ''; - return match ($kind) { - self::DECODE_ATTRIBUTE => Lens::readonly(static fn (ElementLookup $lookup): ?string => $lookup->attribute($name, $namespace)), - self::DECODE_TEXT => Lens::readonly(static fn (ElementLookup $lookup): ?string => $lookup->text()), - self::DECODE_ELEMENT => Lens::readonly(static fn (ElementLookup $lookup): Element|ElementList|null => $lookup->element($name)), - }; + return static fn (ElementLookup $lookup): ?string => $lookup->attribute($name, $namespace); + } + + if ($name === '_') { + return static fn (ElementLookup $lookup): ?string => $lookup->text(); + } + + return static fn (ElementLookup $lookup): Element|ElementList|null => $lookup->element($name); } /** diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index 97e846a..b7b2640 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -3,16 +3,13 @@ namespace Soap\Encoding\Encoder; -use Exception; use Soap\Encoding\Xml\Node\Element; -use Soap\Encoding\Xml\Node\ElementList; use Soap\Encoding\Xml\Node\ElementLookup; use Soap\Encoding\Xml\Writer\AttributeBuilder; use Soap\Encoding\Xml\Writer\NilAttributeBuilder; use Soap\Encoding\Xml\Writer\XsdTypeXmlElementWriter; use Soap\Encoding\Xml\Writer\XsiAttributeBuilder; use VeeWee\Reflecta\Iso\Iso; -use VeeWee\Reflecta\Lens\LensInterface; use function is_array; use function Psl\Fun\lazy; use function VeeWee\Reflecta\Iso\object_data; @@ -86,10 +83,7 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ ]; foreach ($objectAccess->properties as $normalizePropertyName => $property) { /** @var mixed $value */ - $value = self::runLens( - $objectAccess->encoderLenses[$normalizePropertyName], - $data - ); + $value = $objectAccess->encodeReaders[$normalizePropertyName]($data); if ($value === null && $objectAccess->omitWhenAbsent[$normalizePropertyName] && !property_is_initialized($data, $normalizePropertyName) @@ -128,15 +122,8 @@ private function from(Context $context, ObjectAccess $objectAccess, Element $dat $result = []; foreach ($objectAccess->isos as $normalizePropertyName => $iso) { - $read = $objectAccess->decodeReads[$normalizePropertyName]; - /** @var Element|ElementList|string|null $value */ - $value = $read === null - ? self::runLens($objectAccess->decoderLenses[$normalizePropertyName], $lookup) - : match ($read[0]) { - ObjectAccess::DECODE_ELEMENT => $lookup->element($read[1]), - ObjectAccess::DECODE_ATTRIBUTE => $lookup->attribute($read[1], $read[2]), - ObjectAccess::DECODE_TEXT => $lookup->text(), - }; + /** @var string|null $value */ + $value = $objectAccess->decodeReaders[$normalizePropertyName]($lookup); /** * @var mixed @@ -151,14 +138,4 @@ private function from(Context $context, ObjectAccess $objectAccess, Element $dat return $objectData->from($result); } - - private static function runLens(LensInterface $lens, mixed $data, mixed $default = null): mixed - { - try { - /** @var mixed */ - return $lens->get($data); - } catch (Exception $e) { - return $default; - } - } } diff --git a/tests/Support/Throws/allowed-throws.php b/tests/Support/Throws/allowed-throws.php index cf173e1..fbce6da 100644 --- a/tests/Support/Throws/allowed-throws.php +++ b/tests/Support/Throws/allowed-throws.php @@ -13,21 +13,21 @@ [ 'test' => ObjectEncoderCustomLensTest::class.'::test_it_decodes_through_custom_lenses_that_throw_on_a_missing_element', 'throw' => ArrayAccessException::class, - 'via' => 'src/Encoder/ObjectEncoder.php -> VeeWee\\Reflecta\\Lens\\Lens->get', + 'via' => 'src/Encoder/ObjectAccess.php -> VeeWee\\Reflecta\\Lens\\Lens->get', 'throws' => 1, - 'reason' => 'The fixture is a third-party encoder whose decoder lens throws on a missing element, to prove custom lenses still get the optional() and runLens treatment.', + 'reason' => 'The fixture is a third-party encoder whose decoder lens throws on a missing element, to prove custom lenses still get the optional() and null on failure treatment.', ], [ 'test' => ObjectEncoderCustomLensTest::class.'::test_it_encodes_through_custom_lenses_that_throw_on_a_missing_property', 'throw' => UnreflectableException::class, - 'via' => 'src/Encoder/ObjectEncoder.php -> VeeWee\\Reflecta\\Lens\\Lens->get', + 'via' => 'src/Encoder/ObjectAccess.php -> VeeWee\\Reflecta\\Lens\\Lens->get', 'throws' => 1, - 'reason' => 'The fixture is a third-party encoder whose encoder lens throws on a missing property, to prove custom lenses still get the optional() and runLens treatment.', + 'reason' => 'The fixture is a third-party encoder whose encoder lens throws on a missing property, to prove custom lenses still get the optional() and null on failure treatment.', ], [ 'test' => ObjectPropertyPresenceTest::class.'::custom_lens_reading_null_keeps_writing_nil#lens throws on the absent property', 'throw' => UnreflectableException::class, - 'via' => 'src/Encoder/ObjectEncoder.php -> VeeWee\\Reflecta\\Lens\\Lens->get', + 'via' => 'src/Encoder/ObjectAccess.php -> VeeWee\\Reflecta\\Lens\\Lens->get', 'throws' => 1, 'reason' => 'The fixture is a third-party encoder whose encoder lens throws on a missing property, to prove that a failing custom lens keeps writing xsi:nil instead of counting as absent.', ], diff --git a/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php b/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php index fdd716d..184977b 100644 --- a/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php +++ b/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php @@ -3,6 +3,7 @@ namespace Soap\Encoding\Test\Unit\Encoder; +use Closure; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -15,6 +16,7 @@ use Soap\Encoding\Xml\Node\Element; use Soap\Encoding\Xml\Node\ElementLookup; use Soap\WsdlReader\Model\Definitions\BindingUse; +use function array_keys; #[CoversClass(ObjectAccess::class)] final class ObjectAccessDecodeReadTest extends TestCase @@ -46,23 +48,14 @@ final class ObjectAccessDecodeReadTest extends TestCase XML; #[Test] - public function built_in_properties_decode_by_element_attribute_or_text_and_custom_lenses_by_their_lens(): void + public function every_property_decodes_through_a_plain_reader(): void { - static::assertSame( - [ - 'plain' => [ObjectAccess::DECODE_ATTRIBUTE, 'plain', ''], - 'qualified' => [ObjectAccess::DECODE_ATTRIBUTE, 'qualified', 'https://test'], - 'lang' => [ObjectAccess::DECODE_ATTRIBUTE, 'lang', 'http://www.w3.org/XML/1998/namespace'], - 'element' => [ObjectAccess::DECODE_ELEMENT, 'element', ''], - 'list' => [ObjectAccess::DECODE_ELEMENT, 'list', ''], - 'custom' => null, - ], - ObjectAccess::forContext(self::context('mixedType'))->decodeReads - ); - static::assertSame( - ['plain' => [ObjectAccess::DECODE_ATTRIBUTE, 'plain', ''], '_' => [ObjectAccess::DECODE_TEXT, '_', '']], - ObjectAccess::forContext(self::context('textType'))->decodeReads - ); + $mixed = ObjectAccess::forContext(self::context('mixedType'))->decodeReaders; + $text = ObjectAccess::forContext(self::context('textType'))->decodeReaders; + + static::assertSame(['plain', 'qualified', 'lang', 'element', 'list', 'custom'], array_keys($mixed)); + static::assertSame(['plain', '_'], array_keys($text)); + static::assertContainsOnlyInstancesOf(Closure::class, [...$mixed, ...$text]); } /** @return iterable */ @@ -87,23 +80,27 @@ public static function lookups(): iterable } #[Test, DataProvider('lookups')] - public function built_in_decoder_lenses_read_the_same_value_as_the_direct_read(string $typeName, string $xml): void + public function built_in_decoder_readers_read_the_same_value_as_the_direct_read(string $typeName, string $xml): void { $access = ObjectAccess::forContext(self::context($typeName)); $lookup = ElementLookup::fromElement(Element::fromString($xml)); - foreach ($access->decodeReads as $property => $read) { - if ($read === null) { - continue; - } - - $direct = match ($read[0]) { - ObjectAccess::DECODE_ELEMENT => $lookup->element($read[1]), - ObjectAccess::DECODE_ATTRIBUTE => $lookup->attribute($read[1], $read[2]), - ObjectAccess::DECODE_TEXT => $lookup->text(), - }; + $direct = [ + 'mixedType' => [ + 'plain' => $lookup->attribute('plain', ''), + 'qualified' => $lookup->attribute('qualified', 'https://test'), + 'lang' => $lookup->attribute('lang', 'http://www.w3.org/XML/1998/namespace'), + 'element' => $lookup->element('element'), + 'list' => $lookup->element('list'), + ], + 'textType' => [ + 'plain' => $lookup->attribute('plain', ''), + '_' => $lookup->text(), + ], + ][$typeName]; - static::assertSame($direct, $access->decoderLenses[$property]->get($lookup), $property); + foreach ($direct as $property => $expected) { + static::assertSame($expected, $access->decodeReaders[$property]($lookup), $property); } } From 17943048c1fdc6eaa515feaf13484836b9d92676 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 08:29:17 +0200 Subject: [PATCH 6/8] Let a throwing custom object lens fail the encoding --- src/Encoder/ObjectAccess.php | 79 +++---------------- tests/Support/Throws/allowed-throws.php | 24 ------ .../Encoder/ObjectEncoderCustomLensTest.php | 26 ++++-- .../Encoder/ObjectPropertyPresenceTest.php | 24 ++++-- 4 files changed, 49 insertions(+), 104 deletions(-) diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index b655cf4..5c8e2e0 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -4,7 +4,6 @@ namespace Soap\Encoding\Encoder; use Closure; -use Exception; use Soap\Encoding\Cache\ScopedCache; use Soap\Encoding\EncoderRegistry; use Soap\Encoding\Normalizer\PhpPropertyNameNormalizer; @@ -19,7 +18,6 @@ use VeeWee\Reflecta\Iso\IsoInterface; use VeeWee\Reflecta\Lens\LensInterface; use function Psl\Vec\sort_by; -use function VeeWee\Reflecta\Lens\optional; use function VeeWee\Reflecta\Lens\optional_property; final class ObjectAccess @@ -29,8 +27,8 @@ final class ObjectAccess * Every array is keyed by the normalized PHP property name. * * @param array $properties The metadata properties, attributes first. - * @param array $encodeReaders Reads the property value from the PHP object on encode, null when the lens fails. - * @param array $decodeReaders Reads the raw XML value from the element on decode, null when a custom decoder lens fails. + * @param array $encodeReaders Reads the property value from the PHP object on encode. + * @param array $decodeReaders Reads the raw XML value from the element on decode. * @param array> $isos Converts between the PHP value and its XML form. * @param bool $isAnyPropertyQualified Whether at least one property is namespace qualified. * @param array $isAttribute Whether the property is an XML attribute instead of a child element. @@ -102,19 +100,18 @@ private static function build(Context $context): self $normalizedName = PhpPropertyNameNormalizer::normalize($name); $encoder = $context->registry->detectEncoderForContext($propertyContext); - $shouldLensBeOptional = self::shouldLensBeOptional($propertyTypeMeta); $normalizedProperties[$normalizedName] = $property; - $customEncoderLens = self::createCustomEncoderLens($shouldLensBeOptional, $encoder, $type, $property); + $customEncoderLens = self::createCustomEncoderLens($encoder, $type, $property); /** @var Closure(object): mixed $encodeReader */ - $encodeReader = $customEncoderLens !== null - ? self::readOrNull($customEncoderLens) - : optional_property($normalizedName)->get(...); + $encodeReader = ($customEncoderLens ?? optional_property($normalizedName))->get(...); $encodeReaders[$normalizedName] = $encodeReader; - $customDecoderLens = self::createCustomDecoderLens($shouldLensBeOptional, $encoder, $type, $property); - $decodeReaders[$normalizedName] = $customDecoderLens !== null - ? self::readOrNull($customDecoderLens) + $customDecoderLens = self::createCustomDecoderLens($encoder, $type, $property); + /** @var Closure(ElementLookup): mixed $decodeReader */ + $decodeReader = $customDecoderLens !== null + ? $customDecoderLens->get(...) : self::createDecoderReader($name, $propertyType); + $decodeReaders[$normalizedName] = $decodeReader; $isos[$normalizedName] = $encoder->iso($propertyContext); $isAttribute[$normalizedName] = $propertyTypeMeta->isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; @@ -151,42 +148,15 @@ private static function mayBeAbsent(string $propertyName, TypeMeta $meta): bool * @return LensInterface|null */ private static function createCustomEncoderLens( - bool $shouldLensBeOptional, XmlEncoder $encoder, Type $type, Property $property, ): ?LensInterface { - $lens = match (true) { - $encoder instanceof Feature\DecoratingEncoder => self::createCustomEncoderLens($shouldLensBeOptional, $encoder->decoratedEncoder(), $type, $property), + return match (true) { + $encoder instanceof Feature\DecoratingEncoder => self::createCustomEncoderLens($encoder->decoratedEncoder(), $type, $property), $encoder instanceof Feature\ProvidesObjectEncoderLens => $encoder::createObjectEncoderLens($type, $property), default => null, }; - - if ($lens === null) { - return null; - } - - return $shouldLensBeOptional ? optional($lens) : $lens; - } - - /** - * Custom lenses may throw on a missing value, the encoder treats that as null. - * - * @template S - * - * @param LensInterface $lens - * - * @return Closure(S): mixed - */ - private static function readOrNull(LensInterface $lens): Closure - { - return static function (mixed $data) use ($lens): mixed { - try { - return $lens->get($data); - } catch (Exception) { - return null; - } - }; } /** @@ -212,37 +182,14 @@ private static function createDecoderReader(string $name, XsdType $type): Closur * @return LensInterface|null */ private static function createCustomDecoderLens( - bool $shouldLensBeOptional, XmlEncoder $encoder, Type $type, Property $property, ): ?LensInterface { - $lens = match(true) { - $encoder instanceof Feature\DecoratingEncoder => self::createCustomDecoderLens($shouldLensBeOptional, $encoder->decoratedEncoder(), $type, $property), + return match(true) { + $encoder instanceof Feature\DecoratingEncoder => self::createCustomDecoderLens($encoder->decoratedEncoder(), $type, $property), $encoder instanceof Feature\ProvidesObjectDecoderLens => $encoder::createObjectDecoderLens($type, $property), default => null, }; - - if ($lens === null) { - return null; - } - - return $shouldLensBeOptional ? optional($lens) : $lens; - } - - private static function shouldLensBeOptional(TypeMeta $meta): bool - { - if ($meta->isNullable()->unwrapOr(false)) { - return true; - } - - if ( - $meta->isAttribute()->unwrapOr(false) && - $meta->use()->unwrapOr('optional') === 'optional' - ) { - return true; - } - - return false; } } diff --git a/tests/Support/Throws/allowed-throws.php b/tests/Support/Throws/allowed-throws.php index fbce6da..a62f603 100644 --- a/tests/Support/Throws/allowed-throws.php +++ b/tests/Support/Throws/allowed-throws.php @@ -1,36 +1,12 @@ ObjectEncoderCustomLensTest::class.'::test_it_decodes_through_custom_lenses_that_throw_on_a_missing_element', - 'throw' => ArrayAccessException::class, - 'via' => 'src/Encoder/ObjectAccess.php -> VeeWee\\Reflecta\\Lens\\Lens->get', - 'throws' => 1, - 'reason' => 'The fixture is a third-party encoder whose decoder lens throws on a missing element, to prove custom lenses still get the optional() and null on failure treatment.', - ], - [ - 'test' => ObjectEncoderCustomLensTest::class.'::test_it_encodes_through_custom_lenses_that_throw_on_a_missing_property', - 'throw' => UnreflectableException::class, - 'via' => 'src/Encoder/ObjectAccess.php -> VeeWee\\Reflecta\\Lens\\Lens->get', - 'throws' => 1, - 'reason' => 'The fixture is a third-party encoder whose encoder lens throws on a missing property, to prove custom lenses still get the optional() and null on failure treatment.', - ], - [ - 'test' => ObjectPropertyPresenceTest::class.'::custom_lens_reading_null_keeps_writing_nil#lens throws on the absent property', - 'throw' => UnreflectableException::class, - 'via' => 'src/Encoder/ObjectAccess.php -> VeeWee\\Reflecta\\Lens\\Lens->get', - 'throws' => 1, - 'reason' => 'The fixture is a third-party encoder whose encoder lens throws on a missing property, to prove that a failing custom lens keeps writing xsi:nil instead of counting as absent.', - ], [ 'test' => ObjectPropertyPresenceTest::class.'::optional_property_preserves_absence_null_and_value#lazy object whose initializer throws', 'throw' => RuntimeException::class, diff --git a/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php b/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php index a9013da..32362aa 100644 --- a/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php +++ b/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php @@ -7,13 +7,17 @@ use PHPUnit\Framework\TestCase; use Soap\Encoding\Encoder\AnyElementEncoder; use Soap\Encoding\Encoder\Context; +use Soap\Encoding\Encoder\ErrorHandlingEncoder; use Soap\Encoding\Encoder\ObjectAccess; use Soap\Encoding\Encoder\ObjectEncoder; use Soap\Encoding\EncoderRegistry; +use Soap\Encoding\Exception\EncodingException; use Soap\Encoding\Test\Fixture\Encoder\ThrowingLensEncoder; use Soap\Encoding\Test\Unit\ContextCreatorTrait; use Soap\WsdlReader\Model\Definitions\BindingUse; use stdClass; +use VeeWee\Reflecta\ArrayAccess\Exception\ArrayAccessException; +use VeeWee\Reflecta\Reflect\Exception\UnreflectableException; #[CoversClass(ObjectEncoder::class)] #[CoversClass(ObjectAccess::class)] @@ -41,11 +45,15 @@ protected function setUp(): void ThrowingLensEncoder::$decoderLensReads = 0; } - public function test_it_decodes_through_custom_lenses_that_throw_on_a_missing_element(): void + public function test_it_fails_decoding_when_a_custom_lens_throws_on_a_missing_element(): void { - $actual = $this->iso()->from('1x'); + try { + $this->iso()->from('1x'); + static::fail('Expected the throwing custom lens to fail decoding.'); + } catch (EncodingException $exception) { + static::assertInstanceOf(ArrayAccessException::class, $exception->getPrevious()); + } - static::assertEquals((object) ['id' => 1, 'custom' => null, 'any' => ['x']], $actual); static::assertSame(1, ThrowingLensEncoder::$decoderLensReads); } @@ -72,11 +80,15 @@ public function test_it_keeps_any_children_when_an_attribute_has_the_same_name() static::assertEquals((object) ['id' => 1, 'custom' => 'c', 'any' => ['x']], $actual); } - public function test_it_encodes_through_custom_lenses_that_throw_on_a_missing_property(): void + public function test_it_fails_encoding_when_a_custom_lens_throws_on_a_missing_property(): void { - $actual = $this->iso()->to((object) ['id' => 1, 'any' => ['x']]); + try { + $this->iso()->to((object) ['id' => 1, 'any' => ['x']]); + static::fail('Expected the throwing custom lens to fail encoding.'); + } catch (EncodingException $exception) { + static::assertInstanceOf(UnreflectableException::class, $exception->getPrevious()); + } - static::assertSame('1x', $actual); static::assertSame(1, ThrowingLensEncoder::$encoderLensReads); } @@ -103,6 +115,6 @@ private function iso(): \VeeWee\Reflecta\Iso\IsoInterface self::buildNamespaces(), ); - return (new ObjectEncoder(stdClass::class))->iso($context); + return (new ErrorHandlingEncoder(new ObjectEncoder(stdClass::class)))->iso($context); } } diff --git a/tests/Unit/Encoder/ObjectPropertyPresenceTest.php b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php index a882d9a..5192b97 100644 --- a/tests/Unit/Encoder/ObjectPropertyPresenceTest.php +++ b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php @@ -12,15 +12,18 @@ use ReflectionClass; use RuntimeException; use Soap\Encoding\Encoder\Context; +use Soap\Encoding\Encoder\ErrorHandlingEncoder; use Soap\Encoding\Encoder\ObjectAccess; use Soap\Encoding\Encoder\ObjectEncoder; use Soap\Encoding\Encoder\XmlEncoder; use Soap\Encoding\EncoderRegistry; +use Soap\Encoding\Exception\EncodingException; use Soap\Encoding\Test\Fixture\Encoder\NullLensEncoder; use Soap\Encoding\Test\Fixture\Encoder\ThrowingLensEncoder; use Soap\Encoding\Test\Unit\ContextCreatorTrait; use Soap\WsdlReader\Model\Definitions\BindingUse; use stdClass; +use VeeWee\Reflecta\Reflect\Exception\UnreflectableException; #[CoversClass(ObjectEncoder::class), CoversClass(ObjectAccess::class)] final class ObjectPropertyPresenceTest extends TestCase @@ -132,21 +135,28 @@ public function only_optional_nillable_elements_with_the_default_lens_are_left_o /** * A custom lens may read something else than the property of that name, so its null is never taken for absence. */ - #[Test, DataProvider('custom_lenses')] - public function custom_lens_reading_null_keeps_writing_nil(XmlEncoder $customEncoder): void + #[Test] + public function custom_lens_reading_null_keeps_writing_nil(): void { - $context = self::context('eligibilityType', $customEncoder); + $context = self::context('eligibilityType', new NullLensEncoder()); $xml = (new ObjectEncoder(stdClass::class))->iso($context)->to((object) ['requiredNillable' => 'r']); $xpath = self::xpath($xml); static::assertSame('true', $xpath->evaluate('string(/*/custom/@xsi:nil)')); } - /** @return iterable */ - public static function custom_lenses(): iterable + #[Test] + public function custom_lens_throwing_on_the_absent_property_fails_encoding(): void { - yield 'lens throws on the absent property' => [new ThrowingLensEncoder()]; - yield 'lens returns null' => [new NullLensEncoder()]; + $context = self::context('eligibilityType', new ThrowingLensEncoder()); + $iso = (new ErrorHandlingEncoder(new ObjectEncoder(stdClass::class)))->iso($context); + + try { + $iso->to((object) ['requiredNillable' => 'r']); + static::fail('Expected the throwing custom lens to fail encoding.'); + } catch (EncodingException $exception) { + static::assertInstanceOf(UnreflectableException::class, $exception->getPrevious()); + } } private static function withUnsetNote(): object From 36830fbbea4ce288484462665741adb683c0a99f Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 08:50:04 +0200 Subject: [PATCH 7/8] Pin the custom decode reader and keep the full read type --- src/Encoder/ObjectEncoder.php | 3 ++- tests/Unit/Encoder/ObjectAccessDecodeReadTest.php | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index b7b2640..fc32d7d 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -4,6 +4,7 @@ namespace Soap\Encoding\Encoder; use Soap\Encoding\Xml\Node\Element; +use Soap\Encoding\Xml\Node\ElementList; use Soap\Encoding\Xml\Node\ElementLookup; use Soap\Encoding\Xml\Writer\AttributeBuilder; use Soap\Encoding\Xml\Writer\NilAttributeBuilder; @@ -122,7 +123,7 @@ private function from(Context $context, ObjectAccess $objectAccess, Element $dat $result = []; foreach ($objectAccess->isos as $normalizePropertyName => $iso) { - /** @var string|null $value */ + /** @var Element|ElementList|string|null $value */ $value = $objectAccess->decodeReaders[$normalizePropertyName]($lookup); /** diff --git a/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php b/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php index 184977b..2e40161 100644 --- a/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php +++ b/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php @@ -58,6 +58,19 @@ public function every_property_decodes_through_a_plain_reader(): void static::assertContainsOnlyInstancesOf(Closure::class, [...$mixed, ...$text]); } + #[Test] + public function custom_property_decodes_through_its_custom_lens(): void + { + $access = ObjectAccess::forContext(self::context('mixedType')); + $lookup = ElementLookup::fromElement(Element::fromString('c')); + ThrowingLensEncoder::$decoderLensReads = 0; + + $actual = $access->decodeReaders['custom']($lookup); + + static::assertSame($lookup->element('custom'), $actual); + static::assertSame(1, ThrowingLensEncoder::$decoderLensReads); + } + /** @return iterable */ public static function lookups(): iterable { From a9cc8176e89ce7f47a5fa20252a172fe368547ef Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 09:07:34 +0200 Subject: [PATCH 8/8] Build the encode and decode readers the same way --- src/Encoder/ObjectAccess.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 5c8e2e0..381a855 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -104,14 +104,18 @@ private static function build(Context $context): self $customEncoderLens = self::createCustomEncoderLens($encoder, $type, $property); /** @var Closure(object): mixed $encodeReader */ - $encodeReader = ($customEncoderLens ?? optional_property($normalizedName))->get(...); + $encodeReader = $customEncoderLens !== null + ? $customEncoderLens->get(...) + : optional_property($normalizedName)->get(...); $encodeReaders[$normalizedName] = $encodeReader; + $customDecoderLens = self::createCustomDecoderLens($encoder, $type, $property); /** @var Closure(ElementLookup): mixed $decodeReader */ $decodeReader = $customDecoderLens !== null ? $customDecoderLens->get(...) : self::createDecoderReader($name, $propertyType); $decodeReaders[$normalizedName] = $decodeReader; + $isos[$normalizedName] = $encoder->iso($propertyContext); $isAttribute[$normalizedName] = $propertyTypeMeta->isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null;