Skip to content

Commit 8dbfbbc

Browse files
authored
Merge pull request #56 from veewee/feature/xsi-type-detector-cache
Cache xsi:type encoder detection in XsiTypeDetector
2 parents 334cc15 + e74b90a commit 8dbfbbc

5 files changed

Lines changed: 349 additions & 27 deletions

File tree

src/Cache/ScopedCache.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Soap\Encoding\Cache;
5+
6+
use Closure;
7+
use WeakMap;
8+
9+
/**
10+
* GC-safe cache scoped to an object's lifetime.
11+
* When the scope object is garbage collected, all its cached entries are released.
12+
*
13+
* @template TScope of object
14+
* @template TValue
15+
*
16+
* @internal
17+
*/
18+
final class ScopedCache
19+
{
20+
/** @var WeakMap<TScope, array<string, TValue>> */
21+
private WeakMap $cache;
22+
23+
public function __construct()
24+
{
25+
/** @var WeakMap<TScope, array<string, TValue>> */
26+
$this->cache = new WeakMap();
27+
}
28+
29+
/**
30+
* @param TScope $scope
31+
* @param Closure(): TValue $factory
32+
* @return TValue
33+
*/
34+
public function lookup(object $scope, string $key, Closure $factory): mixed
35+
{
36+
$scopeCache = $this->cache[$scope] ?? [];
37+
if (!isset($scopeCache[$key])) {
38+
$scopeCache[$key] = $factory();
39+
$this->cache[$scope] = $scopeCache;
40+
}
41+
42+
return $scopeCache[$key];
43+
}
44+
}

src/Encoder/EncoderDetector.php

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,12 @@
33

44
namespace Soap\Encoding\Encoder;
55

6+
use Soap\Encoding\Cache\ScopedCache;
67
use Soap\Engine\Metadata\Model\XsdType;
78
use stdClass;
8-
use WeakMap;
99

