diff --git a/benchmarks/scripts/profile_encode.php b/benchmarks/scripts/profile_encode.php index beb43c8..5679ced 100644 --- a/benchmarks/scripts/profile_encode.php +++ b/benchmarks/scripts/profile_encode.php @@ -204,12 +204,12 @@ } 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++) { - (new \Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader())($partElement); + \Soap\Encoding\Xml\Node\ElementLookup::fromElement($partElement); } -printf("DocumentToLookupArrayReader: %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); +printf("ElementLookup::fromElement: %6.1fus/call\n", (hrtime(true) - $t0) / 1e3 / $N); // Step 5: object_data from (properties_set) $objectDataIso = \VeeWee\Reflecta\Iso\object_data(\stdClass::class); diff --git a/composer.json b/composer.json index 559ad1a..9baf5a1 100644 --- a/composer.json +++ b/composer.json @@ -39,7 +39,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": "~0.34" }, "require-dev": { "vimeo/psalm": "~6.13", 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 17edde3..5195c88 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. @@ -105,7 +107,7 @@ 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); + ?? self::createDecoderLens($name, $propertyType); $isos[$normalizedName] = $encoder->iso($propertyContext); $isAttribute[$normalizedName] = $propertyTypeMeta->isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; @@ -161,7 +163,26 @@ private static function createCustomEncoderLens( } /** - * @return LensInterface|null, array|null, mixed, mixed>|null + * @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 */ private static function createCustomDecoderLens( bool $shouldLensBeOptional, 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/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 new file mode 100644 index 0000000..486a29c --- /dev/null +++ b/src/Xml/Node/ElementLookup.php @@ -0,0 +1,83 @@ + $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 = []; + + for ($node = $root->firstElementChild; $node !== null; $node = $node->nextElementSibling) { + $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. + * Null when absent, also when an attribute with this local name exists in another namespace. + */ + public function attribute(string $localName, string $namespace = ''): ?string + { + return $this->root->getAttributeNS($namespace, $localName); + } + + /** + * 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 ?? ''; + } +} diff --git a/src/Xml/Reader/DocumentToLookupArrayReader.php b/src/Xml/Reader/DocumentToLookupArrayReader.php deleted file mode 100644 index dd6b7b1..0000000 --- a/src/Xml/Reader/DocumentToLookupArrayReader.php +++ /dev/null @@ -1,69 +0,0 @@ - - */ -final class DocumentToLookupArrayReader -{ - /** - * @return LookupArray - */ - 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; - } -} 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/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/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/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 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/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..b11babd --- /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 in the wrong namespace decodes to null' => [ + '', + '', + (object) ['label' => 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 . ''; + $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/Node/ElementLookupTest.php b/tests/Unit/Xml/Node/ElementLookupTest.php new file mode 100644 index 0000000..028e0e5 --- /dev/null +++ b/tests/Unit/Xml/Node/ElementLookupTest.php @@ -0,0 +1,146 @@ +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 '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 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_exact_namespace( + 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)); + } + + /** + * @param non-empty-string $xml + */ + private static function lookup(string $xml): ElementLookup + { + return ElementLookup::fromElement(Element::fromString($xml)); + } +} 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)); + } +}