From ea0523dc9eaccb32a45871363b6cb038ce3b03b8 Mon Sep 17 00:00:00 2001 From: NickSdot Date: Tue, 13 Oct 2026 14:41:11 +0800 Subject: [PATCH 1/2] fix: separate XML attribute and child element lookup --- src/Encoder/ObjectAccess.php | 17 ++- .../Reader/DocumentToLookupArrayReader.php | 16 ++- tests/Unit/Encoder/ObjectXmlLookupTest.php | 119 ++++++++++++++++++ 3 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Encoder/ObjectXmlLookupTest.php diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 64ebd84..567ea6f 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -137,12 +137,27 @@ private static function createDecoderLensForType( $lens = match(true) { $encoder instanceof Feature\DecoratingEncoder => self::createDecoderLensForType($shouldLensBeOptional, $name, $encoder->decoratedEncoder(), $type, $property), $encoder instanceof Feature\ProvidesObjectDecoderLens => $encoder::createObjectDecoderLens($type, $property), - default => index($name), + default => $property->getType()->getMeta()->isAttribute()->unwrapOr(false) + ? self::createAttributeDecoderLens($property) + : index($name), }; return $shouldLensBeOptional ? optional($lens) : $lens; } + /** + * @return LensInterface|null, array|null, mixed, mixed> + */ + private static function createAttributeDecoderLens(Property $property): LensInterface + { + $type = $property->getType(); + $namespace = $type->getMeta()->isQualified()->unwrapOr(false) + ? $type->getXmlTargetNamespace() + : ''; + + return index('@{' . $namespace . '}' . $property->getName()); + } + private static function shouldLensBeOptional(TypeMeta $meta): bool { if ($meta->isNullable()->unwrapOr(false)) { diff --git a/src/Xml/Reader/DocumentToLookupArrayReader.php b/src/Xml/Reader/DocumentToLookupArrayReader.php index dd6b7b1..a27a3f8 100644 --- a/src/Xml/Reader/DocumentToLookupArrayReader.php +++ b/src/Xml/Reader/DocumentToLookupArrayReader.php @@ -8,6 +8,8 @@ use Soap\Encoding\Xml\Node\Element; use Soap\Encoding\Xml\Node\ElementList; use VeeWee\Xml\Xmlns\Xmlns; +use function array_key_exists; +use function in_array; use function VeeWee\Xml\Dom\Predicate\is_element; /** @@ -61,7 +63,19 @@ public function __invoke(Element $xml): array $attributes = $root->attributes; foreach ($attributes as $attribute) { $key = $attribute->localName; - $nodes[$key] = $attribute->value; + $namespace = $attribute->namespaceURI ?? ''; + $nodes['@{' . $namespace . '}' . $key] = $attribute->value; + + // Retain legacy custom-lens keys without replacing child elements. + // Protocol attributes must not masquerade as schema element values. + if (!array_key_exists($key, $nodes) && !in_array($namespace, [ + Xmlns::xsi()->value(), + 'http://www.w3.org/2000/xmlns/', + 'http://schemas.xmlsoap.org/soap/encoding/', + 'http://www.w3.org/2003/05/soap-encoding', + ], true)) { + $nodes[$key] = $attribute->value; + } } return $nodes; diff --git a/tests/Unit/Encoder/ObjectXmlLookupTest.php b/tests/Unit/Encoder/ObjectXmlLookupTest.php new file mode 100644 index 0000000..8ce34a9 --- /dev/null +++ b/tests/Unit/Encoder/ObjectXmlLookupTest.php @@ -0,0 +1,119 @@ + */ + 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 'unqualified schema type attribute survives xsi:type' => [ + '', + '', + (object) ['type' => 'schema'], + ]; + yield 'qualified schema attribute ignores unqualified namesake' => [ + '', + '', + (object) ['label' => 'correct'], + ]; + yield 'unqualified schema attribute ignores qualified namesake' => [ + '', + '', + (object) ['label' => 'correct'], + ]; + } + + #[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); + } + + #[Test] + public function lookup_keeps_repeated_children_and_qualified_attribute_keys(): void + { + $reader = new DocumentToLookupArrayReader(); + $xml = Element::fromString('firstsecond'); + + $lookup = $reader($xml); + + static::assertInstanceOf(ElementList::class, $lookup['type']); + static::assertCount(2, $lookup['type']); + static::assertSame('attribute', $lookup['@{}type']); + static::assertSame('Record', $lookup['@{http://www.w3.org/2001/XMLSchema-instance}type']); + } + + #[Test] + public function nonconflicting_legacy_attribute_keys_remain_available_for_custom_lenses(): void + { + $reader = new DocumentToLookupArrayReader(); + + $lookup = $reader(Element::fromString('')); + + static::assertSame('text', $lookup['label']); + static::assertSame('text', $lookup['@{}label']); + } +} From d58069b47444aa5fdc9c68a2fa7e91b3dbd636e9 Mon Sep 17 00:00:00 2001 From: NickSdot Date: Tue, 13 Oct 2026 15:37:00 +0800 Subject: [PATCH 2/2] fix: exclude legacy attribute aliases from child decoding --- src/Encoder/ObjectAccess.php | 20 +++++++++++++++++++- tests/Unit/Encoder/ObjectXmlLookupTest.php | 10 ++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 567ea6f..c686d85 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -13,6 +13,7 @@ use Soap\Engine\Metadata\Model\Type; use Soap\Engine\Metadata\Model\TypeMeta; use VeeWee\Reflecta\Iso\IsoInterface; +use VeeWee\Reflecta\Lens\Lens; use VeeWee\Reflecta\Lens\LensInterface; use function Psl\Vec\sort_by; use function VeeWee\Reflecta\Lens\index; @@ -139,7 +140,7 @@ private static function createDecoderLensForType( $encoder instanceof Feature\ProvidesObjectDecoderLens => $encoder::createObjectDecoderLens($type, $property), default => $property->getType()->getMeta()->isAttribute()->unwrapOr(false) ? self::createAttributeDecoderLens($property) - : index($name), + : self::createElementDecoderLens($name), }; return $shouldLensBeOptional ? optional($lens) : $lens; @@ -158,6 +159,23 @@ private static function createAttributeDecoderLens(Property $property): LensInte return index('@{' . $namespace . '}' . $property->getName()); } + /** + * @return LensInterface|null, array|null, mixed, mixed> + */ + private static function createElementDecoderLens(string $name): LensInterface + { + if ($name === '_') { + return index($name); + } + + return Lens::readonly(static function (array $nodes) use ($name): Element|ElementList|null { + /** @var mixed $value */ + $value = $nodes[$name] ?? null; + + return $value instanceof Element || $value instanceof ElementList ? $value : null; + }); + } + private static function shouldLensBeOptional(TypeMeta $meta): bool { if ($meta->isNullable()->unwrapOr(false)) { diff --git a/tests/Unit/Encoder/ObjectXmlLookupTest.php b/tests/Unit/Encoder/ObjectXmlLookupTest.php index 8ce34a9..a7603bc 100644 --- a/tests/Unit/Encoder/ObjectXmlLookupTest.php +++ b/tests/Unit/Encoder/ObjectXmlLookupTest.php @@ -34,6 +34,16 @@ public static function lookup_cases(): iterable '', (object) ['type' => null], ]; + yield 'ordinary attribute does not invent missing child' => [ + '', + '', + (object) ['type' => null], + ]; + yield 'simple content remains available' => [ + '', + 'content', + (object) ['_' => 'content', 'label' => 'attribute'], + ]; yield 'unqualified schema type attribute survives xsi:type' => [ '', '',