From 5937c5d48bbceceadf695699fec064362d16821a Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 13:07:21 +0200 Subject: [PATCH 01/25] Add ElementLookup to read an element without mixing attributes and children DocumentToLookupArrayReader puts child elements and attributes in one flat array keyed by local name, so an attribute overwrites or stands in for a child with the same name, xsi:type and xmlns declarations included. ElementLookup keeps them apart: element() only returns child elements, attribute() reads by namespace and local name, text() follows the existing '_' rule, and toArray() builds the legacy array with the child winning on a collision and protocol attributes left out. attribute() falls back to the only other attribute with that local name when the exact namespace is absent. That keeps xml:lang, other ref attributes and servers that qualify attributes differently from their WSDL decoding, while id next to a:id still resolves to the right one. The child wins rule, the protocol attribute filter and two of the tests come from php-soap/encoding#72. Nothing uses it yet. Co-authored-by: NickSdot --- src/Xml/Node/ElementLookup.php | 161 ++++++++++++++++++ tests/Unit/Xml/Node/ElementLookupTest.php | 194 ++++++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 src/Xml/Node/ElementLookup.php create mode 100644 tests/Unit/Xml/Node/ElementLookupTest.php diff --git a/src/Xml/Node/ElementLookup.php b/src/Xml/Node/ElementLookup.php new file mode 100644 index 0000000..dda5cae --- /dev/null +++ b/src/Xml/Node/ElementLookup.php @@ -0,0 +1,161 @@ + $children Keyed by local name, in document order. + */ + private function __construct( + private DomElement $root, + private array $children, + ) { + } + + public static function fromElement(Element $xml): self + { + $root = $xml->element(); + $children = []; + + /** @var iterable $childNodes */ + $childNodes = $root->childNodes; + foreach ($childNodes as $node) { + if (!is_element($node)) { + continue; + } + + $key = $node->localName; + $previous = $children[$key] ?? null; + $current = Element::fromDOMElement($node); + + $children[$key] = match(true) { + $previous instanceof ElementList => $previous->append($current), + $previous instanceof Element => new ElementList($previous, $current), + default => $current, + }; + } + + return new self($root, $children); + } + + /** + * The child element with this local name, or the list of them when it repeats. Null when there is none. + */ + public function element(string $name): Element|ElementList|null + { + return $this->children[$name] ?? null; + } + + /** + * Every child element, keyed by local name in document order. Repeated children are grouped in one list. + * + * @return array + */ + public function children(): array + { + return $this->children; + } + + /** + * The value of the attribute in this namespace, where '' is the attribute without namespace. + * When that one is absent, the only other attribute with this local name is used, + * so a server that qualifies an attribute differently from its schema still decodes. + * Namespace declarations and xsi or soap-enc attributes never count as that other attribute. + * Null when absent or when the local name is ambiguous. + */ + public function attribute(string $localName, string $namespace = ''): ?string + { + $exact = $this->root->getAttributeNS($namespace, $localName); + if ($exact !== null) { + return $exact; + } + + $found = null; + /** @var iterable $attributes */ + $attributes = $this->root->attributes; + foreach ($attributes as $attribute) { + if ($attribute->localName !== $localName || self::isProtocolNamespace($attribute->namespaceURI)) { + continue; + } + + if ($found !== null) { + return null; + } + + $found = $attribute->value; + } + + return $found; + } + + /** + * The text content of an element without child elements. Null when it has child elements or is xsi:nil. + */ + public function text(): ?string + { + if ($this->children || $this->root->getAttributeNS(Xmlns::xsi()->value(), 'nil') === 'true') { + return null; + } + + return $this->root->textContent ?? ''; + } + + /** + * The element as one flat array, the shape that DocumentToLookupArrayReader returns: + * child elements by local name, the text under '_', and every other attribute by local name. + * A child element wins from an attribute with the same local name. + * Namespace declarations and xsi or soap-enc attributes are left out. + * + * @return LookupArray + */ + public function toArray(): array + { + $nodes = $this->children; + + $text = $this->text(); + if ($text !== null) { + $nodes['_'] = $text; + } + + /** @var iterable $attributes */ + $attributes = $this->root->attributes; + foreach ($attributes as $attribute) { + $key = $attribute->localName; + if (array_key_exists($key, $nodes) || self::isProtocolNamespace($attribute->namespaceURI)) { + continue; + } + + $nodes[$key] = $attribute->value; + } + + return $nodes; + } + + private static function isProtocolNamespace(?string $namespace): bool + { + return match ($namespace) { + Xmlns::xmlns()->value(), Xmlns::xsi()->value(), self::SOAP_ENC_11, self::SOAP_ENC_12 => true, + default => false, + }; + } +} diff --git a/tests/Unit/Xml/Node/ElementLookupTest.php b/tests/Unit/Xml/Node/ElementLookupTest.php new file mode 100644 index 0000000..a13703e --- /dev/null +++ b/tests/Unit/Xml/Node/ElementLookupTest.php @@ -0,0 +1,194 @@ +12'); + + $a = $lookup->element('a'); + $b = $lookup->element('b'); + + static::assertInstanceOf(Element::class, $a); + static::assertSame('1', $a->value()); + static::assertInstanceOf(Element::class, $b); + static::assertSame('2', $b->element()->textContent); + static::assertNull($lookup->element('c')); + } + + #[Test] + public function it_groups_repeated_children_in_a_list(): void + { + $lookup = self::lookup('1x23'); + + $a = $lookup->element('a'); + + static::assertInstanceOf(ElementList::class, $a); + static::assertSame(['1', '2', '3'], $a->traverse(static fn (Element $e): string => $e->element()->textContent)); + static::assertInstanceOf(Element::class, $lookup->element('b')); + } + + #[Test] + public function it_never_returns_an_attribute_as_element(): void + { + $lookup = self::lookup(''); + + static::assertNull($lookup->element('type')); + static::assertNull($lookup->element('label')); + static::assertNull($lookup->element('nil')); + static::assertNull($lookup->element('xsi')); + } + + #[Test] + public function it_returns_all_children_by_local_name_in_document_order(): void + { + $lookup = self::lookup('123text'); + + $children = $lookup->children(); + + static::assertSame(['b', 'a'], array_keys($children)); + static::assertInstanceOf(ElementList::class, $children['b']); + static::assertCount(2, $children['b']); + static::assertSame($lookup->element('a'), $children['a']); + } + + /** + * @return iterable + */ + public static function text_cases(): iterable + { + yield 'text' => ['hello', 'hello']; + yield 'empty element' => ['', '']; + yield 'text next to attributes' => ['hello', 'hello']; + yield 'child elements' => ['1', null]; + yield 'mixed content' => ['hello 1', null]; + yield 'nil' => ['', null]; + yield 'nil false' => ['hello', 'hello']; + } + + #[Test] + #[DataProvider('text_cases')] + public function it_reads_the_text_of_an_element_without_children(string $xml, ?string $expected): void + { + static::assertSame($expected, self::lookup($xml)->text()); + } + + /** + * @return iterable + */ + public static function attribute_cases(): iterable + { + yield 'no namespace' => ['', 'id', '', '1']; + yield 'empty value' => ['', 'id', '', '']; + yield 'absent' => ['', 'id', '', null]; + yield 'namespaced by uri, not by prefix' => ['', 'id', 'urn:t', '1']; + yield 'exact namespace wins over a namesake' => ['', 'id', '', '1']; + yield 'exact target namespace wins over a namesake' => ['', 'id', 'urn:a', '2']; + yield 'two namespaces, same local name' => ['', 'id', 'urn:b', '3']; + yield 'single namesake in another namespace' => ['', 'lang', '', 'nl']; + yield 'single unqualified namesake of a qualified attribute' => ['', 'id', 'urn:t', '1']; + yield 'ambiguous namesakes' => ['', 'id', '', null]; + yield 'xmlns declaration is not an attribute' => ['', 'id', '', null]; + yield 'xsi attribute is not a namesake' => ['', 'type', '', null]; + yield 'soap-enc 1.1 attribute is not a namesake' => ['', 'arrayType', '', null]; + yield 'soap-enc 1.2 attribute is not a namesake' => ['', 'itemType', '', null]; + yield 'xsi attribute by its own namespace' => ['', 'type', self::XSI, 'T']; + yield 'child element is not an attribute' => ['1', 'id', '', null]; + } + + #[Test] + #[DataProvider('attribute_cases')] + public function it_reads_attributes_by_namespace_with_a_single_namesake_fallback( + string $xml, + string $localName, + string $namespace, + ?string $expected + ): void { + static::assertSame($expected, self::lookup($xml)->attribute($localName, $namespace)); + } + + #[Test] + public function it_keeps_repeated_children_and_namespaced_attributes_apart(): void + { + $lookup = self::lookup('firstsecond'); + + $type = $lookup->element('type'); + + static::assertInstanceOf(ElementList::class, $type); + static::assertCount(2, $type); + static::assertSame('attribute', $lookup->attribute('type')); + static::assertSame('Record', $lookup->attribute('type', self::XSI)); + } + + #[Test] + public function it_keeps_nonconflicting_attributes_in_the_array(): void + { + $lookup = self::lookup(''); + + static::assertSame('text', $lookup->toArray()['label']); + static::assertSame('text', $lookup->attribute('label')); + } + + #[Test] + public function it_builds_the_legacy_array(): void + { + $lookup = self::lookup( + '' + . 'child12' + . '' + ); + + $array = $lookup->toArray(); + + static::assertSame(['type', 'b', 'label', 'code'], array_keys($array)); + static::assertSame($lookup->element('type'), $array['type']); + static::assertSame($lookup->element('b'), $array['b']); + static::assertSame('l', $array['label']); + static::assertSame('c', $array['code']); + } + + /** + * @return iterable}> + */ + public static function legacy_text_cases(): iterable + { + yield 'text and attribute' => ['hello', ['_' => 'hello', 'label' => 'l']]; + yield 'empty element' => ['', ['_' => '']]; + yield 'nil' => ['', []]; + } + + /** + * @param array $expected + */ + #[Test] + #[DataProvider('legacy_text_cases')] + public function it_puts_text_under_underscore_in_the_legacy_array(string $xml, array $expected): void + { + static::assertSame($expected, self::lookup($xml)->toArray()); + } + + /** + * @param non-empty-string $xml + */ + private static function lookup(string $xml): ElementLookup + { + return ElementLookup::fromElement(Element::fromString($xml)); + } +} From 687ba61c5235d946030ab50c60f5bcfef49a04b3 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 13:10:50 +0200 Subject: [PATCH 02/25] Decode object properties through ElementLookup The built-in decoder lenses read child elements, attributes and the '_' text from ElementLookup instead of the flat lookup array. An attribute no longer overwrites or stands in for a child element with the same name, xsi:type and namespace declarations included, and an attribute property only reads the attribute in its own namespace, with the single namesake fallback of ElementLookup::attribute(). ObjectEncoder::from builds one lookup per element. DocumentToLookupArrayReader now returns ElementLookup::toArray(), so its callers get the same fix: the child wins on a collision and xsi, soap-enc and xmlns attributes are left out. Custom decoder lenses still receive that array through an adapter that ObjectAccess builds once per property. The next commit hands them the lookup itself. Because the adapter is the innermost frame in src/ now, the allowed throw of the throwing lens fixture moves from ObjectEncoder to ObjectAccess. The tests port php-soap/encoding#72. Its two lookup array tests are in ElementLookupTest since the previous commit, asserting attribute() where #72 asserted '@{ns}name' keys, which this design does not have. Co-authored-by: NickSdot --- src/Encoder/ObjectAccess.php | 31 +++- src/Encoder/ObjectEncoder.php | 6 +- .../Reader/DocumentToLookupArrayReader.php | 52 +------ .../IntermediateThrowScenariosTest.php | 55 +++++++ tests/Support/Throws/allowed-throws.php | 2 +- .../Encoder/ObjectEncoderCustomLensTest.php | 15 ++ tests/Unit/Encoder/ObjectXmlLookupTest.php | 142 ++++++++++++++++++ .../DocumentToLookupArrayReaderTest.php | 38 +++++ 8 files changed, 285 insertions(+), 56 deletions(-) create mode 100644 tests/Unit/Encoder/ObjectXmlLookupTest.php create mode 100644 tests/Unit/Xml/Reader/DocumentToLookupArrayReaderTest.php diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 17edde3..39455ea 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -9,14 +9,16 @@ use Soap\Encoding\TypeInference\ComplexTypeBuilder; use Soap\Encoding\Xml\Node\Element; use Soap\Encoding\Xml\Node\ElementList; +use Soap\Encoding\Xml\Node\ElementLookup; use Soap\Engine\Metadata\Model\Property; use Soap\Engine\Metadata\Model\Type; 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; -use function VeeWee\Reflecta\Lens\optional_index; use function VeeWee\Reflecta\Lens\optional_property; final class ObjectAccess @@ -27,7 +29,7 @@ final class ObjectAccess * * @param array $properties The metadata properties, attributes first. * @param array> $encoderLenses Reads the property value from the PHP object on encode. - * @param array|null, array|null, mixed, mixed>> $decoderLenses Reads the raw XML value from the lookup array of the element on decode. + * @param array> $decoderLenses 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. @@ -104,8 +106,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) - ?? optional_index($name); + $customDecoderLens = self::createCustomDecoderLens($shouldLensBeOptional, $encoder, $type, $property); + $decoderLenses[$normalizedName] = $customDecoderLens !== null + ? Lens::readonly(static fn (ElementLookup $lookup): mixed => $customDecoderLens->get($lookup->toArray())) + : self::createDecoderLens($name, $propertyType); $isos[$normalizedName] = $encoder->iso($propertyContext); $isAttribute[$normalizedName] = $propertyTypeMeta->isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; @@ -160,6 +164,25 @@ private static function createCustomEncoderLens( return $shouldLensBeOptional ? optional($lens) : $lens; } + /** + * @return LensInterface + */ + private static function createDecoderLens(string $name, XsdType $type): LensInterface + { + $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)); + } + + if ($name === '_') { + return Lens::readonly(static fn (ElementLookup $lookup): ?string => $lookup->text()); + } + + return Lens::readonly(static fn (ElementLookup $lookup): Element|ElementList|null => $lookup->element($name)); + } + /** * @return LensInterface|null, array|null, mixed, mixed>|null */ diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index a0831e6..c8ae806 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -5,7 +5,7 @@ use Exception; use Soap\Encoding\Xml\Node\Element; -use Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader; +use Soap\Encoding\Xml\Node\ElementLookup; use Soap\Encoding\Xml\Writer\AttributeBuilder; use Soap\Encoding\Xml\Writer\NilAttributeBuilder; use Soap\Encoding\Xml\Writer\XsdTypeXmlElementWriter; @@ -121,7 +121,7 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ */ private function from(Context $context, ObjectAccess $objectAccess, Element $data): object { - $nodes = (new DocumentToLookupArrayReader())($data); + $lookup = ElementLookup::fromElement($data); /** @var Iso, array> $objectData */ $objectData = object_data($this->className); @@ -130,7 +130,7 @@ private function from(Context $context, ObjectAccess $objectAccess, Element $dat /** @var string|null $value */ $value = self::runLens( $objectAccess->decoderLenses[$normalizePropertyName], - $nodes, + $lookup, ); /** diff --git a/src/Xml/Reader/DocumentToLookupArrayReader.php b/src/Xml/Reader/DocumentToLookupArrayReader.php index dd6b7b1..f920fe4 100644 --- a/src/Xml/Reader/DocumentToLookupArrayReader.php +++ b/src/Xml/Reader/DocumentToLookupArrayReader.php @@ -3,12 +3,9 @@ namespace Soap\Encoding\Xml\Reader; -use Dom\Attr; -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; +use Soap\Encoding\Xml\Node\ElementLookup; /** * @psalm-type LookupArrayValue = string|Element|ElementList @@ -18,52 +15,11 @@ final class DocumentToLookupArrayReader { /** * @return LookupArray + * + * @see ElementLookup::toArray() */ public function __invoke(Element $xml): array { - $root = $xml->element(); - /** @var LookupArray $nodes */ - $nodes = []; - - // Read all child elements. - // The key is the name of the elements - // The value is the raw XML for those element(s) - /** @var iterable $children */ - $children = $root->childNodes; - foreach ($children as $element) { - if (!is_element($element)) { - continue; - } - - $key = $element->localName; - $previousValue = $nodes[$key] ?? null; - $currentElement = Element::fromDOMElement($element); - - // Incrementally build up lists. - /** @var LookupArrayValue $value */ - $value = match(true) { - $previousValue instanceof ElementList => $previousValue->append($currentElement), - $previousValue instanceof Element => new ElementList($previousValue, $currentElement), - default => $currentElement - - }; - $nodes[$key] = $value; - } - - // 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') { - $nodes['_'] = $root->textContent ?? ''; - } - - // All attributes also need to be added as key => value pairs. - /** @var \iterable $attributes */ - $attributes = $root->attributes; - foreach ($attributes as $attribute) { - $key = $attribute->localName; - $nodes[$key] = $attribute->value; - } - - return $nodes; + return ElementLookup::fromElement($xml)->toArray(); } } diff --git a/tests/Integration/IntermediateThrowScenariosTest.php b/tests/Integration/IntermediateThrowScenariosTest.php index 4dc8ba2..5826525 100644 --- a/tests/Integration/IntermediateThrowScenariosTest.php +++ b/tests/Integration/IntermediateThrowScenariosTest.php @@ -67,6 +67,9 @@ final class IntermediateThrowScenariosTest extends TestCase + + + @@ -149,6 +152,35 @@ public static function provideScenarios(): iterable ], ]; + yield 'decode present attributes next to an xsi:type and a namesake child' => [ + 'operation' => static fn (): mixed => $literal->decode( + 'test', + self::response('1child'), + ), + 'expected' => (object) [ + 'code' => 'c', + 'rank' => 2, + 'id' => 1, + 'name' => 'child', + 'price' => null, + 'active' => null, + 'address' => null, + 'tags' => [], + ], + ]; + + $label = self::driver('tns:labelType', 'literal'); + yield 'decode simple content with and without its attribute' => [ + 'operation' => static fn (): mixed => [ + $label->decode('test', self::response('hallo')), + $label->decode('test', self::response('')), + ], + 'expected' => [ + (object) ['lang' => 'nl', '_' => 'hallo'], + (object) ['lang' => null, '_' => ''], + ], + ]; + yield 'encode stdClass with absent properties' => [ 'operation' => static fn (): string => $literal->encode('test', [(object) ['id' => 1]])->getRequest(), 'expected' => self::literalRequest( @@ -373,6 +405,29 @@ public static function provideScenarios(): iterable 'expected' => (object) ['a' => 1, 'b' => 'x'], ]; + yield 'decode soap-enc struct member typed as a wsdl complex type' => [ + 'operation' => static fn (): mixed => $struct->decode( + 'test', + self::response( + '' + .'1
C
' + .'
' + ), + ), + 'expected' => (object) [ + 'member' => (object) [ + 'code' => 'c', + 'rank' => null, + 'id' => 1, + 'name' => null, + 'price' => null, + 'active' => null, + 'address' => (object) ['street' => null, 'city' => 'C', 'zip' => null], + 'tags' => [], + ], + ], + ]; + yield 'encode soap-enc struct' => [ 'operation' => static fn (): string => $struct->encode('test', [(object) ['a' => 1]])->getRequest(), 'expected' => self::encodedRequest( diff --git a/tests/Support/Throws/allowed-throws.php b/tests/Support/Throws/allowed-throws.php index 3fad8c3..dddf8d0 100644 --- a/tests/Support/Throws/allowed-throws.php +++ b/tests/Support/Throws/allowed-throws.php @@ -13,7 +13,7 @@ [ '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.', ], diff --git a/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php b/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php index 0bd3c7c..a9013da 100644 --- a/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php +++ b/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php @@ -57,6 +57,21 @@ public function test_it_decodes_through_custom_lenses_when_the_element_is_presen static::assertSame(1, ThrowingLensEncoder::$decoderLensReads); } + public function test_it_gives_custom_lenses_the_child_when_an_attribute_has_the_same_name(): void + { + $actual = $this->iso()->from('1c'); + + static::assertEquals((object) ['id' => 1, 'custom' => 'c', 'any' => []], $actual); + static::assertSame(1, ThrowingLensEncoder::$decoderLensReads); + } + + public function test_it_keeps_any_children_when_an_attribute_has_the_same_name(): void + { + $actual = $this->iso()->from('1cx'); + + 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 { $actual = $this->iso()->to((object) ['id' => 1, 'any' => ['x']]); diff --git a/tests/Unit/Encoder/ObjectXmlLookupTest.php b/tests/Unit/Encoder/ObjectXmlLookupTest.php new file mode 100644 index 0000000..faa5082 --- /dev/null +++ b/tests/Unit/Encoder/ObjectXmlLookupTest.php @@ -0,0 +1,142 @@ + */ + public static function lookup_cases(): iterable + { + yield 'child type survives xsi:type' => [ + '', + 'child', + (object) ['type' => 'child'], + ]; + yield 'protocol type does not invent missing child' => [ + '', + '', + (object) ['type' => null], + ]; + yield 'ordinary attribute does not invent missing child' => [ + '', + '', + (object) ['type' => null], + ]; + yield 'ordinary attribute does not overwrite a child' => [ + '', + 'child', + (object) ['type' => 'child'], + ]; + yield 'attribute does not invent a missing repeated child' => [ + '', + '', + (object) ['type' => []], + ]; + yield 'namespace declaration does not invent missing child' => [ + '', + '', + (object) ['tns' => null], + ]; + yield 'child does not stand in for a missing attribute' => [ + '', + 'x', + (object) ['label' => null, 'v' => 'x'], + ]; + yield 'simple content remains available' => [ + '', + 'content', + (object) ['_' => 'content', 'label' => 'attribute'], + ]; + yield 'unqualified schema type attribute survives xsi:type' => [ + '', + '', + (object) ['type' => 'schema'], + ]; + yield 'unqualified schema type attribute does not read xsi:type' => [ + '', + '', + (object) ['type' => null], + ]; + yield 'qualified schema attribute ignores unqualified namesake' => [ + '', + '', + (object) ['label' => 'correct'], + ]; + yield 'unqualified schema attribute ignores qualified namesake' => [ + '', + '', + (object) ['label' => 'correct'], + ]; + yield 'qualified schema attribute falls back to its only namesake' => [ + '', + '', + (object) ['label' => 'sent unqualified'], + ]; + yield 'ambiguous namesakes decode to null' => [ + '', + '', + (object) ['label' => null], + ]; + yield 'xml:lang through ref' => [ + '', + '', + (object) ['lang' => 'nl', 'lang2' => 'en'], + ]; + } + + #[Test, DataProvider('lookup_cases')] + public function default_decoder_lenses_distinguish_children_and_attribute_namespaces(string $properties, string $xml, object $expected): void + { + $schema = '' . $properties . ''; + $metadata = self::createMetadataFromWsdl($schema, 'type="tns:testType"'); + $context = self::createContextFromMetadata($metadata, 'testType')->withBindingUse(BindingUse::LITERAL); + $iso = new ObjectEncoder(stdClass::class)->iso($context); + + $actual = $iso->from($xml); + + static::assertEquals($expected, $actual); + } + + #[Test] + public function nested_repeated_type_children_survive_protocol_type_attributes(): void + { + $schema = <<<'XML' + + + + + + + XML; + $metadata = self::createMetadataFromWsdl($schema, 'type="tns:testType"'); + $context = self::createContextFromMetadata($metadata, 'testType')->withBindingUse(BindingUse::LITERAL); + $iso = new ObjectEncoder(stdClass::class)->iso($context); + $xml = <<<'XML' + + first + second + + XML; + + $actual = $iso->from($xml); + + static::assertEquals((object) [ + 'child' => [(object) ['type' => 'first'], (object) ['type' => 'second']], + ], $actual); + } +} diff --git a/tests/Unit/Xml/Reader/DocumentToLookupArrayReaderTest.php b/tests/Unit/Xml/Reader/DocumentToLookupArrayReaderTest.php new file mode 100644 index 0000000..0c73d2d --- /dev/null +++ b/tests/Unit/Xml/Reader/DocumentToLookupArrayReaderTest.php @@ -0,0 +1,38 @@ +firstsecond
' + )); + + static::assertSame(['type'], array_keys($lookup)); + static::assertInstanceOf(ElementList::class, $lookup['type']); + static::assertCount(2, $lookup['type']); + } + + #[Test] + public function it_leaves_out_protocol_attributes(): void + { + $lookup = (new DocumentToLookupArrayReader())(Element::fromString( + 'content' + )); + + static::assertSame(['_' => 'content', 'label' => 'text'], $lookup); + } +} From 4660d61c5365b931b34c6948cfa50bb32c4b26d2 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 15:10:47 +0200 Subject: [PATCH 03/25] Read attributes by exact namespace only ElementLookup::attribute() no longer falls back to the only other attribute with the same local name. The fallback cost about half of the sparse decode regression of the previous commit, because every absent optional attribute walked the attributes of its element, and it only helped servers that send an attribute in another namespace than their WSDL declares. An attribute in the wrong namespace now decodes to null. Attributes declared through ref, like xml:lang, need their own namespace from the metadata to be found. That comes with the wsdl-reader change that reports them as qualified, required in a later commit, which also brings the xml:lang decode test. --- src/Xml/Node/ElementLookup.php | 27 ++-------------------- tests/Unit/Encoder/ObjectXmlLookupTest.php | 15 ++++-------- tests/Unit/Xml/Node/ElementLookupTest.php | 15 ++++++------ 3 files changed, 15 insertions(+), 42 deletions(-) diff --git a/src/Xml/Node/ElementLookup.php b/src/Xml/Node/ElementLookup.php index dda5cae..dfce5e5 100644 --- a/src/Xml/Node/ElementLookup.php +++ b/src/Xml/Node/ElementLookup.php @@ -78,34 +78,11 @@ public function children(): array /** * The value of the attribute in this namespace, where '' is the attribute without namespace. - * When that one is absent, the only other attribute with this local name is used, - * so a server that qualifies an attribute differently from its schema still decodes. - * Namespace declarations and xsi or soap-enc attributes never count as that other attribute. - * Null when absent or when the local name is ambiguous. + * Null when absent, also when an attribute with this local name exists in another namespace. */ public function attribute(string $localName, string $namespace = ''): ?string { - $exact = $this->root->getAttributeNS($namespace, $localName); - if ($exact !== null) { - return $exact; - } - - $found = null; - /** @var iterable $attributes */ - $attributes = $this->root->attributes; - foreach ($attributes as $attribute) { - if ($attribute->localName !== $localName || self::isProtocolNamespace($attribute->namespaceURI)) { - continue; - } - - if ($found !== null) { - return null; - } - - $found = $attribute->value; - } - - return $found; + return $this->root->getAttributeNS($namespace, $localName); } /** diff --git a/tests/Unit/Encoder/ObjectXmlLookupTest.php b/tests/Unit/Encoder/ObjectXmlLookupTest.php index faa5082..d9683a5 100644 --- a/tests/Unit/Encoder/ObjectXmlLookupTest.php +++ b/tests/Unit/Encoder/ObjectXmlLookupTest.php @@ -82,27 +82,22 @@ public static function lookup_cases(): iterable '', (object) ['label' => 'correct'], ]; - yield 'qualified schema attribute falls back to its only namesake' => [ + yield 'qualified schema attribute in the wrong namespace decodes to null' => [ '', '', - (object) ['label' => 'sent unqualified'], + (object) ['label' => null], ]; - yield 'ambiguous namesakes decode to null' => [ + yield 'unqualified schema attribute in the wrong namespace decodes to null' => [ '', - '', + '', (object) ['label' => null], ]; - yield 'xml:lang through ref' => [ - '', - '', - (object) ['lang' => 'nl', 'lang2' => 'en'], - ]; } #[Test, DataProvider('lookup_cases')] public function default_decoder_lenses_distinguish_children_and_attribute_namespaces(string $properties, string $xml, object $expected): void { - $schema = '' . $properties . ''; + $schema = '' . $properties . ''; $metadata = self::createMetadataFromWsdl($schema, 'type="tns:testType"'); $context = self::createContextFromMetadata($metadata, 'testType')->withBindingUse(BindingUse::LITERAL); $iso = new ObjectEncoder(stdClass::class)->iso($context); diff --git a/tests/Unit/Xml/Node/ElementLookupTest.php b/tests/Unit/Xml/Node/ElementLookupTest.php index a13703e..faa28ad 100644 --- a/tests/Unit/Xml/Node/ElementLookupTest.php +++ b/tests/Unit/Xml/Node/ElementLookupTest.php @@ -100,20 +100,21 @@ public static function attribute_cases(): iterable yield 'exact namespace wins over a namesake' => ['', 'id', '', '1']; yield 'exact target namespace wins over a namesake' => ['', 'id', 'urn:a', '2']; yield 'two namespaces, same local name' => ['', 'id', 'urn:b', '3']; - yield 'single namesake in another namespace' => ['', 'lang', '', 'nl']; - yield 'single unqualified namesake of a qualified attribute' => ['', 'id', 'urn:t', '1']; - yield 'ambiguous namesakes' => ['', 'id', '', null]; + yield 'xml:lang by the xml namespace' => ['', 'lang', 'http://www.w3.org/XML/1998/namespace', 'nl']; + yield 'wrong namespace: qualified attribute sent without namespace' => ['', 'id', 'urn:t', null]; + yield 'wrong namespace: unqualified attribute sent in a namespace' => ['', 'id', '', null]; + yield 'wrong namespace: xml:lang read without namespace' => ['', 'lang', '', null]; yield 'xmlns declaration is not an attribute' => ['', 'id', '', null]; - yield 'xsi attribute is not a namesake' => ['', 'type', '', null]; - yield 'soap-enc 1.1 attribute is not a namesake' => ['', 'arrayType', '', null]; - yield 'soap-enc 1.2 attribute is not a namesake' => ['', 'itemType', '', null]; + yield 'xsi attribute is not read without namespace' => ['', 'type', '', null]; + yield 'soap-enc 1.1 attribute is not read without namespace' => ['', 'arrayType', '', null]; + yield 'soap-enc 1.2 attribute is not read without namespace' => ['', 'itemType', '', null]; yield 'xsi attribute by its own namespace' => ['', 'type', self::XSI, 'T']; yield 'child element is not an attribute' => ['1', 'id', '', null]; } #[Test] #[DataProvider('attribute_cases')] - public function it_reads_attributes_by_namespace_with_a_single_namesake_fallback( + public function it_reads_attributes_by_exact_namespace( string $xml, string $localName, string $namespace, From 7044b66923e0748cacc7dbcad8a17df9d88c3c6a Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 15:15:45 +0200 Subject: [PATCH 04/25] Hand custom decoder lenses the ElementLookup ProvidesObjectDecoderLens lenses now read from the ElementLookup of the parent element instead of the flat lookup array, so they can ask for a child element or for an attribute in its namespace without the two being mixed. This breaks third party decoder lenses: read $lookup->element('name') or $lookup->attribute('name') where the lens read $data['name']. AnyElementEncoder, the only implementer in src, takes the children from ElementLookup::children(). The adapter of the previous commits is gone, so the allowed throw of the throwing lens fixture is attributed to ObjectEncoder again. The fixtures read the lookup like a third party lens would. --- src/Encoder/AnyElementEncoder.php | 31 +++++++------------ .../Feature/ProvidesObjectDecoderLens.php | 6 ++-- src/Encoder/ObjectAccess.php | 10 +++--- tests/Fixture/Encoder/ThrowingLensEncoder.php | 11 ++++--- .../Encoder/XsiDetectedLensEncoder.php | 5 +-- tests/Support/Throws/allowed-throws.php | 2 +- 6 files changed, 30 insertions(+), 35 deletions(-) diff --git a/src/Encoder/AnyElementEncoder.php b/src/Encoder/AnyElementEncoder.php index 7801d55..8a42702 100644 --- a/src/Encoder/AnyElementEncoder.php +++ b/src/Encoder/AnyElementEncoder.php @@ -4,7 +4,7 @@ use Soap\Encoding\Xml\Node\Element; use Soap\Encoding\Xml\Node\ElementList; -use Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader; +use Soap\Encoding\Xml\Node\ElementLookup; use Soap\Engine\Metadata\Model\Property; use Soap\Engine\Metadata\Model\Type; use VeeWee\Reflecta\Iso\Iso; @@ -21,9 +21,7 @@ /** * @implements XmlEncoder|string|null, string, ElementList|string> * - * @psalm-import-type LookupArray from DocumentToLookupArrayReader - * - * @template-implements Feature\ProvidesObjectDecoderLens + * @template-implements Feature\ProvidesObjectDecoderLens */ final class AnyElementEncoder implements Feature\ListAware, Feature\OptionalAware, Feature\ProvidesObjectDecoderLens, XmlEncoder { @@ -32,32 +30,27 @@ final class AnyElementEncoder implements Feature\ListAware, Feature\OptionalAwar * It will contain all the XML tags available in the object that is surrounding the 'any' property. * Properties that are already known by the object, will be omitted. * - * @return Lens + * @return Lens */ public static function createObjectDecoderLens(Type $parentType, Property $currentProperty): Lens { - $omittedKeys = reduce( + $omittedKeys = array_flip(reduce( $parentType->getProperties(), static fn (array $omit, Property $property): array => [ ...$omit, ...($property->getName() !== $currentProperty->getName() ? [$property->getName()] : []), ], [] - ); + )); - /** - * @param LookupArray $data - * @return LookupArray - */ - $omit = static fn (array $data): array => diff_by_key($data, array_flip($omittedKeys)); - - /** @var Lens */ + /** @var Lens */ return Lens::readonly( - /** - * @psalm-suppress MixedArgumentTypeCoercion - Psalm gets confused about the result of omit. - * @param LookupArray $data - */ - static fn (array $data): ElementList => ElementList::fromLookupArray($omit($data)) + static function (ElementLookup $lookup) use ($omittedKeys): ElementList { + /** @var array $children */ + $children = diff_by_key($lookup->children(), $omittedKeys); + + return ElementList::fromLookupArray($children); + } ); } diff --git a/src/Encoder/Feature/ProvidesObjectDecoderLens.php b/src/Encoder/Feature/ProvidesObjectDecoderLens.php index bd6bb30..10dcb6e 100644 --- a/src/Encoder/Feature/ProvidesObjectDecoderLens.php +++ b/src/Encoder/Feature/ProvidesObjectDecoderLens.php @@ -2,15 +2,17 @@ namespace Soap\Encoding\Encoder\Feature; +use Soap\Encoding\Xml\Node\ElementLookup; use Soap\Engine\Metadata\Model\Property; use Soap\Engine\Metadata\Model\Type; use VeeWee\Reflecta\Lens\LensInterface; /** * When an encoder implements this feature interface, it knows how to create a lens that will be applied on the parent data that is being decoded. + * The lens reads from the ElementLookup of the parent element, for example $lookup->element('name') or $lookup->attribute('name'). * - * @template-covariant S of array - * @template-covariant T of array + * @template-covariant S of ElementLookup + * @template-covariant T of ElementLookup * @template-covariant A * @template-covariant B */ diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 39455ea..5195c88 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -29,7 +29,7 @@ final class ObjectAccess * * @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> $decoderLenses 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. @@ -106,10 +106,8 @@ private static function build(Context $context): self $customEncoderLens = self::createCustomEncoderLens($shouldLensBeOptional, $encoder, $type, $property); $encoderLenses[$normalizedName] = $customEncoderLens ?? optional_property($normalizedName); - $customDecoderLens = self::createCustomDecoderLens($shouldLensBeOptional, $encoder, $type, $property); - $decoderLenses[$normalizedName] = $customDecoderLens !== null - ? Lens::readonly(static fn (ElementLookup $lookup): mixed => $customDecoderLens->get($lookup->toArray())) - : self::createDecoderLens($name, $propertyType); + $decoderLenses[$normalizedName] = self::createCustomDecoderLens($shouldLensBeOptional, $encoder, $type, $property) + ?? self::createDecoderLens($name, $propertyType); $isos[$normalizedName] = $encoder->iso($propertyContext); $isAttribute[$normalizedName] = $propertyTypeMeta->isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; @@ -184,7 +182,7 @@ private static function createDecoderLens(string $name, XsdType $type): LensInte } /** - * @return LensInterface|null, array|null, mixed, mixed>|null + * @return LensInterface|null */ private static function createCustomDecoderLens( bool $shouldLensBeOptional, diff --git a/tests/Fixture/Encoder/ThrowingLensEncoder.php b/tests/Fixture/Encoder/ThrowingLensEncoder.php index 4978b35..3b2fd7b 100644 --- a/tests/Fixture/Encoder/ThrowingLensEncoder.php +++ b/tests/Fixture/Encoder/ThrowingLensEncoder.php @@ -7,6 +7,7 @@ use Soap\Encoding\Encoder\Feature; use Soap\Encoding\Encoder\XmlEncoder; use Soap\Encoding\Xml\Node\Element; +use Soap\Encoding\Xml\Node\ElementLookup; use Soap\Engine\Metadata\Model\Property; use Soap\Engine\Metadata\Model\Type; use VeeWee\Reflecta\Iso\Iso; @@ -19,7 +20,7 @@ * * @implements XmlEncoder * @template-implements Feature\ProvidesObjectEncoderLens - * @template-implements Feature\ProvidesObjectDecoderLens + * @template-implements Feature\ProvidesObjectDecoderLens */ final class ThrowingLensEncoder implements Feature\DisregardXsiInformation, @@ -46,17 +47,17 @@ public static function createObjectEncoderLens(Type $parentType, Property $curre } /** - * @return Lens + * @return Lens */ public static function createObjectDecoderLens(Type $parentType, Property $currentProperty): Lens { $lens = index($currentProperty->getName()); - /** @var Lens */ - return Lens::readonly(static function (array $data) use ($lens): mixed { + /** @var Lens */ + return Lens::readonly(static function (ElementLookup $lookup) use ($lens): mixed { self::$decoderLensReads++; - return $lens->get($data); + return $lens->get($lookup->children()); }); } diff --git a/tests/Fixture/Encoder/XsiDetectedLensEncoder.php b/tests/Fixture/Encoder/XsiDetectedLensEncoder.php index 7cae268..9b056f2 100644 --- a/tests/Fixture/Encoder/XsiDetectedLensEncoder.php +++ b/tests/Fixture/Encoder/XsiDetectedLensEncoder.php @@ -7,6 +7,7 @@ use Soap\Encoding\Encoder\Feature; use Soap\Encoding\Encoder\XmlEncoder; use Soap\Encoding\Xml\Node\Element; +use Soap\Encoding\Xml\Node\ElementLookup; use Soap\Engine\Metadata\Model\Property; use Soap\Engine\Metadata\Model\Type; use VeeWee\Reflecta\Iso\Iso; @@ -18,7 +19,7 @@ * * @implements XmlEncoder * @template-implements Feature\ProvidesObjectEncoderLens - * @template-implements Feature\ProvidesObjectDecoderLens + * @template-implements Feature\ProvidesObjectDecoderLens */ final class XsiDetectedLensEncoder implements Feature\ProvidesObjectDecoderLens, @@ -34,7 +35,7 @@ public static function createObjectEncoderLens(Type $parentType, Property $curre } /** - * @return Lens + * @return Lens */ public static function createObjectDecoderLens(Type $parentType, Property $currentProperty): Lens { diff --git a/tests/Support/Throws/allowed-throws.php b/tests/Support/Throws/allowed-throws.php index dddf8d0..3fad8c3 100644 --- a/tests/Support/Throws/allowed-throws.php +++ b/tests/Support/Throws/allowed-throws.php @@ -13,7 +13,7 @@ [ 'test' => 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', + 'via' => 'src/Encoder/ObjectEncoder.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.', ], From fda0a6ccb7973417ecfd7573bbe7128c589b9fe4 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 15:17:28 +0200 Subject: [PATCH 05/25] Require the wsdl-reader branch that qualifies ref attributes The wsdl-reader branch fix/qualified-attribute-refs reports attributes declared through ref as qualified, because a global attribute in XSD is always qualified, and it adds the implicit xml prefix to the WSDL namespaces. Until it is released the branch comes in through a VCS repository aliased as 0.34.0. This commit gets replaced by a ~0.34 constraint after the release, so the branch cannot be merged before that. Schema038Test references the global attribute tns:int. ext-soap writes it as int="123" without namespace, the encoder now writes tns:int="123" as the schema says. I changed the expectation instead of keeping ext-soap parity, since a server that validates against the schema rejects the unqualified form. Decoding reads it back by its namespace. --- composer.json | 6 +++++- tests/PhpCompatibility/Schema038Test.php | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index e38cbba..b99ad4e 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,10 @@ { "type": "vcs", "url": "https://github.com/veewee/reflecta" + }, + { + "type": "vcs", + "url": "https://github.com/veewee/wsdl-reader" } ], "require": { @@ -45,7 +49,7 @@ "php-soap/engine": "^2.21", "php-soap/wsdl": "^1.19", "php-soap/xml": "^1.10", - "php-soap/wsdl-reader": "~0.32" + "php-soap/wsdl-reader": "dev-fix/qualified-attribute-refs as 0.34.0" }, "require-dev": { "vimeo/psalm": "~6.13", diff --git a/tests/PhpCompatibility/Schema038Test.php b/tests/PhpCompatibility/Schema038Test.php index 59a1a84..cc24ec4 100644 --- a/tests/PhpCompatibility/Schema038Test.php +++ b/tests/PhpCompatibility/Schema038Test.php @@ -32,6 +32,10 @@ protected function calculateParam(): mixed ]; } + /** + * ext-soap writes int="123" without namespace. A global attribute is always qualified in XSD, + * so the reference to tns:int is written in the target namespace instead. + */ protected function expectXml(): string { return << - + str From bc8ad5ee431c35b368befccd3a4c42a83756d807 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 15:20:52 +0200 Subject: [PATCH 06/25] Write and read ref attributes in their own namespace With the wsdl-reader branch, an attribute declared through ref is qualified and carries the namespace of the schema that declares it, so the encoder writes xml:lang, o:flag and tns:code, and the decoder reads them back by that namespace. The compatibility tests cover a ref to xml:lang, a ref to an attribute of another schema and a ref to a global attribute of the same schema. They fail on wsdl-reader 0.33, which writes all three without namespace. ext-soap writes xml:lang the same way, but writes the foreign and the same-namespace ref without namespace. Schema088Test and Schema089Test follow the schema instead, as Schema038Test already does. AttributeBuilder no longer declares xmlns:xml next to xml:lang. The xml prefix is bound by the XML spec, and XMLWriter only leaves the declaration out when it gets no namespace URI for the prefix. --- src/Xml/Writer/AttributeBuilder.php | 13 +++- tests/PhpCompatibility/Schema087Test.php | 51 ++++++++++++++++ tests/PhpCompatibility/Schema088Test.php | 60 +++++++++++++++++++ tests/PhpCompatibility/Schema089Test.php | 57 ++++++++++++++++++ tests/Unit/Encoder/ObjectXmlLookupTest.php | 7 ++- .../Unit/Xml/Writer/AttributeBuilderTest.php | 53 ++++++++++++++++ 6 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 tests/PhpCompatibility/Schema087Test.php create mode 100644 tests/PhpCompatibility/Schema088Test.php create mode 100644 tests/PhpCompatibility/Schema089Test.php create mode 100644 tests/Unit/Xml/Writer/AttributeBuilderTest.php diff --git a/src/Xml/Writer/AttributeBuilder.php b/src/Xml/Writer/AttributeBuilder.php index 97def5a..e6aea32 100644 --- a/src/Xml/Writer/AttributeBuilder.php +++ b/src/Xml/Writer/AttributeBuilder.php @@ -6,9 +6,11 @@ use Generator; use Soap\Engine\Metadata\Model\XsdType; use VeeWee\Xml\Writer\Builder\Builder; +use VeeWee\Xml\Xmlns\Xmlns; use XMLWriter; use function VeeWee\Xml\Writer\Builder\attribute; use function VeeWee\Xml\Writer\Builder\namespaced_attribute; +use function VeeWee\Xml\Writer\Builder\prefixed_attribute; final class AttributeBuilder implements Builder { @@ -24,10 +26,17 @@ public function __construct( public function __invoke(XMLWriter $writer): Generator { $qualified = $this->type->getMeta()->isQualified()->unwrapOr(false); + $namespace = $this->type->getXmlTargetNamespace(); - if ($qualified && $this->type->getXmlTargetNamespace()) { + // The xml prefix is bound by the XML spec. Passing its namespace to XMLWriter would declare it again. + if ($qualified && $namespace === Xmlns::xml()->value()) { + yield from prefixed_attribute('xml', $this->type->getXmlTargetNodeName(), $this->value)($writer); + return; + } + + if ($qualified && $namespace) { yield from namespaced_attribute( - $this->type->getXmlTargetNamespace(), + $namespace, $this->type->getXmlTargetNamespaceName() ?: null, $this->type->getXmlTargetNodeName(), $this->value diff --git a/tests/PhpCompatibility/Schema087Test.php b/tests/PhpCompatibility/Schema087Test.php new file mode 100644 index 0000000..d91dd26 --- /dev/null +++ b/tests/PhpCompatibility/Schema087Test.php @@ -0,0 +1,51 @@ + + + + + + + + EOXML; + protected string $type = 'type="tns:testType"'; + + protected function calculateParam(): mixed + { + return (object)[ + "str" => "str", + "lang" => "nl", + ]; + } + + protected function expectXml(): string + { + return << + + + + str + + + + + XML; + } +} diff --git a/tests/PhpCompatibility/Schema088Test.php b/tests/PhpCompatibility/Schema088Test.php new file mode 100644 index 0000000..32da371 --- /dev/null +++ b/tests/PhpCompatibility/Schema088Test.php @@ -0,0 +1,60 @@ + + + + + + + + + + + + + EOXML; + protected string $type = 'type="tns:testType"'; + + protected function calculateParam(): mixed + { + return (object)[ + "str" => "str", + "flag" => "on", + ]; + } + + /** + * ext-soap writes flag="on" without namespace. A global attribute is always qualified in XSD, + * so the reference to o:flag is written in the namespace of the schema that declares it. + */ + protected function expectXml(): string + { + return << + + + + str + + + + + XML; + } +} diff --git a/tests/PhpCompatibility/Schema089Test.php b/tests/PhpCompatibility/Schema089Test.php new file mode 100644 index 0000000..9fdc04d --- /dev/null +++ b/tests/PhpCompatibility/Schema089Test.php @@ -0,0 +1,57 @@ + + + + + + + + + EOXML; + protected string $type = 'type="tns:testType"'; + + protected function calculateParam(): mixed + { + return (object)[ + "str" => "str", + "code" => "c", + "label" => "l", + ]; + } + + /** + * ext-soap writes code="c" without namespace. A global attribute is always qualified in XSD, + * so the reference to tns:code is written in the target namespace, next to the local attribute label without one. + */ + protected function expectXml(): string + { + return << + + + + str + + + + + XML; + } +} diff --git a/tests/Unit/Encoder/ObjectXmlLookupTest.php b/tests/Unit/Encoder/ObjectXmlLookupTest.php index d9683a5..b11babd 100644 --- a/tests/Unit/Encoder/ObjectXmlLookupTest.php +++ b/tests/Unit/Encoder/ObjectXmlLookupTest.php @@ -92,12 +92,17 @@ public static function lookup_cases(): iterable '', (object) ['label' => null], ]; + yield 'xml:lang through ref' => [ + '', + '', + (object) ['lang' => 'nl', 'lang2' => 'en'], + ]; } #[Test, DataProvider('lookup_cases')] public function default_decoder_lenses_distinguish_children_and_attribute_namespaces(string $properties, string $xml, object $expected): void { - $schema = '' . $properties . ''; + $schema = '' . $properties . ''; $metadata = self::createMetadataFromWsdl($schema, 'type="tns:testType"'); $context = self::createContextFromMetadata($metadata, 'testType')->withBindingUse(BindingUse::LITERAL); $iso = new ObjectEncoder(stdClass::class)->iso($context); diff --git a/tests/Unit/Xml/Writer/AttributeBuilderTest.php b/tests/Unit/Xml/Writer/AttributeBuilderTest.php new file mode 100644 index 0000000..e3f1209 --- /dev/null +++ b/tests/Unit/Xml/Writer/AttributeBuilderTest.php @@ -0,0 +1,53 @@ + + */ + public static function provideAttributes(): iterable + { + yield 'unqualified' => [static::attribute('label', 'urn:t', 't', false), '

