diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index 64ebd84..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; @@ -137,12 +138,44 @@ 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) + : self::createElementDecoderLens($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()); + } + + /** + * @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/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..a7603bc --- /dev/null +++ b/tests/Unit/Encoder/ObjectXmlLookupTest.php @@ -0,0 +1,129 @@ + */ + 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 'simple content remains available' => [ + '', + 'content', + (object) ['_' => 'content', 'label' => 'attribute'], + ]; + 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']); + } +}