Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/Cache/ScopedCache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);

namespace Soap\Encoding\Cache;

use Closure;
use WeakMap;

/**
* GC-safe cache scoped to an object's lifetime.
* When the scope object is garbage collected, all its cached entries are released.
*
* @template TScope of object
* @template TValue
*
* @internal
*/
final class ScopedCache
{
/** @var WeakMap<TScope, array<string, TValue>> */
private WeakMap $cache;

public function __construct()
{
/** @var WeakMap<TScope, array<string, TValue>> */
$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];
}
}
42 changes: 26 additions & 16 deletions src/Encoder/EncoderDetector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<XsdType, XmlEncoder<mixed, string>>
*/
private WeakMap $cache;

public static function default(): self
{
/** @var self $self */
Expand All @@ -22,10 +17,16 @@ public static function default(): self
return $self;
}

private function __construct()
/**
* @return ScopedCache<XsdType, XmlEncoder<mixed, string>>
*
* @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType, MixedReturnStatement
*/
private static function cache(): ScopedCache
{
/** @var WeakMap<XsdType, XmlEncoder<mixed, string>> cache */
$this->cache = new WeakMap();
static $cache = new ScopedCache();

return $cache;
}

/**
Expand All @@ -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<mixed, string>
*
* @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)
}
);
}
Expand Down
54 changes: 43 additions & 11 deletions src/TypeInference/XsiTypeDetector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,6 +23,18 @@

final class XsiTypeDetector
{
/**
* @return ScopedCache<EncoderRegistry, FixedIsoEncoder<mixed, string>>
*
* @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType, MixedReturnStatement
*/
private static function cache(): ScopedCache
{
static $cache = new ScopedCache();

return $cache;
}

/**
* @psalm-param mixed $value
*/
Expand Down Expand Up @@ -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<non-empty-string>
*/
Expand Down
97 changes: 97 additions & 0 deletions tests/Unit/Cache/ScopedCacheTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);

namespace Soap\Encoding\Test\Unit\Cache;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Soap\Encoding\Cache\ScopedCache;
use stdClass;

#[CoversClass(ScopedCache::class)]
final class ScopedCacheTest extends TestCase
{
public function test_it_returns_cached_value_on_hit(): void
{
/** @var ScopedCache<stdClass, string> */
$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<stdClass, string> */
$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<stdClass, string> */
$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<stdClass, string> */
$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<stdClass, string> */
$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);
}
}
Loading