From 0250aa03d5fe43839438fdc6f0e03dbaff5d3057 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 17:46:55 +0200 Subject: [PATCH 1/2] Build the xsi:type cache key before the detected type The encoder cache key only needs the namespace, the local name and the slot meta. I now read those from the element and only build the XsdType copy when the cache misses. --- src/TypeInference/XsiTypeDetector.php | 80 ++++++++++++++++----------- 1 file changed, 48 insertions(+), 32 deletions(-) diff --git a/src/TypeInference/XsiTypeDetector.php b/src/TypeInference/XsiTypeDetector.php index 2028ffb..1e43f0b 100644 --- a/src/TypeInference/XsiTypeDetector.php +++ b/src/TypeInference/XsiTypeDetector.php @@ -63,32 +63,12 @@ static function () use ($context, $value) { */ public static function detectXsdTypeFromXmlElement(Context $context, Element $element): Option { - $xsiType = $element->getAttributeNS(Xmlns::xsi()->value(), 'type') ?? $element->getAttribute('xsi:type'); - if ($xsiType === null || $xsiType === '') { + $qname = self::detectQNameFromXmlElement($context, $element); + if ($qname === null) { return none(); } - [$prefix, $localName] = (new QnameParser)($xsiType); - if (!$localName) { - return none(); - } - - $namespaceUri = $prefix - ? $element->lookupNamespaceURI($prefix) ?? $context->namespaces->lookupNamespaceFromName($prefix)->unwrapOr(null) - : $element->lookupNamespaceURI(null) ?? $element->namespaceURI; - - if ($namespaceUri === null || $namespaceUri === '') { - return none(); - } - - return some( - // We create a new type based on the detected xsi:type, but we keep the meta information of the original type. - // This way we can still detect if the type is nullable, a union, used on an element, ... - $context->type - ->copy($localName) - ->withXmlTypeName($localName) - ->withXmlNamespace($namespaceUri) - ); + return some(self::createXsdType($context, $qname[0], $qname[1])); } /** @@ -96,19 +76,19 @@ public static function detectXsdTypeFromXmlElement(Context $context, Element $el */ public static function detectEncoderFromXmlElement(Context $context, Element $element): Option { - $requestedXsiType = self::detectXsdTypeFromXmlElement($context, $element); - if (!$requestedXsiType->isSome()) { + $qname = self::detectQNameFromXmlElement($context, $element); + if ($qname === null) { return none(); } - $requestedType = $requestedXsiType->unwrap(); + [$namespaceUri, $localName] = $qname; return some( self::cache()->lookup( $context->registry, - self::cacheKey($requestedType), - static function () use ($context, $requestedType): FixedIsoEncoder { - $type = self::withResolvedSimpleness($context, $requestedType); + self::cacheKey($namespaceUri, $localName, $context->type->getMeta()), + static function () use ($context, $namespaceUri, $localName): FixedIsoEncoder { + $type = self::withResolvedSimpleness($context, self::createXsdType($context, $namespaceUri, $localName)); $normalizedMeta = $type->getMeta() ->withIsNullable(false) ->withIsRepeatingElement(false); @@ -126,11 +106,47 @@ static function () use ($context, $requestedType): FixedIsoEncoder { ); } - private static function cacheKey(XsdType $type): string + /** + * @return array{non-empty-string, non-empty-string}|null + */ + private static function detectQNameFromXmlElement(Context $context, Element $element): ?array { - $meta = $type->getMeta(); + $xsiType = $element->getAttributeNS(Xmlns::xsi()->value(), 'type') ?? $element->getAttribute('xsi:type'); + if ($xsiType === null || $xsiType === '') { + return null; + } + + [$prefix, $localName] = (new QnameParser)($xsiType); + if (!$localName) { + return null; + } + + $namespaceUri = $prefix + ? $element->lookupNamespaceURI($prefix) ?? $context->namespaces->lookupNamespaceFromName($prefix)->unwrapOr(null) + : $element->lookupNamespaceURI(null) ?? $element->namespaceURI; + + if ($namespaceUri === null || $namespaceUri === '') { + return null; + } + + return [$namespaceUri, $localName]; + } + + /** + * We create a new type based on the detected xsi:type, but we keep the meta information of the original type. + * This way we can still detect if the type is nullable, a union, used on an element, ... + */ + private static function createXsdType(Context $context, string $namespaceUri, string $localName): XsdType + { + return $context->type + ->copy($localName) + ->withXmlTypeName($localName) + ->withXmlNamespace($namespaceUri); + } - return $type->getXmlNamespace() . '|' . $type->getXmlTypeName() + private static function cacheKey(string $namespaceUri, string $localName, TypeMeta $meta): string + { + return $namespaceUri . '|' . $localName . '|' . ($meta->isElement()->unwrapOr(false) ? 'e' : '') . ($meta->isAttribute()->unwrapOr(false) ? 'a' : '') . ($meta->isNullable()->unwrapOr(false) ? 'n' : '') From ab0e6e7357d7ef3f7815b72f58c5521702ac443c Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 15 Oct 2026 17:48:14 +0200 Subject: [PATCH 2/2] Resolve the xsi:type slot key once per iso The slot part of the cache key only depends on the context, which is fixed for an iso. XsiTypeEncoder now asks the detector for a per context detector when it builds its iso, so decoding an element only parses the xsi:type and does the cache lookup. --- src/Encoder/XsiTypeEncoder.php | 17 ++--- src/TypeInference/XsiTypeDetector.php | 42 +++++++---- tests/Unit/Encoder/XsiTypeEncoderTest.php | 88 +++++++++++++++++++++++ 3 files changed, 126 insertions(+), 21 deletions(-) create mode 100644 tests/Unit/Encoder/XsiTypeEncoderTest.php diff --git a/src/Encoder/XsiTypeEncoder.php b/src/Encoder/XsiTypeEncoder.php index 1f92fb1..fb2b520 100644 --- a/src/Encoder/XsiTypeEncoder.php +++ b/src/Encoder/XsiTypeEncoder.php @@ -2,6 +2,7 @@ namespace Soap\Encoding\Encoder; +use Closure; use Soap\Encoding\TypeInference\XsiTypeDetector; use Soap\Encoding\Xml\Node\Element; use Soap\Encoding\Xml\Node\ElementList; @@ -28,15 +29,19 @@ public function __construct( public function iso(Context $context): Iso { $innerIso = $this->encoder->iso($context); + $detectEncoder = $this->encoder instanceof Feature\DisregardXsiInformation + ? null + : XsiTypeDetector::encoderDetectorForContext($context); return new Iso( function (mixed $value) use ($innerIso) : string { return $this->to($innerIso, $value); }, - function (string|Element $value) use ($context, $innerIso) : mixed { + function (string|Element $value) use ($context, $innerIso, $detectEncoder) : mixed { return $this->from( $context, $innerIso, + $detectEncoder, ($value instanceof Element ? $value : Element::fromString(non_empty_string()->assert($value))) ); } @@ -60,15 +65,11 @@ private function to(IsoInterface $innerIso, mixed $value): string /** * @param IsoInterface $innerIso + * @param (Closure(\Dom\Element): (FixedIsoEncoder|null))|null $detectEncoder */ - private function from(Context $context, IsoInterface $innerIso, Element $value): mixed + private function from(Context $context, IsoInterface $innerIso, ?Closure $detectEncoder, Element $value): mixed { - $iso = match (true) { - $this->encoder instanceof Feature\DisregardXsiInformation => $innerIso, - default => XsiTypeDetector::detectEncoderFromXmlElement($context, $value->element()) - ->map(static fn (XmlEncoder $encoder): IsoInterface => $encoder->iso($context)) - ->unwrapOr($innerIso), - }; + $iso = ($detectEncoder ? $detectEncoder($value->element()) : null)?->iso($context) ?? $innerIso; /** @psalm-suppress ImplicitToStringCast - Encoders accept string|Element in from() */ return $iso->from($value); diff --git a/src/TypeInference/XsiTypeDetector.php b/src/TypeInference/XsiTypeDetector.php index 1e43f0b..d3e7b57 100644 --- a/src/TypeInference/XsiTypeDetector.php +++ b/src/TypeInference/XsiTypeDetector.php @@ -3,6 +3,7 @@ namespace Soap\Encoding\TypeInference; +use Closure; use Dom\Element; use Psl\Option\Option; use Soap\Encoding\Cache\ScopedCache; @@ -20,6 +21,7 @@ use function is_float; use function is_int; use function is_string; +use function Psl\Option\from_nullable; use function Psl\Option\none; use function Psl\Option\some; use function sprintf; @@ -76,17 +78,32 @@ public static function detectXsdTypeFromXmlElement(Context $context, Element $el */ public static function detectEncoderFromXmlElement(Context $context, Element $element): Option { - $qname = self::detectQNameFromXmlElement($context, $element); - if ($qname === null) { - return none(); - } + return from_nullable(self::encoderDetectorForContext($context)($element)); + } - [$namespaceUri, $localName] = $qname; + /** + * The slot part of the cache key only depends on the context, + * so an iso can resolve it once and reuse the detector for every element it decodes. + * + * @internal + * + * @return Closure(Element): (FixedIsoEncoder|null) + */ + public static function encoderDetectorForContext(Context $context): Closure + { + $slotKey = self::slotKey($context->type->getMeta()); - return some( - self::cache()->lookup( + return static function (Element $element) use ($context, $slotKey): ?FixedIsoEncoder { + $qname = self::detectQNameFromXmlElement($context, $element); + if ($qname === null) { + return null; + } + + [$namespaceUri, $localName] = $qname; + + return self::cache()->lookup( $context->registry, - self::cacheKey($namespaceUri, $localName, $context->type->getMeta()), + $namespaceUri . '|' . $localName . '|' . $slotKey, static function () use ($context, $namespaceUri, $localName): FixedIsoEncoder { $type = self::withResolvedSimpleness($context, self::createXsdType($context, $namespaceUri, $localName)); $normalizedMeta = $type->getMeta() @@ -102,8 +119,8 @@ static function () use ($context, $namespaceUri, $localName): FixedIsoEncoder { ) ); } - ) - ); + ); + }; } /** @@ -144,10 +161,9 @@ private static function createXsdType(Context $context, string $namespaceUri, st ->withXmlNamespace($namespaceUri); } - private static function cacheKey(string $namespaceUri, string $localName, TypeMeta $meta): string + private static function slotKey(TypeMeta $meta): string { - return $namespaceUri . '|' . $localName - . '|' . ($meta->isElement()->unwrapOr(false) ? 'e' : '') + return ($meta->isElement()->unwrapOr(false) ? 'e' : '') . ($meta->isAttribute()->unwrapOr(false) ? 'a' : '') . ($meta->isNullable()->unwrapOr(false) ? 'n' : '') . ($meta->isList()->unwrapOr(false) ? 'l' : '') diff --git a/tests/Unit/Encoder/XsiTypeEncoderTest.php b/tests/Unit/Encoder/XsiTypeEncoderTest.php new file mode 100644 index 0000000..0483dc9 --- /dev/null +++ b/tests/Unit/Encoder/XsiTypeEncoderTest.php @@ -0,0 +1,88 @@ +withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsElement(true)) + ); + $iso = $context->registry->detectEncoderForContext($context)->iso($context); + + static::assertSame(1, $iso->from($this->element('1'))); + static::assertSame('1', $iso->from($this->element('1'))); + static::assertTrue($iso->from($this->element('true'))); + } + + public function test_one_iso_falls_back_to_the_declared_type_for_elements_without_xsi_type(): void + { + $schema = << + + + + + EOXML; + $metadata = self::createMetadataFromWsdl($schema, 'type="tns:itemType"'); + $context = self::createContextFromMetadata($metadata, 'itemType'); + $iso = $context->registry->detectEncoderForContext($context)->iso($context); + + static::assertEquals((object) ['id' => 1], $iso->from($this->element('1'))); + static::assertSame('abc', $iso->from($this->element('abc'))); + static::assertEquals((object) ['id' => 2], $iso->from($this->element('2'))); + } + + public function test_it_ignores_xsi_type_for_encoders_that_disregard_it(): void + { + $context = self::createContext( + XsdType::any()->withMeta(static fn (TypeMeta $meta): TypeMeta => $meta->withIsElement(true)) + ); + $iso = (new XsiTypeEncoder(new DisregardingEncoder()))->iso($context); + + static::assertSame('inner', $iso->from($this->element('1'))); + } + + private function element(string $xml): Element + { + return Element::fromString($xml); + } +} + +/** + * @implements XmlEncoder + */ +final class DisregardingEncoder implements DisregardXsiInformation, XmlEncoder +{ + /** + * @return Iso + */ + public function iso(Context $context): Iso + { + return new Iso( + static fn (mixed $value): string => '', + static fn (Element|string $value): string => 'inner', + ); + } +}