From 6133810c2be90f0caa40937ccc3d92a4af718bf3 Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Thu, 26 Mar 2026 14:56:51 +0100 Subject: [PATCH] Cache ObjectAccess::forContext() using ScopedCache ObjectAccess::forContext() rebuilds the full property mapping for a complex type on every encode/decode: iterates all properties, detects encoders, creates lenses, and builds isos. Now cached per (registry, namespace, typeName, bindingUse) using ScopedCache. Cache key includes bindingUse because the isos capture binding-dependent behavior (xsi:type attributes in encoded mode, XMLWriter bypass in literal mode). ~20-25% improvement on both encode and decode paths. --- src/Encoder/ObjectAccess.php | 29 ++++++ tests/Unit/Encoder/ObjectAccessCacheTest.php | 101 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 tests/Unit/Encoder/ObjectAccessCacheTest.php diff --git a/src/Encoder/ObjectAccess.php b/src/Encoder/ObjectAccess.php index db1a69f..576cf5d 100644 --- a/src/Encoder/ObjectAccess.php +++ b/src/Encoder/ObjectAccess.php @@ -3,6 +3,8 @@ namespace Soap\Encoding\Encoder; +use Soap\Encoding\Cache\ScopedCache; +use Soap\Encoding\EncoderRegistry; use Soap\Encoding\Normalizer\PhpPropertyNameNormalizer; use Soap\Encoding\TypeInference\ComplexTypeBuilder; use Soap\Engine\Metadata\Model\Property; @@ -32,7 +34,34 @@ public function __construct( ) { } + /** + * @return ScopedCache + * + * @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType, MixedReturnStatement + */ + private static function cache(): ScopedCache + { + static $cache = new ScopedCache(); + + return $cache; + } + + private static function cacheKey(Context $context): string + { + return $context->type->getXmlNamespace() . '|' . $context->type->getName() + . '|' . $context->bindingUse->value; + } + public static function forContext(Context $context): self + { + return self::cache()->lookup( + $context->registry, + self::cacheKey($context), + static fn (): self => self::build($context) + ); + } + + private static function build(Context $context): self { $type = ComplexTypeBuilder::default()($context); diff --git a/tests/Unit/Encoder/ObjectAccessCacheTest.php b/tests/Unit/Encoder/ObjectAccessCacheTest.php new file mode 100644 index 0000000..7928dcf --- /dev/null +++ b/tests/Unit/Encoder/ObjectAccessCacheTest.php @@ -0,0 +1,101 @@ + + + + + + + EOXML; + + /** + * Same type with literal vs encoded binding must produce different ObjectAccess + * instances (the isos capture bindingUse-dependent behavior). + */ + public function test_different_binding_use_produces_different_object_access(): void + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:testType"'); + $type = $metadata->getTypes()->fetchFirstByName('testType'); + $registry = EncoderRegistry::default(); + $namespaces = self::buildNamespaces(); + + $literalContext = new Context($type->getXsdType(), $metadata, $registry, $namespaces, BindingUse::LITERAL); + $encodedContext = new Context($type->getXsdType(), $metadata, $registry, $namespaces, BindingUse::ENCODED); + + $literalAccess = ObjectAccess::forContext($literalContext); + $encodedAccess = ObjectAccess::forContext($encodedContext); + + // Both should have the same properties + static::assertSame( + array_keys($literalAccess->properties), + array_keys($encodedAccess->properties) + ); + + // But they must be different instances (different isos due to bindingUse) + static::assertNotSame($literalAccess, $encodedAccess); + + // Encoded adds xsi:type attribute, literal does not + $literalXml = $literalAccess->isos['name']->to('hello'); + $encodedXml = $encodedAccess->isos['name']->to('hello'); + + static::assertStringNotContainsString('xsi:type', $literalXml); + static::assertStringContainsString('xsi:type', $encodedXml); + } + + /** + * Same type on different registries must not share cache entries. + */ + public function test_different_registries_produce_separate_cache_entries(): void + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:testType"'); + $type = $metadata->getTypes()->fetchFirstByName('testType'); + $namespaces = self::buildNamespaces(); + + $context1 = new Context($type->getXsdType(), $metadata, EncoderRegistry::default(), $namespaces); + $context2 = new Context($type->getXsdType(), $metadata, EncoderRegistry::default(), $namespaces); + + static::assertNotSame($context1->registry, $context2->registry); + + $access1 = ObjectAccess::forContext($context1); + $access2 = ObjectAccess::forContext($context2); + + // Both valid, same structure, but different instances (different registries) + static::assertNotSame($access1, $access2); + static::assertSame(array_keys($access1->properties), array_keys($access2->properties)); + } + + /** + * Repeated calls with the same context return the cached instance. + */ + public function test_same_context_returns_cached_instance(): void + { + $metadata = self::createMetadataFromWsdl(self::SCHEMA, 'type="tns:testType"'); + $context = self::createContextFromMetadata($metadata, 'testType'); + + $access1 = ObjectAccess::forContext($context); + $access2 = ObjectAccess::forContext($context); + + static::assertSame($access1, $access2); + } +}