diff --git a/src/Cache/ScopedCache.php b/src/Cache/ScopedCache.php new file mode 100644 index 0000000..7cfd147 --- /dev/null +++ b/src/Cache/ScopedCache.php @@ -0,0 +1,44 @@ +> */ + private WeakMap $cache; + + public function __construct() + { + /** @var WeakMap> */ + $this->cache = new WeakMap(); + } + + /** + * @param TScope $scope + * @param Closure(): TValue $factory + * @return TValue + */ + public function lookup(object $scope, string $key, Closure $factory): mixed + { + $scopeCache = $this->cache[$scope] ?? []; + if (!isset($scopeCache[$key])) { + $scopeCache[$key] = $factory(); + $this->cache[$scope] = $scopeCache; + } + + return $scopeCache[$key]; + } +} diff --git a/src/Encoder/EncoderDetector.php b/src/Encoder/EncoderDetector.php index 19aeb24..3fbc27a 100644 --- a/src/Encoder/EncoderDetector.php +++ b/src/Encoder/EncoderDetector.php @@ -3,17 +3,12 @@ namespace Soap\Encoding\Encoder; +use Soap\Encoding\Cache\ScopedCache; use Soap\Engine\Metadata\Model\XsdType; use stdClass; -use WeakMap; final class EncoderDetector { - /** - * @var WeakMap> - */ - private WeakMap $cache; - public static function default(): self { /** @var self $self */ @@ -22,10 +17,16 @@ public static function default(): self return $self; } - private function __construct() + /** + * @return ScopedCache> + * + * @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType, MixedReturnStatement + */ + private static function cache(): ScopedCache { - /** @var WeakMap> cache */ - $this->cache = new WeakMap(); + static $cache = new ScopedCache(); + + return $cache; } /** @@ -35,18 +36,27 @@ private function __construct() */ public function __invoke(Context $context): XmlEncoder { - $type = $context->type; - if ($cached = $this->cache[$type] ?? null) { - return $cached; - } + return self::cache()->lookup( + $context->type, + 'encoder', + fn (): XmlEncoder => $this->detect($context) + ); + } - $meta = $type->getMeta(); + /** + * @return XmlEncoder + * + * @psalm-suppress PossiblyInvalidArgument - The simple type detector could return string|null, but should not be an issue here. + */ + private function detect(Context $context): XmlEncoder + { + $meta = $context->type->getMeta(); - return $this->cache[$type] = $this->enhanceEncoder( + return $this->enhanceEncoder( $context, match(true) { $meta->isSimple()->unwrapOr(false) => SimpleType\EncoderDetector::default()($context), - default => $this->detectComplexTypeEncoder($type, $context) + default => $this->detectComplexTypeEncoder($context->type, $context) } ); } diff --git a/src/TypeInference/XsiTypeDetector.php b/src/TypeInference/XsiTypeDetector.php index 9868e37..59a80c7 100644 --- a/src/TypeInference/XsiTypeDetector.php +++ b/src/TypeInference/XsiTypeDetector.php @@ -5,8 +5,10 @@ use DOMElement; use Psl\Option\Option; +use Soap\Encoding\Cache\ScopedCache; use Soap\Encoding\Encoder\Context; use Soap\Encoding\Encoder\FixedIsoEncoder; +use Soap\Encoding\EncoderRegistry; use Soap\Engine\Metadata\Model\XsdType; use Soap\WsdlReader\Parser\Xml\QnameParser; use Soap\Xml\Xmlns as SoapXmlns; @@ -21,6 +23,18 @@ final class XsiTypeDetector { + /** + * @return ScopedCache> + * + * @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType, MixedReturnStatement + */ + private static function cache(): ScopedCache + { + static $cache = new ScopedCache(); + + return $cache; + } + /** * @psalm-param mixed $value */ @@ -84,24 +98,42 @@ public static function detectEncoderFromXmlElement(Context $context, DOMElement return none(); } - // Enhance context to avoid duplicate optionals, repeating elements, xsi:type detections, ... $type = $requestedXsiType->unwrap(); - $encoderDetectorTypeMeta = $type->getMeta() - ->withIsNullable(false) - ->withIsRepeatingElement(false); - $encoderDetectorContext = $context - ->withType($type->withMeta(static fn () => $encoderDetectorTypeMeta)) - ->withSkipXsiTypeDetection(true); return some( - new FixedIsoEncoder( - $context->registry->detectEncoderForContext($encoderDetectorContext)->iso( - $context->withType($type) - ), + self::cache()->lookup( + $context->registry, + self::cacheKey($type), + static function () use ($context, $type): FixedIsoEncoder { + $normalizedMeta = $type->getMeta() + ->withIsNullable(false) + ->withIsRepeatingElement(false); + $detectorContext = $context + ->withType($type->withMeta(static fn () => $normalizedMeta)) + ->withSkipXsiTypeDetection(true); + + return new FixedIsoEncoder( + $context->registry->detectEncoderForContext($detectorContext)->iso( + $context->withType($type) + ) + ); + } ) ); } + private static function cacheKey(XsdType $type): string + { + $meta = $type->getMeta(); + + return $type->getXmlNamespace() . '|' . $type->getXmlTypeName() + . '|' . ($meta->isElement()->unwrapOr(false) ? 'e' : '') + . ($meta->isAttribute()->unwrapOr(false) ? 'a' : '') + . ($meta->isNullable()->unwrapOr(false) ? 'n' : '') + . ($meta->isList()->unwrapOr(false) ? 'l' : '') + . ($meta->isQualified()->unwrapOr(false) ? 'q' : ''); + } + /** * @return Option */ diff --git a/tests/Unit/Cache/ScopedCacheTest.php b/tests/Unit/Cache/ScopedCacheTest.php new file mode 100644 index 0000000..a4619d1 --- /dev/null +++ b/tests/Unit/Cache/ScopedCacheTest.php @@ -0,0 +1,97 @@ + */ + $cache = new ScopedCache(); + $scope = new stdClass(); + + $result1 = $cache->lookup($scope, 'key', static fn () => 'built'); + $result2 = $cache->lookup($scope, 'key', static fn () => 'should not be called'); + + static::assertSame('built', $result1); + static::assertSame('built', $result2); + } + + public function test_it_calls_factory_on_miss(): void + { + /** @var ScopedCache */ + $cache = new ScopedCache(); + $scope = new stdClass(); + + $calls = 0; + $cache->lookup($scope, 'a', static function () use (&$calls) { + $calls++; + + return 'value'; + }); + $cache->lookup($scope, 'b', static function () use (&$calls) { + $calls++; + + return 'other'; + }); + + static::assertSame(2, $calls); + } + + public function test_it_separates_keys_within_same_scope(): void + { + /** @var ScopedCache */ + $cache = new ScopedCache(); + $scope = new stdClass(); + + $a = $cache->lookup($scope, 'a', static fn () => 'alpha'); + $b = $cache->lookup($scope, 'b', static fn () => 'beta'); + + static::assertSame('alpha', $a); + static::assertSame('beta', $b); + } + + public function test_it_separates_scopes(): void + { + /** @var ScopedCache */ + $cache = new ScopedCache(); + $scope1 = new stdClass(); + $scope2 = new stdClass(); + + $a = $cache->lookup($scope1, 'key', static fn () => 'from scope 1'); + $b = $cache->lookup($scope2, 'key', static fn () => 'from scope 2'); + + static::assertSame('from scope 1', $a); + static::assertSame('from scope 2', $b); + } + + public function test_it_releases_entries_when_scope_is_garbage_collected(): void + { + /** @var ScopedCache */ + $cache = new ScopedCache(); + $scope = new stdClass(); + + $cache->lookup($scope, 'key', static fn () => str_repeat('x', 1024)); + + unset($scope); + gc_collect_cycles(); + + // Create a new scope with a new key; factory must be called (no stale entry) + $newScope = new stdClass(); + $calls = 0; + $cache->lookup($newScope, 'key', static function () use (&$calls) { + $calls++; + + return 'fresh'; + }); + + static::assertSame(1, $calls); + } +} diff --git a/tests/Unit/TypeInference/XsiTypeDetectorCacheTest.php b/tests/Unit/TypeInference/XsiTypeDetectorCacheTest.php new file mode 100644 index 0000000..7e7af7d --- /dev/null +++ b/tests/Unit/TypeInference/XsiTypeDetectorCacheTest.php @@ -0,0 +1,139 @@ + + + + + hello + world + + + + + XML; + + $schema = << + + + + + + EOXML; + + $metadata = self::createMetadataFromWsdl($schema, 'type="tns:testType"'); + $context = self::createContextFromMetadata($metadata, 'testType'); + $encoder = $context->registry->detectEncoderForContext($context); + $result = $encoder->iso($context)->from( + Element::fromString( + '' + . 'hello' + . 'world' + . '' + ) + ); + + static::assertIsObject($result); + static::assertSame('hello', $result->requiredField); + static::assertSame('world', $result->nullableField); + } + + /** + * Decoding the same xsi:type across two completely different registries + * must not share cache entries (WeakMap scoped to registry). + */ + public function test_different_registries_do_not_share_cache(): void + { + $schema = << + + + + + EOXML; + + $metadata1 = self::createMetadataFromWsdl($schema, 'type="tns:testType"'); + $metadata2 = self::createMetadataFromWsdl($schema, 'type="tns:testType"'); + + $context1 = self::createContextFromMetadata($metadata1, 'testType'); + $context2 = self::createContextFromMetadata($metadata2, 'testType'); + + // Different registries + static::assertNotSame($context1->registry, $context2->registry); + + $element = Element::fromString( + '' + . 'test' + . '' + ); + + $encoder1 = $context1->registry->detectEncoderForContext($context1); + $encoder2 = $context2->registry->detectEncoderForContext($context2); + + $result1 = $encoder1->iso($context1)->from($element); + $result2 = $encoder2->iso($context2)->from($element); + + static::assertSame('test', $result1->value); + static::assertSame('test', $result2->value); + } + + /** + * Same xsi:type used as both a list element and a non-list element. + */ + public function test_same_xsi_type_with_list_and_non_list_decodes_correctly(): void + { + $schema = << + + + + + + EOXML; + + $metadata = self::createMetadataFromWsdl($schema, 'type="tns:testType"'); + $context = self::createContextFromMetadata($metadata, 'testType'); + $encoder = $context->registry->detectEncoderForContext($context); + + $result = $encoder->iso($context)->from( + Element::fromString( + '' + . 'one' + . 'a' + . 'b' + . '' + ) + ); + + static::assertIsObject($result); + static::assertSame('one', $result->single); + static::assertSame(['a', 'b'], $result->multi); + } +}