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
41 changes: 11 additions & 30 deletions src/Symfony/Component/JsonStreamer/Read/StreamReaderGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@

namespace Symfony\Component\JsonStreamer\Read;

use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Config\ConfigCacheFactoryInterface;
use Symfony\Component\JsonStreamer\DataModel\Read\BackedEnumNode;
use Symfony\Component\JsonStreamer\DataModel\Read\CollectionNode;
use Symfony\Component\JsonStreamer\DataModel\Read\CompositeNode;
Expand All @@ -22,6 +21,7 @@
use Symfony\Component\JsonStreamer\Exception\RuntimeException;
use Symfony\Component\JsonStreamer\Exception\UnsupportedException;
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface;
use Symfony\Component\JsonStreamer\StreamerDumper;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\BackedEnumType;
use Symfony\Component\TypeInfo\Type\BuiltinType;
Expand All @@ -40,13 +40,15 @@
*/
final class StreamReaderGenerator
{
private StreamerDumper $dumper;
private ?PhpGenerator $phpGenerator = null;
private ?Filesystem $fs = null;

public function __construct(
private PropertyMetadataLoaderInterface $propertyMetadataLoader,
private string $streamReadersDir,
?ConfigCacheFactoryInterface $cacheFactory = null,
) {
$this->dumper = new StreamerDumper($propertyMetadataLoader, $streamReadersDir, $cacheFactory);
}

/**
Expand All @@ -56,39 +58,18 @@ public function __construct(
*/
public function generate(Type $type, bool $decodeFromStream, array $options = []): string
{
$path = $this->getPath($type, $decodeFromStream);
if (is_file($path)) {
return $path;
}

$this->phpGenerator ??= new PhpGenerator();
$this->fs ??= new Filesystem();
$path = \sprintf('%s%s%s.json%s.php', $this->streamReadersDir, \DIRECTORY_SEPARATOR, hash('xxh128', (string) $type), $decodeFromStream ? '.stream' : '');
$generateContent = function () use ($type, $decodeFromStream, $options): string {
$this->phpGenerator ??= new PhpGenerator();

$dataModel = $this->createDataModel($type, $options);
$php = $this->phpGenerator->generate($dataModel, $decodeFromStream, $options);
return $this->phpGenerator->generate($this->createDataModel($type, $options), $decodeFromStream, $options);
};

if (!$this->fs->exists($this->streamReadersDir)) {
$this->fs->mkdir($this->streamReadersDir);
}

$tmpFile = $this->fs->tempnam(\dirname($path), basename($path));

try {
$this->fs->dumpFile($tmpFile, $php);
$this->fs->rename($tmpFile, $path);
$this->fs->chmod($path, 0o666 & ~umask());
} catch (IOException $e) {
throw new RuntimeException(\sprintf('Failed to write "%s" stream reader file.', $path), previous: $e);
}
$this->dumper->dump($type, $path, $generateContent);

return $path;
}

private function getPath(Type $type, bool $decodeFromStream): string
{
return \sprintf('%s%s%s.json%s.php', $this->streamReadersDir, \DIRECTORY_SEPARATOR, hash('xxh128', (string) $type), $decodeFromStream ? '.stream' : '');
}

/**
* @param array<string, mixed> $options
* @param array<string, mixed> $context
Expand Down
106 changes: 106 additions & 0 deletions src/Symfony/Component/JsonStreamer/StreamerDumper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\JsonStreamer;

use Symfony\Component\Config\ConfigCacheFactoryInterface;
use Symfony\Component\Config\ConfigCacheInterface;
use Symfony\Component\Config\Resource\ReflectionClassResource;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\GenericType;
use Symfony\Component\TypeInfo\Type\ObjectType;

/**
* @author Mathias Arlaud <mathias.arlaud@gmail.com>
*
* @internal
*/
final class StreamerDumper
{
private ?Filesystem $fs = null;

public function __construct(
private PropertyMetadataLoaderInterface $propertyMetadataLoader,
private string $cacheDir,
private ?ConfigCacheFactoryInterface $cacheFactory = null,
) {
}

/**
* Dumps the generated content to the given path, optionally using config cache.
*
* @param callable(): string $generateContent
*/
public function dump(Type $type, string $path, callable $generateContent): void
{
if ($this->cacheFactory) {
$this->cacheFactory->cache(
$path,
function (ConfigCacheInterface $cache) use ($generateContent, $type) {
$resourceClasses = $this->getResourceClassNames($type);
$cache->write(
$generateContent(),
array_map(fn (string $c) => new ReflectionClassResource(new \ReflectionClass($c)), $resourceClasses),
);
},
);

return;
}

$this->fs ??= new Filesystem();

if (!$this->fs->exists($this->cacheDir)) {
$this->fs->mkdir($this->cacheDir);
}

if (!$this->fs->exists($path)) {
$this->fs->dumpFile($path, $generateContent());
}
}

/**
* Retrieves resources class names required for caching based on the provided type.
*
* @param list<class-string> $classNames
* @param array<string, mixed> $context
*
* @return list<class-string>
*/
private function getResourceClassNames(Type $type, array $classNames = [], array $context = []): array
{
$context['original_type'] ??= $type;

foreach ($type->traverse() as $t) {
if ($t instanceof ObjectType) {
if (\in_array($t->getClassName(), $classNames, true)) {
return $classNames;
}

$classNames[] = $t->getClassName();

foreach ($this->propertyMetadataLoader->load($t->getClassName(), [], $context) as $property) {
$classNames = [...$classNames, ...$this->getResourceClassNames($property->getType(), $classNames)];
}
}

if ($t instanceof GenericType) {
foreach ($t->getVariableTypes() as $variableType) {
$classNames = [...$classNames, ...$this->getResourceClassNames($variableType, $classNames)];
}
}
}

return array_values(array_unique($classNames));
}
}
117 changes: 117 additions & 0 deletions src/Symfony/Component/JsonStreamer/Tests/StreamerDumperTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\JsonStreamer\Tests;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Config\ConfigCacheFactory;
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoader;
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface;
use Symfony\Component\JsonStreamer\StreamerDumper;
use Symfony\Component\JsonStreamer\Tests\Fixtures\Enum\DummyBackedEnum;
use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\ClassicDummy;
use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithArray;
use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithNameAttributes;
use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithOtherDummies;
use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\SelfReferencingDummy;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\TypeResolver\TypeResolver;

class StreamerDumperTest extends TestCase
{
private string $cacheDir;

protected function setUp(): void
{
parent::setUp();

$this->cacheDir = \sprintf('%s/symfony_json_streamer_test/any', sys_get_temp_dir());

if (is_dir($this->cacheDir)) {
array_map('unlink', glob($this->cacheDir.'/*'));
rmdir($this->cacheDir);
}
}

public function testDumpWithConfigCache()
{
$path = $this->cacheDir.'/streamer.php';

$dumper = new StreamerDumper($this->createMock(PropertyMetadataLoaderInterface::class), $this->cacheDir, new ConfigCacheFactory(true));
$dumper->dump(Type::int(), $path, fn () => 'CONTENT');

$this->assertFileExists($path);
$this->assertFileExists($path.'.meta');
$this->assertFileExists($path.'.meta.json');

$this->assertStringEqualsFile($path, 'CONTENT');
}

public function testDumpWithoutConfigCache()
{
$path = $this->cacheDir.'/streamer.php';

$dumper = new StreamerDumper($this->createMock(PropertyMetadataLoaderInterface::class), $this->cacheDir);
$dumper->dump(Type::int(), $path, fn () => 'CONTENT');

$this->assertFileExists($path);
$this->assertStringEqualsFile($path, 'CONTENT');
}

/**
* @param list<class-string> $expectedClassNames
*/
#[DataProvider('getCacheResourcesDataProvider')]
public function testGetCacheResources(Type $type, array $expectedClassNames)
{
$path = $this->cacheDir.'/streamer.php';

$dumper = new StreamerDumper(new PropertyMetadataLoader(TypeResolver::create()), $this->cacheDir, new ConfigCacheFactory(true));
$dumper->dump($type, $path, fn () => 'CONTENT');

$resources = json_decode(file_get_contents($path.'.meta.json'), true)['resources'];
$classNames = array_column($resources, 'className');

$this->assertSame($expectedClassNames, $classNames);
}

/**
* @return iterable<array{0: Type, 1: list<class-string>}>
*/
public static function getCacheResourcesDataProvider(): iterable
{
yield 'scalar' => [Type::int(), []];
yield 'enum' => [Type::enum(DummyBackedEnum::class), [DummyBackedEnum::class]];
yield 'object' => [Type::object(ClassicDummy::class), [ClassicDummy::class]];
yield 'collection of objects' => [
Type::list(Type::object(ClassicDummy::class)),
[ClassicDummy::class],
];
yield 'generic with objects' => [
Type::generic(Type::object(ClassicDummy::class), Type::object(DummyWithArray::class)),
[DummyWithArray::class, ClassicDummy::class],
];
yield 'union with objects' => [
Type::union(Type::int(), Type::object(ClassicDummy::class), Type::object(DummyWithArray::class)),
[ClassicDummy::class, DummyWithArray::class],
];
yield 'intersection with objects' => [
Type::intersection(Type::object(ClassicDummy::class), Type::object(DummyWithArray::class)),
[ClassicDummy::class, DummyWithArray::class],
];
yield 'object with object properties' => [
Type::object(DummyWithOtherDummies::class),
[DummyWithOtherDummies::class, DummyWithNameAttributes::class, ClassicDummy::class],
];
yield 'object with self reference' => [Type::object(SelfReferencingDummy::class), [SelfReferencingDummy::class]];
}
}
42 changes: 12 additions & 30 deletions src/Symfony/Component/JsonStreamer/Write/StreamWriterGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@

namespace Symfony\Component\JsonStreamer\Write;

use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Config\ConfigCacheFactoryInterface;
use Symfony\Component\JsonStreamer\DataModel\Write\BackedEnumNode;
use Symfony\Component\JsonStreamer\DataModel\Write\CollectionNode;
use Symfony\Component\JsonStreamer\DataModel\Write\CompositeNode;
Expand All @@ -22,6 +21,7 @@
use Symfony\Component\JsonStreamer\Exception\RuntimeException;
use Symfony\Component\JsonStreamer\Exception\UnsupportedException;
use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface;
use Symfony\Component\JsonStreamer\StreamerDumper;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\BackedEnumType;
use Symfony\Component\TypeInfo\Type\BuiltinType;
Expand All @@ -40,13 +40,15 @@
*/
final class StreamWriterGenerator
{
private StreamerDumper $dumper;
private ?PhpGenerator $phpGenerator = null;
private ?Filesystem $fs = null;

public function __construct(
private PropertyMetadataLoaderInterface $propertyMetadataLoader,
private string $streamWritersDir,
?ConfigCacheFactoryInterface $cacheFactory = null,
) {
$this->dumper = new StreamerDumper($propertyMetadataLoader, $streamWritersDir, $cacheFactory);
}

/**
Expand All @@ -56,46 +58,26 @@ public function __construct(
*/
public function generate(Type $type, array $options = []): string
{
$path = $this->getPath($type);
if (is_file($path)) {
return $path;
}

$this->phpGenerator ??= new PhpGenerator();
$this->fs ??= new Filesystem();
$path = \sprintf('%s%s%s.json.php', $this->streamWritersDir, \DIRECTORY_SEPARATOR, hash('xxh128', (string) $type));
$generateContent = function () use ($type, $options): string {
$this->phpGenerator ??= new PhpGenerator();

$dataModel = $this->createDataModel($type, '$data', $options, ['depth' => 0]);
$php = $this->phpGenerator->generate($dataModel, $options);
return $this->phpGenerator->generate($this->createDataModel($type, '$data', $options), $options);
};

if (!$this->fs->exists($this->streamWritersDir)) {
$this->fs->mkdir($this->streamWritersDir);
}

$tmpFile = $this->fs->tempnam(\dirname($path), basename($path));

try {
$this->fs->dumpFile($tmpFile, $php);
$this->fs->rename($tmpFile, $path);
$this->fs->chmod($path, 0o666 & ~umask());
} catch (IOException $e) {
throw new RuntimeException(\sprintf('Failed to write "%s" stream writer file.', $path), previous: $e);
}
$this->dumper->dump($type, $path, $generateContent);

return $path;
}

private function getPath(Type $type): string
{
return \sprintf('%s%s%s.json.php', $this->streamWritersDir, \DIRECTORY_SEPARATOR, hash('xxh128', (string) $type));
}

/**
* @param array<string, mixed> $options
* @param array<string, mixed> $context
*/
private function createDataModel(Type $type, string $accessor, array $options = [], array $context = []): DataModelNodeInterface
{
$context['original_type'] ??= $type;
$context['depth'] ??= 0;

if ($type instanceof UnionType) {
return new CompositeNode($accessor, array_map(fn (Type $t): DataModelNodeInterface => $this->createDataModel($t, $accessor, $options, $context), $type->getTypes()));
Expand Down
Loading