From 5937c5d48bbceceadf695699fec064362d16821a Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 13:07:21 +0200 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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);