1010
final class EncoderDetector
1111
{
12-
/**
13-
* @var WeakMap<XsdType, XmlEncoder<mixed, string>>
14-
*/
15-
private WeakMap $cache;
16-
1712
public static function default(): self
1813
{
1914
/** @var self $self */
@@ -22,10 +17,16 @@ public static function default(): self
2217
return $self;
2318
}
2419

25-
private function __construct()
20+
/**
21+
* @return ScopedCache<XsdType, XmlEncoder<mixed, string>>
22+
*
23+
* @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType, MixedReturnStatement
24+
*/
25+
private static function cache(): ScopedCache
2626
{
27-
/** @var WeakMap<XsdType, XmlEncoder<mixed, string>> cache */
28-
$this->cache = new WeakMap();
27+
static $cache = new ScopedCache();
28+
29+
return $cache;
2930
}
3031

3132
/**
@@ -35,18 +36,27 @@ private function __construct()
3536
*/
3637
public function __invoke(Context $context): XmlEncoder
3738
{
38-
$type = $context->type;
39-
if ($cached = $this->cache[$type] ?? null) {
40-
return $cached;
41-
}
39+
return self::cache()->lookup(
40+
$context->type,
41+
'encoder',
42+
fn (): XmlEncoder => $this->detect($context)
43+
);
44+
}
4245

43-
$meta = $type->getMeta();
46+
/**
47+
* @return XmlEncoder<mixed, string>
48+
*
49+
* @psalm-suppress PossiblyInvalidArgument - The simple type detector could return string|null, but should not be an issue here.
50+
*/
51+
private function detect(Context $context): XmlEncoder
52+
{
53+
$meta = $context->type->getMeta();
4454

45-
return $this->cache[$type] = $this->enhanceEncoder(
55+
return $this->enhanceEncoder(
4656
$context,
4757
match(true) {
4858
$meta->isSimple()->unwrapOr(false) => SimpleType\EncoderDetector::default()($context),
49-
default => $this->detectComplexTypeEncoder($type, $context)
59+
default => $this->detectComplexTypeEncoder($context->type, $context)
5060
}
5161
);
5262
}

src/TypeInference/XsiTypeDetector.php

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55

66
use DOMElement;
77
use Psl\Option\Option;
8+
use Soap\Encoding\Cache\ScopedCache;
89
use Soap\Encoding\Encoder\Context;
910
use Soap\Encoding\Encoder\FixedIsoEncoder;
11+
use Soap\Encoding\EncoderRegistry;
1012
use Soap\Engine\Metadata\Model\XsdType;
1113
use Soap\WsdlReader\Parser\Xml\QnameParser;
1214
use Soap\Xml\Xmlns as SoapXmlns;
@@ -21,6 +23,18 @@
2123

2224
final class XsiTypeDetector
2325
{
26+
/**
27+
* @return ScopedCache<EncoderRegistry, FixedIsoEncoder<mixed, string>>
28+
*
29+
* @psalm-suppress LessSpecificReturnStatement, MoreSpecificReturnType, MixedReturnStatement
30+
*/
31+
private static function cache(): ScopedCache
32+
{
33+
static $cache = new ScopedCache();
34+
35+
return $cache;
36+
}
37+
2438
/**
2539
* @psalm-param mixed $value
2640
*/
@@ -84,24 +98,42 @@ public static function detectEncoderFromXmlElement(Context $context, DOMElement
8498
return none();
8599
}
86100

87-
// Enhance context to avoid duplicate optionals, repeating elements, xsi:type detections, ...
88101
$type = $requestedXsiType->unwrap();
89-
$encoderDetectorTypeMeta = $type->getMeta()
90-
->withIsNullable(false)
91-
->withIsRepeatingElement(false);
92-
$encoderDetectorContext = $context
93-
->withType($type->withMeta(static fn () => $encoderDetectorTypeMeta))
94-
->withSkipXsiTypeDetection(true);
95102

96103
return some(
97-
new FixedIsoEncoder(
98-
$context->registry->detectEncoderForContext($encoderDetectorContext)->iso(
99-
$context->withType($type)
100-
),
104+
self::cache()->lookup(
105+
$context->registry,
106+
self::cacheKey($type),
107+
static function () use ($context, $type): FixedIsoEncoder {
108+
$normalizedMeta = $type->getMeta()
109+
->withIsNullable(false)
110+
->withIsRepeatingElement(false);
111+
$detectorContext = $context
112+
->withType($type->withMeta(static fn () => $normalizedMeta))
113+
->withSkipXsiTypeDetection(true);
114+
115+
return new FixedIsoEncoder(
116+
$context->registry->detectEncoderForContext($detectorContext)->iso(
117+
$context->withType($type)
118+
)
119+
);
120+
}
101121
)
102122
);
103123
}
104124

125+
private static function cacheKey(XsdType $type): string
126+
{
127+
$meta = $type->getMeta();
128+
129+
return $type->getXmlNamespace() . '|' . $type->getXmlTypeName()
130+
. '|' . ($meta->isElement()->unwrapOr(false) ? 'e' : '')
131+
. ($meta->isAttribute()->unwrapOr(false) ? 'a' : '')
132+
. ($meta->isNullable()->unwrapOr(false) ? 'n' : '')
133+
. ($meta->isList()->unwrapOr(false) ? 'l' : '')
134+
. ($meta->isQualified()->unwrapOr(false) ? 'q' : '');
135+
}
136+
105137
/**
106138
* @return Option<non-empty-string>
107139
*/
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Soap\Encoding\Test\Unit\Cache;
5+
6+
use PHPUnit\Framework\Attributes\CoversClass;
7+
use PHPUnit\Framework\TestCase;
8+
use Soap\Encoding\Cache\ScopedCache;
9+
use stdClass;
10+
11+
#[CoversClass(ScopedCache::class)]
12+
final class ScopedCacheTest extends TestCase
13+
{
14+
public function test_it_returns_cached_value_on_hit(): void
15+
{
16+
/** @var ScopedCache<stdClass, string> */
17+
$cache = new ScopedCache();
18+
$scope = new stdClass();
19+
20+
$result1 = $cache->lookup($scope, 'key', static fn () => 'built');
21+
$result2 = $cache->lookup($scope, 'key', static fn () => 'should not be called');
22+
23+
static::assertSame('built', $result1);
24+
static::assertSame('built', $result2);
25+
}
26+
27+
public function test_it_calls_factory_on_miss(): void
28+
{
29+
/** @var ScopedCache<stdClass, string> */
30+
$cache = new ScopedCache();
31+
$scope = new stdClass();
32+
33+
$calls = 0;
34+
$cache->lookup($scope, 'a', static function () use (&$calls) {
35+
$calls++;
36+
37+
return 'value';
38+
});
39+
$cache->lookup($scope, 'b', static function () use (&$calls) {
40+
$calls++;
41+
42+
return 'other';
43+
});
44+
45+
static::assertSame(2, $calls);
46+
}
47+
48+
public function test_it_separates_keys_within_same_scope(): void
49+
{
50+
/** @var ScopedCache<stdClass, string> */
51+
$cache = new ScopedCache();
52+
$scope = new stdClass();
53+
54+
$a = $cache->lookup($scope, 'a', static fn () => 'alpha');
55+
$b = $cache->lookup($scope, 'b', static fn () => 'beta');
56+
57+
static::assertSame('alpha', $a);
58+
static::assertSame('beta', $b);
59+
}
60+
61+
public function test_it_separates_scopes(): void
62+
{
63+
/** @var ScopedCache<stdClass, string> */
64+
$cache = new ScopedCache();
65+
$scope1 = new stdClass();
66+
$scope2 = new stdClass();
67+
68+
$a = $cache->lookup($scope1, 'key', static fn () => 'from scope 1');
69+
$b = $cache->lookup($scope2, 'key', static fn () => 'from scope 2');
70+
71+
static::assertSame('from scope 1', $a);
72+
static::assertSame('from scope 2', $b);
73+
}
74+
75+
public function test_it_releases_entries_when_scope_is_garbage_collected(): void
76+
{
77+
/** @var ScopedCache<stdClass, string> */
78+
$cache = new ScopedCache();
79+
$scope = new stdClass();
80+
81+
$cache->lookup($scope, 'key', static fn () => str_repeat('x', 1024));
82+
83+
unset($scope);
84+
gc_collect_cycles();
85+
86+
// Create a new scope with a new key; factory must be called (no stale entry)
87+
$newScope = new stdClass();
88+
$calls = 0;
89+
$cache->lookup($newScope, 'key', static function () use (&$calls) {
90+
$calls++;
91+
92+
return 'fresh';
93+
});
94+
95+
static::assertSame(1, $calls);
96+
}
97+
}

0 commit comments

Comments
 (0)