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/ErrorHandlingEncoder.php b/src/Encoder/ErrorHandlingEncoder.php index 4f3133f..aa90e9f 100644 --- a/src/Encoder/ErrorHandlingEncoder.php +++ b/src/Encoder/ErrorHandlingEncoder.php @@ -16,7 +16,7 @@ * @implements XmlEncoder * @implements Feature\DecoratingEncoder */ -final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, XmlEncoder +final class ErrorHandlingEncoder implements Feature\DecoratingEncoder, Feature\NullValueAware, XmlEncoder { /** * @param XmlEncoder $encoder @@ -34,6 +34,13 @@ public function decoratedEncoder(): XmlEncoder return $this->encoder; } + public function nullValueHandling(Context $context): NullValueHandling + { + return $this->encoder instanceof Feature\NullValueAware + ? $this->encoder->nullValueHandling($context) + : NullValueHandling::Write; + } + /** * @return Iso */ diff --git a/src/Encoder/Feature/NullValueAware.php b/src/Encoder/Feature/NullValueAware.php new file mode 100644 index 0000000..f71b00a --- /dev/null +++ b/src/Encoder/Feature/NullValueAware.php @@ -0,0 +1,14 @@ +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/NullValueHandling.php b/src/Encoder/NullValueHandling.php new file mode 100644 index 0000000..23cd0cc --- /dev/null +++ b/src/Encoder/NullValueHandling.php @@ -0,0 +1,27 @@ + $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 $encodeReaders Reads the property value from the PHP object on encode. + * @param array $decodeReaders Reads the raw XML value from the element on decode. * @param array> $isos Converts between the PHP value and its XML form. * @param bool $isAnyPropertyQualified Whether at least one property is namespace qualified. * @param array $isAttribute Whether the property is an XML attribute instead of a child element. * @param array|null> $decodeDefaultValues The value a property decodes to when its element is missing from the XML. - * @param array $omitWhenAbsent Whether encoding leaves the element out, instead of writing xsi:nil, when the property is absent from the PHP value. + * @param array $onNull What encoding does with a null value for this property. */ public function __construct( public readonly array $properties, - public readonly array $encoderLenses, - public readonly array $decoderLenses, + public readonly array $encodeReaders, + public readonly array $decodeReaders, public readonly array $isos, public readonly bool $isAnyPropertyQualified, public readonly array $isAttribute, public readonly array $decodeDefaultValues, - public readonly array $omitWhenAbsent, + public readonly array $onNull, ) { } @@ -83,12 +84,12 @@ private static function build(Context $context): self ); $normalizedProperties = []; - $encoderLenses = []; - $decoderLenses = []; + $encodeReaders = []; + $decodeReaders = []; $isos = []; $isAttribute = []; $decodeDefaultValues = []; - $omitWhenAbsent = []; + $onNull = []; $isAnyPropertyQualified = false; foreach ($sortedProperties as $property) { @@ -99,102 +100,108 @@ private static function build(Context $context): self $normalizedName = PhpPropertyNameNormalizer::normalize($name); $encoder = $context->registry->detectEncoderForContext($propertyContext); - $shouldLensBeOptional = self::shouldLensBeOptional($propertyTypeMeta); $normalizedProperties[$normalizedName] = $property; - $customEncoderLens = self::createCustomEncoderLens($shouldLensBeOptional, $encoder, $type, $property); - $encoderLenses[$normalizedName] = $customEncoderLens ?? optional_property($normalizedName); - $decoderLenses[$normalizedName] = self::createCustomDecoderLens($shouldLensBeOptional, $encoder, $type, $property) - ?? optional_index($name); + $customEncoderLens = self::createCustomEncoderLens($encoder, $type, $property); + /** @var Closure(object): mixed $encodeReader */ + $encodeReader = $customEncoderLens !== null + ? $customEncoderLens->get(...) + : optional_property($normalizedName)->get(...); + $encodeReaders[$normalizedName] = $encodeReader; + + $customDecoderLens = self::createCustomDecoderLens($encoder, $type, $property); + /** @var Closure(ElementLookup): mixed $decodeReader */ + $decodeReader = $customDecoderLens !== null + ? $customDecoderLens->get(...) + : self::createDecoderReader($name, $propertyType); + $decodeReaders[$normalizedName] = $decodeReader; + $isos[$normalizedName] = $encoder->iso($propertyContext); $isAttribute[$normalizedName] = $propertyTypeMeta->isAttribute()->unwrapOr(false); $decodeDefaultValues[$normalizedName] = $propertyTypeMeta->isList()->unwrapOr(false) ? [] : null; - $omitWhenAbsent[$normalizedName] = $customEncoderLens === null && self::mayBeAbsent($name, $propertyTypeMeta); + $onNull[$normalizedName] = self::onNull($name, $propertyTypeMeta, $encoder, $propertyContext, $customEncoderLens !== null); $isAnyPropertyQualified = $isAnyPropertyQualified || $propertyTypeMeta->isQualified()->unwrapOr(false); } return new self( $normalizedProperties, - $encoderLenses, - $decoderLenses, + $encodeReaders, + $decodeReaders, $isos, $isAnyPropertyQualified, $isAttribute, $decodeDefaultValues, - $omitWhenAbsent, + $onNull, ); } /** - * Null and absent only encode differently for an optional nillable element: null writes xsi:nil, absent writes nothing. - * Every other element, attribute and the '_' text value encodes null and absent the same way. + * Attributes and the '_' text value always go through their encoder; elements follow what their encoder says. + * A custom lens may read something else than the property of that name, so its null never counts as absent. */ - private static function mayBeAbsent(string $propertyName, TypeMeta $meta): bool - { - return $propertyName !== '_' - && !$meta->isAttribute()->unwrapOr(false) - && $meta->isNil()->unwrapOr(false) - && $meta->minOccurs()->unwrapOr(1) === 0; + private static function onNull( + string $propertyName, + TypeMeta $meta, + XmlEncoder $encoder, + Context $context, + bool $hasCustomEncoderLens, + ): NullValueHandling { + return match (true) { + $propertyName === '_' => NullValueHandling::Write, + $meta->isAttribute()->unwrapOr(false) => NullValueHandling::Write, + !$encoder instanceof Feature\NullValueAware => NullValueHandling::Write, + $hasCustomEncoderLens && $encoder->nullValueHandling($context) === NullValueHandling::SkipWhenAbsent => NullValueHandling::Write, + default => $encoder->nullValueHandling($context), + }; } /** * @return LensInterface|null */ private static function createCustomEncoderLens( - bool $shouldLensBeOptional, XmlEncoder $encoder, Type $type, Property $property, ): ?LensInterface { - $lens = match (true) { - $encoder instanceof Feature\DecoratingEncoder => self::createCustomEncoderLens($shouldLensBeOptional, $encoder->decoratedEncoder(), $type, $property), + return match (true) { + $encoder instanceof Feature\DecoratingEncoder => self::createCustomEncoderLens($encoder->decoratedEncoder(), $type, $property), $encoder instanceof Feature\ProvidesObjectEncoderLens => $encoder::createObjectEncoderLens($type, $property), default => null, }; + } + + /** + * @return Closure(ElementLookup): mixed + */ + private static function createDecoderReader(string $name, XsdType $type): Closure + { + $meta = $type->getMeta(); + if ($meta->isAttribute()->unwrapOr(false)) { + $namespace = $meta->isQualified()->unwrapOr(false) ? $type->getXmlTargetNamespace() : ''; + + return static fn (ElementLookup $lookup): ?string => $lookup->attribute($name, $namespace); + } - if ($lens === null) { - return null; + if ($name === '_') { + return static fn (ElementLookup $lookup): ?string => $lookup->text(); } - return $shouldLensBeOptional ? optional($lens) : $lens; + return static fn (ElementLookup $lookup): Element|ElementList|null => $lookup->element($name); } /** - * @return LensInterface|null, array|null, mixed, mixed>|null + * @return LensInterface|null */ private static function createCustomDecoderLens( - bool $shouldLensBeOptional, XmlEncoder $encoder, Type $type, Property $property, ): ?LensInterface { - $lens = match(true) { - $encoder instanceof Feature\DecoratingEncoder => self::createCustomDecoderLens($shouldLensBeOptional, $encoder->decoratedEncoder(), $type, $property), + return match(true) { + $encoder instanceof Feature\DecoratingEncoder => self::createCustomDecoderLens($encoder->decoratedEncoder(), $type, $property), $encoder instanceof Feature\ProvidesObjectDecoderLens => $encoder::createObjectDecoderLens($type, $property), default => null, }; - - if ($lens === null) { - return null; - } - - return $shouldLensBeOptional ? optional($lens) : $lens; - } - - private static function shouldLensBeOptional(TypeMeta $meta): bool - { - if ($meta->isNullable()->unwrapOr(false)) { - return true; - } - - if ( - $meta->isAttribute()->unwrapOr(false) && - $meta->use()->unwrapOr('optional') === 'optional' - ) { - return true; - } - - return false; } } diff --git a/src/Encoder/ObjectEncoder.php b/src/Encoder/ObjectEncoder.php index a0831e6..9d68be3 100644 --- a/src/Encoder/ObjectEncoder.php +++ b/src/Encoder/ObjectEncoder.php @@ -3,15 +3,14 @@ namespace Soap\Encoding\Encoder; -use Exception; use Soap\Encoding\Xml\Node\Element; -use Soap\Encoding\Xml\Reader\DocumentToLookupArrayReader; +use Soap\Encoding\Xml\Node\ElementList; +use Soap\Encoding\Xml\Node\ElementLookup; use Soap\Encoding\Xml\Writer\AttributeBuilder; use Soap\Encoding\Xml\Writer\NilAttributeBuilder; use Soap\Encoding\Xml\Writer\XsdTypeXmlElementWriter; use Soap\Encoding\Xml\Writer\XsiAttributeBuilder; use VeeWee\Reflecta\Iso\Iso; -use VeeWee\Reflecta\Lens\LensInterface; use function is_array; use function Psl\Fun\lazy; use function VeeWee\Reflecta\Iso\object_data; @@ -85,14 +84,12 @@ private function to(Context $context, ObjectAccess $objectAccess, object|array $ ]; foreach ($objectAccess->properties as $normalizePropertyName => $property) { /** @var mixed $value */ - $value = self::runLens( - $objectAccess->encoderLenses[$normalizePropertyName], - $data - ); - if ($value === null - && $objectAccess->omitWhenAbsent[$normalizePropertyName] - && !property_is_initialized($data, $normalizePropertyName) - ) { + $value = $objectAccess->encodeReaders[$normalizePropertyName]($data); + if ($value === null && match ($objectAccess->onNull[$normalizePropertyName]) { + NullValueHandling::Write => false, + NullValueHandling::Skip => true, + NullValueHandling::SkipWhenAbsent => !property_is_initialized($data, $normalizePropertyName), + }) { continue; } @@ -121,17 +118,14 @@ 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); $result = []; foreach ($objectAccess->isos as $normalizePropertyName => $iso) { - /** @var string|null $value */ - $value = self::runLens( - $objectAccess->decoderLenses[$normalizePropertyName], - $nodes, - ); + /** @var Element|ElementList|string|null $value */ + $value = $objectAccess->decodeReaders[$normalizePropertyName]($lookup); /** * @var mixed @@ -146,14 +140,4 @@ private function from(Context $context, ObjectAccess $objectAccess, Element $dat return $objectData->from($result); } - - private static function runLens(LensInterface $lens, mixed $data, mixed $default = null): mixed - { - try { - /** @var mixed */ - return $lens->get($data); - } catch (Exception $e) { - return $default; - } - } } diff --git a/src/Encoder/OptionalElementEncoder.php b/src/Encoder/OptionalElementEncoder.php index fb77a14..cee8c95 100644 --- a/src/Encoder/OptionalElementEncoder.php +++ b/src/Encoder/OptionalElementEncoder.php @@ -18,7 +18,7 @@ * @implements XmlEncoder * @implements Feature\DecoratingEncoder */ -final class OptionalElementEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, Feature\OptionalAware, XmlEncoder +final class OptionalElementEncoder implements Feature\DecoratingEncoder, Feature\ElementAware, Feature\NullValueAware, Feature\OptionalAware, XmlEncoder { /** * @param XmlEncoder $elementEncoder @@ -36,6 +36,18 @@ public function decoratedEncoder(): XmlEncoder return $this->elementEncoder; } + public function nullValueHandling(Context $context): NullValueHandling + { + $meta = $context->type->getMeta(); + + return match (true) { + !$meta->isNullable()->unwrapOr(false) => NullValueHandling::Write, + !$meta->isNil()->unwrapOr(false) => NullValueHandling::Skip, + $meta->minOccurs()->unwrapOr(1) === 0 => NullValueHandling::SkipWhenAbsent, + default => NullValueHandling::Write, + }; + } + /** * @return IsoInterface */ @@ -51,6 +63,8 @@ public function iso(Context $context): IsoInterface return $elementIso; } + $xsiNamespace = Xmlns::xsi()->value(); + return new Iso( /** * @param T|null $raw @@ -63,7 +77,7 @@ public function iso(Context $context): IsoInterface /** * @return T|null */ - static function (ElementList|Element|string $xml) use ($elementIso, $isList) : mixed { + static function (ElementList|Element|string $xml) use ($elementIso, $isList, $xsiNamespace) : mixed { if ($xml === '') { return null; } @@ -79,7 +93,7 @@ static function (ElementList|Element|string $xml) use ($elementIso, $isList) : m $parsedXml instanceof Element => $parsedXml->element(), default => null }; - if ($documentElement && $documentElement->getAttributeNS(Xmlns::xsi()->value(), 'nil') === 'true') { + if ($documentElement && $documentElement->getAttributeNS($xsiNamespace, 'nil') === 'true') { return null; } diff --git a/src/Xml/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/Reader/ElementValueReader.php b/src/Xml/Reader/ElementValueReader.php index e5865c3..48c2210 100644 --- a/src/Xml/Reader/ElementValueReader.php +++ b/src/Xml/Reader/ElementValueReader.php @@ -7,8 +7,6 @@ use Soap\Encoding\Encoder\Context; use Soap\Encoding\Encoder\XmlEncoder; use VeeWee\Reflecta\Iso\IsoInterface; -use function Psl\Type\string; -use function VeeWee\Xml\Dom\Locator\Node\value as readValue; final class ElementValueReader { @@ -30,9 +28,7 @@ public function __invoke( */ public static function forEncoder(Context $context, XmlEncoder $encoder, Element $element): mixed { - return $encoder->iso($context)->from( - readValue($element, string()) - ); + return $encoder->iso($context)->from($element->substitutedNodeValue); } /** @@ -41,8 +37,6 @@ public static function forEncoder(Context $context, XmlEncoder $encoder, Element */ public static function forIso(IsoInterface $iso, Element $element): mixed { - return $iso->from( - readValue($element, string()) - ); + return $iso->from($element->substitutedNodeValue); } } 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/NullWritingEncoder.php b/tests/Fixture/Encoder/NullWritingEncoder.php new file mode 100644 index 0000000..089cf52 --- /dev/null +++ b/tests/Fixture/Encoder/NullWritingEncoder.php @@ -0,0 +1,29 @@ + + */ +final class NullWritingEncoder implements Feature\OptionalAware, XmlEncoder +{ + /** + * @return Iso + */ + public function iso(Context $context): Iso + { + return new Iso( + static fn (?string $value): string => '' . ($value ?? 'none') . '', + static fn (Element|string $xml): string => ($xml instanceof Element ? $xml : Element::fromString($xml))->element()->textContent, + ); + } +} diff --git a/tests/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/Support/Throws/allowed-throws.php b/tests/Support/Throws/allowed-throws.php index cf173e1..a62f603 100644 --- a/tests/Support/Throws/allowed-throws.php +++ b/tests/Support/Throws/allowed-throws.php @@ -1,36 +1,12 @@ ObjectEncoderCustomLensTest::class.'::test_it_decodes_through_custom_lenses_that_throw_on_a_missing_element', - 'throw' => ArrayAccessException::class, - 'via' => 'src/Encoder/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.', - ], - [ - 'test' => ObjectEncoderCustomLensTest::class.'::test_it_encodes_through_custom_lenses_that_throw_on_a_missing_property', - 'throw' => UnreflectableException::class, - 'via' => 'src/Encoder/ObjectEncoder.php -> VeeWee\\Reflecta\\Lens\\Lens->get', - 'throws' => 1, - 'reason' => 'The fixture is a third-party encoder whose encoder lens throws on a missing property, to prove custom lenses still get the optional() and runLens treatment.', - ], - [ - 'test' => ObjectPropertyPresenceTest::class.'::custom_lens_reading_null_keeps_writing_nil#lens throws on the absent property', - 'throw' => UnreflectableException::class, - 'via' => 'src/Encoder/ObjectEncoder.php -> VeeWee\\Reflecta\\Lens\\Lens->get', - 'throws' => 1, - 'reason' => 'The fixture is a third-party encoder whose encoder lens throws on a missing property, to prove that a failing custom lens keeps writing xsi:nil instead of counting as absent.', - ], [ 'test' => ObjectPropertyPresenceTest::class.'::optional_property_preserves_absence_null_and_value#lazy object whose initializer throws', 'throw' => RuntimeException::class, diff --git a/tests/Unit/Encoder/Feature/NullValueAwareTest.php b/tests/Unit/Encoder/Feature/NullValueAwareTest.php new file mode 100644 index 0000000..1186bd6 --- /dev/null +++ b/tests/Unit/Encoder/Feature/NullValueAwareTest.php @@ -0,0 +1,78 @@ + + */ + public static function provideMeta(): iterable + { + yield 'required' => [false, false, 1, NullValueHandling::Write]; + yield 'optional, not nillable' => [true, false, 0, NullValueHandling::Skip]; + yield 'optional, nillable' => [true, true, 0, NullValueHandling::SkipWhenAbsent]; + yield 'required, nillable' => [true, true, 1, NullValueHandling::Write]; + } + + #[Test] + #[DataProvider('provideMeta')] + public function optional_element_encoder_decides_from_the_meta(bool $nullable, bool $nil, int $minOccurs, NullValueHandling $expected): void + { + $encoder = new OptionalElementEncoder(new ElementEncoder(new StringTypeEncoder())); + $context = self::createContext(self::type($nullable, $nil, $minOccurs)); + + static::assertSame($expected, $encoder->nullValueHandling($context)); + if ($expected === NullValueHandling::Skip) { + static::assertSame('', $encoder->iso($context)->to(null)); + } + } + + #[Test] + #[DataProvider('provideMeta')] + public function error_handling_encoder_forwards_the_answer(bool $nullable, bool $nil, int $minOccurs, NullValueHandling $expected): void + { + $encoder = new ErrorHandlingEncoder(new OptionalElementEncoder(new ElementEncoder(new StringTypeEncoder()))); + + static::assertSame($expected, $encoder->nullValueHandling(self::createContext(self::type($nullable, $nil, $minOccurs)))); + } + + #[Test] + public function error_handling_encoder_writes_for_an_encoder_that_does_not_tell(): void + { + $encoder = new ErrorHandlingEncoder(new ElementEncoder(new StringTypeEncoder())); + + static::assertSame(NullValueHandling::Write, $encoder->nullValueHandling(self::createContext(self::type(true, false, 0)))); + } + + private static function type(bool $nullable, bool $nil, int $minOccurs): XsdType + { + return XsdType::guess('string') + ->withXmlTargetNodeName('hello') + ->withMeta( + static fn (TypeMeta $meta): TypeMeta => $meta + ->withIsNullable($nullable) + ->withIsNil($nil) + ->withMinOccurs($minOccurs) + ->withIsSimple(true) + ->withIsElement(true) + ); + } +} diff --git a/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php b/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php new file mode 100644 index 0000000..2e40161 --- /dev/null +++ b/tests/Unit/Encoder/ObjectAccessDecodeReadTest.php @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + XML; + + #[Test] + public function every_property_decodes_through_a_plain_reader(): void + { + $mixed = ObjectAccess::forContext(self::context('mixedType'))->decodeReaders; + $text = ObjectAccess::forContext(self::context('textType'))->decodeReaders; + + static::assertSame(['plain', 'qualified', 'lang', 'element', 'list', 'custom'], array_keys($mixed)); + static::assertSame(['plain', '_'], array_keys($text)); + static::assertContainsOnlyInstancesOf(Closure::class, [...$mixed, ...$text]); + } + + #[Test] + public function custom_property_decodes_through_its_custom_lens(): void + { + $access = ObjectAccess::forContext(self::context('mixedType')); + $lookup = ElementLookup::fromElement(Element::fromString('c')); + ThrowingLensEncoder::$decoderLensReads = 0; + + $actual = $access->decodeReaders['custom']($lookup); + + static::assertSame($lookup->element('custom'), $actual); + static::assertSame(1, ThrowingLensEncoder::$decoderLensReads); + } + + /** @return iterable */ + public static function lookups(): iterable + { + yield 'every property present' => [ + 'mixedType', + '' + . 'e12c', + ]; + yield 'every property absent' => ['mixedType', '']; + yield 'attributes in the wrong namespace' => [ + 'mixedType', + '', + ]; + yield 'text present' => ['textType', 'content']; + yield 'text nil' => [ + 'textType', + '', + ]; + yield 'text with child elements' => ['textType', '']; + } + + #[Test, DataProvider('lookups')] + public function built_in_decoder_readers_read_the_same_value_as_the_direct_read(string $typeName, string $xml): void + { + $access = ObjectAccess::forContext(self::context($typeName)); + $lookup = ElementLookup::fromElement(Element::fromString($xml)); + + $direct = [ + 'mixedType' => [ + 'plain' => $lookup->attribute('plain', ''), + 'qualified' => $lookup->attribute('qualified', 'https://test'), + 'lang' => $lookup->attribute('lang', 'http://www.w3.org/XML/1998/namespace'), + 'element' => $lookup->element('element'), + 'list' => $lookup->element('list'), + ], + 'textType' => [ + 'plain' => $lookup->attribute('plain', ''), + '_' => $lookup->text(), + ], + ][$typeName]; + + foreach ($direct as $property => $expected) { + static::assertSame($expected, $access->decodeReaders[$property]($lookup), $property); + } + } + + private static function context(string $typeName): Context + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:'.$typeName.'"'); + $registry = EncoderRegistry::default() + ->addComplexTypeConverter('https://test', 'customType', new ThrowingLensEncoder()); + + return new Context( + $metadata->getTypes()->fetchByNameAndXmlNamespace($typeName, 'https://test')->getXsdType(), + $metadata, + $registry, + self::buildNamespaces(), + BindingUse::LITERAL, + ); + } +} diff --git a/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php b/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php index 0bd3c7c..32362aa 100644 --- a/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php +++ b/tests/Unit/Encoder/ObjectEncoderCustomLensTest.php @@ -7,13 +7,17 @@ use PHPUnit\Framework\TestCase; use Soap\Encoding\Encoder\AnyElementEncoder; use Soap\Encoding\Encoder\Context; +use Soap\Encoding\Encoder\ErrorHandlingEncoder; use Soap\Encoding\Encoder\ObjectAccess; use Soap\Encoding\Encoder\ObjectEncoder; use Soap\Encoding\EncoderRegistry; +use Soap\Encoding\Exception\EncodingException; use Soap\Encoding\Test\Fixture\Encoder\ThrowingLensEncoder; use Soap\Encoding\Test\Unit\ContextCreatorTrait; use Soap\WsdlReader\Model\Definitions\BindingUse; use stdClass; +use VeeWee\Reflecta\ArrayAccess\Exception\ArrayAccessException; +use VeeWee\Reflecta\Reflect\Exception\UnreflectableException; #[CoversClass(ObjectEncoder::class)] #[CoversClass(ObjectAccess::class)] @@ -41,11 +45,15 @@ protected function setUp(): void ThrowingLensEncoder::$decoderLensReads = 0; } - public function test_it_decodes_through_custom_lenses_that_throw_on_a_missing_element(): void + public function test_it_fails_decoding_when_a_custom_lens_throws_on_a_missing_element(): void { - $actual = $this->iso()->from('1x'); + try { + $this->iso()->from('1x'); + static::fail('Expected the throwing custom lens to fail decoding.'); + } catch (EncodingException $exception) { + static::assertInstanceOf(ArrayAccessException::class, $exception->getPrevious()); + } - static::assertEquals((object) ['id' => 1, 'custom' => null, 'any' => ['x']], $actual); static::assertSame(1, ThrowingLensEncoder::$decoderLensReads); } @@ -57,11 +65,30 @@ public function test_it_decodes_through_custom_lenses_when_the_element_is_presen static::assertSame(1, ThrowingLensEncoder::$decoderLensReads); } - public function test_it_encodes_through_custom_lenses_that_throw_on_a_missing_property(): void + public function test_it_gives_custom_lenses_the_child_when_an_attribute_has_the_same_name(): void { - $actual = $this->iso()->to((object) ['id' => 1, 'any' => ['x']]); + $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_fails_encoding_when_a_custom_lens_throws_on_a_missing_property(): void + { + try { + $this->iso()->to((object) ['id' => 1, 'any' => ['x']]); + static::fail('Expected the throwing custom lens to fail encoding.'); + } catch (EncodingException $exception) { + static::assertInstanceOf(UnreflectableException::class, $exception->getPrevious()); + } - static::assertSame('1x', $actual); static::assertSame(1, ThrowingLensEncoder::$encoderLensReads); } @@ -88,6 +115,6 @@ private function iso(): \VeeWee\Reflecta\Iso\IsoInterface self::buildNamespaces(), ); - return (new ObjectEncoder(stdClass::class))->iso($context); + return (new ErrorHandlingEncoder(new ObjectEncoder(stdClass::class)))->iso($context); } } diff --git a/tests/Unit/Encoder/ObjectEncoderNullElementTest.php b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php new file mode 100644 index 0000000..ddc8bbe --- /dev/null +++ b/tests/Unit/Encoder/ObjectEncoderNullElementTest.php @@ -0,0 +1,176 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + XML; + + /** @return iterable */ + public static function encoded_values(): iterable + { + $optional = static fn (array $data): object => (object) [...['attribute' => null, 'first' => null, 'second' => null], ...$data]; + + yield 'all optional elements null' => ['optionalType', $optional([]), '']; + yield 'all optional elements absent' => ['optionalType', (object) [], '']; + yield 'attribute and all optional elements null' => [ + 'optionalType', + $optional(['attribute' => 'a']), + '', + ]; + yield 'first optional element null' => [ + 'optionalType', + $optional(['second' => 's']), + 's', + ]; + yield 'last optional element null' => [ + 'optionalType', + $optional(['first' => 'f']), + 'f', + ]; + yield 'nested object with all optional elements null' => [ + 'parentType', + (object) ['child' => $optional([]), 'last' => null], + '', + ]; + yield 'mixed null elements' => [ + 'mixedType', + (object) [ + 'attribute' => null, + 'optional' => null, + 'optionalList' => null, + 'optionalNillable' => null, + 'requiredNillable' => null, + 'required' => 'r', + 'custom' => null, + 'nullWriting' => null, + ], + '' + . '' + . 'rnone', + ]; + } + + /** + * An empty element keeps its separate end tag when its null optional elements write nothing. + */ + #[Test, DataProvider('encoded_values')] + public function null_optional_elements_encode_byte_for_byte(string $typeName, object $data, string $expected): void + { + $actual = (new ObjectEncoder(stdClass::class))->iso(self::context($typeName))->to($data); + + static::assertSame($expected, $actual); + } + + #[Test] + public function the_encoder_decides_what_null_does_for_an_element(): void + { + static::assertSame( + [ + 'attribute' => NullValueHandling::Write, + 'optional' => NullValueHandling::Skip, + 'optionalList' => NullValueHandling::Write, + 'optionalNillable' => NullValueHandling::SkipWhenAbsent, + 'requiredNillable' => NullValueHandling::Write, + 'required' => NullValueHandling::Write, + 'custom' => NullValueHandling::Skip, + 'nullWriting' => NullValueHandling::Write, + ], + ObjectAccess::forContext(self::context('mixedType'))->onNull + ); + static::assertSame( + ['attribute' => NullValueHandling::Write, '_' => NullValueHandling::Write], + ObjectAccess::forContext(self::context('textType'))->onNull + ); + } + + /** + * Only the encoder can tell that it leaves an absent value out, so an OptionalAware encoder that does not implement NullValueAware is called for it. + */ + #[Test] + public function absent_optional_nillable_element_is_written_by_an_encoder_that_does_not_tell(): void + { + $actual = (new ObjectEncoder(stdClass::class))->iso(self::context('nillableNullWritingType'))->to((object) []); + + static::assertSame('none', $actual); + } + + private static function context(string $typeName): Context + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:'.$typeName.'"'); + $registry = EncoderRegistry::default() + ->addComplexTypeConverter('https://test', 'customType', new ThrowingLensEncoder()) + ->addComplexTypeConverter('https://test', 'nullWritingType', new NullWritingEncoder()); + + return new Context( + $metadata->getTypes()->fetchByNameAndXmlNamespace($typeName, 'https://test')->getXsdType(), + $metadata, + $registry, + self::buildNamespaces(), + BindingUse::LITERAL, + ); + } +} diff --git a/tests/Unit/Encoder/ObjectPropertyPresenceTest.php b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php index a882d9a..7a74ba6 100644 --- a/tests/Unit/Encoder/ObjectPropertyPresenceTest.php +++ b/tests/Unit/Encoder/ObjectPropertyPresenceTest.php @@ -12,15 +12,19 @@ use ReflectionClass; use RuntimeException; use Soap\Encoding\Encoder\Context; +use Soap\Encoding\Encoder\ErrorHandlingEncoder; +use Soap\Encoding\Encoder\NullValueHandling; use Soap\Encoding\Encoder\ObjectAccess; use Soap\Encoding\Encoder\ObjectEncoder; use Soap\Encoding\Encoder\XmlEncoder; use Soap\Encoding\EncoderRegistry; +use Soap\Encoding\Exception\EncodingException; use Soap\Encoding\Test\Fixture\Encoder\NullLensEncoder; use Soap\Encoding\Test\Fixture\Encoder\ThrowingLensEncoder; use Soap\Encoding\Test\Unit\ContextCreatorTrait; use Soap\WsdlReader\Model\Definitions\BindingUse; use stdClass; +use VeeWee\Reflecta\Reflect\Exception\UnreflectableException; #[CoversClass(ObjectEncoder::class), CoversClass(ObjectAccess::class)] final class ObjectPropertyPresenceTest extends TestCase @@ -112,41 +116,48 @@ public function absent_required_nullable_property_keeps_existing_nil_behavior(): #[Test] public function only_optional_nillable_elements_with_the_default_lens_are_left_out_when_absent(): void { - $eligibility = ObjectAccess::forContext(self::context('eligibilityType', new ThrowingLensEncoder()))->omitWhenAbsent; - $text = ObjectAccess::forContext(self::context('textType'))->omitWhenAbsent; + $eligibility = ObjectAccess::forContext(self::context('eligibilityType', new ThrowingLensEncoder()))->onNull; + $text = ObjectAccess::forContext(self::context('textType'))->onNull; static::assertSame( [ - 'attribute' => false, - 'optionalNillable' => true, - 'optionalNillableList' => true, - 'requiredNillable' => false, - 'optional' => false, - 'custom' => false, + 'attribute' => NullValueHandling::Write, + 'optionalNillable' => NullValueHandling::SkipWhenAbsent, + 'optionalNillableList' => NullValueHandling::SkipWhenAbsent, + 'requiredNillable' => NullValueHandling::Write, + 'optional' => NullValueHandling::Skip, + 'custom' => NullValueHandling::Write, ], $eligibility ); - static::assertSame(['attribute' => false, '_' => false], $text); + static::assertSame(['attribute' => NullValueHandling::Write, '_' => NullValueHandling::Write], $text); } /** * A custom lens may read something else than the property of that name, so its null is never taken for absence. */ - #[Test, DataProvider('custom_lenses')] - public function custom_lens_reading_null_keeps_writing_nil(XmlEncoder $customEncoder): void + #[Test] + public function custom_lens_reading_null_keeps_writing_nil(): void { - $context = self::context('eligibilityType', $customEncoder); + $context = self::context('eligibilityType', new NullLensEncoder()); $xml = (new ObjectEncoder(stdClass::class))->iso($context)->to((object) ['requiredNillable' => 'r']); $xpath = self::xpath($xml); static::assertSame('true', $xpath->evaluate('string(/*/custom/@xsi:nil)')); } - /** @return iterable */ - public static function custom_lenses(): iterable + #[Test] + public function custom_lens_throwing_on_the_absent_property_fails_encoding(): void { - yield 'lens throws on the absent property' => [new ThrowingLensEncoder()]; - yield 'lens returns null' => [new NullLensEncoder()]; + $context = self::context('eligibilityType', new ThrowingLensEncoder()); + $iso = (new ErrorHandlingEncoder(new ObjectEncoder(stdClass::class)))->iso($context); + + try { + $iso->to((object) ['requiredNillable' => 'r']); + static::fail('Expected the throwing custom lens to fail encoding.'); + } catch (EncodingException $exception) { + static::assertInstanceOf(UnreflectableException::class, $exception->getPrevious()); + } } private static function withUnsetNote(): object 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)); + } +}