diff --git a/src/Symfony/Component/JsonStreamer/DataModel/FunctionDataAccessor.php b/src/Symfony/Component/JsonStreamer/DataModel/FunctionDataAccessor.php index f2e579787f6da..8ad8960674d57 100644 --- a/src/Symfony/Component/JsonStreamer/DataModel/FunctionDataAccessor.php +++ b/src/Symfony/Component/JsonStreamer/DataModel/FunctionDataAccessor.php @@ -33,6 +33,16 @@ public function __construct( ) { } + public function getObjectAccessor(): ?DataAccessorInterface + { + return $this->objectAccessor; + } + + public function withObjectAccessor(?DataAccessorInterface $accessor): self + { + return new self($this->functionName, $this->arguments, $accessor); + } + public function toPhpExpr(): Expr { $builder = new BuilderFactory(); diff --git a/src/Symfony/Component/JsonStreamer/DataModel/PropertyDataAccessor.php b/src/Symfony/Component/JsonStreamer/DataModel/PropertyDataAccessor.php index a9ca03e02a512..f48c98064bb65 100644 --- a/src/Symfony/Component/JsonStreamer/DataModel/PropertyDataAccessor.php +++ b/src/Symfony/Component/JsonStreamer/DataModel/PropertyDataAccessor.php @@ -29,6 +29,16 @@ public function __construct( ) { } + public function getObjectAccessor(): DataAccessorInterface + { + return $this->objectAccessor; + } + + public function withObjectAccessor(DataAccessorInterface $accessor): self + { + return new self($accessor, $this->propertyName); + } + public function toPhpExpr(): Expr { return (new BuilderFactory())->propertyFetch($this->objectAccessor->toPhpExpr(), $this->propertyName); diff --git a/src/Symfony/Component/JsonStreamer/DataModel/Read/ObjectNode.php b/src/Symfony/Component/JsonStreamer/DataModel/Read/ObjectNode.php index ca39a4cfee5a9..25d53c15fff60 100644 --- a/src/Symfony/Component/JsonStreamer/DataModel/Read/ObjectNode.php +++ b/src/Symfony/Component/JsonStreamer/DataModel/Read/ObjectNode.php @@ -30,11 +30,11 @@ final class ObjectNode implements DataModelNodeInterface public function __construct( private ObjectType $type, private array $properties, - private bool $ghost = false, + private bool $mock = false, ) { } - public static function createGhost(ObjectType|UnionType $type): self + public static function createMock(ObjectType|UnionType $type): self { return new self($type, [], true); } @@ -57,8 +57,8 @@ public function getProperties(): array return $this->properties; } - public function isGhost(): bool + public function isMock(): bool { - return $this->ghost; + return $this->mock; } } diff --git a/src/Symfony/Component/JsonStreamer/DataModel/Write/BackedEnumNode.php b/src/Symfony/Component/JsonStreamer/DataModel/Write/BackedEnumNode.php index 4cda4df831d28..ba96b98319d1e 100644 --- a/src/Symfony/Component/JsonStreamer/DataModel/Write/BackedEnumNode.php +++ b/src/Symfony/Component/JsonStreamer/DataModel/Write/BackedEnumNode.php @@ -31,6 +31,16 @@ public function __construct( ) { } + public function withAccessor(DataAccessorInterface $accessor): self + { + return new self($accessor, $this->type); + } + + public function getIdentifier(): string + { + return (string) $this->getType(); + } + public function getAccessor(): DataAccessorInterface { return $this->accessor; diff --git a/src/Symfony/Component/JsonStreamer/DataModel/Write/CollectionNode.php b/src/Symfony/Component/JsonStreamer/DataModel/Write/CollectionNode.php index 276679db210ca..2f324fb404908 100644 --- a/src/Symfony/Component/JsonStreamer/DataModel/Write/CollectionNode.php +++ b/src/Symfony/Component/JsonStreamer/DataModel/Write/CollectionNode.php @@ -30,6 +30,16 @@ public function __construct( ) { } + public function withAccessor(DataAccessorInterface $accessor): self + { + return new self($accessor, $this->type, $this->item); + } + + public function getIdentifier(): string + { + return (string) $this->getType(); + } + public function getAccessor(): DataAccessorInterface { return $this->accessor; diff --git a/src/Symfony/Component/JsonStreamer/DataModel/Write/CompositeNode.php b/src/Symfony/Component/JsonStreamer/DataModel/Write/CompositeNode.php index 63eff63af7e5a..705d610fe7932 100644 --- a/src/Symfony/Component/JsonStreamer/DataModel/Write/CompositeNode.php +++ b/src/Symfony/Component/JsonStreamer/DataModel/Write/CompositeNode.php @@ -60,6 +60,16 @@ public function __construct( $this->nodes = $nodes; } + public function withAccessor(DataAccessorInterface $accessor): self + { + return new self($accessor, array_map(static fn (DataModelNodeInterface $n): DataModelNodeInterface => $n->withAccessor($accessor), $this->nodes)); + } + + public function getIdentifier(): string + { + return (string) $this->getType(); + } + public function getAccessor(): DataAccessorInterface { return $this->accessor; diff --git a/src/Symfony/Component/JsonStreamer/DataModel/Write/DataModelNodeInterface.php b/src/Symfony/Component/JsonStreamer/DataModel/Write/DataModelNodeInterface.php index 9b72503af1a3c..fa94649cda40a 100644 --- a/src/Symfony/Component/JsonStreamer/DataModel/Write/DataModelNodeInterface.php +++ b/src/Symfony/Component/JsonStreamer/DataModel/Write/DataModelNodeInterface.php @@ -23,7 +23,11 @@ */ interface DataModelNodeInterface { + public function getIdentifier(): string; + public function getType(): Type; public function getAccessor(): DataAccessorInterface; + + public function withAccessor(DataAccessorInterface $accessor): self; } diff --git a/src/Symfony/Component/JsonStreamer/DataModel/Write/ExceptionNode.php b/src/Symfony/Component/JsonStreamer/DataModel/Write/ExceptionNode.php deleted file mode 100644 index 2145f9061dfc7..0000000000000 --- a/src/Symfony/Component/JsonStreamer/DataModel/Write/ExceptionNode.php +++ /dev/null @@ -1,49 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\JsonStreamer\DataModel\Write; - -use PhpParser\Node\Expr\New_; -use PhpParser\Node\Name\FullyQualified; -use Symfony\Component\JsonStreamer\DataModel\DataAccessorInterface; -use Symfony\Component\JsonStreamer\DataModel\PhpExprDataAccessor; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\ObjectType; - -/** - * Represent an exception to be thrown. - * - * Exceptions are leaves in the data model tree. - * - * @author Mathias Arlaud - * - * @internal - */ -final class ExceptionNode implements DataModelNodeInterface -{ - /** - * @param class-string<\Exception> $className - */ - public function __construct( - private string $className, - ) { - } - - public function getAccessor(): DataAccessorInterface - { - return new PhpExprDataAccessor(new New_(new FullyQualified($this->className))); - } - - public function getType(): ObjectType - { - return Type::object($this->className); - } -} diff --git a/src/Symfony/Component/JsonStreamer/DataModel/Write/ObjectNode.php b/src/Symfony/Component/JsonStreamer/DataModel/Write/ObjectNode.php index 04bc16dab957a..56dfcad38c0fe 100644 --- a/src/Symfony/Component/JsonStreamer/DataModel/Write/ObjectNode.php +++ b/src/Symfony/Component/JsonStreamer/DataModel/Write/ObjectNode.php @@ -12,6 +12,8 @@ namespace Symfony\Component\JsonStreamer\DataModel\Write; use Symfony\Component\JsonStreamer\DataModel\DataAccessorInterface; +use Symfony\Component\JsonStreamer\DataModel\FunctionDataAccessor; +use Symfony\Component\JsonStreamer\DataModel\PropertyDataAccessor; use Symfony\Component\TypeInfo\Type\ObjectType; /** @@ -30,9 +32,36 @@ public function __construct( private DataAccessorInterface $accessor, private ObjectType $type, private array $properties, + private bool $mock = false, ) { } + public static function createMock(DataAccessorInterface $accessor, ObjectType $type): self + { + return new self($accessor, $type, [], true); + } + + public function withAccessor(DataAccessorInterface $accessor): self + { + $properties = []; + foreach ($this->properties as $key => $property) { + $propertyAccessor = $property->getAccessor(); + + if ($propertyAccessor instanceof PropertyDataAccessor || $propertyAccessor instanceof FunctionDataAccessor && $propertyAccessor->getObjectAccessor()) { + $propertyAccessor = $propertyAccessor->withObjectAccessor($accessor); + } + + $properties[$key] = $property->withAccessor($propertyAccessor); + } + + return new self($accessor, $this->type, $properties, $this->mock); + } + + public function getIdentifier(): string + { + return (string) $this->getType(); + } + public function getAccessor(): DataAccessorInterface { return $this->accessor; @@ -50,4 +79,9 @@ public function getProperties(): array { return $this->properties; } + + public function isMock(): bool + { + return $this->mock; + } } diff --git a/src/Symfony/Component/JsonStreamer/DataModel/Write/ScalarNode.php b/src/Symfony/Component/JsonStreamer/DataModel/Write/ScalarNode.php index 9f7e048faf86a..53dc88b321d3f 100644 --- a/src/Symfony/Component/JsonStreamer/DataModel/Write/ScalarNode.php +++ b/src/Symfony/Component/JsonStreamer/DataModel/Write/ScalarNode.php @@ -31,6 +31,16 @@ public function __construct( ) { } + public function withAccessor(DataAccessorInterface $accessor): self + { + return new self($accessor, $this->type); + } + + public function getIdentifier(): string + { + return (string) $this->getType(); + } + public function getAccessor(): DataAccessorInterface { return $this->accessor; diff --git a/src/Symfony/Component/JsonStreamer/Exception/MaxDepthException.php b/src/Symfony/Component/JsonStreamer/Exception/NotEncodableValueException.php similarity index 69% rename from src/Symfony/Component/JsonStreamer/Exception/MaxDepthException.php rename to src/Symfony/Component/JsonStreamer/Exception/NotEncodableValueException.php index 98ac498d1b7ca..7d69ea49a5b77 100644 --- a/src/Symfony/Component/JsonStreamer/Exception/MaxDepthException.php +++ b/src/Symfony/Component/JsonStreamer/Exception/NotEncodableValueException.php @@ -16,10 +16,6 @@ * * @experimental */ -final class MaxDepthException extends RuntimeException +class NotEncodableValueException extends UnexpectedValueException { - public function __construct() - { - parent::__construct('Max depth of 512 has been reached.'); - } } diff --git a/src/Symfony/Component/JsonStreamer/Read/PhpAstBuilder.php b/src/Symfony/Component/JsonStreamer/Read/PhpAstBuilder.php index 0189c7987d81e..7a6e23762beca 100644 --- a/src/Symfony/Component/JsonStreamer/Read/PhpAstBuilder.php +++ b/src/Symfony/Component/JsonStreamer/Read/PhpAstBuilder.php @@ -421,7 +421,7 @@ private function buildCollectionNodeStatements(CollectionNode $node, bool $decod */ private function buildObjectNodeStatements(ObjectNode $node, bool $decodeFromStream, array &$context): array { - if ($node->isGhost()) { + if ($node->isMock()) { return []; } diff --git a/src/Symfony/Component/JsonStreamer/Read/StreamReaderGenerator.php b/src/Symfony/Component/JsonStreamer/Read/StreamReaderGenerator.php index 77f34ffef142b..c363cb7b70284 100644 --- a/src/Symfony/Component/JsonStreamer/Read/StreamReaderGenerator.php +++ b/src/Symfony/Component/JsonStreamer/Read/StreamReaderGenerator.php @@ -123,7 +123,7 @@ public function createDataModel(Type $type, array $options = [], array $context $className = $type->getClassName(); if ($context['generated_classes'][$typeString] ??= false) { - return ObjectNode::createGhost($type); + return ObjectNode::createMock($type); } $propertiesNodes = []; diff --git a/src/Symfony/Component/JsonStreamer/Tests/DataModel/Write/CompositeNodeTest.php b/src/Symfony/Component/JsonStreamer/Tests/DataModel/Write/CompositeNodeTest.php index 9fb197d23c7f7..a7ef7df343d6f 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/DataModel/Write/CompositeNodeTest.php +++ b/src/Symfony/Component/JsonStreamer/Tests/DataModel/Write/CompositeNodeTest.php @@ -54,4 +54,17 @@ public function testSortNodesOnCreation() $this->assertSame([$collection, $object, $scalar], $composite->getNodes()); } + + public function testWithAccessor() + { + $composite = new CompositeNode(new VariableDataAccessor('data'), [ + new ScalarNode(new VariableDataAccessor('foo'), Type::int()), + new ScalarNode(new VariableDataAccessor('bar'), Type::int()), + ]); + $composite = $composite->withAccessor($newAccessor = new VariableDataAccessor('baz')); + + foreach ($composite->getNodes() as $node) { + $this->assertSame($newAccessor, $node->getAccessor()); + } + } } diff --git a/src/Symfony/Component/JsonStreamer/Tests/DataModel/Write/ObjectNodeTest.php b/src/Symfony/Component/JsonStreamer/Tests/DataModel/Write/ObjectNodeTest.php new file mode 100644 index 0000000000000..0667f731e3d9f --- /dev/null +++ b/src/Symfony/Component/JsonStreamer/Tests/DataModel/Write/ObjectNodeTest.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonStreamer\Tests\DataModel\Write; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonStreamer\DataModel\FunctionDataAccessor; +use Symfony\Component\JsonStreamer\DataModel\PropertyDataAccessor; +use Symfony\Component\JsonStreamer\DataModel\VariableDataAccessor; +use Symfony\Component\JsonStreamer\DataModel\Write\ObjectNode; +use Symfony\Component\JsonStreamer\DataModel\Write\ScalarNode; +use Symfony\Component\TypeInfo\Type; + +class ObjectNodeTest extends TestCase +{ + public function testWithAccessor() + { + $object = new ObjectNode(new VariableDataAccessor('foo'), Type::object(self::class), [ + new ScalarNode(new PropertyDataAccessor(new VariableDataAccessor('foo'), 'property'), Type::int()), + new ScalarNode(new FunctionDataAccessor('function', [], new VariableDataAccessor('foo')), Type::int()), + new ScalarNode(new FunctionDataAccessor('function', []), Type::int()), + new ScalarNode(new VariableDataAccessor('bar'), Type::int()), + ]); + $object = $object->withAccessor($newAccessor = new VariableDataAccessor('baz')); + + $this->assertSame($newAccessor, $object->getAccessor()); + $this->assertSame($newAccessor, $object->getProperties()[0]->getAccessor()->getObjectAccessor()); + $this->assertSame($newAccessor, $object->getProperties()[1]->getAccessor()->getObjectAccessor()); + $this->assertNull($object->getProperties()[2]->getAccessor()->getObjectAccessor()); + $this->assertNotSame($newAccessor, $object->getProperties()[3]->getAccessor()); + } +} diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/backed_enum.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/backed_enum.php index 6f92d380a89df..cd64125f0a71e 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/backed_enum.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/backed_enum.php @@ -1,5 +1,9 @@ value); + try { + yield \json_encode($data->value, \JSON_THROW_ON_ERROR, 512); + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); + } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/bool.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/bool.php index e3d7a957734d4..f645b7c3cc391 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/bool.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/bool.php @@ -1,5 +1,9 @@ getMessage(), 0, $e); + } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/bool_list.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/bool_list.php index c46da0946cf27..cd6e53ba38da1 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/bool_list.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/bool_list.php @@ -1,5 +1,9 @@ getMessage(), 0, $e); + } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/dict.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/dict.php index c46da0946cf27..cd6e53ba38da1 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/dict.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/dict.php @@ -1,5 +1,9 @@ getMessage(), 0, $e); + } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/iterable.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/iterable.php index c46da0946cf27..cd6e53ba38da1 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/iterable.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/iterable.php @@ -1,5 +1,9 @@ getMessage(), 0, $e); + } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/list.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/list.php index c46da0946cf27..cd6e53ba38da1 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/list.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/list.php @@ -1,5 +1,9 @@ getMessage(), 0, $e); + } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/mixed.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/mixed.php index c46da0946cf27..cd6e53ba38da1 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/mixed.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/mixed.php @@ -1,5 +1,9 @@ getMessage(), 0, $e); + } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/null.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/null.php index 5d496f61029e7..f28312c425ce0 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/null.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/null.php @@ -1,5 +1,9 @@ getMessage(), 0, $e); + } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/null_list.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/null_list.php index c46da0946cf27..cd6e53ba38da1 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/null_list.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/null_list.php @@ -1,5 +1,9 @@ getMessage(), 0, $e); + } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_backed_enum.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_backed_enum.php index 5959bb1eb2f6c..42f62c6037f05 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_backed_enum.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_backed_enum.php @@ -1,11 +1,15 @@ value); - } elseif (null === $data) { - yield 'null'; - } else { - throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + try { + if ($data instanceof \Symfony\Component\JsonStreamer\Tests\Fixtures\Enum\DummyBackedEnum) { + yield \json_encode($data->value, \JSON_THROW_ON_ERROR, 512); + } elseif (null === $data) { + yield 'null'; + } else { + throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_object.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_object.php index ee372d7282657..fc816873d6818 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_object.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_object.php @@ -1,15 +1,19 @@ id); - yield ',"name":'; - yield \json_encode($data->name); - yield '}'; - } elseif (null === $data) { - yield 'null'; - } else { - throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + try { + if ($data instanceof \Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithNameAttributes) { + yield '{"@id":'; + yield \json_encode($data->id, \JSON_THROW_ON_ERROR, 511); + yield ',"name":'; + yield \json_encode($data->name, \JSON_THROW_ON_ERROR, 511); + yield '}'; + } elseif (null === $data) { + yield 'null'; + } else { + throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_object_dict.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_object_dict.php index dfebf6436bef0..b466dd89c9871 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_object_dict.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_object_dict.php @@ -1,23 +1,27 @@ $value) { - $key = \substr(\json_encode($key), 1, -1); - yield "{$prefix}\"{$key}\":"; - yield '{"@id":'; - yield \json_encode($value->id); - yield ',"name":'; - yield \json_encode($value->name); + try { + if (\is_array($data)) { + yield '{'; + $prefix = ''; + foreach ($data as $key => $value) { + $key = \substr(\json_encode($key), 1, -1); + yield "{$prefix}\"{$key}\":"; + yield '{"@id":'; + yield \json_encode($value->id, \JSON_THROW_ON_ERROR, 510); + yield ',"name":'; + yield \json_encode($value->name, \JSON_THROW_ON_ERROR, 510); + yield '}'; + $prefix = ','; + } yield '}'; - $prefix = ','; + } elseif (null === $data) { + yield 'null'; + } else { + throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); } - yield '}'; - } elseif (null === $data) { - yield 'null'; - } else { - throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_object_list.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_object_list.php index c36e3a6c318be..f891ae0a649bc 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_object_list.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_object_list.php @@ -1,22 +1,26 @@ id); - yield ',"name":'; - yield \json_encode($value->name); - yield '}'; - $prefix = ','; + try { + if (\is_array($data)) { + yield '['; + $prefix = ''; + foreach ($data as $value) { + yield $prefix; + yield '{"@id":'; + yield \json_encode($value->id, \JSON_THROW_ON_ERROR, 510); + yield ',"name":'; + yield \json_encode($value->name, \JSON_THROW_ON_ERROR, 510); + yield '}'; + $prefix = ','; + } + yield ']'; + } elseif (null === $data) { + yield 'null'; + } else { + throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); } - yield ']'; - } elseif (null === $data) { - yield 'null'; - } else { - throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object.php index 39c8ea6d3983c..36499b3d3035c 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object.php @@ -1,9 +1,13 @@ id); - yield ',"name":'; - yield \json_encode($data->name); - yield '}'; + try { + yield '{"@id":'; + yield \json_encode($data->id, \JSON_THROW_ON_ERROR, 511); + yield ',"name":'; + yield \json_encode($data->name, \JSON_THROW_ON_ERROR, 511); + yield '}'; + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); + } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_dict.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_dict.php index 81d92f22a04f3..9959ab8211300 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_dict.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_dict.php @@ -1,17 +1,21 @@ $value) { - $key = \substr(\json_encode($key), 1, -1); - yield "{$prefix}\"{$key}\":"; - yield '{"@id":'; - yield \json_encode($value->id); - yield ',"name":'; - yield \json_encode($value->name); + try { + yield '{'; + $prefix = ''; + foreach ($data as $key => $value) { + $key = \substr(\json_encode($key), 1, -1); + yield "{$prefix}\"{$key}\":"; + yield '{"@id":'; + yield \json_encode($value->id, \JSON_THROW_ON_ERROR, 510); + yield ',"name":'; + yield \json_encode($value->name, \JSON_THROW_ON_ERROR, 510); + yield '}'; + $prefix = ','; + } yield '}'; - $prefix = ','; + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); } - yield '}'; }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_in_object.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_in_object.php index 3b4651722d8c6..3f6dc691cbba9 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_in_object.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_in_object.php @@ -1,15 +1,19 @@ name); - yield ',"otherDummyOne":{"@id":'; - yield \json_encode($data->otherDummyOne->id); - yield ',"name":'; - yield \json_encode($data->otherDummyOne->name); - yield '},"otherDummyTwo":{"id":'; - yield \json_encode($data->otherDummyTwo->id); - yield ',"name":'; - yield \json_encode($data->otherDummyTwo->name); - yield '}}'; + try { + yield '{"name":'; + yield \json_encode($data->name, \JSON_THROW_ON_ERROR, 511); + yield ',"otherDummyOne":{"@id":'; + yield \json_encode($data->otherDummyOne->id, \JSON_THROW_ON_ERROR, 510); + yield ',"name":'; + yield \json_encode($data->otherDummyOne->name, \JSON_THROW_ON_ERROR, 510); + yield '},"otherDummyTwo":{"id":'; + yield \json_encode($data->otherDummyTwo->id, \JSON_THROW_ON_ERROR, 510); + yield ',"name":'; + yield \json_encode($data->otherDummyTwo->name, \JSON_THROW_ON_ERROR, 510); + yield '}}'; + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); + } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_iterable.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_iterable.php index c7c859fdc2cb9..5eff34f5e59b8 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_iterable.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_iterable.php @@ -1,17 +1,21 @@ $value) { - $key = is_int($key) ? $key : \substr(\json_encode($key), 1, -1); - yield "{$prefix}\"{$key}\":"; - yield '{"id":'; - yield \json_encode($value->id); - yield ',"name":'; - yield \json_encode($value->name); + try { + yield '{'; + $prefix = ''; + foreach ($data as $key => $value) { + $key = is_int($key) ? $key : \substr(\json_encode($key), 1, -1); + yield "{$prefix}\"{$key}\":"; + yield '{"id":'; + yield \json_encode($value->id, \JSON_THROW_ON_ERROR, 510); + yield ',"name":'; + yield \json_encode($value->name, \JSON_THROW_ON_ERROR, 510); + yield '}'; + $prefix = ','; + } yield '}'; - $prefix = ','; + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); } - yield '}'; }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_list.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_list.php index 967e6351b4ac8..bb4a6a45d0a46 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_list.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_list.php @@ -1,16 +1,20 @@ id); - yield ',"name":'; - yield \json_encode($value->name); - yield '}'; - $prefix = ','; + try { + yield '['; + $prefix = ''; + foreach ($data as $value) { + yield $prefix; + yield '{"@id":'; + yield \json_encode($value->id, \JSON_THROW_ON_ERROR, 510); + yield ',"name":'; + yield \json_encode($value->name, \JSON_THROW_ON_ERROR, 510); + yield '}'; + $prefix = ','; + } + yield ']'; + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); } - yield ']'; }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_with_union.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_with_union.php index 1b47d6246f525..bc069637c4e42 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_with_union.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_with_union.php @@ -1,15 +1,19 @@ value instanceof \Symfony\Component\JsonStreamer\Tests\Fixtures\Enum\DummyBackedEnum) { - yield \json_encode($data->value->value); - } elseif (null === $data->value) { - yield 'null'; - } elseif (\is_string($data->value)) { - yield \json_encode($data->value); - } else { - throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data->value))); + try { + yield '{"value":'; + if ($data->value instanceof \Symfony\Component\JsonStreamer\Tests\Fixtures\Enum\DummyBackedEnum) { + yield \json_encode($data->value->value, \JSON_THROW_ON_ERROR, 511); + } elseif (null === $data->value) { + yield 'null'; + } elseif (\is_string($data->value)) { + yield \json_encode($data->value, \JSON_THROW_ON_ERROR, 511); + } else { + throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data->value))); + } + yield '}'; + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); } - yield '}'; }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_with_value_transformer.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_with_value_transformer.php index ecb3490600103..08d0941b9b5f0 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_with_value_transformer.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_with_value_transformer.php @@ -1,13 +1,17 @@ get('Symfony\Component\JsonStreamer\Tests\Fixtures\ValueTransformer\DoubleIntAndCastToStringValueTransformer')->transform($data->id, $options)); - yield ',"active":'; - yield \json_encode($valueTransformers->get('Symfony\Component\JsonStreamer\Tests\Fixtures\ValueTransformer\BooleanToStringValueTransformer')->transform($data->active, $options)); - yield ',"name":'; - yield \json_encode(strtolower($data->name)); - yield ',"range":'; - yield \json_encode(Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithValueTransformerAttributes::concatRange($data->range, $options)); - yield '}'; + try { + yield '{"id":'; + yield \json_encode($valueTransformers->get('Symfony\Component\JsonStreamer\Tests\Fixtures\ValueTransformer\DoubleIntAndCastToStringValueTransformer')->transform($data->id, $options), \JSON_THROW_ON_ERROR, 511); + yield ',"active":'; + yield \json_encode($valueTransformers->get('Symfony\Component\JsonStreamer\Tests\Fixtures\ValueTransformer\BooleanToStringValueTransformer')->transform($data->active, $options), \JSON_THROW_ON_ERROR, 511); + yield ',"name":'; + yield \json_encode(strtolower($data->name), \JSON_THROW_ON_ERROR, 511); + yield ',"range":'; + yield \json_encode(Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithValueTransformerAttributes::concatRange($data->range, $options), \JSON_THROW_ON_ERROR, 511); + yield '}'; + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); + } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/scalar.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/scalar.php index c46da0946cf27..cd6e53ba38da1 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/scalar.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/scalar.php @@ -1,5 +1,9 @@ getMessage(), 0, $e); + } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/self_referencing_object.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/self_referencing_object.php new file mode 100644 index 0000000000000..de186a874e04d --- /dev/null +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/self_referencing_object.php @@ -0,0 +1,23 @@ += 512) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException('Maximum stack depth exceeded'); + } + yield '{"@self":'; + if ($data->self instanceof \Symfony\Component\JsonStreamer\Tests\Fixtures\Model\SelfReferencingDummy) { + yield from $generators['Symfony\Component\JsonStreamer\Tests\Fixtures\Model\SelfReferencingDummy']($data->self, $depth + 1); + } elseif (null === $data->self) { + yield 'null'; + } else { + throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data->self))); + } + yield '}'; + }; + try { + yield from $generators['Symfony\Component\JsonStreamer\Tests\Fixtures\Model\SelfReferencingDummy']($data, 0); + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); + } +}; diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/union.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/union.php index c0a882935ecbc..edb5e5c46fe7c 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/union.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/union.php @@ -1,24 +1,28 @@ value); - $prefix = ','; + try { + if (\is_array($data)) { + yield '['; + $prefix = ''; + foreach ($data as $value) { + yield $prefix; + yield \json_encode($value->value, \JSON_THROW_ON_ERROR, 511); + $prefix = ','; + } + yield ']'; + } elseif ($data instanceof \Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithNameAttributes) { + yield '{"@id":'; + yield \json_encode($data->id, \JSON_THROW_ON_ERROR, 511); + yield ',"name":'; + yield \json_encode($data->name, \JSON_THROW_ON_ERROR, 511); + yield '}'; + } elseif (\is_int($data)) { + yield \json_encode($data, \JSON_THROW_ON_ERROR, 512); + } else { + throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); } - yield ']'; - } elseif ($data instanceof \Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithNameAttributes) { - yield '{"@id":'; - yield \json_encode($data->id); - yield ',"name":'; - yield \json_encode($data->name); - yield '}'; - } elseif (\is_int($data)) { - yield \json_encode($data); - } else { - throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } catch (\JsonException $e) { + throw new \Symfony\Component\JsonStreamer\Exception\NotEncodableValueException($e->getMessage(), 0, $e); } }; diff --git a/src/Symfony/Component/JsonStreamer/Tests/JsonStreamWriterTest.php b/src/Symfony/Component/JsonStreamer/Tests/JsonStreamWriterTest.php index b37820e5da215..4fd987a6d4d11 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/JsonStreamWriterTest.php +++ b/src/Symfony/Component/JsonStreamer/Tests/JsonStreamWriterTest.php @@ -12,7 +12,7 @@ namespace Symfony\Component\JsonStreamer\Tests; use PHPUnit\Framework\TestCase; -use Symfony\Component\JsonStreamer\Exception\MaxDepthException; +use Symfony\Component\JsonStreamer\Exception\NotEncodableValueException; use Symfony\Component\JsonStreamer\JsonStreamWriter; use Symfony\Component\JsonStreamer\Tests\Fixtures\Enum\DummyBackedEnum; use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\ClassicDummy; @@ -173,22 +173,52 @@ public function testWriteObjectWithDateTimes() ); } - public function testThrowWhenMaxDepthIsReached() + /** + * @dataProvider throwWhenMaxDepthIsReachedDataProvider + */ + public function testThrowWhenMaxDepthIsReached(Type $type, mixed $data) { $writer = JsonStreamWriter::create(streamWritersDir: $this->streamWritersDir); + $this->expectException(NotEncodableValueException::class); + $this->expectExceptionMessage('Maximum stack depth exceeded'); + + (string) $writer->write($data, $type); + } + + /** + * @return iterable + */ + public static function throwWhenMaxDepthIsReachedDataProvider(): iterable + { $dummy = new SelfReferencingDummy(); for ($i = 0; $i < 512; ++$i) { $tmp = new SelfReferencingDummy(); $tmp->self = $dummy; + $dummy = $tmp; + } + yield [Type::object(SelfReferencingDummy::class), $dummy]; + + $dummy = new SelfReferencingDummy(); + for ($i = 0; $i < 511; ++$i) { + $tmp = new SelfReferencingDummy(); + $tmp->self = $dummy; $dummy = $tmp; } - $this->expectException(MaxDepthException::class); - $this->expectExceptionMessage('Max depth of 512 has been reached.'); + yield [Type::list(Type::object(SelfReferencingDummy::class)), [$dummy]]; + yield [Type::dict(Type::object(SelfReferencingDummy::class)), ['k' => $dummy]]; + } + + public function testThrowWhenEncodeError() + { + $writer = JsonStreamWriter::create(streamWritersDir: $this->streamWritersDir); + + $this->expectException(NotEncodableValueException::class); + $this->expectExceptionMessage('Inf and NaN cannot be JSON encoded'); - (string) $writer->write($dummy, Type::object(SelfReferencingDummy::class)); + (string) $writer->write(\INF, Type::int()); } public function testCreateStreamWriterFile() diff --git a/src/Symfony/Component/JsonStreamer/Tests/Write/StreamWriterGeneratorTest.php b/src/Symfony/Component/JsonStreamer/Tests/Write/StreamWriterGeneratorTest.php index c3ee755ecc810..8ddbef67d9a65 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Write/StreamWriterGeneratorTest.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Write/StreamWriterGeneratorTest.php @@ -25,6 +25,7 @@ use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithOtherDummies; use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithUnionProperties; use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithValueTransformerAttributes; +use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\SelfReferencingDummy; use Symfony\Component\JsonStreamer\Tests\Fixtures\ValueTransformer\BooleanToStringValueTransformer; use Symfony\Component\JsonStreamer\Tests\Fixtures\ValueTransformer\DoubleIntAndCastToStringValueTransformer; use Symfony\Component\JsonStreamer\Tests\ServiceContainer; @@ -104,6 +105,7 @@ public static function generatedStreamWriterDataProvider(): iterable yield ['nullable_object', Type::nullable(Type::object(DummyWithNameAttributes::class))]; yield ['object_in_object', Type::object(DummyWithOtherDummies::class)]; yield ['object_with_value_transformer', Type::object(DummyWithValueTransformerAttributes::class)]; + yield ['self_referencing_object', Type::object(SelfReferencingDummy::class)]; yield ['union', Type::union(Type::int(), Type::list(Type::enum(DummyBackedEnum::class)), Type::object(DummyWithNameAttributes::class))]; yield ['object_with_union', Type::object(DummyWithUnionProperties::class)]; @@ -138,7 +140,7 @@ public function testCallPropertyMetadataLoaderWithProperContext() ->method('load') ->with(self::class, [], [ 'original_type' => $type, - 'depth' => 1, + 'generated_classes' => [self::class => true], ]) ->willReturn([]); diff --git a/src/Symfony/Component/JsonStreamer/Write/PhpAstBuilder.php b/src/Symfony/Component/JsonStreamer/Write/PhpAstBuilder.php index f6a4d9b6e7c2c..f0b429b42c8f3 100644 --- a/src/Symfony/Component/JsonStreamer/Write/PhpAstBuilder.php +++ b/src/Symfony/Component/JsonStreamer/Write/PhpAstBuilder.php @@ -12,36 +12,44 @@ namespace Symfony\Component\JsonStreamer\Write; use PhpParser\BuilderFactory; +use PhpParser\Node\ClosureUse; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\ArrayDimFetch; use PhpParser\Node\Expr\Assign; +use PhpParser\Node\Expr\BinaryOp\GreaterOrEqual; use PhpParser\Node\Expr\BinaryOp\Identical; +use PhpParser\Node\Expr\BinaryOp\Plus; use PhpParser\Node\Expr\Closure; use PhpParser\Node\Expr\Instanceof_; use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\Ternary; use PhpParser\Node\Expr\Throw_; use PhpParser\Node\Expr\Yield_; +use PhpParser\Node\Expr\YieldFrom; use PhpParser\Node\Identifier; use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Param; use PhpParser\Node\Scalar\Encapsed; use PhpParser\Node\Scalar\EncapsedStringPart; use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\Catch_; use PhpParser\Node\Stmt\Else_; use PhpParser\Node\Stmt\ElseIf_; use PhpParser\Node\Stmt\Expression; use PhpParser\Node\Stmt\Foreach_; use PhpParser\Node\Stmt\If_; use PhpParser\Node\Stmt\Return_; +use PhpParser\Node\Stmt\TryCatch; use Psr\Container\ContainerInterface; +use Symfony\Component\JsonStreamer\DataModel\VariableDataAccessor; use Symfony\Component\JsonStreamer\DataModel\Write\BackedEnumNode; use Symfony\Component\JsonStreamer\DataModel\Write\CollectionNode; use Symfony\Component\JsonStreamer\DataModel\Write\CompositeNode; use Symfony\Component\JsonStreamer\DataModel\Write\DataModelNodeInterface; -use Symfony\Component\JsonStreamer\DataModel\Write\ExceptionNode; use Symfony\Component\JsonStreamer\DataModel\Write\ObjectNode; use Symfony\Component\JsonStreamer\DataModel\Write\ScalarNode; use Symfony\Component\JsonStreamer\Exception\LogicException; +use Symfony\Component\JsonStreamer\Exception\NotEncodableValueException; use Symfony\Component\JsonStreamer\Exception\RuntimeException; use Symfony\Component\JsonStreamer\Exception\UnexpectedValueException; use Symfony\Component\TypeInfo\Type\BuiltinType; @@ -73,7 +81,13 @@ public function __construct() */ public function build(DataModelNodeInterface $dataModel, array $options = [], array $context = []): array { - $closureStmts = $this->buildClosureStatements($dataModel, $options, $context); + $context['depth'] = 0; + + $generatorStmts = $this->buildGeneratorStatementsByIdentifiers($dataModel, $options, $context); + + // filter generators to mock only + $generatorStmts = array_merge(...array_values(array_intersect_key($generatorStmts, $context['mocks'] ?? []))); + $context['generators'] = array_intersect_key($context['generators'] ?? [], $context['mocks'] ?? []); return [new Return_(new Closure([ 'static' => true, @@ -83,29 +97,121 @@ public function build(DataModelNodeInterface $dataModel, array $options = [], ar new Param($this->builder->var('options'), type: new Identifier('array')), ], 'returnType' => new FullyQualified(\Traversable::class), - 'stmts' => $closureStmts, + 'stmts' => [ + ...$generatorStmts, + new TryCatch( + $this->buildYieldStatements($dataModel, $options, $context), + [new Catch_([new FullyQualified(\JsonException::class)], $this->builder->var('e'), [ + new Expression(new Throw_($this->builder->new(new FullyQualified(NotEncodableValueException::class), [ + $this->builder->methodCall($this->builder->var('e'), 'getMessage'), + $this->builder->val(0), + $this->builder->var('e'), + ]))), + ])] + ), + ], ]))]; } + /** + * @param array $options + * @param array $context + * + * @return array> + */ + private function buildGeneratorStatementsByIdentifiers(DataModelNodeInterface $node, array $options, array &$context): array + { + if ($context['generators'][$node->getIdentifier()] ?? false) { + return []; + } + + if ($node instanceof CollectionNode) { + return $this->buildGeneratorStatementsByIdentifiers($node->getItemNode(), $options, $context); + } + + if ($node instanceof CompositeNode) { + $stmts = []; + + foreach ($node->getNodes() as $n) { + $stmts = [ + ...$stmts, + ...$this->buildGeneratorStatementsByIdentifiers($n, $options, $context), + ]; + } + + return $stmts; + } + + if (!$node instanceof ObjectNode) { + return []; + } + + if ($node->isMock()) { + $context['mocks'][$node->getIdentifier()] = true; + + return []; + } + + $context['building_generator'] = true; + + $stmts = [ + $node->getIdentifier() => [ + new Expression(new Assign( + new ArrayDimFetch($this->builder->var('generators'), $this->builder->val($node->getIdentifier())), + new Closure([ + 'static' => true, + 'params' => [ + new Param($this->builder->var('data')), + new Param($this->builder->var('depth')), + ], + 'uses' => [ + new ClosureUse($this->builder->var('valueTransformers')), + new ClosureUse($this->builder->var('options')), + new ClosureUse($this->builder->var('generators'), byRef: true), + ], + 'stmts' => [ + new If_(new GreaterOrEqual($this->builder->var('depth'), $this->builder->val(512)), [ + 'stmts' => [new Expression(new Throw_($this->builder->new(new FullyQualified(NotEncodableValueException::class), [$this->builder->val('Maximum stack depth exceeded')])))], + ]), + ...$this->buildYieldStatements($node->withAccessor(new VariableDataAccessor('data')), $options, $context), + ], + ]), + )), + ], + ]; + + foreach ($node->getProperties() as $n) { + $stmts = [ + ...$stmts, + ...$this->buildGeneratorStatementsByIdentifiers($n, $options, $context), + ]; + } + + unset($context['building_generator']); + $context['generators'][$node->getIdentifier()] = true; + + return $stmts; + } + /** * @param array $options * @param array $context * * @return list */ - private function buildClosureStatements(DataModelNodeInterface $dataModelNode, array $options, array $context): array + private function buildYieldStatements(DataModelNodeInterface $dataModelNode, array $options, array $context): array { $accessor = $dataModelNode->getAccessor()->toPhpExpr(); - if ($dataModelNode instanceof ExceptionNode) { + if ($this->dataModelOnlyNeedsEncode($dataModelNode)) { return [ - new Expression(new Throw_($accessor)), + new Expression(new Yield_($this->encodeValue($accessor, $context))), ]; } - if ($this->nodeOnlyNeedsEncode($dataModelNode)) { + if ($context['depth'] >= 512) { return [ - new Expression(new Yield_($this->encodeValue($accessor))), + new Expression(new Throw_($this->builder->new(new FullyQualified(NotEncodableValueException::class), [$this->builder->val('Maximum stack depth exceeded')]))), ]; } @@ -113,7 +219,7 @@ private function buildClosureStatements(DataModelNodeInterface $dataModelNode, a $scalarAccessor = match (true) { TypeIdentifier::NULL === $dataModelNode->getType()->getTypeIdentifier() => $this->builder->val('null'), TypeIdentifier::BOOL === $dataModelNode->getType()->getTypeIdentifier() => new Ternary($accessor, $this->builder->val('true'), $this->builder->val('false')), - default => $this->encodeValue($accessor), + default => $this->encodeValue($accessor, $context), }; return [ @@ -123,7 +229,7 @@ private function buildClosureStatements(DataModelNodeInterface $dataModelNode, a if ($dataModelNode instanceof BackedEnumNode) { return [ - new Expression(new Yield_($this->encodeValue(new PropertyFetch($accessor, 'value')))), + new Expression(new Yield_($this->encodeValue(new PropertyFetch($accessor, 'value'), $context))), ]; } @@ -165,7 +271,7 @@ private function buildClosureStatements(DataModelNodeInterface $dataModelNode, a $stmtsAndConditions = array_map(fn (DataModelNodeInterface $n): array => [ 'condition' => $nodeCondition($n), - 'stmts' => $this->buildClosureStatements($n, $options, $context), + 'stmts' => $this->buildYieldStatements($n, $options, $context), ], $dataModelNode->getNodes()); $if = $stmtsAndConditions[0]; @@ -186,6 +292,8 @@ private function buildClosureStatements(DataModelNodeInterface $dataModelNode, a } if ($dataModelNode instanceof CollectionNode) { + ++$context['depth']; + if ($dataModelNode->getType()->isList()) { return [ new Expression(new Yield_($this->builder->val('['))), @@ -193,7 +301,7 @@ private function buildClosureStatements(DataModelNodeInterface $dataModelNode, a new Foreach_($accessor, $dataModelNode->getItemNode()->getAccessor()->toPhpExpr(), [ 'stmts' => [ new Expression(new Yield_($this->builder->var('prefix'))), - ...$this->buildClosureStatements($dataModelNode->getItemNode(), $options, $context), + ...$this->buildYieldStatements($dataModelNode->getItemNode(), $options, $context), new Expression(new Assign($this->builder->var('prefix'), $this->builder->val(','))), ], ]), @@ -218,7 +326,7 @@ private function buildClosureStatements(DataModelNodeInterface $dataModelNode, a $this->builder->var('key'), new EncapsedStringPart('":'), ]))), - ...$this->buildClosureStatements($dataModelNode->getItemNode(), $options, $context), + ...$this->buildYieldStatements($dataModelNode->getItemNode(), $options, $context), new Expression(new Assign($this->builder->var('prefix'), $this->builder->val(','))), ], ]), @@ -227,9 +335,24 @@ private function buildClosureStatements(DataModelNodeInterface $dataModelNode, a } if ($dataModelNode instanceof ObjectNode) { + if (isset($context['generators'][$dataModelNode->getIdentifier()]) || $dataModelNode->isMock()) { + $depthArgument = ($context['building_generator'] ?? false) + ? new Plus($this->builder->var('depth'), $this->builder->val(1)) + : $this->builder->val($context['depth']); + + return [ + new Expression(new YieldFrom($this->builder->funcCall( + new ArrayDimFetch($this->builder->var('generators'), $this->builder->val($dataModelNode->getIdentifier())), + [$accessor, $depthArgument], + ))), + ]; + } + $objectStmts = [new Expression(new Yield_($this->builder->val('{')))]; $separator = ''; + ++$context['depth']; + foreach ($dataModelNode->getProperties() as $name => $propertyNode) { $encodedName = json_encode($name); if (false === $encodedName) { @@ -244,7 +367,7 @@ private function buildClosureStatements(DataModelNodeInterface $dataModelNode, a new Expression(new Yield_($this->builder->val('"'))), new Expression(new Yield_($this->builder->val($encodedName))), new Expression(new Yield_($this->builder->val('":'))), - ...$this->buildClosureStatements($propertyNode, $options, $context), + ...$this->buildYieldStatements($propertyNode, $options, $context), ]; $separator = ','; @@ -258,21 +381,32 @@ private function buildClosureStatements(DataModelNodeInterface $dataModelNode, a throw new LogicException(\sprintf('Unexpected "%s" node', $dataModelNode::class)); } - private function encodeValue(Expr $value): Expr + /** + * @param array $context + */ + private function encodeValue(Expr $value, array $context): Expr { - return $this->builder->funcCall('\json_encode', [$value]); + return $this->builder->funcCall('\json_encode', [ + $value, + $this->builder->constFetch('\\JSON_THROW_ON_ERROR'), + $this->builder->val(512 - $context['depth']), + ]); } private function escapeString(Expr $string): Expr { - return $this->builder->funcCall('\substr', [$this->encodeValue($string), $this->builder->val(1), $this->builder->val(-1)]); + return $this->builder->funcCall('\substr', [ + $this->builder->funcCall('\json_encode', [$string]), + $this->builder->val(1), + $this->builder->val(-1), + ]); } - private function nodeOnlyNeedsEncode(DataModelNodeInterface $node, int $nestingLevel = 0): bool + private function dataModelOnlyNeedsEncode(DataModelNodeInterface $dataModel, int $depth = 0): bool { - if ($node instanceof CompositeNode) { - foreach ($node->getNodes() as $n) { - if (!$this->nodeOnlyNeedsEncode($n, $nestingLevel + 1)) { + if ($dataModel instanceof CompositeNode) { + foreach ($dataModel->getNodes() as $node) { + if (!$this->dataModelOnlyNeedsEncode($node, $depth)) { return false; } } @@ -280,27 +414,23 @@ private function nodeOnlyNeedsEncode(DataModelNodeInterface $node, int $nestingL return true; } - if ($node instanceof CollectionNode) { - return $this->nodeOnlyNeedsEncode($node->getItemNode(), $nestingLevel + 1); + if ($dataModel instanceof CollectionNode) { + return $this->dataModelOnlyNeedsEncode($dataModel->getItemNode(), $depth + 1); } - if ($node instanceof ScalarNode) { - $type = $node->getType(); - - // "null" will be written directly using the "null" string - // "bool" will be written directly using the "true" or "false" string - // but it must not prevent any json_encode if nested - if ($type->isIdentifiedBy(TypeIdentifier::NULL) || $type->isIdentifiedBy(TypeIdentifier::BOOL)) { - return $nestingLevel > 0; - } - - return true; + if (!$dataModel instanceof ScalarNode) { + return false; } - if ($node instanceof ExceptionNode) { - return true; + $type = $dataModel->getType(); + + // "null" will be written directly using the "null" string + // "bool" will be written directly using the "true" or "false" string + // but it must not prevent any json_encode if nested + if ($type->isIdentifiedBy(TypeIdentifier::NULL) || $type->isIdentifiedBy(TypeIdentifier::BOOL)) { + return $depth > 0; } - return false; + return true; } } diff --git a/src/Symfony/Component/JsonStreamer/Write/StreamWriterGenerator.php b/src/Symfony/Component/JsonStreamer/Write/StreamWriterGenerator.php index 44e05c494cd03..41618e8e7f303 100644 --- a/src/Symfony/Component/JsonStreamer/Write/StreamWriterGenerator.php +++ b/src/Symfony/Component/JsonStreamer/Write/StreamWriterGenerator.php @@ -25,10 +25,8 @@ use Symfony\Component\JsonStreamer\DataModel\Write\CollectionNode; use Symfony\Component\JsonStreamer\DataModel\Write\CompositeNode; use Symfony\Component\JsonStreamer\DataModel\Write\DataModelNodeInterface; -use Symfony\Component\JsonStreamer\DataModel\Write\ExceptionNode; use Symfony\Component\JsonStreamer\DataModel\Write\ObjectNode; use Symfony\Component\JsonStreamer\DataModel\Write\ScalarNode; -use Symfony\Component\JsonStreamer\Exception\MaxDepthException; use Symfony\Component\JsonStreamer\Exception\RuntimeException; use Symfony\Component\JsonStreamer\Exception\UnsupportedException; use Symfony\Component\JsonStreamer\Mapping\PropertyMetadataLoaderInterface; @@ -49,8 +47,6 @@ */ final class StreamWriterGenerator { - private const MAX_DEPTH = 512; - private ?PhpAstBuilder $phpAstBuilder = null; private ?PhpOptimizer $phpOptimizer = null; private ?PrettyPrinter $phpPrinter = null; @@ -114,12 +110,6 @@ private function getPath(Type $type): string */ private function createDataModel(Type $type, DataAccessorInterface $accessor, array $options = [], array $context = []): DataModelNodeInterface { - $context['depth'] ??= 0; - - if ($context['depth'] > self::MAX_DEPTH) { - return new ExceptionNode(MaxDepthException::class); - } - $context['original_type'] ??= $type; if ($type instanceof UnionType) { @@ -135,9 +125,14 @@ private function createDataModel(Type $type, DataAccessorInterface $accessor, ar } if ($type instanceof ObjectType && !$type instanceof EnumType) { - ++$context['depth']; - + $typeString = (string) $type; $className = $type->getClassName(); + + if ($context['generated_classes'][$typeString] ??= false) { + return ObjectNode::createMock($accessor, $type); + } + + $context['generated_classes'][$typeString] = true; $propertiesMetadata = $this->propertyMetadataLoader->load($className, $options, ['original_type' => $type] + $context); try { @@ -180,8 +175,6 @@ private function createDataModel(Type $type, DataAccessorInterface $accessor, ar } if ($type instanceof CollectionType) { - ++$context['depth']; - return new CollectionNode( $accessor, $type,