From d662f85c65b7f293a0d5389ebb7a3cac79c17924 Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Mon, 2 Jun 2025 13:57:50 +0200 Subject: [PATCH] [JsonStreamer] Add `include_null_properties` option --- .../Component/JsonStreamer/CHANGELOG.md | 1 + .../JsonStreamer/JsonStreamWriter.php | 5 +- .../Model/DummyWithDollarNamedProperties.php | 14 +++ .../stream_writer/double_nested_list.php | 45 ++++---- .../Fixtures/stream_writer/nested_list.php | 31 +++--- .../Tests/Fixtures/stream_writer/null.php | 2 +- .../stream_writer/nullable_backed_enum.php | 2 +- .../stream_writer/nullable_object.php | 10 +- .../stream_writer/nullable_object_dict.php | 19 ++-- .../stream_writer/nullable_object_list.php | 19 ++-- .../Tests/Fixtures/stream_writer/object.php | 8 +- .../Fixtures/stream_writer/object_dict.php | 17 +-- .../stream_writer/object_in_object.php | 20 ++-- .../stream_writer/object_iterable.php | 17 +-- .../Fixtures/stream_writer/object_list.php | 17 +-- .../object_with_dollar_named_properties.php | 18 ++++ .../stream_writer/object_with_union.php | 25 +++-- .../object_with_value_transformer.php | 12 ++- .../stream_writer/self_referencing_object.php | 15 +-- .../Tests/Fixtures/stream_writer/union.php | 18 ++-- .../Tests/JsonStreamWriterTest.php | 21 +++- .../Tests/Write/StreamWriterGeneratorTest.php | 2 + .../JsonStreamer/Write/PhpGenerator.php | 101 +++++++++++++----- 23 files changed, 291 insertions(+), 148 deletions(-) create mode 100644 src/Symfony/Component/JsonStreamer/Tests/Fixtures/Model/DummyWithDollarNamedProperties.php create mode 100644 src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_with_dollar_named_properties.php diff --git a/src/Symfony/Component/JsonStreamer/CHANGELOG.md b/src/Symfony/Component/JsonStreamer/CHANGELOG.md index fdc1439a90748..f271c7e1964c4 100644 --- a/src/Symfony/Component/JsonStreamer/CHANGELOG.md +++ b/src/Symfony/Component/JsonStreamer/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Remove `nikic/php-parser` dependency * Add `_current_object` to the context passed to value transformers during write operations + * Add `include_null_properties` option to encode the properties with `null` value 7.3 --- diff --git a/src/Symfony/Component/JsonStreamer/JsonStreamWriter.php b/src/Symfony/Component/JsonStreamer/JsonStreamWriter.php index bbe31af9de57a..638d0acd07167 100644 --- a/src/Symfony/Component/JsonStreamer/JsonStreamWriter.php +++ b/src/Symfony/Component/JsonStreamer/JsonStreamWriter.php @@ -29,7 +29,10 @@ /** * @author Mathias Arlaud * - * @implements StreamWriterInterface> + * @implements StreamWriterInterface, + * }> * * @experimental */ diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/Model/DummyWithDollarNamedProperties.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/Model/DummyWithDollarNamedProperties.php new file mode 100644 index 0000000000000..531c490aece99 --- /dev/null +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/Model/DummyWithDollarNamedProperties.php @@ -0,0 +1,14 @@ +bar}')] + public bool $bar = true; +} diff --git a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/double_nested_list.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/double_nested_list.php index c793d180e648d..507b7b5288950 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/double_nested_list.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/double_nested_list.php @@ -5,36 +5,41 @@ */ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { try { - yield '['; - $prefix = ''; + yield "["; + $prefix1 = ''; foreach ($data as $value1) { - yield $prefix; - yield '{"dummies":['; - $prefix = ''; + $prefix2 = ''; + yield "{$prefix1}{{$prefix2}\"dummies\":"; + yield "["; + $prefix3 = ''; foreach ($value1->dummies as $value2) { - yield $prefix; - yield '{"dummies":['; - $prefix = ''; + $prefix4 = ''; + yield "{$prefix3}{{$prefix4}\"dummies\":"; + yield "["; + $prefix5 = ''; foreach ($value2->dummies as $value3) { - yield $prefix; - yield '{"id":'; + $prefix6 = ''; + yield "{$prefix5}{{$prefix6}\"id\":"; yield \json_encode($value3->id, \JSON_THROW_ON_ERROR, 506); - yield ',"name":'; + $prefix6 = ','; + yield "{$prefix6}\"name\":"; yield \json_encode($value3->name, \JSON_THROW_ON_ERROR, 506); - yield '}'; - $prefix = ','; + yield "}"; + $prefix5 = ','; } - yield '],"customProperty":'; + $prefix4 = ','; + yield "]{$prefix4}\"customProperty\":"; yield \json_encode($value2->customProperty, \JSON_THROW_ON_ERROR, 508); - yield '}'; - $prefix = ','; + yield "}"; + $prefix3 = ','; } - yield '],"stringProperty":'; + $prefix2 = ','; + yield "]{$prefix2}\"stringProperty\":"; yield \json_encode($value1->stringProperty, \JSON_THROW_ON_ERROR, 510); - yield '}'; - $prefix = ','; + yield "}"; + $prefix1 = ','; } - yield ']'; + 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/nested_list.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nested_list.php index 292ee5fe00245..debe2321cdfb0 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nested_list.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nested_list.php @@ -5,27 +5,30 @@ */ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { try { - yield '['; - $prefix = ''; + yield "["; + $prefix1 = ''; foreach ($data as $value1) { - yield $prefix; - yield '{"dummies":['; - $prefix = ''; + $prefix2 = ''; + yield "{$prefix1}{{$prefix2}\"dummies\":"; + yield "["; + $prefix3 = ''; foreach ($value1->dummies as $value2) { - yield $prefix; - yield '{"id":'; + $prefix4 = ''; + yield "{$prefix3}{{$prefix4}\"id\":"; yield \json_encode($value2->id, \JSON_THROW_ON_ERROR, 508); - yield ',"name":'; + $prefix4 = ','; + yield "{$prefix4}\"name\":"; yield \json_encode($value2->name, \JSON_THROW_ON_ERROR, 508); - yield '}'; - $prefix = ','; + yield "}"; + $prefix3 = ','; } - yield '],"customProperty":'; + $prefix2 = ','; + yield "]{$prefix2}\"customProperty\":"; yield \json_encode($value1->customProperty, \JSON_THROW_ON_ERROR, 510); - yield '}'; - $prefix = ','; + yield "}"; + $prefix1 = ','; } - yield ']'; + 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/null.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/null.php index bc35cb47ccfd2..3ddfeda41fadf 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/null.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/null.php @@ -5,7 +5,7 @@ */ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { try { - yield 'null'; + yield "null"; } 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_backed_enum.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/nullable_backed_enum.php index 76ed43bba41f5..c9fec1503601e 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 @@ -8,7 +8,7 @@ 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'; + yield "null"; } else { throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); } 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 7a9228464cf11..77499e1569d3f 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 @@ -6,13 +6,15 @@ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { try { if ($data instanceof \Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithNameAttributes) { - yield '{"@id":'; + $prefix1 = ''; + yield "{{$prefix1}\"@id\":"; yield \json_encode($data->id, \JSON_THROW_ON_ERROR, 511); - yield ',"name":'; + $prefix1 = ','; + yield "{$prefix1}\"name\":"; yield \json_encode($data->name, \JSON_THROW_ON_ERROR, 511); - yield '}'; + yield "}"; } elseif (null === $data) { - yield 'null'; + yield "null"; } else { throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); } 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 690346221c8f8..e811c49cff792 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 @@ -6,21 +6,22 @@ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { try { if (\is_array($data)) { - yield '{'; - $prefix = ''; + yield "{"; + $prefix1 = ''; foreach ($data as $key1 => $value1) { $key1 = \substr(\json_encode($key1), 1, -1); - yield "{$prefix}\"{$key1}\":"; - yield '{"@id":'; + $prefix2 = ''; + yield "{$prefix1}\"{$key1}\":{{$prefix2}\"@id\":"; yield \json_encode($value1->id, \JSON_THROW_ON_ERROR, 510); - yield ',"name":'; + $prefix2 = ','; + yield "{$prefix2}\"name\":"; yield \json_encode($value1->name, \JSON_THROW_ON_ERROR, 510); - yield '}'; - $prefix = ','; + yield "}"; + $prefix1 = ','; } - yield '}'; + yield "}"; } elseif (null === $data) { - yield 'null'; + yield "null"; } else { throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); } 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 7cc2df4c67a5a..ed64975b984d0 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 @@ -6,20 +6,21 @@ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { try { if (\is_array($data)) { - yield '['; - $prefix = ''; + yield "["; + $prefix1 = ''; foreach ($data as $value1) { - yield $prefix; - yield '{"@id":'; + $prefix2 = ''; + yield "{$prefix1}{{$prefix2}\"@id\":"; yield \json_encode($value1->id, \JSON_THROW_ON_ERROR, 510); - yield ',"name":'; + $prefix2 = ','; + yield "{$prefix2}\"name\":"; yield \json_encode($value1->name, \JSON_THROW_ON_ERROR, 510); - yield '}'; - $prefix = ','; + yield "}"; + $prefix1 = ','; } - yield ']'; + yield "]"; } elseif (null === $data) { - yield 'null'; + yield "null"; } else { throw new \Symfony\Component\JsonStreamer\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); } 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 7fbc49cf96edc..8919bf27bb8fa 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object.php @@ -5,11 +5,13 @@ */ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { try { - yield '{"@id":'; + $prefix1 = ''; + yield "{{$prefix1}\"@id\":"; yield \json_encode($data->id, \JSON_THROW_ON_ERROR, 511); - yield ',"name":'; + $prefix1 = ','; + yield "{$prefix1}\"name\":"; yield \json_encode($data->name, \JSON_THROW_ON_ERROR, 511); - yield '}'; + 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 7e63d0d052596..aa1be64cf9acb 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 @@ -5,19 +5,20 @@ */ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { try { - yield '{'; - $prefix = ''; + yield "{"; + $prefix1 = ''; foreach ($data as $key1 => $value1) { $key1 = \substr(\json_encode($key1), 1, -1); - yield "{$prefix}\"{$key1}\":"; - yield '{"@id":'; + $prefix2 = ''; + yield "{$prefix1}\"{$key1}\":{{$prefix2}\"@id\":"; yield \json_encode($value1->id, \JSON_THROW_ON_ERROR, 510); - yield ',"name":'; + $prefix2 = ','; + yield "{$prefix2}\"name\":"; yield \json_encode($value1->name, \JSON_THROW_ON_ERROR, 510); - yield '}'; - $prefix = ','; + yield "}"; + $prefix1 = ','; } - yield '}'; + 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_in_object.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_in_object.php index 1e04f6b1d8e6a..24f797633d2db 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 @@ -5,17 +5,25 @@ */ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { try { - yield '{"name":'; + $prefix1 = ''; + yield "{{$prefix1}\"name\":"; yield \json_encode($data->name, \JSON_THROW_ON_ERROR, 511); - yield ',"otherDummyOne":{"@id":'; + $prefix1 = ','; + yield "{$prefix1}\"otherDummyOne\":"; + $prefix2 = ''; + yield "{{$prefix2}\"@id\":"; yield \json_encode($data->otherDummyOne->id, \JSON_THROW_ON_ERROR, 510); - yield ',"name":'; + $prefix2 = ','; + yield "{$prefix2}\"name\":"; yield \json_encode($data->otherDummyOne->name, \JSON_THROW_ON_ERROR, 510); - yield '},"otherDummyTwo":{"id":'; + yield "}{$prefix1}\"otherDummyTwo\":"; + $prefix2 = ''; + yield "{{$prefix2}\"id\":"; yield \json_encode($data->otherDummyTwo->id, \JSON_THROW_ON_ERROR, 510); - yield ',"name":'; + $prefix2 = ','; + yield "{$prefix2}\"name\":"; yield \json_encode($data->otherDummyTwo->name, \JSON_THROW_ON_ERROR, 510); - yield '}}'; + 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 b47c07b6d9491..9ada91b74d888 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 @@ -5,19 +5,20 @@ */ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { try { - yield '{'; - $prefix = ''; + yield "{"; + $prefix1 = ''; foreach ($data as $key1 => $value1) { $key1 = is_int($key1) ? $key1 : \substr(\json_encode($key1), 1, -1); - yield "{$prefix}\"{$key1}\":"; - yield '{"id":'; + $prefix2 = ''; + yield "{$prefix1}\"{$key1}\":{{$prefix2}\"id\":"; yield \json_encode($value1->id, \JSON_THROW_ON_ERROR, 510); - yield ',"name":'; + $prefix2 = ','; + yield "{$prefix2}\"name\":"; yield \json_encode($value1->name, \JSON_THROW_ON_ERROR, 510); - yield '}'; - $prefix = ','; + yield "}"; + $prefix1 = ','; } - yield '}'; + 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_list.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_list.php index b6da99bae7d72..a14bc5423a14e 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 @@ -5,18 +5,19 @@ */ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { try { - yield '['; - $prefix = ''; + yield "["; + $prefix1 = ''; foreach ($data as $value1) { - yield $prefix; - yield '{"@id":'; + $prefix2 = ''; + yield "{$prefix1}{{$prefix2}\"@id\":"; yield \json_encode($value1->id, \JSON_THROW_ON_ERROR, 510); - yield ',"name":'; + $prefix2 = ','; + yield "{$prefix2}\"name\":"; yield \json_encode($value1->name, \JSON_THROW_ON_ERROR, 510); - yield '}'; - $prefix = ','; + yield "}"; + $prefix1 = ','; } - yield ']'; + 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_with_dollar_named_properties.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_with_dollar_named_properties.php new file mode 100644 index 0000000000000..ff9c70eb028db --- /dev/null +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_with_dollar_named_properties.php @@ -0,0 +1,18 @@ +foo ? 'true' : 'false'; + $prefix1 = ','; + yield "{$prefix1}\"{\$foo->bar}\":"; + yield $data->bar ? 'true' : 'false'; + 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_with_union.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/object_with_union.php index cd99dd4630fe7..debcb94c4772a 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 @@ -5,17 +5,22 @@ */ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { 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))); + $prefix1 = ''; + yield "{"; + if (null === $data->value && ($options['include_null_properties'] ?? false)) { + yield "{$prefix1}\"value\":null"; } - yield '}'; + if (null !== $data->value) { + yield "{$prefix1}\"value\":"; + if ($data->value instanceof \Symfony\Component\JsonStreamer\Tests\Fixtures\Enum\DummyBackedEnum) { + yield \json_encode($data->value->value, \JSON_THROW_ON_ERROR, 511); + } 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); } 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 1b6fb0d2c4e10..e960b5e7057d1 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 @@ -5,15 +5,17 @@ */ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { try { - yield '{"id":'; + $prefix1 = ''; + yield "{{$prefix1}\"id\":"; yield \json_encode($valueTransformers->get('Symfony\Component\JsonStreamer\Tests\Fixtures\ValueTransformer\DoubleIntAndCastToStringValueTransformer')->transform($data->id, ['_current_object' => $data] + $options), \JSON_THROW_ON_ERROR, 511); - yield ',"active":'; + $prefix1 = ','; + yield "{$prefix1}\"active\":"; yield \json_encode($valueTransformers->get('Symfony\Component\JsonStreamer\Tests\Fixtures\ValueTransformer\BooleanToStringValueTransformer')->transform($data->active, ['_current_object' => $data] + $options), \JSON_THROW_ON_ERROR, 511); - yield ',"name":'; + yield "{$prefix1}\"name\":"; yield \json_encode(strtolower($data->name), \JSON_THROW_ON_ERROR, 511); - yield ',"range":'; + yield "{$prefix1}\"range\":"; yield \json_encode(Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithValueTransformerAttributes::concatRange($data->range, ['_current_object' => $data] + $options), \JSON_THROW_ON_ERROR, 511); - yield '}'; + 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/self_referencing_object.php b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/self_referencing_object.php index f55d8045ce4db..5c13782a08e27 100644 --- 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 @@ -8,15 +8,16 @@ if ($depth >= 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) { + $prefix1 = ''; + yield "{"; + if (null === $data->self && ($options['include_null_properties'] ?? false)) { + yield "{$prefix1}\"@self\":null"; + } + if (null !== $data->self) { + yield "{$prefix1}\"@self\":"; 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 '}'; + yield "}"; }; try { yield from $generators['Symfony\Component\JsonStreamer\Tests\Fixtures\Model\SelfReferencingDummy']($data, 0); 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 3080920e02c07..f001fce54aebd 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/union.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Fixtures/stream_writer/union.php @@ -6,20 +6,22 @@ return static function (mixed $data, \Psr\Container\ContainerInterface $valueTransformers, array $options): \Traversable { try { if (\is_array($data)) { - yield '['; - $prefix = ''; + yield "["; + $prefix1 = ''; foreach ($data as $value1) { - yield $prefix; + yield "{$prefix1}"; yield \json_encode($value1->value, \JSON_THROW_ON_ERROR, 511); - $prefix = ','; + $prefix1 = ','; } - yield ']'; + yield "]"; } elseif ($data instanceof \Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithNameAttributes) { - yield '{"@id":'; + $prefix1 = ''; + yield "{{$prefix1}\"@id\":"; yield \json_encode($data->id, \JSON_THROW_ON_ERROR, 511); - yield ',"name":'; + $prefix1 = ','; + yield "{$prefix1}\"name\":"; yield \json_encode($data->name, \JSON_THROW_ON_ERROR, 511); - yield '}'; + yield "}"; } elseif (\is_int($data)) { yield \json_encode($data, \JSON_THROW_ON_ERROR, 512); } else { diff --git a/src/Symfony/Component/JsonStreamer/Tests/JsonStreamWriterTest.php b/src/Symfony/Component/JsonStreamer/Tests/JsonStreamWriterTest.php index 52f40a94087c3..df059fec3e81f 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/JsonStreamWriterTest.php +++ b/src/Symfony/Component/JsonStreamer/Tests/JsonStreamWriterTest.php @@ -18,6 +18,7 @@ use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\ClassicDummy; use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithArray; use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithDateTimes; +use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithDollarNamedProperties; use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithGenerics; use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithNameAttributes; use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithNestedArray; @@ -81,7 +82,7 @@ public function testWriteUnion() $this->assertWritten('{"value":"foo"}', $dummy, Type::object(DummyWithUnionProperties::class)); $dummy->value = null; - $this->assertWritten('{"value":null}', $dummy, Type::object(DummyWithUnionProperties::class)); + $this->assertWritten('{}', $dummy, Type::object(DummyWithUnionProperties::class)); } public function testWriteCollection() @@ -238,7 +239,18 @@ public function testWriteObjectWithNullableProperties() { $dummy = new DummyWithNullableProperties(); - $this->assertWritten('{"name":null,"enum":null}', $dummy, Type::object(DummyWithNullableProperties::class)); + $this->assertWritten('{}', $dummy, Type::object(DummyWithNullableProperties::class)); + + $dummy->name = 'name'; + + $this->assertWritten('{"name":"name"}', $dummy, Type::object(DummyWithNullableProperties::class)); + $this->assertWritten('{"name":"name","enum":null}', $dummy, Type::object(DummyWithNullableProperties::class), options: ['include_null_properties' => true]); + + $dummy->name = null; + $dummy->enum = DummyBackedEnum::ONE; + + $this->assertWritten('{"enum":1}', $dummy, Type::object(DummyWithNullableProperties::class)); + $this->assertWritten('{"name":null,"enum":1}', $dummy, Type::object(DummyWithNullableProperties::class), options: ['include_null_properties' => true]); } public function testWriteObjectWithDateTimes() @@ -255,6 +267,11 @@ public function testWriteObjectWithDateTimes() ); } + public function testWriteObjectWithDollarNamedProperties() + { + $this->assertWritten('{"$foo":true,"{$foo->bar}":true}', new DummyWithDollarNamedProperties(), Type::object(DummyWithDollarNamedProperties::class)); + } + /** * @dataProvider throwWhenMaxDepthIsReachedDataProvider */ diff --git a/src/Symfony/Component/JsonStreamer/Tests/Write/StreamWriterGeneratorTest.php b/src/Symfony/Component/JsonStreamer/Tests/Write/StreamWriterGeneratorTest.php index 6f1024303a358..723ba8828812f 100644 --- a/src/Symfony/Component/JsonStreamer/Tests/Write/StreamWriterGeneratorTest.php +++ b/src/Symfony/Component/JsonStreamer/Tests/Write/StreamWriterGeneratorTest.php @@ -22,6 +22,7 @@ use Symfony\Component\JsonStreamer\Tests\Fixtures\Enum\DummyEnum; use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\ClassicDummy; use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithArray; +use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithDollarNamedProperties; use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithNameAttributes; use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithNestedArray; use Symfony\Component\JsonStreamer\Tests\Fixtures\Model\DummyWithOtherDummies; @@ -110,6 +111,7 @@ public static function generatedStreamWriterDataProvider(): iterable 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 ['object_with_dollar_named_properties', Type::object(DummyWithDollarNamedProperties::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)]; diff --git a/src/Symfony/Component/JsonStreamer/Write/PhpGenerator.php b/src/Symfony/Component/JsonStreamer/Write/PhpGenerator.php index 3d79403e5dde4..f8280eece6ef4 100644 --- a/src/Symfony/Component/JsonStreamer/Write/PhpGenerator.php +++ b/src/Symfony/Component/JsonStreamer/Write/PhpGenerator.php @@ -23,6 +23,7 @@ use Symfony\Component\JsonStreamer\Exception\RuntimeException; use Symfony\Component\JsonStreamer\Exception\UnexpectedValueException; use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\NullableType; use Symfony\Component\TypeInfo\Type\ObjectType; use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; use Symfony\Component\TypeInfo\TypeIdentifier; @@ -159,7 +160,7 @@ private function generateYields(DataModelNodeInterface $dataModelNode, array $op if ($dataModelNode instanceof ScalarNode) { return match (true) { - TypeIdentifier::NULL === $dataModelNode->getType()->getTypeIdentifier() => $this->yieldString('null', $context), + TypeIdentifier::NULL === $dataModelNode->getType()->getTypeIdentifier() => $this->yieldInterpolatedString('null', $context), TypeIdentifier::BOOL === $dataModelNode->getType()->getTypeIdentifier() => $this->yield("$accessor ? 'true' : 'false'", $context), default => $this->yield($this->encode($accessor, $context), $context), }; @@ -191,22 +192,22 @@ private function generateYields(DataModelNodeInterface $dataModelNode, array $op ++$context['depth']; if ($dataModelNode->getType()->isList()) { - $php = $this->yieldString('[', $context) + $php = $this->yieldInterpolatedString('[', $context) .$this->flushYieldBuffer($context) - .$this->line('$prefix = \'\';', $context) + .$this->line('$prefix'.$context['depth'].' = \'\';', $context) .$this->line("foreach ($accessor as ".$dataModelNode->getItemNode()->getAccessor().') {', $context); ++$context['indentation_level']; - $php .= $this->yield('$prefix', $context) + $php .= $this->yieldInterpolatedString('{$prefix'.$context['depth'].'}', $context, false) .$this->generateYields($dataModelNode->getItemNode(), $options, $context) .$this->flushYieldBuffer($context) - .$this->line('$prefix = \',\';', $context); + .$this->line('$prefix'.$context['depth'].' = \',\';', $context); --$context['indentation_level']; return $php .$this->line('}', $context) - .$this->yieldString(']', $context); + .$this->yieldInterpolatedString(']', $context); } $keyAccessor = $dataModelNode->getKeyNode()->getAccessor(); @@ -215,23 +216,23 @@ private function generateYields(DataModelNodeInterface $dataModelNode, array $op ? "$keyAccessor = is_int($keyAccessor) ? $keyAccessor : \substr(\json_encode($keyAccessor), 1, -1);" : "$keyAccessor = \substr(\json_encode($keyAccessor), 1, -1);"; - $php = $this->yieldString('{', $context) + $php = $this->yieldInterpolatedString('{', $context) .$this->flushYieldBuffer($context) - .$this->line('$prefix = \'\';', $context) + .$this->line('$prefix'.$context['depth'].' = \'\';', $context) .$this->line("foreach ($accessor as $keyAccessor => ".$dataModelNode->getItemNode()->getAccessor().') {', $context); ++$context['indentation_level']; $php .= $this->line($escapedKey, $context) - .$this->yield('"{$prefix}\"{'.$keyAccessor.'}\":"', $context) + .$this->yieldInterpolatedString('{$prefix'.$context['depth'].'}"{'.$keyAccessor.'}":', $context, false) .$this->generateYields($dataModelNode->getItemNode(), $options, $context) .$this->flushYieldBuffer($context) - .$this->line('$prefix = \',\';', $context); + .$this->line('$prefix'.$context['depth'].' = \',\';', $context); --$context['indentation_level']; return $php .$this->line('}', $context) - .$this->yieldString('}', $context); + .$this->yieldInterpolatedString('}', $context); } if ($dataModelNode instanceof ObjectNode) { @@ -241,11 +242,13 @@ private function generateYields(DataModelNodeInterface $dataModelNode, array $op return $this->line('yield from $generators[\''.$dataModelNode->getIdentifier().'\']('.$accessor.', '.$depthArgument.');', $context); } - $php = $this->yieldString('{', $context); - $separator = ''; - ++$context['depth']; + $php = $this->line('$prefix'.$context['depth'].' = \'\';', $context) + .$this->yieldInterpolatedString('{', $context); + + $prefixIsCommaForSure = false; + foreach ($dataModelNode->getProperties() as $name => $propertyNode) { $encodedName = json_encode($name); if (false === $encodedName) { @@ -254,17 +257,67 @@ private function generateYields(DataModelNodeInterface $dataModelNode, array $op $encodedName = substr($encodedName, 1, -1); - $php .= $this->yieldString($separator, $context) - .$this->yieldString('"', $context) - .$this->yieldString($encodedName, $context) - .$this->yieldString('":', $context) - .$this->generateYields($propertyNode, $options, $context); + if ($propertyNode instanceof CompositeNode && $propertyNode->getType() instanceof NullableType) { + $nonNullableCompositeParts = array_values(array_filter( + $propertyNode->getNodes(), + static fn (DataModelNodeInterface $n): bool => !($n instanceof ScalarNode && $n->getType()->isIdentifiedBy(TypeIdentifier::NULL)), + )); + + $propertyNode = 1 === \count($nonNullableCompositeParts) + ? $nonNullableCompositeParts[0] + : new CompositeNode($propertyNode->getAccessor(), $nonNullableCompositeParts); + + $php .= $this->flushYieldBuffer($context) + .$this->line('if (null === '.$propertyNode->getAccessor().' && ($options[\'include_null_properties\'] ?? false)) {', $context); + + ++$context['indentation_level']; + + $php .= $this->yieldInterpolatedString('{$prefix'.$context['depth'].'}', $context, false) + .$this->yieldInterpolatedString('"'.$encodedName.'":', $context) + .$this->yieldInterpolatedString('null', $context) + .$this->flushYieldBuffer($context); + + if (!$prefixIsCommaForSure && $name !== array_key_last($dataModelNode->getProperties())) { + $php .= $this->line('$prefix'.$context['depth'].' = \',\';', $context); + } + + --$context['indentation_level']; - $separator = ','; + $php .= $this->line('}', $context) + .$this->flushYieldBuffer($context) + .$this->line('if (null !== '.$propertyNode->getAccessor().') {', $context); + + ++$context['indentation_level']; + + $php .= $this->yieldInterpolatedString('{$prefix'.$context['depth'].'}', $context, false) + .$this->yieldInterpolatedString('"'.$encodedName.'":', $context) + .$this->flushYieldBuffer($context) + .$this->generateYields($propertyNode, $options, $context) + .$this->flushYieldBuffer($context); + + if (!$prefixIsCommaForSure && $name !== array_key_last($dataModelNode->getProperties())) { + $php .= $this->line('$prefix'.$context['depth'].' = \',\';', $context); + } + + --$context['indentation_level']; + + $php .= $this->line('}', $context); + } else { + $php .= $this->yieldInterpolatedString('{$prefix'.$context['depth'].'}', $context, false) + .$this->yieldInterpolatedString('"'.$encodedName.'":', $context) + .$this->flushYieldBuffer($context) + .$this->generateYields($propertyNode, $options, $context); + + if (!$prefixIsCommaForSure && $name !== array_key_last($dataModelNode->getProperties())) { + $php .= $this->line('$prefix'.$context['depth'].' = \',\';', $context); + } + + $prefixIsCommaForSure = true; + } } return $php - .$this->yieldString('}', $context); + .$this->yieldInterpolatedString('}', $context); } throw new LogicException(\sprintf('Unexpected "%s" node', $dataModelNode::class)); @@ -290,9 +343,9 @@ private function yield(string $value, array $context): string /** * @param array $context */ - private function yieldString(string $string, array $context): string + private function yieldInterpolatedString(string $string, array $context, bool $escapeDollar = true): string { - $this->yieldBuffer .= $string; + $this->yieldBuffer .= addcslashes($string, "\\\"\n\r\t\v\e\f".($escapeDollar ? '$' : '')); return ''; } @@ -309,7 +362,7 @@ private function flushYieldBuffer(array $context): string $yieldBuffer = $this->yieldBuffer; $this->yieldBuffer = ''; - return $this->yield("'$yieldBuffer'", $context); + return $this->yield('"'.$yieldBuffer.'"', $context); } private function generateCompositeNodeItemCondition(DataModelNodeInterface $node): string