Skip to content

Commit b1da8d9

Browse files
committed
[JsonStreamer] Rebuild cache on class update
1 parent 73e7d6b commit b1da8d9

File tree

8 files changed

+266
-71
lines changed

8 files changed

+266
-71
lines changed

CacheWarmer/StreamerCacheWarmer.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Psr\Log\LoggerInterface;
1515
use Psr\Log\NullLogger;
16+
use Symfony\Component\Config\ConfigCacheFactoryInterface;
1617
use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface;
1718
use Symfony\Component\JsonStreamer\Exception\ExceptionInterface;
1819
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface;
@@ -42,9 +43,10 @@ public function __construct(
4243
string $streamWritersDir,
4344
string $streamReadersDir,
4445
private LoggerInterface $logger = new NullLogger(),
46+
?ConfigCacheFactoryInterface $configCacheFactory = null,
4547
) {
46-
$this->streamWriterGenerator = new StreamWriterGenerator($streamWriterPropertyMetadataLoader, $streamWritersDir);
47-
$this->streamReaderGenerator = new StreamReaderGenerator($streamReaderPropertyMetadataLoader, $streamReadersDir);
48+
$this->streamWriterGenerator = new StreamWriterGenerator($streamWriterPropertyMetadataLoader, $streamWritersDir, $configCacheFactory);
49+
$this->streamReaderGenerator = new StreamReaderGenerator($streamReaderPropertyMetadataLoader, $streamReadersDir, $configCacheFactory);
4850
}
4951

5052
public function warmUp(string $cacheDir, ?string $buildDir = null): array

JsonStreamReader.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use PHPStan\PhpDocParser\Parser\PhpDocParser;
1515
use Psr\Container\ContainerInterface;
16+
use Symfony\Component\Config\ConfigCacheFactoryInterface;
1617
use Symfony\Component\JsonStreamer\Mapping\GenericTypePropertyMetadataLoader;
1718
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoader;
1819
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface;
@@ -46,8 +47,9 @@ public function __construct(
4647
PropertyMetadataLoaderInterface $propertyMetadataLoader,
4748
string $streamReadersDir,
4849
?string $lazyGhostsDir = null,
50+
?ConfigCacheFactoryInterface $configCacheFactory = null,
4951
) {
50-
$this->streamReaderGenerator = new StreamReaderGenerator($propertyMetadataLoader, $streamReadersDir);
52+
$this->streamReaderGenerator = new StreamReaderGenerator($propertyMetadataLoader, $streamReadersDir, $configCacheFactory);
5153
$this->instantiator = new Instantiator();
5254
$this->lazyInstantiator = new LazyInstantiator($lazyGhostsDir);
5355
}

JsonStreamWriter.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use PHPStan\PhpDocParser\Parser\PhpDocParser;
1515
use Psr\Container\ContainerInterface;
16+
use Symfony\Component\Config\ConfigCacheFactoryInterface;
1617
use Symfony\Component\JsonStreamer\Mapping\GenericTypePropertyMetadataLoader;
1718
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoader;
1819
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface;
@@ -41,8 +42,9 @@ public function __construct(
4142
private ContainerInterface $valueTransformers,
4243
PropertyMetadataLoaderInterface $propertyMetadataLoader,
4344
string $streamWritersDir,
45+
?ConfigCacheFactoryInterface $configCacheFactory = null,
4446
) {
45-
$this->streamWriterGenerator = new StreamWriterGenerator($propertyMetadataLoader, $streamWritersDir);
47+
$this->streamWriterGenerator = new StreamWriterGenerator($propertyMetadataLoader, $streamWritersDir, $configCacheFactory);
4648
}
4749

4850
public function write(mixed $data, Type $type, array $options = []): \Traversable&\Stringable

Read/StreamReaderGenerator.php