']; + yield 'qualified' => [static::attribute('label', 'urn:t', 't', true), '

']; + yield 'qualified without target namespace' => [static::attribute('label', '', '', true), '

']; + yield 'xml namespace, without declaring the reserved xml prefix' => [ + static::attribute('lang', 'http://www.w3.org/XML/1998/namespace', 'xml', true), + '

', + ]; + } + + #[Test] + #[DataProvider('provideAttributes')] + public function it_writes_the_attribute_in_its_namespace(XsdType $type, string $expected): void + { + $actual = Writer::inMemory() + ->write(element('p', new AttributeBuilder($type, 'v'))) + ->map(memory_output()); + + static::assertSame($expected, $actual); + } + + private static function attribute(string $name, string $namespace, string $prefix, bool $qualified): XsdType + { + return XsdType::guess('string') + ->withXmlTargetNodeName($name) + ->withXmlTargetNamespace($namespace) + ->withXmlTargetNamespaceName($prefix) + ->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsAttribute(true)->withIsQualified($qualified)); + } +} From 54e3f0980ba0107e5b14ab7f9bbe64e89be820f8 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 15:21:39 +0200 Subject: [PATCH 07/25] Profile ElementLookup next to the legacy lookup array reader ObjectEncoder decodes from ElementLookup now, so the profile script times that step too. DocumentToLookupArrayReader stays in the profile because it is built on top of the lookup. --- benchmarks/scripts/profile_encode.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/benchmarks/scripts/profile_encode.php b/benchmarks/scripts/profile_encode.php index beb43c8..c71a533 100644 --- a/benchmarks/scripts/profile_encode.php +++ b/benchmarks/scripts/profile_encode.php @@ -204,7 +204,14 @@ } printf("typeIso->from(\$element) (no parse): %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); -// Step 4: Just DocumentToLookupArrayReader +// Step 4: ElementLookup, what ObjectEncoder decodes from +$t0 = hrtime(true); +for ($i = 0; $i < $N; $i++) { + \Soap\Encoding\Xml\Node\ElementLookup::fromElement($partElement); +} +printf("ElementLookup::fromElement: %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); + +// Step 4b: DocumentToLookupArrayReader, the legacy array on top of ElementLookup $t0 = hrtime(true); for ($i = 0; $i < $N; $i++) { (new \Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader())($partElement); From 70276272978ab4398e5bfa23898734b6a20ab8bc Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 17:23:52 +0200 Subject: [PATCH 08/25] Remove DocumentToLookupArrayReader and ElementLookup::toArray Nothing in the package reads the flat lookup array any more: built-in and custom decoder lenses read ElementLookup, and AnyElementEncoder reads its children. ElementLookup is now only a read view over one element, with the child index as the one thing it stores. --- benchmarks/scripts/profile_encode.php | 7 --- src/Xml/Node/ElementList.php | 10 +--- src/Xml/Node/ElementLookup.php | 47 ------------------ .../Reader/DocumentToLookupArrayReader.php | 25 ---------- tests/Unit/Xml/Node/ElementLookupTest.php | 49 ------------------- .../DocumentToLookupArrayReaderTest.php | 38 -------------- 6 files changed, 2 insertions(+), 174 deletions(-) delete mode 100644 src/Xml/Reader/DocumentToLookupArrayReader.php delete mode 100644 tests/Unit/Xml/Reader/DocumentToLookupArrayReaderTest.php diff --git a/benchmarks/scripts/profile_encode.php b/benchmarks/scripts/profile_encode.php index c71a533..5679ced 100644 --- a/benchmarks/scripts/profile_encode.php +++ b/benchmarks/scripts/profile_encode.php @@ -211,13 +211,6 @@ } printf("ElementLookup::fromElement: %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); -// Step 4b: DocumentToLookupArrayReader, the legacy array on top of ElementLookup -$t0 = hrtime(true); -for ($i = 0; $i < $N; $i++) { - (new \Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader())($partElement); -} -printf("DocumentToLookupArrayReader: %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); - // Step 5: object_data from (properties_set) $objectDataIso = \VeeWee\Reflecta\Iso\object_data(\stdClass::class); $propData = ['id' => 42, 'name' => 'Widget', 'description' => 'A widget', 'price' => 29.99, 'quantity' => 100, 'active' => true, 'sku' => 'WDG-001', 'category' => 'Electronics', 'address' => null]; diff --git a/src/Xml/Node/ElementList.php b/src/Xml/Node/ElementList.php index eb71b13..52288ae 100644 --- a/src/Xml/Node/ElementList.php +++ b/src/Xml/Node/ElementList.php @@ -6,7 +6,6 @@ use Closure; use Countable; use Dom\Element as DomElement; -use Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader; use Stringable; use VeeWee\Xml\Dom\Document; use function count; @@ -14,9 +13,6 @@ use function Psl\Vec\map; use function VeeWee\Xml\Dom\Locator\Element\children as readChildren; -/** - * @psalm-import-type LookupArray from DocumentToLookupArrayReader - */ final class ElementList implements Countable, Stringable { /** @var list */ @@ -31,11 +27,9 @@ public function __construct(Element ...$elements) } /** - * Can be used to parse a nested array structure to a full flattened ElementList. + * Flattens elements and lists of elements into one list. Strings are simple values and are skipped. * - * @see \Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader::__invoke - * - * @param LookupArray $data + * @param array $data */ public static function fromLookupArray(array $data): self { diff --git a/src/Xml/Node/ElementLookup.php b/src/Xml/Node/ElementLookup.php index dfce5e5..075b8f2 100644 --- a/src/Xml/Node/ElementLookup.php +++ b/src/Xml/Node/ElementLookup.php @@ -3,26 +3,18 @@ namespace Soap\Encoding\Xml\Node; -use Dom\Attr; use Dom\Element as DomElement; use Dom\Node; -use Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader; use VeeWee\Xml\Xmlns\Xmlns; -use function array_key_exists; use function VeeWee\Xml\Dom\Predicate\is_element; /** * Read model of one XML element that is being decoded: * child elements by local name, attributes by namespace and local name, and the text content. * Attributes and child elements never stand in for each other. - * - * @psalm-import-type LookupArray from DocumentToLookupArrayReader */ final readonly class ElementLookup { - private const SOAP_ENC_11 = 'http://schemas.xmlsoap.org/soap/encoding/'; - private const SOAP_ENC_12 = 'http://www.w3.org/2003/05/soap-encoding'; - /** * @param array $children Keyed by local name, in document order. */ @@ -96,43 +88,4 @@ public function text(): ?string return $this->root->textContent ?? ''; } - - /** - * The element as one flat array, the shape that DocumentToLookupArrayReader returns: - * child elements by local name, the text under '_', and every other attribute by local name. - * A child element wins from an attribute with the same local name. - * Namespace declarations and xsi or soap-enc attributes are left out. - * - * @return LookupArray - */ - public function toArray(): array - { - $nodes = $this->children; - - $text = $this->text(); - if ($text !== null) { - $nodes['_'] = $text; - } - - /** @var iterable $attributes */ - $attributes = $this->root->attributes; - foreach ($attributes as $attribute) { - $key = $attribute->localName; - if (array_key_exists($key, $nodes) || self::isProtocolNamespace($attribute->namespaceURI)) { - continue; - } - - $nodes[$key] = $attribute->value; - } - - return $nodes; - } - - private static function isProtocolNamespace(?string $namespace): bool - { - return match ($namespace) { - Xmlns::xmlns()->value(), Xmlns::xsi()->value(), self::SOAP_ENC_11, self::SOAP_ENC_12 => true, - default => false, - }; - } } diff --git a/src/Xml/Reader/DocumentToLookupArrayReader.php b/src/Xml/Reader/DocumentToLookupArrayReader.php deleted file mode 100644 index f920fe4..0000000 --- a/src/Xml/Reader/DocumentToLookupArrayReader.php +++ /dev/null @@ -1,25 +0,0 @@ - - */ -final class DocumentToLookupArrayReader -{ - /** - * @return LookupArray - * - * @see ElementLookup::toArray() - */ - public function __invoke(Element $xml): array - { - return ElementLookup::fromElement($xml)->toArray(); - } -} diff --git a/tests/Unit/Xml/Node/ElementLookupTest.php b/tests/Unit/Xml/Node/ElementLookupTest.php index faa28ad..028e0e5 100644 --- a/tests/Unit/Xml/Node/ElementLookupTest.php +++ b/tests/Unit/Xml/Node/ElementLookupTest.php @@ -136,55 +136,6 @@ public function it_keeps_repeated_children_and_namespaced_attributes_apart(): vo static::assertSame('Record', $lookup->attribute('type', self::XSI)); } - #[Test] - public function it_keeps_nonconflicting_attributes_in_the_array(): void - { - $lookup = self::lookup(''); - - static::assertSame('text', $lookup->toArray()['label']); - static::assertSame('text', $lookup->attribute('label')); - } - - #[Test] - public function it_builds_the_legacy_array(): void - { - $lookup = self::lookup( - '' - . 'child12' - . '' - ); - - $array = $lookup->toArray(); - - static::assertSame(['type', 'b', 'label', 'code'], array_keys($array)); - static::assertSame($lookup->element('type'), $array['type']); - static::assertSame($lookup->element('b'), $array['b']); - static::assertSame('l', $array['label']); - static::assertSame('c', $array['code']); - } - - /** - * @return iterable}> - */ - public static function legacy_text_cases(): iterable - { - yield 'text and attribute' => ['hello', ['_' => 'hello', 'label' => 'l']]; - yield 'empty element' => ['', ['_' => '']]; - yield 'nil' => ['', []]; - } - - /** - * @param array $expected - */ - #[Test] - #[DataProvider('legacy_text_cases')] - public function it_puts_text_under_underscore_in_the_legacy_array(string $xml, array $expected): void - { - static::assertSame($expected, self::lookup($xml)->toArray()); - } - /** * @param non-empty-string $xml */ diff --git a/tests/Unit/Xml/Reader/DocumentToLookupArrayReaderTest.php b/tests/Unit/Xml/Reader/DocumentToLookupArrayReaderTest.php deleted file mode 100644 index 0c73d2d..0000000 --- a/tests/Unit/Xml/Reader/DocumentToLookupArrayReaderTest.php +++ /dev/null @@ -1,38 +0,0 @@ -firstsecond' - )); - - static::assertSame(['type'], array_keys($lookup)); - static::assertInstanceOf(ElementList::class, $lookup['type']); - static::assertCount(2, $lookup['type']); - } - - #[Test] - public function it_leaves_out_protocol_attributes(): void - { - $lookup = (new DocumentToLookupArrayReader())(Element::fromString( - 'content' - )); - - static::assertSame(['_' => 'content', 'label' => 'text'], $lookup); - } -} From ce051f37cb94e4119f5cd2e1903732904d48f581 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 17:27:54 +0200 Subject: [PATCH 09/25] Walk element children directly when building the lookup firstElementChild and nextElementSibling skip text and comment nodes without a predicate call: about 1.0us instead of 2.4us for an element with 30 children (PHP 8.4, hrtime probe). --- src/Xml/Node/ElementLookup.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Xml/Node/ElementLookup.php b/src/Xml/Node/ElementLookup.php index 075b8f2..486a29c 100644 --- a/src/Xml/Node/ElementLookup.php +++ b/src/Xml/Node/ElementLookup.php @@ -4,9 +4,7 @@ namespace Soap\Encoding\Xml\Node; use Dom\Element as DomElement; -use Dom\Node; use VeeWee\Xml\Xmlns\Xmlns; -use function VeeWee\Xml\Dom\Predicate\is_element; /** * Read model of one XML element that is being decoded: @@ -29,13 +27,7 @@ public static function fromElement(Element $xml): self $root = $xml->element(); $children = []; - /** @var iterable $childNodes */ - $childNodes = $root->childNodes; - foreach ($childNodes as $node) { - if (!is_element($node)) { - continue; - } - + for ($node = $root->firstElementChild; $node !== null; $node = $node->nextElementSibling) { $key = $node->localName; $previous = $children[$key] ?? null; $current = Element::fromDOMElement($node); From e8e9ca2b87eb529437fd4df90543b5ce7946fc32 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 18:28:50 +0200 Subject: [PATCH 10/25] Skip the encoder for null optional elements that write nothing A null value on an optional element that is not nillable goes through ErrorHandlingEncoder and OptionalElementEncoder only to come back as an empty string. ObjectAccess now records per property whether that is the case, and ObjectEncoder skips the iso for those nulls. The empty write still closes the start tag of the parent, so the first skipped element of an object keeps it to write as before. Custom encoders that handle null themselves are not wrapped in the optional element encoder and keep their output. --- src/Encoder/ObjectAccess.php | 19 +++ src/Encoder/ObjectEncoder.php | 10 ++ tests/Fixture/Encoder/NullWritingEncoder.php | 29 ++++ .../Encoder/ObjectEncoderNullElementTest.php | 134 ++++++++++++++++++ 4 files changed, 192 insertions(+) create mode 100644 tests/Fixture/Encoder/NullWritingEncoder.php create mode 100644 tests/Unit/Encoder/ObjectEncoderNullElementTest.php diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 17edde3..189791e 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -33,6 +33,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 $nullWritesNothing Whether a null value encodes to an empty string for this element, so encoding can skip its iso. */ public function __construct( public readonly array $properties, @@ -43,6 +44,7 @@ public function __construct( public readonly array $isAttribute, public readonly array $decodeDefaultValues, public readonly array $omitWhenAbsent, + public readonly array $nullWritesNothing, ) { } @@ -89,6 +91,7 @@ private static function build(Context $context): self $isAttribute = []; $decodeDefaultValues = []; $omitWhenAbsent = []; + $nullWritesNothing = []; $isAnyPropertyQualified = false; foreach ($sortedProperties as $property) { @@ -110,6 +113,7 @@ private static function build(Context $context): self $isAttribute[$normalizedName] = $propertyTypeMeta->isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; $omitWhenAbsent[$normalizedName] = $customEncoderLens === null && self::mayBeAbsent($name, $propertyTypeMeta); + $nullWritesNothing[$normalizedName] = self::nullWritesNothing($name, $propertyTypeMeta, $encoder); $isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false); } @@ -123,9 +127,24 @@ private static function build(Context $context): self $isAttribute, $decodeDefaultValues, $omitWhenAbsent, + $nullWritesNothing, ); } + /** + * The optional element encoder turns null into an empty string for a nullable element that is not nillable. + * A custom encoder that handles null itself is never wrapped in it, so it keeps writing whatever it writes for null. + */ + private static function nullWritesNothing(string $propertyName, TypeMeta $meta, XmlEncoder $encoder): bool + { + return $propertyName !== '_' + && !$meta->isAttribute()->unwrapOr(false) + && $meta->isNullable()->unwrapOr(false) + && !$meta->isNil()->unwrapOr(false) + && $encoder instanceof ErrorHandlingEncoder + && $encoder->decoratedEncoder() instanceof OptionalElementEncoder; + } + /** * Null and absent only encode differently for an optional nillable element: null writes xsi:nil, absent writes nothing. * Every other element, attribute and the '_' text value encodes null and absent the same way. diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index a0831e6..fc9279d 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -74,6 +74,7 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ /** @var object $data */ $data = is_array($data) ? (object) $data : $data; $defaultAction = writeChildren([]); + $keptEmptyWrite = false; $children = [ XsiAttributeBuilder::forEncodedValue( @@ -89,6 +90,15 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ $objectAccess->encoderLenses[$normalizePropertyName], $data ); + if ($value === null && $objectAccess->nullWritesNothing[$normalizePropertyName]) { + // Writing the empty string still closes the start tag, which turns into . + // The first skipped element keeps that write, the later ones change nothing in the output. + if (!$keptEmptyWrite) { + $children[] = raw(''); + $keptEmptyWrite = true; + } + continue; + } if ($value === null && $objectAccess->omitWhenAbsent[$normalizePropertyName] && !property_is_initialized($data, $normalizePropertyName) diff --git a/tests/Fixture/Encoder/NullWritingEncoder.php b/tests/Fixture/Encoder/NullWritingEncoder.php new file mode 100644 index 0000000..089cf52 --- /dev/null +++ b/tests/Fixture/Encoder/NullWritingEncoder.php @@ -0,0 +1,29 @@ + + */ +final class NullWritingEncoder implements Feature\OptionalAware, XmlEncoder +{ + /** + * @return Iso + */ + public function iso(Context $context): Iso + { + return new Iso( + static fn (?string $value): string => '' . ($value ?? 'none') . '', + static fn (Element|string $xml): string => ($xml instanceof Element ? $xml : Element::fromString($xml))->element()->textContent, + ); + } +} diff --git a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php new file mode 100644 index 0000000..44f7f59 --- /dev/null +++ b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + XML; + + /** @return iterable, string}> */ + public static function encoded_values(): iterable + { + yield 'all optional elements null' => ['optionalType', [], '']; + yield 'attribute and all optional elements null' => [ + 'optionalType', + ['attribute' => 'a'], + '', + ]; + yield 'one optional element null' => [ + 'optionalType', + ['second' => 's'], + 's', + ]; + yield 'mixed null elements' => [ + 'mixedType', + ['required' => 'r'], + '' + . '' + . 'rnone', + ]; + } + + /** + * An empty element keeps its separate end tag when its null optional elements write nothing. + */ + #[Test, DataProvider('encoded_values')] + public function null_optional_elements_encode_byte_for_byte(string $typeName, array $data, string $expected): void + { + $actual = (new ObjectEncoder(stdClass::class))->iso(self::context($typeName))->to((object) [ + ...array_fill_keys(array_keys(ObjectAccess::forContext(self::context($typeName))->properties), null), + ...$data, + ]); + + static::assertSame($expected, $actual); + } + + #[Test] + public function only_default_optional_non_nillable_elements_write_nothing_for_null(): void + { + static::assertSame( + [ + 'attribute' => false, + 'optional' => true, + 'optionalList' => false, + 'optionalNillable' => false, + 'requiredNillable' => false, + 'required' => false, + 'custom' => true, + 'nullWriting' => false, + ], + ObjectAccess::forContext(self::context('mixedType'))->nullWritesNothing + ); + static::assertSame( + ['attribute' => false, '_' => false], + ObjectAccess::forContext(self::context('textType'))->nullWritesNothing + ); + } + + private static function context(string $typeName): Context + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:'.$typeName.'"'); + $registry = EncoderRegistry::default() + ->addComplexTypeConverter('https://test', 'customType', new ThrowingLensEncoder()) + ->addComplexTypeConverter('https://test', 'nullWritingType', new NullWritingEncoder()); + + return new Context( + $metadata->getTypes()->fetchByNameAndXmlNamespace($typeName, 'https://test')->getXsdType(), + $metadata, + $registry, + self::buildNamespaces(), + BindingUse::LITERAL, + ); + } +} From c976f421fc1e3b23e6211a069932a7034c29034b Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 18:34:17 +0200 Subject: [PATCH 11/25] Pin absent, trailing and nested null optional elements byte for byte Every expectation also holds on the base branch, so the skip leaves the output of these cases as it was. --- .../Encoder/ObjectEncoderNullElementTest.php | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php index 44f7f59..f63b49e 100644 --- a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php +++ b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php @@ -30,6 +30,12 @@ final class ObjectEncoderNullElementTest extends TestCase + + + + + + @@ -57,23 +63,45 @@ final class ObjectEncoderNullElementTest extends TestCase XML; - /** @return iterable, string}> */ + /** @return iterable */ public static function encoded_values(): iterable { - yield 'all optional elements null' => ['optionalType', [], '']; + $optional = static fn (array $data): object => (object) [...['attribute' => null, 'first' => null, 'second' => null], ...$data]; + + yield 'all optional elements null' => ['optionalType', $optional([]), '']; + yield 'all optional elements absent' => ['optionalType', (object) [], '']; yield 'attribute and all optional elements null' => [ 'optionalType', - ['attribute' => 'a'], + $optional(['attribute' => 'a']), '', ]; - yield 'one optional element null' => [ + yield 'first optional element null' => [ 'optionalType', - ['second' => 's'], + $optional(['second' => 's']), 's', ]; + yield 'last optional element null' => [ + 'optionalType', + $optional(['first' => 'f']), + 'f', + ]; + yield 'nested object with all optional elements null' => [ + 'parentType', + (object) ['child' => $optional([]), 'last' => null], + '', + ]; yield 'mixed null elements' => [ 'mixedType', - ['required' => 'r'], + (object) [ + 'attribute' => null, + 'optional' => null, + 'optionalList' => null, + 'optionalNillable' => null, + 'requiredNillable' => null, + 'required' => 'r', + 'custom' => null, + 'nullWriting' => null, + ], '' . '' . 'rnone', @@ -84,12 +112,9 @@ public static function encoded_values(): iterable * An empty element keeps its separate end tag when its null optional elements write nothing. */ #[Test, DataProvider('encoded_values')] - public function null_optional_elements_encode_byte_for_byte(string $typeName, array $data, string $expected): void + public function null_optional_elements_encode_byte_for_byte(string $typeName, object $data, string $expected): void { - $actual = (new ObjectEncoder(stdClass::class))->iso(self::context($typeName))->to((object) [ - ...array_fill_keys(array_keys(ObjectAccess::forContext(self::context($typeName))->properties), null), - ...$data, - ]); + $actual = (new ObjectEncoder(stdClass::class))->iso(self::context($typeName))->to($data); static::assertSame($expected, $actual); } From 755bfc1f533ef94cc8c75e1929349366d05d1dd7 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 20:35:28 +0200 Subject: [PATCH 12/25] 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 13/25] 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 085fe111bb9d1fde966564cc08e3804dcbfb8a9b Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 07:32:46 +0200 Subject: [PATCH 14/25] Let the encoder say whether null writes nothing ObjectAccess no longer depends on ErrorHandlingEncoder wrapping OptionalElementEncoder directly. An encoder that implements WritesNothingForNull answers for itself, ErrorHandlingEncoder forwards the question, and any other encoder keeps its own null handling. --- src/Encoder/ErrorHandlingEncoder.php | 7 +- src/Encoder/Feature/WritesNothingForNull.php | 13 ++++ src/Encoder/ObjectAccess.php | 13 ++-- src/Encoder/OptionalElementEncoder.php | 9 ++- .../Feature/WritesNothingForNullTest.php | 75 +++++++++++++++++++ 5 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 src/Encoder/Feature/WritesNothingForNull.php create mode 100644 tests/Unit/Encoder/Feature/WritesNothingForNullTest.php diff --git a/src/Encoder/ErrorHandlingEncoder.php b/src/Encoder/ErrorHandlingEncoder.php index 4f3133f..40f0df1 100644 --- a/src/Encoder/ErrorHandlingEncoder.php +++ b/src/Encoder/ErrorHandlingEncoder.php @@ -16,7 +16,7 @@ * @implements XmlEncoder * @implements Feature\DecoratingEncoder */ -final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, XmlEncoder +final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, Feature\WritesNothingForNull, XmlEncoder { /** * @param XmlEncoder $encoder @@ -34,6 +34,11 @@ public function decoratedEncoder(): XmlEncoder return $this->encoder; } + public function writesNothingForNull(Context $context): bool + { + return $this->encoder instanceof Feature\WritesNothingForNull && $this->encoder->writesNothingForNull($context); + } + /** * @return Iso */ diff --git a/src/Encoder/Feature/WritesNothingForNull.php b/src/Encoder/Feature/WritesNothingForNull.php new file mode 100644 index 0000000..782126c --- /dev/null +++ b/src/Encoder/Feature/WritesNothingForNull.php @@ -0,0 +1,13 @@ +isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; $omitWhenAbsent[$normalizedName] = $customEncoderLens === null && self::mayBeAbsent($name, $propertyTypeMeta); - $nullWritesNothing[$normalizedName] = self::nullWritesNothing($name, $propertyTypeMeta, $encoder); + $nullWritesNothing[$normalizedName] = self::nullWritesNothing($name, $propertyTypeMeta, $encoder, $propertyContext); $isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false); } @@ -132,17 +132,14 @@ private static function build(Context $context): self } /** - * The optional element encoder turns null into an empty string for a nullable element that is not nillable. - * A custom encoder that handles null itself is never wrapped in it, so it keeps writing whatever it writes for null. + * The encoder decides whether null encodes to nothing; one that does not say so keeps its own null handling. */ - private static function nullWritesNothing(string $propertyName, TypeMeta $meta, XmlEncoder $encoder): bool + private static function nullWritesNothing(string $propertyName, TypeMeta $meta, XmlEncoder $encoder, Context $context): bool { return $propertyName !== '_' && !$meta->isAttribute()->unwrapOr(false) - && $meta->isNullable()->unwrapOr(false) - && !$meta->isNil()->unwrapOr(false) - && $encoder instanceof ErrorHandlingEncoder - && $encoder->decoratedEncoder() instanceof OptionalElementEncoder; + && $encoder instanceof Feature\WritesNothingForNull + && $encoder->writesNothingForNull($context); } /** diff --git a/src/Encoder/OptionalElementEncoder.php b/src/Encoder/OptionalElementEncoder.php index fb77a14..baddf3e 100644 --- a/src/Encoder/OptionalElementEncoder.php +++ b/src/Encoder/OptionalElementEncoder.php @@ -18,7 +18,7 @@ * @implements XmlEncoder * @implements Feature\DecoratingEncoder */ -final class OptionalElementEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, Feature\OptionalAware, XmlEncoder +final class OptionalElementEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, Feature\OptionalAware, Feature\WritesNothingForNull, XmlEncoder { /** * @param XmlEncoder $elementEncoder @@ -36,6 +36,13 @@ public function decoratedEncoder(): XmlEncoder return $this->elementEncoder; } + public function writesNothingForNull(Context $context): bool + { + $meta = $context->type->getMeta(); + + return $meta->isNullable()->unwrapOr(false) && !$meta->isNil()->unwrapOr(false); + } + /** * @return IsoInterface */ diff --git a/tests/Unit/Encoder/Feature/WritesNothingForNullTest.php b/tests/Unit/Encoder/Feature/WritesNothingForNullTest.php new file mode 100644 index 0000000..347becd --- /dev/null +++ b/tests/Unit/Encoder/Feature/WritesNothingForNullTest.php @@ -0,0 +1,75 @@ + + */ + public static function provideMeta(): iterable + { + yield 'optional, not nillable' => [true, false, true]; + yield 'optional, nillable' => [true, true, false]; + yield 'required' => [false, false, false]; + } + + #[Test] + #[DataProvider('provideMeta')] + public function optional_element_encoder_writes_nothing_for_null_unless_nillable(bool $nullable, bool $nil, bool $expected): void + { + $encoder = new OptionalElementEncoder(new ElementEncoder(new StringTypeEncoder())); + $context = self::createContext(self::type($nullable, $nil)); + + static::assertSame($expected, $encoder->writesNothingForNull($context)); + if ($expected) { + static::assertSame('', $encoder->iso($context)->to(null)); + } + } + + #[Test] + #[DataProvider('provideMeta')] + public function error_handling_encoder_forwards_the_answer(bool $nullable, bool $nil, bool $expected): void + { + $encoder = new ErrorHandlingEncoder(new OptionalElementEncoder(new ElementEncoder(new StringTypeEncoder()))); + + static::assertSame($expected, $encoder->writesNothingForNull(self::createContext(self::type($nullable, $nil)))); + } + + #[Test] + public function error_handling_encoder_answers_no_for_an_encoder_that_does_not_tell(): void + { + $encoder = new ErrorHandlingEncoder(new ElementEncoder(new StringTypeEncoder())); + + static::assertFalse($encoder->writesNothingForNull(self::createContext(self::type(true, false)))); + } + + private static function type(bool $nullable, bool $nil): XsdType + { + return XsdType::guess('string') + ->withXmlTargetNodeName('hello') + ->withMeta( + static fn (TypeMeta $meta): TypeMeta => $meta + ->withIsNullable($nullable) + ->withIsNil($nil) + ->withIsSimple(true) + ->withIsElement(true) + ); + } +} From d8b7f19a70156e64ff5a021a6924b3f0603daef8 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 07:33:31 +0200 Subject: [PATCH 15/25] Skip null optional elements without writing anything An object whose elements are all null now encodes as instead of . Both are the same empty element; the short form is what an object without children already gets elsewhere. --- src/Encoder/ObjectEncoder.php | 7 ------- tests/Unit/Encoder/ObjectEncoderNullElementTest.php | 8 ++++---- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index fc9279d..b8ec961 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -74,7 +74,6 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ /** @var object $data */ $data = is_array($data) ? (object) $data : $data; $defaultAction = writeChildren([]); - $keptEmptyWrite = false; $children = [ XsiAttributeBuilder::forEncodedValue( @@ -91,12 +90,6 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ $data ); if ($value === null && $objectAccess->nullWritesNothing[$normalizePropertyName]) { - // Writing the empty string still closes the start tag, which turns into . - // The first skipped element keeps that write, the later ones change nothing in the output. - if (!$keptEmptyWrite) { - $children[] = raw(''); - $keptEmptyWrite = true; - } continue; } if ($value === null diff --git a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php index f63b49e..274410a 100644 --- a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php +++ b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php @@ -68,12 +68,12 @@ public static function encoded_values(): iterable { $optional = static fn (array $data): object => (object) [...['attribute' => null, 'first' => null, 'second' => null], ...$data]; - yield 'all optional elements null' => ['optionalType', $optional([]), '']; - yield 'all optional elements absent' => ['optionalType', (object) [], '']; + yield 'all optional elements null' => ['optionalType', $optional([]), '']; + yield 'all optional elements absent' => ['optionalType', (object) [], '']; yield 'attribute and all optional elements null' => [ 'optionalType', $optional(['attribute' => 'a']), - '', + '', ]; yield 'first optional element null' => [ 'optionalType', @@ -88,7 +88,7 @@ public static function encoded_values(): iterable yield 'nested object with all optional elements null' => [ 'parentType', (object) ['child' => $optional([]), 'last' => null], - '', + '', ]; yield 'mixed null elements' => [ 'mixedType', From 6e7abd15dcd8b2eac943088f6c750e28a0431240 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 08:09:23 +0200 Subject: [PATCH 16/25] Let the encoder decide what null does for a property Two booleans per property, one for absent nillable elements and one for elements that write nothing, described a single decision from two sides. The encoder now answers it once through NullValueAware with a NullValueHandling case, and ObjectAccess keeps one map of those answers. A custom OptionalAware encoder that is not wrapped in OptionalElementEncoder no longer has its absent optional nillable elements left out, unless it implements NullValueAware itself, because only the encoder can tell what it writes for null. --- src/Encoder/ErrorHandlingEncoder.php | 8 ++-- src/Encoder/Feature/NullValueAware.php | 14 ++++++ src/Encoder/Feature/WritesNothingForNull.php | 13 ----- src/Encoder/NullValueHandling.php | 24 ++++++++++ src/Encoder/ObjectAccess.php | 48 ++++++++----------- src/Encoder/ObjectEncoder.php | 12 ++--- src/Encoder/OptionalElementEncoder.php | 11 +++-- ...ForNullTest.php => NullValueAwareTest.php} | 31 ++++++------ .../Encoder/ObjectEncoderNullElementTest.php | 41 +++++++++++----- .../Encoder/ObjectPropertyPresenceTest.php | 19 ++++---- 10 files changed, 132 insertions(+), 89 deletions(-) create mode 100644 src/Encoder/Feature/NullValueAware.php delete mode 100644 src/Encoder/Feature/WritesNothingForNull.php create mode 100644 src/Encoder/NullValueHandling.php rename tests/Unit/Encoder/Feature/{WritesNothingForNullTest.php => NullValueAwareTest.php} (57%) diff --git a/src/Encoder/ErrorHandlingEncoder.php b/src/Encoder/ErrorHandlingEncoder.php index 40f0df1..aa90e9f 100644 --- a/src/Encoder/ErrorHandlingEncoder.php +++ b/src/Encoder/ErrorHandlingEncoder.php @@ -16,7 +16,7 @@ * @implements XmlEncoder * @implements Feature\DecoratingEncoder */ -final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, Feature\WritesNothingForNull, XmlEncoder +final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, Feature\NullValueAware, XmlEncoder { /** * @param XmlEncoder $encoder @@ -34,9 +34,11 @@ public function decoratedEncoder(): XmlEncoder return $this->encoder; } - public function writesNothingForNull(Context $context): bool + public function nullValueHandling(Context $context): NullValueHandling { - return $this->encoder instanceof Feature\WritesNothingForNull && $this->encoder->writesNothingForNull($context); + return $this->encoder instanceof Feature\NullValueAware + ? $this->encoder->nullValueHandling($context) + : NullValueHandling::Write; } /** diff --git a/src/Encoder/Feature/NullValueAware.php b/src/Encoder/Feature/NullValueAware.php new file mode 100644 index 0000000..f71b00a --- /dev/null +++ b/src/Encoder/Feature/NullValueAware.php @@ -0,0 +1,14 @@ + $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 $nullWritesNothing Whether a null value encodes to an empty string for this element, so encoding can skip its iso. + * @param array $onNull What encoding does with a null value for this property. */ public function __construct( public readonly array $properties, @@ -43,8 +42,7 @@ public function __construct( public readonly bool $isAnyPropertyQualified, public readonly array $isAttribute, public readonly array $decodeDefaultValues, - public readonly array $omitWhenAbsent, - public readonly array $nullWritesNothing, + public readonly array $onNull, ) { } @@ -90,8 +88,7 @@ private static function build(Context $context): self $isos = []; $isAttribute = []; $decodeDefaultValues = []; - $omitWhenAbsent = []; - $nullWritesNothing = []; + $onNull = []; $isAnyPropertyQualified = false; foreach ($sortedProperties as $property) { @@ -112,8 +109,7 @@ private static function build(Context $context): self $isos[$normalizedName] = $encoder->iso($propertyContext); $isAttribute[$normalizedName] = $propertyTypeMeta->isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; - $omitWhenAbsent[$normalizedName] = $customEncoderLens === null && self::mayBeAbsent($name, $propertyTypeMeta); - $nullWritesNothing[$normalizedName] = self::nullWritesNothing($name, $propertyTypeMeta, $encoder, $propertyContext); + $onNull[$normalizedName] = self::onNull($name, $propertyTypeMeta, $encoder, $propertyContext, $customEncoderLens !== null); $isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false); } @@ -126,32 +122,28 @@ private static function build(Context $context): self $isAnyPropertyQualified, $isAttribute, $decodeDefaultValues, - $omitWhenAbsent, - $nullWritesNothing, + $onNull, ); } /** - * The encoder decides whether null encodes to nothing; one that does not say so keeps its own null handling. + * Attributes and the '_' text value always go through their encoder; elements follow what their encoder says. + * A custom lens may read something else than the property of that name, so its null never counts as absent. */ - private static function nullWritesNothing(string $propertyName, TypeMeta $meta, XmlEncoder $encoder, Context $context): bool - { - return $propertyName !== '_' - && !$meta->isAttribute()->unwrapOr(false) - && $encoder instanceof Feature\WritesNothingForNull - && $encoder->writesNothingForNull($context); - } + private static function onNull( + string $propertyName, + TypeMeta $meta, + XmlEncoder $encoder, + Context $context, + bool $hasCustomEncoderLens, + ): NullValueHandling { + if ($propertyName === '_' || $meta->isAttribute()->unwrapOr(false) || !$encoder instanceof Feature\NullValueAware) { + return NullValueHandling::Write; + } - /** - * Null and absent only encode differently for an optional nillable element: null writes xsi:nil, absent writes nothing. - * Every other element, attribute and the '_' text value encodes null and absent the same way. - */ - private static function mayBeAbsent(string $propertyName, TypeMeta $meta): bool - { - return $propertyName !== '_' - && !$meta->isAttribute()->unwrapOr(false) - && $meta->isNil()->unwrapOr(false) - && $meta->minOccurs()->unwrapOr(1) === 0; + $handling = $encoder->nullValueHandling($context); + + return $hasCustomEncoderLens && $handling === NullValueHandling::SkipWhenAbsent ? NullValueHandling::Write : $handling; } /** diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index b8ec961..d0fb151 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -89,13 +89,11 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ $objectAccess->encoderLenses[$normalizePropertyName], $data ); - if ($value === null && $objectAccess->nullWritesNothing[$normalizePropertyName]) { - continue; - } - if ($value === null - && $objectAccess->omitWhenAbsent[$normalizePropertyName] - && !property_is_initialized($data, $normalizePropertyName) - ) { + if ($value === null && match ($objectAccess->onNull[$normalizePropertyName]) { + NullValueHandling::Write => false, + NullValueHandling::Skip => true, + NullValueHandling::SkipWhenAbsent => !property_is_initialized($data, $normalizePropertyName), + }) { continue; } diff --git a/src/Encoder/OptionalElementEncoder.php b/src/Encoder/OptionalElementEncoder.php index baddf3e..032387a 100644 --- a/src/Encoder/OptionalElementEncoder.php +++ b/src/Encoder/OptionalElementEncoder.php @@ -18,7 +18,7 @@ * @implements XmlEncoder * @implements Feature\DecoratingEncoder */ -final class OptionalElementEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, Feature\OptionalAware, Feature\WritesNothingForNull, XmlEncoder +final class OptionalElementEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, Feature\NullValueAware, Feature\OptionalAware, XmlEncoder { /** * @param XmlEncoder $elementEncoder @@ -36,11 +36,16 @@ public function decoratedEncoder(): XmlEncoder return $this->elementEncoder; } - public function writesNothingForNull(Context $context): bool + public function nullValueHandling(Context $context): NullValueHandling { $meta = $context->type->getMeta(); - return $meta->isNullable()->unwrapOr(false) && !$meta->isNil()->unwrapOr(false); + return match (true) { + !$meta->isNullable()->unwrapOr(false) => NullValueHandling::Write, + !$meta->isNil()->unwrapOr(false) => NullValueHandling::Skip, + $meta->minOccurs()->unwrapOr(1) === 0 => NullValueHandling::SkipWhenAbsent, + default => NullValueHandling::Write, + }; } /** diff --git a/tests/Unit/Encoder/Feature/WritesNothingForNullTest.php b/tests/Unit/Encoder/Feature/NullValueAwareTest.php similarity index 57% rename from tests/Unit/Encoder/Feature/WritesNothingForNullTest.php rename to tests/Unit/Encoder/Feature/NullValueAwareTest.php index 347becd..1186bd6 100644 --- a/tests/Unit/Encoder/Feature/WritesNothingForNullTest.php +++ b/tests/Unit/Encoder/Feature/NullValueAwareTest.php @@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase; use Soap\Encoding\Encoder\ElementEncoder; use Soap\Encoding\Encoder\ErrorHandlingEncoder; +use Soap\Encoding\Encoder\NullValueHandling; use Soap\Encoding\Encoder\OptionalElementEncoder; use Soap\Encoding\Encoder\SimpleType\StringTypeEncoder; use Soap\Encoding\Test\Unit\ContextCreatorTrait; @@ -16,51 +17,52 @@ use Soap\Engine\Metadata\Model\XsdType; #[CoversClass(OptionalElementEncoder::class), CoversClass(ErrorHandlingEncoder::class)] -final class WritesNothingForNullTest extends TestCase +final class NullValueAwareTest extends TestCase { use ContextCreatorTrait; /** - * @return iterable + * @return iterable */ public static function provideMeta(): iterable { - yield 'optional, not nillable' => [true, false, true]; - yield 'optional, nillable' => [true, true, false]; - yield 'required' => [false, false, false]; + yield 'required' => [false, false, 1, NullValueHandling::Write]; + yield 'optional, not nillable' => [true, false, 0, NullValueHandling::Skip]; + yield 'optional, nillable' => [true, true, 0, NullValueHandling::SkipWhenAbsent]; + yield 'required, nillable' => [true, true, 1, NullValueHandling::Write]; } #[Test] #[DataProvider('provideMeta')] - public function optional_element_encoder_writes_nothing_for_null_unless_nillable(bool $nullable, bool $nil, bool $expected): void + public function optional_element_encoder_decides_from_the_meta(bool $nullable, bool $nil, int $minOccurs, NullValueHandling $expected): void { $encoder = new OptionalElementEncoder(new ElementEncoder(new StringTypeEncoder())); - $context = self::createContext(self::type($nullable, $nil)); + $context = self::createContext(self::type($nullable, $nil, $minOccurs)); - static::assertSame($expected, $encoder->writesNothingForNull($context)); - if ($expected) { + static::assertSame($expected, $encoder->nullValueHandling($context)); + if ($expected === NullValueHandling::Skip) { static::assertSame('', $encoder->iso($context)->to(null)); } } #[Test] #[DataProvider('provideMeta')] - public function error_handling_encoder_forwards_the_answer(bool $nullable, bool $nil, bool $expected): void + public function error_handling_encoder_forwards_the_answer(bool $nullable, bool $nil, int $minOccurs, NullValueHandling $expected): void { $encoder = new ErrorHandlingEncoder(new OptionalElementEncoder(new ElementEncoder(new StringTypeEncoder()))); - static::assertSame($expected, $encoder->writesNothingForNull(self::createContext(self::type($nullable, $nil)))); + static::assertSame($expected, $encoder->nullValueHandling(self::createContext(self::type($nullable, $nil, $minOccurs)))); } #[Test] - public function error_handling_encoder_answers_no_for_an_encoder_that_does_not_tell(): void + public function error_handling_encoder_writes_for_an_encoder_that_does_not_tell(): void { $encoder = new ErrorHandlingEncoder(new ElementEncoder(new StringTypeEncoder())); - static::assertFalse($encoder->writesNothingForNull(self::createContext(self::type(true, false)))); + static::assertSame(NullValueHandling::Write, $encoder->nullValueHandling(self::createContext(self::type(true, false, 0)))); } - private static function type(bool $nullable, bool $nil): XsdType + private static function type(bool $nullable, bool $nil, int $minOccurs): XsdType { return XsdType::guess('string') ->withXmlTargetNodeName('hello') @@ -68,6 +70,7 @@ private static function type(bool $nullable, bool $nil): XsdType static fn (TypeMeta $meta): TypeMeta => $meta ->withIsNullable($nullable) ->withIsNil($nil) + ->withMinOccurs($minOccurs) ->withIsSimple(true) ->withIsElement(true) ); diff --git a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php index 274410a..ddc8bbe 100644 --- a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php +++ b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Soap\Encoding\Encoder\Context; +use Soap\Encoding\Encoder\NullValueHandling; use Soap\Encoding\Encoder\ObjectAccess; use Soap\Encoding\Encoder\ObjectEncoder; use Soap\Encoding\EncoderRegistry; @@ -55,6 +56,11 @@ final class ObjectEncoderNullElementTest extends TestCase + + + + + @@ -120,27 +126,38 @@ public function null_optional_elements_encode_byte_for_byte(string $typeName, ob } #[Test] - public function only_default_optional_non_nillable_elements_write_nothing_for_null(): void + public function the_encoder_decides_what_null_does_for_an_element(): void { static::assertSame( [ - 'attribute' => false, - 'optional' => true, - 'optionalList' => false, - 'optionalNillable' => false, - 'requiredNillable' => false, - 'required' => false, - 'custom' => true, - 'nullWriting' => false, + 'attribute' => NullValueHandling::Write, + 'optional' => NullValueHandling::Skip, + 'optionalList' => NullValueHandling::Write, + 'optionalNillable' => NullValueHandling::SkipWhenAbsent, + 'requiredNillable' => NullValueHandling::Write, + 'required' => NullValueHandling::Write, + 'custom' => NullValueHandling::Skip, + 'nullWriting' => NullValueHandling::Write, ], - ObjectAccess::forContext(self::context('mixedType'))->nullWritesNothing + ObjectAccess::forContext(self::context('mixedType'))->onNull ); static::assertSame( - ['attribute' => false, '_' => false], - ObjectAccess::forContext(self::context('textType'))->nullWritesNothing + ['attribute' => NullValueHandling::Write, '_' => NullValueHandling::Write], + ObjectAccess::forContext(self::context('textType'))->onNull ); } + /** + * Only the encoder can tell that it leaves an absent value out, so an OptionalAware encoder that does not implement NullValueAware is called for it. + */ + #[Test] + public function absent_optional_nillable_element_is_written_by_an_encoder_that_does_not_tell(): void + { + $actual = (new ObjectEncoder(stdClass::class))->iso(self::context('nillableNullWritingType'))->to((object) []); + + static::assertSame('none', $actual); + } + private static function context(string $typeName): Context { $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:'.$typeName.'"'); diff --git a/tests/Unit/Encoder/ObjectPropertyPresenceTest.php b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php index a882d9a..0bc3e8b 100644 --- a/tests/Unit/Encoder/ObjectPropertyPresenceTest.php +++ b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php @@ -12,6 +12,7 @@ use ReflectionClass; use RuntimeException; use Soap\Encoding\Encoder\Context; +use Soap\Encoding\Encoder\NullValueHandling; use Soap\Encoding\Encoder\ObjectAccess; use Soap\Encoding\Encoder\ObjectEncoder; use Soap\Encoding\Encoder\XmlEncoder; @@ -112,21 +113,21 @@ public function absent_required_nullable_property_keeps_existing_nil_behavior(): #[Test] public function only_optional_nillable_elements_with_the_default_lens_are_left_out_when_absent(): void { - $eligibility = ObjectAccess::forContext(self::context('eligibilityType', new ThrowingLensEncoder()))->omitWhenAbsent; - $text = ObjectAccess::forContext(self::context('textType'))->omitWhenAbsent; + $eligibility = ObjectAccess::forContext(self::context('eligibilityType', new ThrowingLensEncoder()))->onNull; + $text = ObjectAccess::forContext(self::context('textType'))->onNull; static::assertSame( [ - 'attribute' => false, - 'optionalNillable' => true, - 'optionalNillableList' => true, - 'requiredNillable' => false, - 'optional' => false, - 'custom' => false, + 'attribute' => NullValueHandling::Write, + 'optionalNillable' => NullValueHandling::SkipWhenAbsent, + 'optionalNillableList' => NullValueHandling::SkipWhenAbsent, + 'requiredNillable' => NullValueHandling::Write, + 'optional' => NullValueHandling::Skip, + 'custom' => NullValueHandling::Write, ], $eligibility ); - static::assertSame(['attribute' => false, '_' => false], $text); + static::assertSame(['attribute' => NullValueHandling::Write, '_' => NullValueHandling::Write], $text); } /** From 41bb8e70710f64186720473361d68df4fa18b848 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 08:36:53 +0200 Subject: [PATCH 17/25] Describe SkipWhenAbsent without xsi:nil --- src/Encoder/NullValueHandling.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Encoder/NullValueHandling.php b/src/Encoder/NullValueHandling.php index 20ece35..db4fcf9 100644 --- a/src/Encoder/NullValueHandling.php +++ b/src/Encoder/NullValueHandling.php @@ -18,7 +18,7 @@ enum NullValueHandling case Skip; /** - * Leave the element out when the property is absent from the PHP value, let the encoder write xsi:nil when it is set to null. + * Leave the element out when the property is absent from the PHP value, behave like Write when it is set to null. */ case SkipWhenAbsent; } From 64a9906abe8e8c30b85850cf632082f67d5f099f Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 08:37:57 +0200 Subject: [PATCH 18/25] Spell out what each null value handling case writes --- src/Encoder/NullValueHandling.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Encoder/NullValueHandling.php b/src/Encoder/NullValueHandling.php index db4fcf9..23cd0cc 100644 --- a/src/Encoder/NullValueHandling.php +++ b/src/Encoder/NullValueHandling.php @@ -3,22 +3,25 @@ namespace Soap\Encoding\Encoder; /** - * What an object encoder does with a property whose value is null. + * What ObjectEncoder does with a property that is null or absent from the PHP value. + * An absent property reads as null, unless the case below tells them apart. */ enum NullValueHandling { /** - * Let the encoder write it. + * Pass null to the property's encoder and write what it returns. + * For a nillable element that is the element with xsi:nil="true". */ case Write; /** - * The encoder writes nothing for null, so the element is left out. + * Write nothing for the property, so the element is left out. */ case Skip; /** - * Leave the element out when the property is absent from the PHP value, behave like Write when it is set to null. + * Write nothing when the property is absent from the PHP value. + * When it is set to null, behave like Write. */ case SkipWhenAbsent; } From 36e63a30b7d0db8bc88ebfa6f37432d8dd6ceeee Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Fri, 16 Oct 2026 08:40:16 +0200 Subject: [PATCH 19/25] Write onNull as one match --- src/Encoder/ObjectAccess.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 08d39dc..7f794f1 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -137,13 +137,13 @@ private static function onNull( Context $context, bool $hasCustomEncoderLens, ): NullValueHandling { - if ($propertyName === '_' || $meta->isAttribute()->unwrapOr(false) || !$encoder instanceof Feature\NullValueAware) { - return NullValueHandling::Write; - } - - $handling = $encoder->nullValueHandling($context); - - return $hasCustomEncoderLens && $handling === NullValueHandling::SkipWhenAbsent ? NullValueHandling::Write : $handling; + return match (true) { + $propertyName === '_' => NullValueHandling::Write, + $meta->isAttribute()->unwrapOr(false) => NullValueHandling::Write, + !$encoder instanceof Feature\NullValueAware => NullValueHandling::Write, + $hasCustomEncoderLens && $encoder->nullValueHandling($context) === NullValueHandling::SkipWhenAbsent => NullValueHandling::Write, + default => $encoder->nullValueHandling($context), + }; } /** From 85c702f395b307d830847e02a4c0397669dfc742 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 18:34:29 +0200 Subject: [PATCH 20/25] 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 21/25] 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 22/25] 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 23/25] 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 24/25] 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 25/25] 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;