Lines changed: 15 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@
1414
use PhpParser\PhpVersion;
1515
use PhpParser\PrettyPrinter;
1616
use PhpParser\PrettyPrinter\Standard;
17-
use Symfony\Component\Filesystem\Exception\IOException;
18-
use Symfony\Component\Filesystem\Filesystem;
17+
use Symfony\Component\Config\ConfigCacheFactoryInterface;
1918
use Symfony\Component\JsonStreamer\DataModel\DataAccessorInterface;
2019
use Symfony\Component\JsonStreamer\DataModel\FunctionDataAccessor;
2120
use Symfony\Component\JsonStreamer\DataModel\Read\BackedEnumNode;
@@ -29,6 +28,7 @@
2928
use Symfony\Component\JsonStreamer\Exception\RuntimeException;
3029
use Symfony\Component\JsonStreamer\Exception\UnsupportedException;
3130
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface;
31+
use Symfony\Component\JsonStreamer\StreamerDumper;
3232
use Symfony\Component\TypeInfo\Type;
3333
use Symfony\Component\TypeInfo\Type\BackedEnumType;
3434
use Symfony\Component\TypeInfo\Type\BuiltinType;
@@ -47,14 +47,16 @@
4747
*/
4848
final class StreamReaderGenerator
4949
{
50+
private StreamerDumper $dumper;
5051
private ?PhpAstBuilder $phpAstBuilder = null;
5152
private ?PrettyPrinter $phpPrinter = null;
52-
private ?Filesystem $fs = null;
5353

5454
public function __construct(
5555
private PropertyMetadataLoaderInterface $propertyMetadataLoader,
5656
private string $streamReadersDir,
57+
?ConfigCacheFactoryInterface $cacheFactory = null,
5758
) {
59+
$this->dumper = new StreamerDumper($propertyMetadataLoader, $streamReadersDir, $cacheFactory);
5860
}
5961

6062
/**
@@ -64,46 +66,27 @@ public function __construct(
6466
*/
6567
public function generate(Type $type, bool $decodeFromStream, array $options = []): string
6668
{
67-
$path = $this->getPath($type, $decodeFromStream);
68-
if (is_file($path)) {
69-
return $path;
70-
}
71-
72-
$this->phpAstBuilder ??= new PhpAstBuilder();
73-
$this->phpPrinter ??= new Standard(['phpVersion' => PhpVersion::fromComponents(8, 2)]);
74-
$this->fs ??= new Filesystem();
69+
$path = \sprintf('%s%s%s.json%s.php', $this->streamReadersDir, \DIRECTORY_SEPARATOR, hash('xxh128', (string) $type), $decodeFromStream ? '.stream' : '');
70+
$generateContent = function () use ($type, $decodeFromStream, $options): string {
71+
$this->phpAstBuilder ??= new PhpAstBuilder();
72+
$this->phpPrinter ??= new Standard(['phpVersion' => PhpVersion::fromComponents(8, 2)]);
7573

76-
$dataModel = $this->createDataModel($type, $options);
77-
$nodes = $this->phpAstBuilder->build($dataModel, $decodeFromStream, $options);
78-
$content = $this->phpPrinter->prettyPrintFile($nodes)."\n";
79-
80-
if (!$this->fs->exists($this->streamReadersDir)) {
81-
$this->fs->mkdir($this->streamReadersDir);
82-
}
74+
$dataModel = $this->createDataModel($type, $options);
75+
$nodes = $this->phpAstBuilder->build($dataModel, $decodeFromStream, $options);
8376

84-
$tmpFile = $this->fs->tempnam(\dirname($path), basename($path));
77+
return $this->phpPrinter->prettyPrintFile($nodes)."\n";
78+
};
8579

86-
try {
87-
$this->fs->dumpFile($tmpFile, $content);
88-
$this->fs->rename($tmpFile, $path);
89-
$this->fs->chmod($path, 0666 & ~umask());
90-
} catch (IOException $e) {
91-
throw new RuntimeException(\sprintf('Failed to write "%s" stream reader file.', $path), previous: $e);
92-
}
80+
$this->dumper->dump($type, $path, $generateContent);
9381

9482
return $path;
9583
}
9684

97-
private function getPath(Type $type, bool $decodeFromStream): string
98-
{
99-
return \sprintf('%s%s%s.json%s.php', $this->streamReadersDir, \DIRECTORY_SEPARATOR, hash('xxh128', (string) $type), $decodeFromStream ? '.stream' : '');
100-
}
101-
10285
/**
10386
* @param array<string, mixed> $options
10487
* @param array<string, mixed> $context
10588
*/
106-
public function createDataModel(Type $type, array $options = [], array $context = []): DataModelNodeInterface
89+
private function createDataModel(Type $type, array $options = [], array $context = []): DataModelNodeInterface
10790
{
10891
$context['original_type'] ??= $type;
10992

StreamerDumper.php

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\JsonStreamer;
13+
14+
use Symfony\Component\Config\ConfigCacheFactoryInterface;
15+
use Symfony\Component\Config\ConfigCacheInterface;
16+
use Symfony\Component\Config\Resource\ReflectionClassResource;
17+
use Symfony\Component\Filesystem\Filesystem;
18+
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface;
19+
use Symfony\Component\TypeInfo\Type;
20+
use Symfony\Component\TypeInfo\Type\GenericType;
21+
use Symfony\Component\TypeInfo\Type\ObjectType;
22+
23+
/**
24+
* @author Mathias Arlaud <mathias.arlaud@gmail.com>
25+
*
26+
* @internal
27+
*/
28+
final class StreamerDumper
29+
{
30+
private ?Filesystem $fs = null;
31+
32+
public function __construct(
33+
private PropertyMetadataLoaderInterface $propertyMetadataLoader,
34+
private string $cacheDir,
35+
private ?ConfigCacheFactoryInterface $cacheFactory = null,
36+
) {
37+
}
38+
39+
/**
40+
* Dumps the generated content to the given path, optionally using config cache.
41+
*
42+
* @param callable(): string $generateContent
43+
*/
44+
public function dump(Type $type, string $path, callable $generateContent): void
45+
{
46+
if ($this->cacheFactory) {
47+
$this->cacheFactory->cache(
48+
$path,
49+
function (ConfigCacheInterface $cache) use ($generateContent, $type) {
50+
$resourceClasses = $this->getResourceClassNames($type);
51+
$cache->write(
52+
$generateContent(),
53+
array_map(fn (string $c) => new ReflectionClassResource(new \ReflectionClass($c)), $resourceClasses),
54+
);
55+
},
56+
);
57+
58+
return;
59+
}
60+
61+
$this->fs ??= new Filesystem();
62+
63+
if (!$this->fs->exists($this->cacheDir)) {
64+
$this->fs->mkdir($this->cacheDir);
65+
}
66+
67+
if (!$this->fs->exists($path)) {
68+
$this->fs->dumpFile($path, $generateContent());
69+
}
70+
}
71+
72+
/**
73+
* Retrieves resources class names required for caching based on the provided type.
74+
*
75+
* @param list<class-string> $classNames
76+
* @param array<string, mixed> $context
77+
*
78+
* @return list<class-string>
79+
*/
80+
private function getResourceClassNames(Type $type, array $classNames = [], array $context = []): array
81+
{
82+
$context['original_type'] ??= $type;
83+
84+
foreach ($type->traverse() as $t) {
85+
if ($t instanceof ObjectType) {
86+
if (\in_array($t->getClassName(), $classNames, true)) {
87+
return $classNames;
88+
}
89+
90+
$classNames[] = $t->getClassName();
91+
92+
foreach ($this->propertyMetadataLoader->load($t->getClassName(), [], $context) as $property) {
93+
$classNames = [...$classNames, ...$this->getResourceClassNames($property->getType(), $classNames)];
94+
}
95+
}
96+
97+
if ($t instanceof GenericType) {
98+
foreach ($t->getVariableTypes() as $variableType) {
99+
$classNames = [...$classNames, ...$this->getResourceClassNames($variableType, $classNames)];
100+
}
101+
}
102+
}
103+
104+
return array_values(array_unique($classNames));
105+
}
106+
}

Tests/StreamerDumperTest.php

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\JsonStreamer\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Config\ConfigCacheFactory;
16+
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoader;
17+
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface;
18+
use Symfony\Component\JsonStreamer\StreamerDumper;
19+
use Symfony\Component\JsonStreamer\Tests\Fixtures\Enum\DummyBackedEnum;
20+
use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\ClassicDummy;
21+
use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithArray;
22+
use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithNameAttributes;
23+
use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithOtherDummies;
24+
use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\SelfReferencingDummy;
25+
use Symfony\Component\TypeInfo\Type;
26+
use Symfony\Component\TypeInfo\TypeResolver\TypeResolver;
27+
28+
class StreamerDumperTest extends TestCase
29+
{
30+
private string $cacheDir;
31+
32+
protected function setUp(): void
33+
{
34+
parent::setUp();
35+
36+
$this->cacheDir = \sprintf('%s/symfony_json_streamer_test/any', sys_get_temp_dir());
37+
38+
if (is_dir($this->cacheDir)) {
39+
array_map('unlink', glob($this->cacheDir.'/*'));
40+
rmdir($this->cacheDir);
41+
}
42+
}
43+
44+
public function testDumpWithConfigCache()
45+
{
46+
$path = $this->cacheDir.'/streamer.php';
47+
48+
$dumper = new StreamerDumper($this->createMock(PropertyMetadataLoaderInterface::class), $this->cacheDir, new ConfigCacheFactory(true));
49+
$dumper->dump(Type::int(), $path, fn () => 'CONTENT');
50+
51+
$this->assertFileExists($path);
52+
$this->assertFileExists($path.'.meta');
53+
$this->assertFileExists($path.'.meta.json');
54+
55+
$this->assertStringEqualsFile($path, 'CONTENT');
56+
}
57+
58+
public function testDumpWithoutConfigCache()
59+
{
60+
$path = $this->cacheDir.'/streamer.php';
61+
62+
$dumper = new StreamerDumper($this->createMock(PropertyMetadataLoaderInterface::class), $this->cacheDir);
63+
$dumper->dump(Type::int(), $path, fn () => 'CONTENT');
64+
65+
$this->assertFileExists($path);
66+
$this->assertStringEqualsFile($path, 'CONTENT');
67+
}
68+
69+
/**
70+
* @dataProvider getCacheResourcesDataProvider
71+
*
72+
* @param list<class-string> $expectedClassNames
73+
*/
74+
public function testGetCacheResources(Type $type, array $expectedClassNames)
75+
{
76+
$path = $this->cacheDir.'/streamer.php';
77+
78+
$dumper = new StreamerDumper(new PropertyMetadataLoader(TypeResolver::create()), $this->cacheDir, new ConfigCacheFactory(true));
79+
$dumper->dump($type, $path, fn () => 'CONTENT');
80+
81+
$resources = json_decode(file_get_contents($path.'.meta.json'), true)['resources'];
82+
$classNames = array_column($resources, 'className');
83+
84+
$this->assertSame($expectedClassNames, $classNames);
85+
}
86+
87+
/**
88+
* @return iterable<array{0: Type, 1: list<class-string>}>
89+
*/
90+
public static function getCacheResourcesDataProvider(): iterable
91+
{
92+
yield 'scalar' => [Type::int(), []];
93+
yield 'enum' => [Type::enum(DummyBackedEnum::class), [DummyBackedEnum::class]];
94+
yield 'object' => [Type::object(ClassicDummy::class), [ClassicDummy::class]];
95+
yield 'collection of objects' => [
96+
Type::list(Type::object(ClassicDummy::class)),
97+
[ClassicDummy::class],
98+
];
99+
yield 'generic with objects' => [
100+
Type::generic(Type::object(ClassicDummy::class), Type::object(DummyWithArray::class)),
101+
[DummyWithArray::class, ClassicDummy::class],
102+
];
103+
yield 'union with objects' => [
104+
Type::union(Type::int(), Type::object(ClassicDummy::class), Type::object(DummyWithArray::class)),
105+
[ClassicDummy::class, DummyWithArray::class],
106+
];
107+
yield 'intersection with objects' => [
108+
Type::intersection(Type::object(ClassicDummy::class), Type::object(DummyWithArray::class)),
109+
[ClassicDummy::class, DummyWithArray::class],
110+
];
111+
yield 'object with object properties' => [
112+
Type::object(DummyWithOtherDummies::class),
113+
[DummyWithOtherDummies::class, DummyWithNameAttributes::class, ClassicDummy::class],
114+
];
115+
yield 'object with self reference' => [Type::object(SelfReferencingDummy::class), [SelfReferencingDummy::class]];
116+
}
117+
}

0 commit comments

Comments
 (0)