From aed69bb98bbb0e2bfd9b7ae6d85ccc9aa478f1b3 Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Mon, 20 Mar 2023 11:04:47 +0100 Subject: [PATCH] [JsonEncoder] Introduce component --- .gitattributes | 2 + composer.json | 1 + .../Component/JsonEncoder/.gitattributes | 4 + src/Symfony/Component/JsonEncoder/.gitignore | 3 + .../JsonEncoder/Attribute/Denormalizer.php | 43 ++ .../JsonEncoder/Attribute/EncodedName.php | 33 + .../JsonEncoder/Attribute/Normalizer.php | 43 ++ .../Component/JsonEncoder/CHANGELOG.md | 7 + .../CacheWarmer/EncoderDecoderCacheWarmer.php | 91 +++ .../CacheWarmer/LazyGhostCacheWarmer.php | 78 +++ .../DataModel/DataAccessorInterface.php | 29 + .../DataModel/Decode/BackedEnumNode.php | 41 ++ .../DataModel/Decode/CollectionNode.php | 45 ++ .../DataModel/Decode/CompositeNode.php | 77 +++ .../Decode/DataModelNodeInterface.php | 28 + .../DataModel/Decode/ObjectNode.php | 64 ++ .../DataModel/Decode/ScalarNode.php | 41 ++ .../DataModel/Encode/BackedEnumNode.php | 43 ++ .../DataModel/Encode/CollectionNode.php | 47 ++ .../DataModel/Encode/CompositeNode.php | 80 +++ .../Encode/DataModelNodeInterface.php | 29 + .../DataModel/Encode/ExceptionNode.php | 49 ++ .../DataModel/Encode/ObjectNode.php | 59 ++ .../DataModel/Encode/ScalarNode.php | 43 ++ .../DataModel/FunctionDataAccessor.php | 47 ++ .../DataModel/PhpExprDataAccessor.php | 34 + .../DataModel/PropertyDataAccessor.php | 36 ++ .../DataModel/ScalarDataAccessor.php | 35 ++ .../DataModel/VariableDataAccessor.php | 35 ++ .../JsonEncoder/Decode/DecoderGenerator.php | 175 ++++++ .../Denormalizer/DateTimeDenormalizer.php | 86 +++ .../Denormalizer/DenormalizerInterface.php | 31 + .../JsonEncoder/Decode/Instantiator.php | 50 ++ .../JsonEncoder/Decode/LazyInstantiator.php | 96 +++ .../Component/JsonEncoder/Decode/Lexer.php | 285 +++++++++ .../JsonEncoder/Decode/NativeDecoder.php | 49 ++ .../JsonEncoder/Decode/PhpAstBuilder.php | 582 ++++++++++++++++++ .../Component/JsonEncoder/Decode/Splitter.php | 188 ++++++ .../JsonEncoder/DecoderInterface.php | 32 + .../JsonEncoder/Encode/EncoderGenerator.php | 208 +++++++ .../Encode/MergingStringVisitor.php | 60 ++ .../Encode/Normalizer/DateTimeNormalizer.php | 46 ++ .../Encode/Normalizer/NormalizerInterface.php | 31 + .../JsonEncoder/Encode/PhpAstBuilder.php | 307 +++++++++ .../JsonEncoder/Encode/PhpOptimizer.php | 43 ++ src/Symfony/Component/JsonEncoder/Encoded.php | 48 ++ .../JsonEncoder/EncoderInterface.php | 33 + .../Exception/ExceptionInterface.php | 21 + .../Exception/InvalidArgumentException.php | 21 + .../Exception/InvalidStreamException.php | 21 + .../JsonEncoder/Exception/LogicException.php | 21 + .../Exception/MaxDepthException.php | 25 + .../Exception/RuntimeException.php | 21 + .../Exception/UnexpectedValueException.php | 21 + .../Exception/UnsupportedException.php | 21 + .../Component/JsonEncoder/JsonDecoder.php | 107 ++++ .../Component/JsonEncoder/JsonEncoder.php | 102 +++ src/Symfony/Component/JsonEncoder/LICENSE | 19 + .../AttributePropertyMetadataLoader.php | 124 ++++ .../DateTimeTypePropertyMetadataLoader.php | 52 ++ .../AttributePropertyMetadataLoader.php | 120 ++++ .../DateTimeTypePropertyMetadataLoader.php | 48 ++ .../GenericTypePropertyMetadataLoader.php | 145 +++++ .../JsonEncoder/Mapping/PropertyMetadata.php | 108 ++++ .../Mapping/PropertyMetadataLoader.php | 54 ++ .../PropertyMetadataLoaderInterface.php | 34 + src/Symfony/Component/JsonEncoder/README.md | 18 + .../EncoderDecoderCacheWarmerTest.php | 72 +++ .../CacheWarmer/LazyGhostCacheWarmerTest.php | 43 ++ .../DataModel/Decode/CompositeNodeTest.php | 56 ++ .../DataModel/Encode/CompositeNodeTest.php | 57 ++ .../Tests/Decode/DecoderGeneratorTest.php | 151 +++++ .../Denormalizer/DateTimeDenormalizerTest.php | 76 +++ .../Tests/Decode/InstantiatorTest.php | 44 ++ .../Tests/Decode/LazyInstantiatorTest.php | 48 ++ .../JsonEncoder/Tests/Decode/LexerTest.php | 398 ++++++++++++ .../Tests/Decode/NativeDecoderTest.php | 62 ++ .../JsonEncoder/Tests/Decode/SplitterTest.php | 151 +++++ .../Tests/Encode/EncoderGeneratorTest.php | 154 +++++ .../Normalizer/DateTimeNormalizerTest.php | 42 ++ .../JsonEncoder/Tests/EncodedTest.php | 28 + .../Attribute/BooleanStringDenormalizer.php | 15 + .../Attribute/BooleanStringNormalizer.php | 15 + .../BooleanStringDenormalizer.php | 19 + .../DivideStringAndCastToIntDenormalizer.php | 19 + .../Tests/Fixtures/Enum/DummyBackedEnum.php | 9 + .../Tests/Fixtures/Enum/DummyEnum.php | 9 + .../Tests/Fixtures/Model/AbstractDummy.php | 7 + .../Tests/Fixtures/Model/ClassicDummy.php | 9 + .../Fixtures/Model/DummyWithDateTimes.php | 10 + .../Fixtures/Model/DummyWithGenerics.php | 14 + .../Tests/Fixtures/Model/DummyWithMethods.php | 13 + .../Model/DummyWithNameAttributes.php | 13 + .../Model/DummyWithNormalizerAttributes.php | 45 ++ .../Model/DummyWithNullableProperties.php | 11 + .../Fixtures/Model/DummyWithOtherDummies.php | 10 + .../Tests/Fixtures/Model/DummyWithPhpDoc.php | 31 + .../Model/DummyWithUnionProperties.php | 10 + .../Fixtures/Model/SelfReferencingDummy.php | 11 + .../Normalizer/BooleanStringNormalizer.php | 19 + .../DoubleIntAndCastToStringNormalizer.php | 19 + .../Tests/Fixtures/decoder/backed_enum.php | 8 + .../Fixtures/decoder/backed_enum.stream.php | 8 + .../Tests/Fixtures/decoder/dict.php | 5 + .../Tests/Fixtures/decoder/dict.stream.php | 14 + .../Tests/Fixtures/decoder/iterable_dict.php | 5 + .../Fixtures/decoder/iterable_dict.stream.php | 14 + .../Tests/Fixtures/decoder/iterable_list.php | 5 + .../Fixtures/decoder/iterable_list.stream.php | 14 + .../Tests/Fixtures/decoder/list.php | 5 + .../Tests/Fixtures/decoder/list.stream.php | 14 + .../Tests/Fixtures/decoder/mixed.php | 5 + .../Tests/Fixtures/decoder/mixed.stream.php | 5 + .../Tests/Fixtures/decoder/null.php | 5 + .../Tests/Fixtures/decoder/null.stream.php | 5 + .../Fixtures/decoder/nullable_backed_enum.php | 17 + .../decoder/nullable_backed_enum.stream.php | 18 + .../Fixtures/decoder/nullable_object.php | 19 + .../decoder/nullable_object.stream.php | 31 + .../Fixtures/decoder/nullable_object_dict.php | 27 + .../decoder/nullable_object_dict.stream.php | 40 ++ .../Fixtures/decoder/nullable_object_list.php | 27 + .../decoder/nullable_object_list.stream.php | 40 ++ .../Tests/Fixtures/decoder/object.php | 10 + .../Tests/Fixtures/decoder/object.stream.php | 21 + .../Tests/Fixtures/decoder/object_dict.php | 18 + .../Fixtures/decoder/object_dict.stream.php | 30 + .../Fixtures/decoder/object_in_object.php | 20 + .../decoder/object_in_object.stream.php | 56 ++ .../Tests/Fixtures/decoder/object_list.php | 18 + .../Fixtures/decoder/object_list.stream.php | 30 + .../decoder/object_with_denormalizer.php | 10 + .../object_with_denormalizer.stream.php | 27 + .../object_with_nullable_properties.php | 22 + ...object_with_nullable_properties.stream.php | 34 + .../Fixtures/decoder/object_with_union.php | 25 + .../decoder/object_with_union.stream.php | 34 + .../Tests/Fixtures/decoder/scalar.php | 5 + .../Tests/Fixtures/decoder/scalar.stream.php | 5 + .../Tests/Fixtures/decoder/union.php | 33 + .../Tests/Fixtures/decoder/union.stream.php | 46 ++ .../Tests/Fixtures/encoder/backed_enum.php | 5 + .../Fixtures/encoder/backed_enum.stream.php | 5 + .../Tests/Fixtures/encoder/bool.php | 5 + .../Tests/Fixtures/encoder/bool.stream.php | 5 + .../Tests/Fixtures/encoder/bool_list.php | 5 + .../Fixtures/encoder/bool_list.stream.php | 12 + .../Tests/Fixtures/encoder/dict.php | 5 + .../Tests/Fixtures/encoder/dict.stream.php | 13 + .../Tests/Fixtures/encoder/iterable_dict.php | 5 + .../Fixtures/encoder/iterable_dict.stream.php | 13 + .../Tests/Fixtures/encoder/iterable_list.php | 5 + .../Fixtures/encoder/iterable_list.stream.php | 12 + .../Tests/Fixtures/encoder/list.php | 5 + .../Tests/Fixtures/encoder/list.stream.php | 12 + .../Tests/Fixtures/encoder/mixed.php | 5 + .../Tests/Fixtures/encoder/mixed.stream.php | 5 + .../Tests/Fixtures/encoder/null.php | 5 + .../Tests/Fixtures/encoder/null.stream.php | 5 + .../Tests/Fixtures/encoder/null_list.php | 5 + .../Fixtures/encoder/null_list.stream.php | 12 + .../Fixtures/encoder/nullable_backed_enum.php | 11 + .../encoder/nullable_backed_enum.stream.php | 11 + .../Fixtures/encoder/nullable_object.php | 15 + .../encoder/nullable_object.stream.php | 15 + .../Fixtures/encoder/nullable_object_dict.php | 23 + .../encoder/nullable_object_dict.stream.php | 23 + .../Fixtures/encoder/nullable_object_list.php | 22 + .../encoder/nullable_object_list.stream.php | 22 + .../Tests/Fixtures/encoder/object.php | 9 + .../Tests/Fixtures/encoder/object.stream.php | 9 + .../Tests/Fixtures/encoder/object_dict.php | 17 + .../Fixtures/encoder/object_dict.stream.php | 17 + .../Fixtures/encoder/object_in_object.php | 13 + .../encoder/object_in_object.stream.php | 15 + .../Tests/Fixtures/encoder/object_list.php | 16 + .../Fixtures/encoder/object_list.stream.php | 16 + .../encoder/object_with_normalizer.php | 13 + .../encoder/object_with_normalizer.stream.php | 13 + .../Fixtures/encoder/object_with_union.php | 15 + .../encoder/object_with_union.stream.php | 15 + .../Tests/Fixtures/encoder/scalar.php | 5 + .../Tests/Fixtures/encoder/scalar.stream.php | 5 + .../Tests/Fixtures/encoder/union.php | 24 + .../Tests/Fixtures/encoder/union.stream.php | 24 + .../JsonEncoder/Tests/JsonDecoderTest.php | 192 ++++++ .../JsonEncoder/Tests/JsonEncoderTest.php | 209 +++++++ .../AttributePropertyMetadataLoaderTest.php | 74 +++ ...DateTimeTypePropertyMetadataLoaderTest.php | 55 ++ .../AttributePropertyMetadataLoaderTest.php | 74 +++ ...DateTimeTypePropertyMetadataLoaderTest.php | 51 ++ .../GenericTypePropertyMetadataLoaderTest.php | 63 ++ .../Mapping/PropertyMetadataLoaderTest.php | 32 + .../JsonEncoder/Tests/ServiceContainer.php | 38 ++ .../Component/JsonEncoder/composer.json | 36 ++ .../Component/JsonEncoder/phpunit.xml.dist | 30 + 196 files changed, 8451 insertions(+) create mode 100644 src/Symfony/Component/JsonEncoder/.gitattributes create mode 100644 src/Symfony/Component/JsonEncoder/.gitignore create mode 100644 src/Symfony/Component/JsonEncoder/Attribute/Denormalizer.php create mode 100644 src/Symfony/Component/JsonEncoder/Attribute/EncodedName.php create mode 100644 src/Symfony/Component/JsonEncoder/Attribute/Normalizer.php create mode 100644 src/Symfony/Component/JsonEncoder/CHANGELOG.md create mode 100644 src/Symfony/Component/JsonEncoder/CacheWarmer/EncoderDecoderCacheWarmer.php create mode 100644 src/Symfony/Component/JsonEncoder/CacheWarmer/LazyGhostCacheWarmer.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/DataAccessorInterface.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/Decode/BackedEnumNode.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/Decode/CollectionNode.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/Decode/CompositeNode.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/Decode/DataModelNodeInterface.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/Decode/ObjectNode.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/Decode/ScalarNode.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/Encode/BackedEnumNode.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/Encode/CollectionNode.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/Encode/CompositeNode.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/Encode/DataModelNodeInterface.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/Encode/ExceptionNode.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/Encode/ObjectNode.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/Encode/ScalarNode.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/FunctionDataAccessor.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/PhpExprDataAccessor.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/PropertyDataAccessor.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/ScalarDataAccessor.php create mode 100644 src/Symfony/Component/JsonEncoder/DataModel/VariableDataAccessor.php create mode 100644 src/Symfony/Component/JsonEncoder/Decode/DecoderGenerator.php create mode 100644 src/Symfony/Component/JsonEncoder/Decode/Denormalizer/DateTimeDenormalizer.php create mode 100644 src/Symfony/Component/JsonEncoder/Decode/Denormalizer/DenormalizerInterface.php create mode 100644 src/Symfony/Component/JsonEncoder/Decode/Instantiator.php create mode 100644 src/Symfony/Component/JsonEncoder/Decode/LazyInstantiator.php create mode 100644 src/Symfony/Component/JsonEncoder/Decode/Lexer.php create mode 100644 src/Symfony/Component/JsonEncoder/Decode/NativeDecoder.php create mode 100644 src/Symfony/Component/JsonEncoder/Decode/PhpAstBuilder.php create mode 100644 src/Symfony/Component/JsonEncoder/Decode/Splitter.php create mode 100644 src/Symfony/Component/JsonEncoder/DecoderInterface.php create mode 100644 src/Symfony/Component/JsonEncoder/Encode/EncoderGenerator.php create mode 100644 src/Symfony/Component/JsonEncoder/Encode/MergingStringVisitor.php create mode 100644 src/Symfony/Component/JsonEncoder/Encode/Normalizer/DateTimeNormalizer.php create mode 100644 src/Symfony/Component/JsonEncoder/Encode/Normalizer/NormalizerInterface.php create mode 100644 src/Symfony/Component/JsonEncoder/Encode/PhpAstBuilder.php create mode 100644 src/Symfony/Component/JsonEncoder/Encode/PhpOptimizer.php create mode 100644 src/Symfony/Component/JsonEncoder/Encoded.php create mode 100644 src/Symfony/Component/JsonEncoder/EncoderInterface.php create mode 100644 src/Symfony/Component/JsonEncoder/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/JsonEncoder/Exception/InvalidArgumentException.php create mode 100644 src/Symfony/Component/JsonEncoder/Exception/InvalidStreamException.php create mode 100644 src/Symfony/Component/JsonEncoder/Exception/LogicException.php create mode 100644 src/Symfony/Component/JsonEncoder/Exception/MaxDepthException.php create mode 100644 src/Symfony/Component/JsonEncoder/Exception/RuntimeException.php create mode 100644 src/Symfony/Component/JsonEncoder/Exception/UnexpectedValueException.php create mode 100644 src/Symfony/Component/JsonEncoder/Exception/UnsupportedException.php create mode 100644 src/Symfony/Component/JsonEncoder/JsonDecoder.php create mode 100644 src/Symfony/Component/JsonEncoder/JsonEncoder.php create mode 100644 src/Symfony/Component/JsonEncoder/LICENSE create mode 100644 src/Symfony/Component/JsonEncoder/Mapping/Decode/AttributePropertyMetadataLoader.php create mode 100644 src/Symfony/Component/JsonEncoder/Mapping/Decode/DateTimeTypePropertyMetadataLoader.php create mode 100644 src/Symfony/Component/JsonEncoder/Mapping/Encode/AttributePropertyMetadataLoader.php create mode 100644 src/Symfony/Component/JsonEncoder/Mapping/Encode/DateTimeTypePropertyMetadataLoader.php create mode 100644 src/Symfony/Component/JsonEncoder/Mapping/GenericTypePropertyMetadataLoader.php create mode 100644 src/Symfony/Component/JsonEncoder/Mapping/PropertyMetadata.php create mode 100644 src/Symfony/Component/JsonEncoder/Mapping/PropertyMetadataLoader.php create mode 100644 src/Symfony/Component/JsonEncoder/Mapping/PropertyMetadataLoaderInterface.php create mode 100644 src/Symfony/Component/JsonEncoder/README.md create mode 100644 src/Symfony/Component/JsonEncoder/Tests/CacheWarmer/EncoderDecoderCacheWarmerTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/CacheWarmer/LazyGhostCacheWarmerTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/DataModel/Decode/CompositeNodeTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/DataModel/Encode/CompositeNodeTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Decode/DecoderGeneratorTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Decode/Denormalizer/DateTimeDenormalizerTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Decode/InstantiatorTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Decode/LazyInstantiatorTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Decode/LexerTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Decode/NativeDecoderTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Decode/SplitterTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Encode/EncoderGeneratorTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Encode/Normalizer/DateTimeNormalizerTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/EncodedTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Attribute/BooleanStringDenormalizer.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Attribute/BooleanStringNormalizer.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Denormalizer/BooleanStringDenormalizer.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Denormalizer/DivideStringAndCastToIntDenormalizer.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Enum/DummyBackedEnum.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Enum/DummyEnum.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/AbstractDummy.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/ClassicDummy.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithDateTimes.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithGenerics.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithMethods.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithNameAttributes.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithNormalizerAttributes.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithNullableProperties.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithOtherDummies.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithPhpDoc.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithUnionProperties.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/SelfReferencingDummy.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Normalizer/BooleanStringNormalizer.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/Normalizer/DoubleIntAndCastToStringNormalizer.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/backed_enum.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/backed_enum.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/dict.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/dict.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/iterable_dict.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/iterable_dict.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/iterable_list.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/iterable_list.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/list.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/list.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/mixed.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/mixed.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/null.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/null.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_backed_enum.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_backed_enum.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_dict.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_dict.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_list.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_list.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_dict.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_dict.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_in_object.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_in_object.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_list.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_list.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_denormalizer.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_denormalizer.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_nullable_properties.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_nullable_properties.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_union.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_union.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/scalar.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/scalar.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/union.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/union.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/backed_enum.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/backed_enum.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/bool.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/bool.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/bool_list.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/bool_list.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/dict.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/dict.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/iterable_dict.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/iterable_dict.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/iterable_list.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/iterable_list.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/list.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/list.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/mixed.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/mixed.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/null.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/null.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/null_list.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/null_list.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_backed_enum.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_backed_enum.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_dict.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_dict.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_list.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_list.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_dict.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_dict.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_in_object.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_in_object.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_list.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_list.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_normalizer.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_normalizer.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_union.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_union.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/scalar.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/scalar.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/union.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/union.stream.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/JsonDecoderTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/JsonEncoderTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Mapping/Decode/AttributePropertyMetadataLoaderTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Mapping/Decode/DateTimeTypePropertyMetadataLoaderTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Mapping/Encode/AttributePropertyMetadataLoaderTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Mapping/Encode/DateTimeTypePropertyMetadataLoaderTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Mapping/GenericTypePropertyMetadataLoaderTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/Mapping/PropertyMetadataLoaderTest.php create mode 100644 src/Symfony/Component/JsonEncoder/Tests/ServiceContainer.php create mode 100644 src/Symfony/Component/JsonEncoder/composer.json create mode 100644 src/Symfony/Component/JsonEncoder/phpunit.xml.dist diff --git a/.gitattributes b/.gitattributes index c633c0256911d..c7aefa05ef8be 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,5 +7,7 @@ /src/Symfony/Component/Translation/Bridge export-ignore /src/Symfony/Component/Emoji/Resources/data/* linguist-generated=true /src/Symfony/Component/Intl/Resources/data/*/* linguist-generated=true +/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/* linguist-generated=true +/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/* linguist-generated=true /src/Symfony/**/.github/workflows/close-pull-request.yml linguist-generated=true /src/Symfony/**/.github/PULL_REQUEST_TEMPLATE.md linguist-generated=true diff --git a/composer.json b/composer.json index 49162a3b81f8a..d3f57d09ae9d7 100644 --- a/composer.json +++ b/composer.json @@ -83,6 +83,7 @@ "symfony/http-foundation": "self.version", "symfony/http-kernel": "self.version", "symfony/intl": "self.version", + "symfony/json-encoder": "self.version", "symfony/ldap": "self.version", "symfony/lock": "self.version", "symfony/mailer": "self.version", diff --git a/src/Symfony/Component/JsonEncoder/.gitattributes b/src/Symfony/Component/JsonEncoder/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/JsonEncoder/.gitignore b/src/Symfony/Component/JsonEncoder/.gitignore new file mode 100644 index 0000000000000..c49a5d8df5c65 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/.gitignore @@ -0,0 +1,3 @@ +vendor/ +composer.lock +phpunit.xml diff --git a/src/Symfony/Component/JsonEncoder/Attribute/Denormalizer.php b/src/Symfony/Component/JsonEncoder/Attribute/Denormalizer.php new file mode 100644 index 0000000000000..c48da727265d7 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Attribute/Denormalizer.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Attribute; + +/** + * Defines a callable or a {@see \Symfony\Component\JsonEncoder\Decode\Denormalizer\DenormalizerInterface} service id + * that will be used to denormalize the property data during decoding. + * + * @author Mathias Arlaud + * + * @experimental + */ +#[\Attribute(\Attribute::TARGET_PROPERTY)] +class Denormalizer +{ + private string|\Closure $denormalizer; + + /** + * @param string|(callable(mixed, array?): mixed)|(callable(mixed): mixed) $denormalizer + */ + public function __construct(mixed $denormalizer) + { + if (\is_callable($denormalizer)) { + $denormalizer = \Closure::fromCallable($denormalizer); + } + + $this->denormalizer = $denormalizer; + } + + public function getDenormalizer(): string|\Closure + { + return $this->denormalizer; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Attribute/EncodedName.php b/src/Symfony/Component/JsonEncoder/Attribute/EncodedName.php new file mode 100644 index 0000000000000..3da35bc9e0549 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Attribute/EncodedName.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Attribute; + +/** + * Defines the encoded property name. + * + * @author Mathias Arlaud + * + * @experimental + */ +#[\Attribute(\Attribute::TARGET_PROPERTY)] +final class EncodedName +{ + public function __construct( + private string $name, + ) { + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Attribute/Normalizer.php b/src/Symfony/Component/JsonEncoder/Attribute/Normalizer.php new file mode 100644 index 0000000000000..e8c1ea314dcdf --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Attribute/Normalizer.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Attribute; + +/** + * Defines a callable or a {@see \Symfony\Component\JsonEncoder\Encode\Normalizer\NormalizerInterface} service id + * that will be used to normalize the property data during encoding. + * + * @author Mathias Arlaud + * + * @experimental + */ +#[\Attribute(\Attribute::TARGET_PROPERTY)] +class Normalizer +{ + private string|\Closure $normalizer; + + /** + * @param string|(callable(mixed, array?): mixed)|(callable(mixed): mixed) $normalizer + */ + public function __construct(mixed $normalizer) + { + if (\is_callable($normalizer)) { + $normalizer = \Closure::fromCallable($normalizer); + } + + $this->normalizer = $normalizer; + } + + public function getNormalizer(): string|\Closure + { + return $this->normalizer; + } +} diff --git a/src/Symfony/Component/JsonEncoder/CHANGELOG.md b/src/Symfony/Component/JsonEncoder/CHANGELOG.md new file mode 100644 index 0000000000000..5294c5b5f3637 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +7.3 +--- + + * Introduce the component as experimental diff --git a/src/Symfony/Component/JsonEncoder/CacheWarmer/EncoderDecoderCacheWarmer.php b/src/Symfony/Component/JsonEncoder/CacheWarmer/EncoderDecoderCacheWarmer.php new file mode 100644 index 0000000000000..d5d00afbeec4a --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/CacheWarmer/EncoderDecoderCacheWarmer.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\CacheWarmer; + +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; +use Symfony\Component\JsonEncoder\Decode\DecoderGenerator; +use Symfony\Component\JsonEncoder\Encode\EncoderGenerator; +use Symfony\Component\JsonEncoder\Exception\ExceptionInterface; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\TypeInfo\Type; + +/** + * Generates encoders and decoders PHP files. + * + * @author Mathias Arlaud + * + * @internal + */ +final class EncoderDecoderCacheWarmer implements CacheWarmerInterface +{ + private EncoderGenerator $encoderGenerator; + private DecoderGenerator $decoderGenerator; + + /** + * @param iterable $encodableClassNames + */ + public function __construct( + private iterable $encodableClassNames, + PropertyMetadataLoaderInterface $encodePropertyMetadataLoader, + PropertyMetadataLoaderInterface $decodePropertyMetadataLoader, + string $encodersDir, + string $decodersDir, + bool $forceEncodeChunks = false, + private LoggerInterface $logger = new NullLogger(), + ) { + $this->encoderGenerator = new EncoderGenerator($encodePropertyMetadataLoader, $encodersDir, $forceEncodeChunks); + $this->decoderGenerator = new DecoderGenerator($decodePropertyMetadataLoader, $decodersDir); + } + + public function warmUp(string $cacheDir, ?string $buildDir = null): array + { + foreach ($this->encodableClassNames as $className) { + $type = Type::object($className); + + $this->warmUpEncoder($type); + $this->warmUpDecoders($type); + } + + return []; + } + + public function isOptional(): bool + { + return true; + } + + private function warmUpEncoder(Type $type): void + { + try { + $this->encoderGenerator->generate($type); + } catch (ExceptionInterface $e) { + $this->logger->debug('Cannot generate "json" encoder for "{type}": {exception}', ['type' => (string) $type, 'exception' => $e]); + } + } + + private function warmUpDecoders(Type $type): void + { + try { + $this->decoderGenerator->generate($type, decodeFromStream: false); + } catch (ExceptionInterface $e) { + $this->logger->debug('Cannot generate "json" decoder for "{type}": {exception}', ['type' => (string) $type, 'exception' => $e]); + } + + try { + $this->decoderGenerator->generate($type, decodeFromStream: true); + } catch (ExceptionInterface $e) { + $this->logger->debug('Cannot generate "json" streaming decoder for "{type}": {exception}', ['type' => (string) $type, 'exception' => $e]); + } + } +} diff --git a/src/Symfony/Component/JsonEncoder/CacheWarmer/LazyGhostCacheWarmer.php b/src/Symfony/Component/JsonEncoder/CacheWarmer/LazyGhostCacheWarmer.php new file mode 100644 index 0000000000000..25a00e4c9f39e --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/CacheWarmer/LazyGhostCacheWarmer.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\CacheWarmer; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmer; +use Symfony\Component\JsonEncoder\Exception\RuntimeException; +use Symfony\Component\VarExporter\ProxyHelper; + +/** + * Generates lazy ghost {@see \Symfony\Component\VarExporter\LazyGhostTrait} + * PHP files for $encodable types. + * + * @author Mathias Arlaud + * + * @internal + */ +final class LazyGhostCacheWarmer extends CacheWarmer +{ + private Filesystem $fs; + + /** + * @param iterable $encodableClassNames + */ + public function __construct( + private iterable $encodableClassNames, + private string $lazyGhostsDir, + ) { + $this->fs = new Filesystem(); + } + + public function warmUp(string $cacheDir, ?string $buildDir = null): array + { + if (!$this->fs->exists($this->lazyGhostsDir)) { + $this->fs->mkdir($this->lazyGhostsDir); + } + + foreach ($this->encodableClassNames as $className) { + $this->warmClassLazyGhost($className); + } + + return []; + } + + public function isOptional(): bool + { + return true; + } + + /** + * @param class-string $className + */ + private function warmClassLazyGhost(string $className): void + { + $path = \sprintf('%s%s%s.php', $this->lazyGhostsDir, \DIRECTORY_SEPARATOR, hash('xxh128', $className)); + + try { + $classReflection = new \ReflectionClass($className); + } catch (\ReflectionException $e) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + + $this->writeCacheFile($path, \sprintf( + 'class %s%s', + \sprintf('%sGhost', preg_replace('/\\\\/', '', $className)), + ProxyHelper::generateLazyGhost($classReflection), + )); + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/DataAccessorInterface.php b/src/Symfony/Component/JsonEncoder/DataModel/DataAccessorInterface.php new file mode 100644 index 0000000000000..807ea749f4421 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/DataAccessorInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel; + +use PhpParser\Node\Expr; + +/** + * Represents a way to access data on PHP. + * + * @author Mathias Arlaud + * + * @internal + */ +interface DataAccessorInterface +{ + /** + * Converts to "nikic/php-parser" PHP expression. + */ + public function toPhpExpr(): Expr; +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/Decode/BackedEnumNode.php b/src/Symfony/Component/JsonEncoder/DataModel/Decode/BackedEnumNode.php new file mode 100644 index 0000000000000..1f78edf309eb5 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/Decode/BackedEnumNode.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel\Decode; + +use Symfony\Component\TypeInfo\Type\BackedEnumType; + +/** + * Represents a backed enum in the data model graph representation. + * + * Backed enums are leaves in the data model tree. + * + * @author Mathias Arlaud + * + * @internal + */ +final class BackedEnumNode implements DataModelNodeInterface +{ + public function __construct( + public BackedEnumType $type, + ) { + } + + public function getIdentifier(): string + { + return (string) $this->type; + } + + public function getType(): BackedEnumType + { + return $this->type; + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/Decode/CollectionNode.php b/src/Symfony/Component/JsonEncoder/DataModel/Decode/CollectionNode.php new file mode 100644 index 0000000000000..72bf2dd2be276 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/Decode/CollectionNode.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel\Decode; + +use Symfony\Component\TypeInfo\Type\CollectionType; + +/** + * Represents a collection in the data model graph representation. + * + * @author Mathias Arlaud + * + * @internal + */ +final class CollectionNode implements DataModelNodeInterface +{ + public function __construct( + private CollectionType $type, + private DataModelNodeInterface $item, + ) { + } + + public function getIdentifier(): string + { + return (string) $this->type; + } + + public function getType(): CollectionType + { + return $this->type; + } + + public function getItemNode(): DataModelNodeInterface + { + return $this->item; + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/Decode/CompositeNode.php b/src/Symfony/Component/JsonEncoder/DataModel/Decode/CompositeNode.php new file mode 100644 index 0000000000000..b767451722fa9 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/Decode/CompositeNode.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel\Decode; + +use Symfony\Component\JsonEncoder\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\UnionType; + +/** + * Represents a "OR" node composition in the data model graph representation. + * + * Composing nodes are sorted by their precision (descending). + * + * @author Mathias Arlaud + * + * @internal + */ +final class CompositeNode implements DataModelNodeInterface +{ + private const NODE_PRECISION = [ + CollectionNode::class => 3, + ObjectNode::class => 2, + BackedEnumNode::class => 1, + ScalarNode::class => 0, + ]; + + /** + * @var list + */ + private array $nodes; + + /** + * @param list $nodes + */ + public function __construct(array $nodes) + { + if (\count($nodes) < 2) { + throw new InvalidArgumentException(\sprintf('"%s" expects at least 2 nodes.', self::class)); + } + + foreach ($nodes as $n) { + if ($n instanceof self) { + throw new InvalidArgumentException(\sprintf('Cannot set "%s" as a "%s" node.', self::class, self::class)); + } + } + + usort($nodes, fn (CollectionNode|ObjectNode|BackedEnumNode|ScalarNode $a, CollectionNode|ObjectNode|BackedEnumNode|ScalarNode $b): int => self::NODE_PRECISION[$b::class] <=> self::NODE_PRECISION[$a::class]); + $this->nodes = $nodes; + } + + public function getIdentifier(): string + { + return (string) $this->getType(); + } + + public function getType(): UnionType + { + return Type::union(...array_map(fn (DataModelNodeInterface $n): Type => $n->getType(), $this->nodes)); + } + + /** + * @return list + */ + public function getNodes(): array + { + return $this->nodes; + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/Decode/DataModelNodeInterface.php b/src/Symfony/Component/JsonEncoder/DataModel/Decode/DataModelNodeInterface.php new file mode 100644 index 0000000000000..b9e81c1889edd --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/Decode/DataModelNodeInterface.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel\Decode; + +use Symfony\Component\TypeInfo\Type; + +/** + * Represents a node in the decoding data model graph representation. + * + * @author Mathias Arlaud + * + * @internal + */ +interface DataModelNodeInterface +{ + public function getIdentifier(): string; + + public function getType(): Type; +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/Decode/ObjectNode.php b/src/Symfony/Component/JsonEncoder/DataModel/Decode/ObjectNode.php new file mode 100644 index 0000000000000..01e081dcc635f --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/Decode/ObjectNode.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel\Decode; + +use Symfony\Component\JsonEncoder\DataModel\DataAccessorInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; + +/** + * Represents an object in the data model graph representation. + * + * @author Mathias Arlaud + * + * @internal + */ +final class ObjectNode implements DataModelNodeInterface +{ + /** + * @param array $properties + */ + public function __construct( + private ObjectType $type, + private array $properties, + private bool $ghost = false, + ) { + } + + public static function createGhost(ObjectType|UnionType $type): self + { + return new self($type, [], true); + } + + public function getIdentifier(): string + { + return (string) $this->type; + } + + public function getType(): ObjectType + { + return $this->type; + } + + /** + * @return array + */ + public function getProperties(): array + { + return $this->properties; + } + + public function isGhost(): bool + { + return $this->ghost; + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/Decode/ScalarNode.php b/src/Symfony/Component/JsonEncoder/DataModel/Decode/ScalarNode.php new file mode 100644 index 0000000000000..ae2f572b38faa --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/Decode/ScalarNode.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel\Decode; + +use Symfony\Component\TypeInfo\Type\BuiltinType; + +/** + * Represents a scalar in the data model graph representation. + * + * Scalars are leaves in the data model tree. + * + * @author Mathias Arlaud + * + * @internal + */ +final class ScalarNode implements DataModelNodeInterface +{ + public function __construct( + public BuiltinType $type, + ) { + } + + public function getIdentifier(): string + { + return (string) $this->type; + } + + public function getType(): BuiltinType + { + return $this->type; + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/Encode/BackedEnumNode.php b/src/Symfony/Component/JsonEncoder/DataModel/Encode/BackedEnumNode.php new file mode 100644 index 0000000000000..519f7b977078c --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/Encode/BackedEnumNode.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel\Encode; + +use Symfony\Component\JsonEncoder\DataModel\DataAccessorInterface; +use Symfony\Component\TypeInfo\Type\BackedEnumType; + +/** + * Represents a backed enum in the data model graph representation. + * + * Backed enums are leaves in the data model tree. + * + * @author Mathias Arlaud + * + * @internal + */ +final class BackedEnumNode implements DataModelNodeInterface +{ + public function __construct( + private DataAccessorInterface $accessor, + private BackedEnumType $type, + ) { + } + + public function getAccessor(): DataAccessorInterface + { + return $this->accessor; + } + + public function getType(): BackedEnumType + { + return $this->type; + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/Encode/CollectionNode.php b/src/Symfony/Component/JsonEncoder/DataModel/Encode/CollectionNode.php new file mode 100644 index 0000000000000..2827eca9de241 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/Encode/CollectionNode.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel\Encode; + +use Symfony\Component\JsonEncoder\DataModel\DataAccessorInterface; +use Symfony\Component\TypeInfo\Type\CollectionType; + +/** + * Represents a collection in the data model graph representation. + * + * @author Mathias Arlaud + * + * @internal + */ +final class CollectionNode implements DataModelNodeInterface +{ + public function __construct( + private DataAccessorInterface $accessor, + private CollectionType $type, + private DataModelNodeInterface $item, + ) { + } + + public function getAccessor(): DataAccessorInterface + { + return $this->accessor; + } + + public function getType(): CollectionType + { + return $this->type; + } + + public function getItemNode(): DataModelNodeInterface + { + return $this->item; + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/Encode/CompositeNode.php b/src/Symfony/Component/JsonEncoder/DataModel/Encode/CompositeNode.php new file mode 100644 index 0000000000000..7e7ee4120b1cc --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/Encode/CompositeNode.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel\Encode; + +use Symfony\Component\JsonEncoder\DataModel\DataAccessorInterface; +use Symfony\Component\JsonEncoder\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\UnionType; + +/** + * Represents a "OR" node composition in the data model graph representation. + * + * Composing nodes are sorted by their precision (descending). + * + * @author Mathias Arlaud + * + * @internal + */ +final class CompositeNode implements DataModelNodeInterface +{ + private const NODE_PRECISION = [ + CollectionNode::class => 3, + ObjectNode::class => 2, + BackedEnumNode::class => 1, + ScalarNode::class => 0, + ]; + + /** + * @var list + */ + private array $nodes; + + /** + * @param list $nodes + */ + public function __construct( + private DataAccessorInterface $accessor, + array $nodes, + ) { + if (\count($nodes) < 2) { + throw new InvalidArgumentException(\sprintf('"%s" expects at least 2 nodes.', self::class)); + } + + foreach ($nodes as $n) { + if ($n instanceof self) { + throw new InvalidArgumentException(\sprintf('Cannot set "%s" as a "%s" node.', self::class, self::class)); + } + } + + usort($nodes, fn (CollectionNode|ObjectNode|BackedEnumNode|ScalarNode $a, CollectionNode|ObjectNode|BackedEnumNode|ScalarNode $b): int => self::NODE_PRECISION[$b::class] <=> self::NODE_PRECISION[$a::class]); + $this->nodes = $nodes; + } + + public function getAccessor(): DataAccessorInterface + { + return $this->accessor; + } + + public function getType(): UnionType + { + return Type::union(...array_map(fn (DataModelNodeInterface $n): Type => $n->getType(), $this->nodes)); + } + + /** + * @return list + */ + public function getNodes(): array + { + return $this->nodes; + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/Encode/DataModelNodeInterface.php b/src/Symfony/Component/JsonEncoder/DataModel/Encode/DataModelNodeInterface.php new file mode 100644 index 0000000000000..eed57a5dd2d79 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/Encode/DataModelNodeInterface.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel\Encode; + +use Symfony\Component\JsonEncoder\DataModel\DataAccessorInterface; +use Symfony\Component\TypeInfo\Type; + +/** + * Represents a node in the encoding data model graph representation. + * + * @author Mathias Arlaud + * + * @internal + */ +interface DataModelNodeInterface +{ + public function getType(): Type; + + public function getAccessor(): DataAccessorInterface; +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/Encode/ExceptionNode.php b/src/Symfony/Component/JsonEncoder/DataModel/Encode/ExceptionNode.php new file mode 100644 index 0000000000000..e026199aebb00 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/Encode/ExceptionNode.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel\Encode; + +use PhpParser\Node\Expr\New_; +use PhpParser\Node\Name\FullyQualified; +use Symfony\Component\JsonEncoder\DataModel\DataAccessorInterface; +use Symfony\Component\JsonEncoder\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/JsonEncoder/DataModel/Encode/ObjectNode.php b/src/Symfony/Component/JsonEncoder/DataModel/Encode/ObjectNode.php new file mode 100644 index 0000000000000..a5ac0f956d34e --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/Encode/ObjectNode.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel\Encode; + +use Symfony\Component\JsonEncoder\DataModel\DataAccessorInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; + +/** + * Represents an object in the data model graph representation. + * + * @author Mathias Arlaud + * + * @internal + */ +final class ObjectNode implements DataModelNodeInterface +{ + /** + * @param array $properties + */ + public function __construct( + private DataAccessorInterface $accessor, + private ObjectType $type, + private array $properties, + private bool $transformed, + ) { + } + + public function getAccessor(): DataAccessorInterface + { + return $this->accessor; + } + + public function getType(): ObjectType + { + return $this->type; + } + + /** + * @return array + */ + public function getProperties(): array + { + return $this->properties; + } + + public function isTransformed(): bool + { + return $this->transformed; + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/Encode/ScalarNode.php b/src/Symfony/Component/JsonEncoder/DataModel/Encode/ScalarNode.php new file mode 100644 index 0000000000000..4bf032eb193af --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/Encode/ScalarNode.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel\Encode; + +use Symfony\Component\JsonEncoder\DataModel\DataAccessorInterface; +use Symfony\Component\TypeInfo\Type\BuiltinType; + +/** + * Represents a scalar in the data model graph representation. + * + * Scalars are leaves in the data model tree. + * + * @author Mathias Arlaud + * + * @internal + */ +final class ScalarNode implements DataModelNodeInterface +{ + public function __construct( + private DataAccessorInterface $accessor, + private BuiltinType $type, + ) { + } + + public function getAccessor(): DataAccessorInterface + { + return $this->accessor; + } + + public function getType(): BuiltinType + { + return $this->type; + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/FunctionDataAccessor.php b/src/Symfony/Component/JsonEncoder/DataModel/FunctionDataAccessor.php new file mode 100644 index 0000000000000..a52e179e9f6a1 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/FunctionDataAccessor.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel; + +use PhpParser\BuilderFactory; +use PhpParser\Node\Expr; + +/** + * Defines the way to access data using a function (or a method). + * + * @author Mathias Arlaud + * + * @internal + */ +final class FunctionDataAccessor implements DataAccessorInterface +{ + /** + * @param list $arguments + */ + public function __construct( + private string $functionName, + private array $arguments, + private ?DataAccessorInterface $objectAccessor = null, + ) { + } + + public function toPhpExpr(): Expr + { + $builder = new BuilderFactory(); + $arguments = array_map(static fn (DataAccessorInterface $argument): Expr => $argument->toPhpExpr(), $this->arguments); + + if (null === $this->objectAccessor) { + return $builder->funcCall($this->functionName, $arguments); + } + + return $builder->methodCall($this->objectAccessor->toPhpExpr(), $this->functionName, $arguments); + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/PhpExprDataAccessor.php b/src/Symfony/Component/JsonEncoder/DataModel/PhpExprDataAccessor.php new file mode 100644 index 0000000000000..ee8f15ef20ed6 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/PhpExprDataAccessor.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel; + +use PhpParser\Node\Expr; + +/** + * Defines the way to access data using PHP AST. + * + * @author Mathias Arlaud + * + * @internal + */ +final class PhpExprDataAccessor implements DataAccessorInterface +{ + public function __construct( + private Expr $php, + ) { + } + + public function toPhpExpr(): Expr + { + return $this->php; + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/PropertyDataAccessor.php b/src/Symfony/Component/JsonEncoder/DataModel/PropertyDataAccessor.php new file mode 100644 index 0000000000000..69cf7aa13f14c --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/PropertyDataAccessor.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel; + +use PhpParser\BuilderFactory; +use PhpParser\Node\Expr; + +/** + * Defines the way to access data using an object property. + * + * @author Mathias Arlaud + * + * @internal + */ +final class PropertyDataAccessor implements DataAccessorInterface +{ + public function __construct( + private DataAccessorInterface $objectAccessor, + private string $propertyName, + ) { + } + + public function toPhpExpr(): Expr + { + return (new BuilderFactory())->propertyFetch($this->objectAccessor->toPhpExpr(), $this->propertyName); + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/ScalarDataAccessor.php b/src/Symfony/Component/JsonEncoder/DataModel/ScalarDataAccessor.php new file mode 100644 index 0000000000000..b5f7776a9d002 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/ScalarDataAccessor.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel; + +use PhpParser\BuilderFactory; +use PhpParser\Node\Expr; + +/** + * Defines the way to access a scalar value. + * + * @author Mathias Arlaud + * + * @internal + */ +final class ScalarDataAccessor implements DataAccessorInterface +{ + public function __construct( + private mixed $value, + ) { + } + + public function toPhpExpr(): Expr + { + return (new BuilderFactory())->val($this->value); + } +} diff --git a/src/Symfony/Component/JsonEncoder/DataModel/VariableDataAccessor.php b/src/Symfony/Component/JsonEncoder/DataModel/VariableDataAccessor.php new file mode 100644 index 0000000000000..783ffba07bb86 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DataModel/VariableDataAccessor.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\DataModel; + +use PhpParser\BuilderFactory; +use PhpParser\Node\Expr; + +/** + * Defines the way to access data using a variable. + * + * @author Mathias Arlaud + * + * @internal + */ +final class VariableDataAccessor implements DataAccessorInterface +{ + public function __construct( + private string $name, + ) { + } + + public function toPhpExpr(): Expr + { + return (new BuilderFactory())->var($this->name); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Decode/DecoderGenerator.php b/src/Symfony/Component/JsonEncoder/Decode/DecoderGenerator.php new file mode 100644 index 0000000000000..78bafadb629dd --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Decode/DecoderGenerator.php @@ -0,0 +1,175 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Decode; + +use PhpParser\PhpVersion; +use PhpParser\PrettyPrinter; +use PhpParser\PrettyPrinter\Standard; +use Symfony\Component\Filesystem\Exception\IOException; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\JsonEncoder\DataModel\DataAccessorInterface; +use Symfony\Component\JsonEncoder\DataModel\Decode\BackedEnumNode; +use Symfony\Component\JsonEncoder\DataModel\Decode\CollectionNode; +use Symfony\Component\JsonEncoder\DataModel\Decode\CompositeNode; +use Symfony\Component\JsonEncoder\DataModel\Decode\DataModelNodeInterface; +use Symfony\Component\JsonEncoder\DataModel\Decode\ObjectNode; +use Symfony\Component\JsonEncoder\DataModel\Decode\ScalarNode; +use Symfony\Component\JsonEncoder\DataModel\FunctionDataAccessor; +use Symfony\Component\JsonEncoder\DataModel\ScalarDataAccessor; +use Symfony\Component\JsonEncoder\DataModel\VariableDataAccessor; +use Symfony\Component\JsonEncoder\Exception\RuntimeException; +use Symfony\Component\JsonEncoder\Exception\UnsupportedException; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BackedEnumType; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\EnumType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; + +/** + * Generates and writes decoders PHP files. + * + * @author Mathias Arlaud + * + * @internal + */ +final class DecoderGenerator +{ + private ?PhpAstBuilder $phpAstBuilder = null; + private ?PrettyPrinter $phpPrinter = null; + private ?Filesystem $fs = null; + + public function __construct( + private PropertyMetadataLoaderInterface $propertyMetadataLoader, + private string $decodersDir, + ) { + } + + /** + * Generates and writes a decoder PHP file and return its path. + * + * @param array $options + */ + public function generate(Type $type, bool $decodeFromStream, array $options = []): string + { + $path = $this->getPath($type, $decodeFromStream); + if (is_file($path)) { + return $path; + } + + $this->phpAstBuilder ??= new PhpAstBuilder(); + $this->phpPrinter ??= new Standard(['phpVersion' => PhpVersion::fromComponents(8, 2)]); + $this->fs ??= new Filesystem(); + + $dataModel = $this->createDataModel($type, $options); + $nodes = $this->phpAstBuilder->build($dataModel, $decodeFromStream, $options); + $content = $this->phpPrinter->prettyPrintFile($nodes)."\n"; + + if (!$this->fs->exists($this->decodersDir)) { + $this->fs->mkdir($this->decodersDir); + } + + $tmpFile = $this->fs->tempnam(\dirname($path), basename($path)); + + try { + $this->fs->dumpFile($tmpFile, $content); + $this->fs->rename($tmpFile, $path); + $this->fs->chmod($path, 0666 & ~umask()); + } catch (IOException $e) { + throw new RuntimeException(\sprintf('Failed to write "%s" decoder file.', $path), previous: $e); + } + + return $path; + } + + private function getPath(Type $type, bool $decodeFromStream): string + { + return \sprintf('%s%s%s.json%s.php', $this->decodersDir, \DIRECTORY_SEPARATOR, hash('xxh128', (string) $type), $decodeFromStream ? '.stream' : ''); + } + + /** + * @param array $options + * @param array $context + */ + public function createDataModel(Type $type, array $options = [], array $context = []): DataModelNodeInterface + { + $context['original_type'] ??= $type; + + if ($type instanceof UnionType) { + return new CompositeNode(array_map(fn (Type $t): DataModelNodeInterface => $this->createDataModel($t, $options, $context), $type->getTypes())); + } + + if ($type instanceof BuiltinType) { + return new ScalarNode($type); + } + + if ($type instanceof BackedEnumType) { + return new BackedEnumNode($type); + } + + if ($type instanceof ObjectType && !$type instanceof EnumType) { + $typeString = (string) $type; + $className = $type->getClassName(); + + if ($context['generated_classes'][$typeString] ??= false) { + return ObjectNode::createGhost($type); + } + + $propertiesNodes = []; + $context['generated_classes'][$typeString] = true; + + $propertiesMetadata = $this->propertyMetadataLoader->load($className, $options, $context); + + foreach ($propertiesMetadata as $encodedName => $propertyMetadata) { + $propertiesNodes[$encodedName] = [ + 'name' => $propertyMetadata->getName(), + 'value' => $this->createDataModel($propertyMetadata->getType(), $options, $context), + 'accessor' => function (DataAccessorInterface $accessor) use ($propertyMetadata): DataAccessorInterface { + foreach ($propertyMetadata->getDenormalizers() as $denormalizer) { + if (\is_string($denormalizer)) { + $denormalizerServiceAccessor = new FunctionDataAccessor('get', [new ScalarDataAccessor($denormalizer)], new VariableDataAccessor('denormalizers')); + $accessor = new FunctionDataAccessor('denormalize', [$accessor, new VariableDataAccessor('options')], $denormalizerServiceAccessor); + + continue; + } + + try { + $functionReflection = new \ReflectionFunction($denormalizer); + } catch (\ReflectionException $e) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + + $functionName = !$functionReflection->getClosureScopeClass() + ? $functionReflection->getName() + : \sprintf('%s::%s', $functionReflection->getClosureScopeClass()->getName(), $functionReflection->getName()); + $arguments = $functionReflection->isUserDefined() ? [$accessor, new VariableDataAccessor('options')] : [$accessor]; + + $accessor = new FunctionDataAccessor($functionName, $arguments); + } + + return $accessor; + }, + ]; + } + + return new ObjectNode($type, $propertiesNodes); + } + + if ($type instanceof CollectionType) { + return new CollectionNode($type, $this->createDataModel($type->getCollectionValueType(), $options, $context)); + } + + throw new UnsupportedException(\sprintf('"%s" type is not supported.', (string) $type)); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Decode/Denormalizer/DateTimeDenormalizer.php b/src/Symfony/Component/JsonEncoder/Decode/Denormalizer/DateTimeDenormalizer.php new file mode 100644 index 0000000000000..90c335c1b8237 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Decode/Denormalizer/DateTimeDenormalizer.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Decode\Denormalizer; + +use Symfony\Component\JsonEncoder\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Casts string to DateTimeInterface. + * + * @author Mathias Arlaud + * + * @internal + */ +final class DateTimeDenormalizer implements DenormalizerInterface +{ + public const FORMAT_KEY = 'date_time_format'; + + public function __construct( + private bool $immutable, + ) { + } + + public function denormalize(mixed $normalized, array $options = []): \DateTime|\DateTimeImmutable + { + if (!\is_string($normalized) || '' === trim($normalized)) { + throw new InvalidArgumentException('The normalized data is either not an string, or an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.'); + } + + $dateTimeFormat = $options[self::FORMAT_KEY] ?? null; + $dateTimeClassName = $this->immutable ? \DateTimeImmutable::class : \DateTime::class; + + if (null !== $dateTimeFormat) { + if (false !== $dateTime = $dateTimeClassName::createFromFormat($dateTimeFormat, $normalized)) { + return $dateTime; + } + + $dateTimeErrors = $dateTimeClassName::getLastErrors(); + + throw new InvalidArgumentException(\sprintf('Parsing datetime string "%s" using format "%s" resulted in %d errors: ', $normalized, $dateTimeFormat, $dateTimeErrors['error_count'])."\n".implode("\n", $this->formatDateTimeErrors($dateTimeErrors['errors']))); + } + + try { + return new $dateTimeClassName($normalized); + } catch (\Throwable) { + $dateTimeErrors = $dateTimeClassName::getLastErrors(); + + throw new InvalidArgumentException(\sprintf('Parsing datetime string "%s" resulted in %d errors: ', $normalized, $dateTimeErrors['error_count'])."\n".implode("\n", $this->formatDateTimeErrors($dateTimeErrors['errors']))); + } + } + + /** + * @return BuiltinType + */ + public static function getNormalizedType(): BuiltinType + { + return Type::string(); + } + + /** + * @param array $errors + * + * @return list + */ + private function formatDateTimeErrors(array $errors): array + { + $formattedErrors = []; + + foreach ($errors as $pos => $message) { + $formattedErrors[] = \sprintf('at position %d: %s', $pos, $message); + } + + return $formattedErrors; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Decode/Denormalizer/DenormalizerInterface.php b/src/Symfony/Component/JsonEncoder/Decode/Denormalizer/DenormalizerInterface.php new file mode 100644 index 0000000000000..2291b0879413f --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Decode/Denormalizer/DenormalizerInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Decode\Denormalizer; + +use Symfony\Component\TypeInfo\Type; + +/** + * Denormalizes data during the decoding process. + * + * @author Mathias Arlaud + * + * @experimental + */ +interface DenormalizerInterface +{ + /** + * @param array $options + */ + public function denormalize(mixed $normalized, array $options = []): mixed; + + public static function getNormalizedType(): Type; +} diff --git a/src/Symfony/Component/JsonEncoder/Decode/Instantiator.php b/src/Symfony/Component/JsonEncoder/Decode/Instantiator.php new file mode 100644 index 0000000000000..6b4e986551e96 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Decode/Instantiator.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Decode; + +use Symfony\Component\JsonEncoder\Exception\UnexpectedValueException; + +/** + * Instantiates a new $className eagerly, then sets the given properties. + * + * The $className class must have a constructor without any parameter + * and the related properties must be public. + * + * @author Mathias Arlaud + * + * @internal + */ +final class Instantiator +{ + /** + * @template T of object + * + * @param class-string $className + * @param array $properties + * + * @return T + */ + public function instantiate(string $className, array $properties): object + { + $object = new $className(); + + foreach ($properties as $name => $value) { + try { + $object->{$name} = $value; + } catch (\TypeError $e) { + throw new UnexpectedValueException($e->getMessage(), previous: $e); + } + } + + return $object; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Decode/LazyInstantiator.php b/src/Symfony/Component/JsonEncoder/Decode/LazyInstantiator.php new file mode 100644 index 0000000000000..cda7281812603 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Decode/LazyInstantiator.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Decode; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\JsonEncoder\Exception\RuntimeException; +use Symfony\Component\VarExporter\ProxyHelper; + +/** + * Instantiates a new $className lazy ghost {@see \Symfony\Component\VarExporter\LazyGhostTrait}. + * + * The $className class must not final. + * + * A property must be a callable that returns the actual value when being called. + * + * @author Mathias Arlaud + * + * @internal + */ +final class LazyInstantiator +{ + private Filesystem $fs; + + /** + * @var array{reflection: array>, lazy_class_name: array} + */ + private static array $cache = [ + 'reflection' => [], + 'lazy_class_name' => [], + ]; + + /** + * @var array + */ + private static array $lazyClassesLoaded = []; + + public function __construct( + private string $lazyGhostsDir, + ) { + $this->fs = new Filesystem(); + } + + /** + * @template T of object + * + * @param class-string $className + * @param array $propertiesCallables + * + * @return T + */ + public function instantiate(string $className, array $propertiesCallables): object + { + try { + $classReflection = self::$cache['reflection'][$className] ??= new \ReflectionClass($className); + } catch (\ReflectionException $e) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + + $lazyClassName = self::$cache['lazy_class_name'][$className] ??= \sprintf('%sGhost', preg_replace('/\\\\/', '', $className)); + + $initializer = function (object $object) use ($propertiesCallables) { + foreach ($propertiesCallables as $name => $propertyCallable) { + $object->{$name} = $propertyCallable(); + } + }; + + if (isset(self::$lazyClassesLoaded[$className]) && class_exists($lazyClassName)) { + return $lazyClassName::createLazyGhost($initializer); + } + + if (!is_file($path = \sprintf('%s%s%s.php', $this->lazyGhostsDir, \DIRECTORY_SEPARATOR, hash('xxh128', $className)))) { + if (!$this->fs->exists($this->lazyGhostsDir)) { + $this->fs->mkdir($this->lazyGhostsDir); + } + + $lazyClassName = \sprintf('%sGhost', preg_replace('/\\\\/', '', $className)); + + file_put_contents($path, \sprintf('lazyGhostsDir, \DIRECTORY_SEPARATOR, hash('xxh128', $className)); + + self::$lazyClassesLoaded[$className] = true; + + return $lazyClassName::createLazyGhost($initializer); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Decode/Lexer.php b/src/Symfony/Component/JsonEncoder/Decode/Lexer.php new file mode 100644 index 0000000000000..c538750711c33 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Decode/Lexer.php @@ -0,0 +1,285 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Decode; + +use Symfony\Component\JsonEncoder\Exception\InvalidStreamException; + +/** + * Retrieves lexical tokens from a given stream. + * + * @author Mathias Arlaud + * + * @internal + */ +final class Lexer +{ + private const MAX_CHUNK_LENGTH = 8192; + + private const WHITESPACE_CHARS = [' ' => true, "\r" => true, "\t" => true, "\n" => true]; + private const STRUCTURE_CHARS = [',' => true, ':' => true, '{' => true, '}' => true, '[' => true, ']' => true]; + + private const TOKEN_DICT_START = 1; + private const TOKEN_DICT_END = 2; + private const TOKEN_LIST_START = 4; + private const TOKEN_LIST_END = 8; + private const TOKEN_KEY = 16; + private const TOKEN_COLUMN = 32; + private const TOKEN_COMMA = 64; + private const TOKEN_SCALAR = 128; + private const TOKEN_END = 256; + private const TOKEN_VALUE = self::TOKEN_DICT_START | self::TOKEN_LIST_START | self::TOKEN_SCALAR; + + private const KEY_REGEX = '/^(?:(?>"(?>\\\\(?>["\\\\\/bfnrt]|u[a-fA-F0-9]{4})|[^\0-\x1F\\\\"]+)*"))$/u'; + private const SCALAR_REGEX = '/^(?:(?:(?>"(?>\\\\(?>["\\\\\/bfnrt]|u[a-fA-F0-9]{4})|[^\0-\x1F\\\\"]+)*"))|(?:(?>-?(?>0|[1-9][0-9]*)(?>\.[0-9]+)?(?>[eE][+-]?[0-9]+)?))|true|false|null)$/u'; + + /** + * @param resource $stream + * + * @return \Iterator + * + * @throws InvalidStreamException + */ + public function getTokens($stream, int $offset, ?int $length): \Iterator + { + /** + * @var array{expected_token: int-mask-of, pointer: int, structures: array, keys: list>} $context + */ + $context = [ + 'expected_token' => self::TOKEN_VALUE, + 'pointer' => -1, + 'structures' => [], + 'keys' => [], + ]; + + $currentTokenPosition = $offset; + $token = ''; + $inString = $escaping = false; + + foreach ($this->getChunks($stream, $offset, $length) as $chunk) { + foreach (str_split($chunk) as $byte) { + if ($escaping) { + $escaping = false; + $token .= $byte; + + continue; + } + + if ($inString) { + $token .= $byte; + + if ('"' === $byte) { + $inString = false; + } elseif ('\\' === $byte) { + $escaping = true; + } + + continue; + } + + if ('"' === $byte) { + $token .= $byte; + $inString = true; + + continue; + } + + if (isset(self::STRUCTURE_CHARS[$byte]) || isset(self::WHITESPACE_CHARS[$byte])) { + if ('' !== $token) { + $this->validateToken($token, $context); + yield [$token, $currentTokenPosition]; + + $currentTokenPosition += \strlen($token); + $token = ''; + } + + if (!isset(self::WHITESPACE_CHARS[$byte])) { + $this->validateToken($byte, $context); + yield [$byte, $currentTokenPosition]; + } + + if ('' !== $byte) { + ++$currentTokenPosition; + } + + continue; + } + + $token .= $byte; + } + } + + if ('' !== $token) { + $this->validateToken($token, $context); + yield [$token, $currentTokenPosition]; + } + + if (!(self::TOKEN_END & $context['expected_token'])) { + throw new InvalidStreamException('Unterminated JSON.'); + } + } + + /** + * @param resource $stream + * + * @return \Iterator + */ + private function getChunks($stream, int $offset, ?int $length): \Iterator + { + $infiniteLength = null === $length; + $chunkLength = $infiniteLength ? self::MAX_CHUNK_LENGTH : min($length, self::MAX_CHUNK_LENGTH); + $toReadLength = $length; + + rewind($stream); + + while (!feof($stream) && ($infiniteLength || $toReadLength > 0)) { + $chunk = stream_get_contents($stream, $infiniteLength ? $chunkLength : min($chunkLength, $toReadLength), $offset); + $toReadLength -= $l = \strlen($chunk); + $offset += $l; + + yield $chunk; + } + } + + /** + * @param array{expected_token: int-mask-of, pointer: int, structures: list<'list'|'dict'>, keys: list>} $context + * + * @throws InvalidStreamException + */ + private function validateToken(string $token, array &$context): void + { + if ('{' === $token) { + if (!(self::TOKEN_DICT_START & $context['expected_token'])) { + throw new InvalidStreamException(\sprintf('Unexpected "%s" token.', $token)); + } + + ++$context['pointer']; + $context['structures'][$context['pointer']] = 'dict'; + $context['keys'][$context['pointer']] = []; + $context['expected_token'] = self::TOKEN_DICT_END | self::TOKEN_KEY; + + return; + } + + if ('}' === $token) { + if (!(self::TOKEN_DICT_END & $context['expected_token']) || -1 === $context['pointer']) { + throw new InvalidStreamException(\sprintf('Unexpected "%s" token.', $token)); + } + + unset($context['keys'][$context['pointer']]); + --$context['pointer']; + + if (-1 === $context['pointer']) { + $context['expected_token'] = self::TOKEN_END; + } else { + $context['expected_token'] = 'list' === $context['structures'][$context['pointer']] ? self::TOKEN_LIST_END | self::TOKEN_COMMA : self::TOKEN_DICT_END | self::TOKEN_COMMA; + } + + return; + } + + if ('[' === $token) { + if (!(self::TOKEN_LIST_START & $context['expected_token'])) { + throw new InvalidStreamException(\sprintf('Unexpected "%s" token.', $token)); + } + + $context['expected_token'] = self::TOKEN_LIST_END | self::TOKEN_VALUE; + $context['structures'][++$context['pointer']] = 'list'; + + return; + } + + if (']' === $token) { + if (!(self::TOKEN_LIST_END & $context['expected_token']) || -1 === $context['pointer']) { + throw new InvalidStreamException(\sprintf('Unexpected "%s" token.', $token)); + } + + --$context['pointer']; + + if (-1 === $context['pointer']) { + $context['expected_token'] = self::TOKEN_END; + } else { + $context['expected_token'] = 'list' === $context['structures'][$context['pointer']] ? self::TOKEN_LIST_END | self::TOKEN_COMMA : self::TOKEN_DICT_END | self::TOKEN_COMMA; + } + + return; + } + + if (',' === $token) { + if (!(self::TOKEN_COMMA & $context['expected_token']) || -1 === $context['pointer']) { + throw new InvalidStreamException(\sprintf('Unexpected "%s" token.', $token)); + } + + $context['expected_token'] = 'dict' === $context['structures'][$context['pointer']] ? self::TOKEN_KEY : self::TOKEN_VALUE; + + return; + } + + if (':' === $token) { + if (!(self::TOKEN_COLUMN & $context['expected_token']) || 'dict' !== ($context['structures'][$context['pointer']] ?? null)) { + throw new InvalidStreamException(\sprintf('Unexpected "%s" token.', $token)); + } + + $context['expected_token'] = self::TOKEN_VALUE; + + return; + } + + if (self::TOKEN_VALUE & $context['expected_token'] && !preg_match(self::SCALAR_REGEX, $token)) { + throw new InvalidStreamException(\sprintf('Expected scalar value, but got "%s".', $token)); + } + + if (-1 === $context['pointer']) { + if (self::TOKEN_VALUE & $context['expected_token']) { + $context['expected_token'] = self::TOKEN_END; + + return; + } + + throw new InvalidStreamException(\sprintf('Expected end, but got "%s".', $token)); + } + + if ('dict' === $context['structures'][$context['pointer']]) { + if (self::TOKEN_KEY & $context['expected_token']) { + if (!preg_match(self::KEY_REGEX, $token)) { + throw new InvalidStreamException(\sprintf('Expected dict key, but got "%s".', $token)); + } + + if (isset($context['keys'][$context['pointer']][$token])) { + throw new InvalidStreamException(\sprintf('Got %s dict key twice.', $token)); + } + + $context['keys'][$context['pointer']][$token] = true; + $context['expected_token'] = self::TOKEN_COLUMN; + + return; + } + + if (self::TOKEN_VALUE & $context['expected_token']) { + $context['expected_token'] = self::TOKEN_DICT_END | self::TOKEN_COMMA; + + return; + } + + throw new InvalidStreamException(\sprintf('Unexpected "%s" token.', $token)); + } + + if ('list' === $context['structures'][$context['pointer']]) { + if (self::TOKEN_VALUE & $context['expected_token']) { + $context['expected_token'] = self::TOKEN_LIST_END | self::TOKEN_COMMA; + + return; + } + + throw new InvalidStreamException(\sprintf('Unexpected "%s" token.', $token)); + } + } +} diff --git a/src/Symfony/Component/JsonEncoder/Decode/NativeDecoder.php b/src/Symfony/Component/JsonEncoder/Decode/NativeDecoder.php new file mode 100644 index 0000000000000..62f8e4f05a004 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Decode/NativeDecoder.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Decode; + +use Symfony\Component\JsonEncoder\Exception\UnexpectedValueException; + +/** + * Decodes string or stream using the native "json_decode" PHP function. + * + * @author Mathias Arlaud + * + * @internal + */ +final class NativeDecoder +{ + public static function decodeString(string $json): mixed + { + try { + return json_decode($json, associative: true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new UnexpectedValueException('JSON is not valid: '.$e->getMessage()); + } + } + + public static function decodeStream($stream, int $offset = 0, ?int $length = null): mixed + { + if (\is_resource($stream)) { + $json = stream_get_contents($stream, $length ?? -1, $offset); + } else { + $stream->seek($offset); + $json = $stream->read($length); + } + + try { + return json_decode($json, associative: true, flags: \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new UnexpectedValueException('JSON is not valid: '.$e->getMessage()); + } + } +} diff --git a/src/Symfony/Component/JsonEncoder/Decode/PhpAstBuilder.php b/src/Symfony/Component/JsonEncoder/Decode/PhpAstBuilder.php new file mode 100644 index 0000000000000..3ade6a5de4d53 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Decode/PhpAstBuilder.php @@ -0,0 +1,582 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Decode; + +use PhpParser\BuilderFactory; +use PhpParser\Node; +use PhpParser\Node\Expr; +use PhpParser\Node\Expr\Array_; +use PhpParser\Node\Expr\ArrayDimFetch; +use PhpParser\Node\Expr\ArrayItem; +use PhpParser\Node\Expr\Assign; +use PhpParser\Node\Expr\BinaryOp\BooleanAnd; +use PhpParser\Node\Expr\BinaryOp\Coalesce; +use PhpParser\Node\Expr\BinaryOp\Identical; +use PhpParser\Node\Expr\BinaryOp\NotIdentical; +use PhpParser\Node\Expr\Cast\Object_ as ObjectCast; +use PhpParser\Node\Expr\Cast\String_ as StringCast; +use PhpParser\Node\Expr\ClassConstFetch; +use PhpParser\Node\Expr\Closure; +use PhpParser\Node\Expr\ClosureUse; +use PhpParser\Node\Expr\Match_; +use PhpParser\Node\Expr\Ternary; +use PhpParser\Node\Expr\Throw_; +use PhpParser\Node\Expr\Yield_; +use PhpParser\Node\Identifier; +use PhpParser\Node\MatchArm; +use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Param; +use PhpParser\Node\Stmt; +use PhpParser\Node\Stmt\Expression; +use PhpParser\Node\Stmt\Foreach_; +use PhpParser\Node\Stmt\If_; +use PhpParser\Node\Stmt\Return_; +use Psr\Container\ContainerInterface; +use Symfony\Component\JsonEncoder\DataModel\Decode\BackedEnumNode; +use Symfony\Component\JsonEncoder\DataModel\Decode\CollectionNode; +use Symfony\Component\JsonEncoder\DataModel\Decode\CompositeNode; +use Symfony\Component\JsonEncoder\DataModel\Decode\DataModelNodeInterface; +use Symfony\Component\JsonEncoder\DataModel\Decode\ObjectNode; +use Symfony\Component\JsonEncoder\DataModel\Decode\ScalarNode; +use Symfony\Component\JsonEncoder\DataModel\PhpExprDataAccessor; +use Symfony\Component\JsonEncoder\Exception\LogicException; +use Symfony\Component\JsonEncoder\Exception\UnexpectedValueException; +use Symfony\Component\TypeInfo\Type\BackedEnumType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; + +/** + * Builds a PHP syntax tree that decodes JSON. + * + * @author Mathias Arlaud + * + * @internal + */ +final class PhpAstBuilder +{ + private BuilderFactory $builder; + + public function __construct() + { + $this->builder = new BuilderFactory(); + } + + /** + * @param array $options + * @param array $context + * + * @return list + */ + public function build(DataModelNodeInterface $dataModel, bool $decodeFromStream, array $options = [], array $context = []): array + { + if ($decodeFromStream) { + return [new Return_(new Closure([ + 'static' => true, + 'params' => [ + new Param($this->builder->var('stream'), type: new Identifier('mixed')), + new Param($this->builder->var('denormalizers'), type: new FullyQualified(ContainerInterface::class)), + new Param($this->builder->var('instantiator'), type: new FullyQualified(LazyInstantiator::class)), + new Param($this->builder->var('options'), type: new Identifier('array')), + ], + 'returnType' => new Identifier('mixed'), + 'stmts' => [ + ...$this->buildProvidersStatements($dataModel, $decodeFromStream, $context), + new Return_( + $this->nodeOnlyNeedsDecode($dataModel, $decodeFromStream) + ? $this->builder->staticCall(new FullyQualified(NativeDecoder::class), 'decodeStream', [ + $this->builder->var('stream'), + $this->builder->val(0), + $this->builder->val(null), + ]) + : $this->builder->funcCall(new ArrayDimFetch($this->builder->var('providers'), $this->builder->val($dataModel->getIdentifier())), [ + $this->builder->var('stream'), + $this->builder->val(0), + $this->builder->val(null), + ]), + ), + ], + ]))]; + } + + return [new Return_(new Closure([ + 'static' => true, + 'params' => [ + new Param($this->builder->var('string'), type: new Identifier('string|\\Stringable')), + new Param($this->builder->var('denormalizers'), type: new FullyQualified(ContainerInterface::class)), + new Param($this->builder->var('instantiator'), type: new FullyQualified(Instantiator::class)), + new Param($this->builder->var('options'), type: new Identifier('array')), + ], + 'returnType' => new Identifier('mixed'), + 'stmts' => [ + ...$this->buildProvidersStatements($dataModel, $decodeFromStream, $context), + new Return_( + $this->nodeOnlyNeedsDecode($dataModel, $decodeFromStream) + ? $this->builder->staticCall(new FullyQualified(NativeDecoder::class), 'decodeString', [new StringCast($this->builder->var('string'))]) + : $this->builder->funcCall(new ArrayDimFetch($this->builder->var('providers'), $this->builder->val($dataModel->getIdentifier())), [ + $this->builder->staticCall(new FullyQualified(NativeDecoder::class), 'decodeString', [new StringCast($this->builder->var('string'))]), + ]), + ), + ], + ]))]; + } + + /** + * @param array $context + * + * @return list + */ + private function buildProvidersStatements(DataModelNodeInterface $node, bool $decodeFromStream, array &$context): array + { + if ($context['providers'][$node->getIdentifier()] ?? false) { + return []; + } + + $context['providers'][$node->getIdentifier()] = true; + + if ($this->nodeOnlyNeedsDecode($node, $decodeFromStream)) { + return []; + } + + return match (true) { + $node instanceof ScalarNode || $node instanceof BackedEnumNode => $this->buildLeafProviderStatements($node, $decodeFromStream), + $node instanceof CompositeNode => $this->buildCompositeNodeStatements($node, $decodeFromStream, $context), + $node instanceof CollectionNode => $this->buildCollectionNodeStatements($node, $decodeFromStream, $context), + $node instanceof ObjectNode => $this->buildObjectNodeStatements($node, $decodeFromStream, $context), + default => throw new LogicException(\sprintf('Unexpected "%s" data model node', $node::class)), + }; + } + + /** + * @return list + */ + private function buildLeafProviderStatements(ScalarNode|BackedEnumNode $node, bool $decodeFromStream): array + { + $accessor = $decodeFromStream + ? $this->builder->staticCall(new FullyQualified(NativeDecoder::class), 'decodeStream', [ + $this->builder->var('stream'), + $this->builder->var('offset'), + $this->builder->var('length'), + ]) + : $this->builder->var('data'); + + $params = $decodeFromStream + ? [new Param($this->builder->var('stream')), new Param($this->builder->var('offset')), new Param($this->builder->var('length'))] + : [new Param($this->builder->var('data'))]; + + return [ + new Expression(new Assign( + new ArrayDimFetch($this->builder->var('providers'), $this->builder->val($node->getIdentifier())), + new Closure([ + 'static' => true, + 'params' => $params, + 'stmts' => [new Return_($this->buildFormatValueStatement($node, $accessor))], + ]), + )), + ]; + } + + private function buildFormatValueStatement(DataModelNodeInterface $node, Expr $accessor): Node + { + $type = $node->getType(); + + if ($node instanceof BackedEnumNode) { + return $this->builder->staticCall(new FullyQualified($type->getClassName()), 'from', [$accessor]); + } + + if ($node instanceof ScalarNode) { + return match (true) { + TypeIdentifier::NULL === $type->getTypeIdentifier() => $this->builder->val(null), + TypeIdentifier::OBJECT === $type->getTypeIdentifier() => new ObjectCast($accessor), + default => $accessor, + }; + } + + return $accessor; + } + + /** + * @param array $context + * + * @return list + */ + private function buildCompositeNodeStatements(CompositeNode $node, bool $decodeFromStream, array &$context): array + { + $prepareDataStmts = $decodeFromStream ? [ + new Expression(new Assign($this->builder->var('data'), $this->builder->staticCall(new FullyQualified(NativeDecoder::class), 'decodeStream', [ + $this->builder->var('stream'), + $this->builder->var('offset'), + $this->builder->var('length'), + ]))), + ] : []; + + $providersStmts = []; + $nodesStmts = []; + + $nodeCondition = function (DataModelNodeInterface $node, Expr $accessor): Expr { + $type = $node->getType(); + + if ($type->isIdentifiedBy(TypeIdentifier::NULL)) { + return new Identical($this->builder->val(null), $this->builder->var('data')); + } + + if ($type->isIdentifiedBy(TypeIdentifier::TRUE)) { + return new Identical($this->builder->val(true), $this->builder->var('data')); + } + + if ($type->isIdentifiedBy(TypeIdentifier::FALSE)) { + return new Identical($this->builder->val(false), $this->builder->var('data')); + } + + if ($type->isIdentifiedBy(TypeIdentifier::MIXED)) { + return $this->builder->val(true); + } + + if ($type instanceof CollectionType) { + return $type->isList() + ? new BooleanAnd($this->builder->funcCall('\is_array', [$this->builder->var('data')]), $this->builder->funcCall('\array_is_list', [$this->builder->var('data')])) + : $this->builder->funcCall('\is_array', [$this->builder->var('data')]); + } + + while ($type instanceof WrappingTypeInterface) { + $type = $type->getWrappedType(); + } + + if ($type instanceof BackedEnumType) { + return $this->builder->funcCall('\is_'.$type->getBackingType()->getTypeIdentifier()->value, [$this->builder->var('data')]); + } + + if ($type instanceof ObjectType) { + return $this->builder->funcCall('\is_array', [$this->builder->var('data')]); + } + + return $this->builder->funcCall('\is_'.$type->getTypeIdentifier()->value, [$this->builder->var('data')]); + }; + + foreach ($node->getNodes() as $n) { + if ($this->nodeOnlyNeedsDecode($n, $decodeFromStream)) { + $nodeValueStmt = $this->buildFormatValueStatement($n, $this->builder->var('data')); + } else { + $providersStmts = [...$providersStmts, ...$this->buildProvidersStatements($n, $decodeFromStream, $context)]; + $nodeValueStmt = $this->builder->funcCall( + new ArrayDimFetch($this->builder->var('providers'), $this->builder->val($n->getIdentifier())), + [$this->builder->var('data')], + ); + } + + $nodesStmts[] = new If_($nodeCondition($n, $this->builder->var('data')), ['stmts' => [new Return_($nodeValueStmt)]]); + } + + $params = $decodeFromStream + ? [new Param($this->builder->var('stream')), new Param($this->builder->var('offset')), new Param($this->builder->var('length'))] + : [new Param($this->builder->var('data'))]; + + return [ + ...$providersStmts, + new Expression(new Assign( + new ArrayDimFetch($this->builder->var('providers'), $this->builder->val($node->getIdentifier())), + new Closure([ + 'static' => true, + 'params' => $params, + 'uses' => [ + new ClosureUse($this->builder->var('options')), + new ClosureUse($this->builder->var('denormalizers')), + new ClosureUse($this->builder->var('instantiator')), + new ClosureUse($this->builder->var('providers'), byRef: true), + ], + 'stmts' => [ + ...$prepareDataStmts, + ...$nodesStmts, + new Expression(new Throw_($this->builder->new(new FullyQualified(UnexpectedValueException::class), [$this->builder->funcCall('\sprintf', [ + $this->builder->val(\sprintf('Unexpected "%%s" value for "%s".', $node->getIdentifier())), + $this->builder->funcCall('\get_debug_type', [$this->builder->var('data')]), + ])]))), + ], + ]), + )), + ]; + } + + /** + * @param array $context + * + * @return list + */ + private function buildCollectionNodeStatements(CollectionNode $node, bool $decodeFromStream, array &$context): array + { + if ($decodeFromStream) { + $itemValueStmt = $this->nodeOnlyNeedsDecode($node->getItemNode(), $decodeFromStream) + ? $this->buildFormatValueStatement( + $node->getItemNode(), + $this->builder->staticCall(new FullyQualified(NativeDecoder::class), 'decodeStream', [ + $this->builder->var('stream'), + new ArrayDimFetch($this->builder->var('v'), $this->builder->val(0)), + new ArrayDimFetch($this->builder->var('v'), $this->builder->val(1)), + ]), + ) + : $this->builder->funcCall( + new ArrayDimFetch($this->builder->var('providers'), $this->builder->val($node->getItemNode()->getIdentifier())), [ + $this->builder->var('stream'), + new ArrayDimFetch($this->builder->var('v'), $this->builder->val(0)), + new ArrayDimFetch($this->builder->var('v'), $this->builder->val(1)), + ], + ); + } else { + $itemValueStmt = $this->nodeOnlyNeedsDecode($node->getItemNode(), $decodeFromStream) + ? $this->builder->var('v') + : $this->builder->funcCall( + new ArrayDimFetch($this->builder->var('providers'), $this->builder->val($node->getItemNode()->getIdentifier())), + [$this->builder->var('v')], + ); + } + + $iterableClosureParams = $decodeFromStream + ? [new Param($this->builder->var('stream')), new Param($this->builder->var('data'))] + : [new Param($this->builder->var('data'))]; + + $iterableClosureStmts = [ + new Expression(new Assign( + $this->builder->var('iterable'), + new Closure([ + 'static' => true, + 'params' => $iterableClosureParams, + 'uses' => [ + new ClosureUse($this->builder->var('options')), + new ClosureUse($this->builder->var('denormalizers')), + new ClosureUse($this->builder->var('instantiator')), + new ClosureUse($this->builder->var('providers'), byRef: true), + ], + 'stmts' => [ + new Foreach_($this->builder->var('data'), $this->builder->var('v'), [ + 'keyVar' => $this->builder->var('k'), + 'stmts' => [new Expression(new Yield_($itemValueStmt, $this->builder->var('k')))], + ]), + ], + ]), + )), + ]; + + $iterableValueStmt = $decodeFromStream + ? $this->builder->funcCall($this->builder->var('iterable'), [$this->builder->var('stream'), $this->builder->var('data')]) + : $this->builder->funcCall($this->builder->var('iterable'), [$this->builder->var('data')]); + + $prepareDataStmts = $decodeFromStream ? [ + new Expression(new Assign($this->builder->var('data'), $this->builder->staticCall( + new FullyQualified(Splitter::class), + $node->getType()->isList() ? 'splitList' : 'splitDict', + [$this->builder->var('stream'), $this->builder->var('offset'), $this->builder->var('length')], + ))), + ] : []; + + $params = $decodeFromStream + ? [new Param($this->builder->var('stream')), new Param($this->builder->var('offset')), new Param($this->builder->var('length'))] + : [new Param($this->builder->var('data'))]; + + return [ + new Expression(new Assign( + new ArrayDimFetch($this->builder->var('providers'), $this->builder->val($node->getIdentifier())), + new Closure([ + 'static' => true, + 'params' => $params, + 'uses' => [ + new ClosureUse($this->builder->var('options')), + new ClosureUse($this->builder->var('denormalizers')), + new ClosureUse($this->builder->var('instantiator')), + new ClosureUse($this->builder->var('providers'), byRef: true), + ], + 'stmts' => [ + ...$prepareDataStmts, + ...$iterableClosureStmts, + new Return_($node->getType()->isIdentifiedBy(TypeIdentifier::ARRAY) ? $this->builder->funcCall('\iterator_to_array', [$iterableValueStmt]) : $iterableValueStmt), + ], + ]), + )), + ...($this->nodeOnlyNeedsDecode($node->getItemNode(), $decodeFromStream) ? [] : $this->buildProvidersStatements($node->getItemNode(), $decodeFromStream, $context)), + ]; + } + + /** + * @param array $context + * + * @return list + */ + private function buildObjectNodeStatements(ObjectNode $node, bool $decodeFromStream, array &$context): array + { + if ($node->isGhost()) { + return []; + } + + $propertyValueProvidersStmts = []; + $stringPropertiesValuesStmts = []; + $streamPropertiesValuesStmts = []; + + foreach ($node->getProperties() as $encodedName => $property) { + $propertyValueProvidersStmts = [ + ...$propertyValueProvidersStmts, + ...($this->nodeOnlyNeedsDecode($property['value'], $decodeFromStream) ? [] : $this->buildProvidersStatements($property['value'], $decodeFromStream, $context)), + ]; + + if ($decodeFromStream) { + $propertyValueStmt = $this->nodeOnlyNeedsDecode($property['value'], $decodeFromStream) + ? $this->buildFormatValueStatement( + $property['value'], + $this->builder->staticCall(new FullyQualified(NativeDecoder::class), 'decodeStream', [ + $this->builder->var('stream'), + new ArrayDimFetch($this->builder->var('v'), $this->builder->val(0)), + new ArrayDimFetch($this->builder->var('v'), $this->builder->val(1)), + ]), + ) + : $this->builder->funcCall( + new ArrayDimFetch($this->builder->var('providers'), $this->builder->val($property['value']->getIdentifier())), [ + $this->builder->var('stream'), + new ArrayDimFetch($this->builder->var('v'), $this->builder->val(0)), + new ArrayDimFetch($this->builder->var('v'), $this->builder->val(1)), + ], + ); + + $streamPropertiesValuesStmts[] = new MatchArm([$this->builder->val($encodedName)], new Assign( + new ArrayDimFetch($this->builder->var('properties'), $this->builder->val($property['name'])), + new Closure([ + 'static' => true, + 'uses' => [ + new ClosureUse($this->builder->var('stream')), + new ClosureUse($this->builder->var('v')), + new ClosureUse($this->builder->var('options')), + new ClosureUse($this->builder->var('denormalizers')), + new ClosureUse($this->builder->var('instantiator')), + new ClosureUse($this->builder->var('providers'), byRef: true), + ], + 'stmts' => [ + new Return_($property['accessor'](new PhpExprDataAccessor($propertyValueStmt))->toPhpExpr()), + ], + ]), + )); + } else { + $propertyValueStmt = $this->nodeOnlyNeedsDecode($property['value'], $decodeFromStream) + ? new Coalesce(new ArrayDimFetch($this->builder->var('data'), $this->builder->val($encodedName)), $this->builder->val('_symfony_missing_value')) + : new Ternary( + $this->builder->funcCall('\array_key_exists', [$this->builder->val($encodedName), $this->builder->var('data')]), + $this->builder->funcCall( + new ArrayDimFetch($this->builder->var('providers'), $this->builder->val($property['value']->getIdentifier())), + [new ArrayDimFetch($this->builder->var('data'), $this->builder->val($encodedName))], + ), + $this->builder->val('_symfony_missing_value'), + ); + + $stringPropertiesValuesStmts[] = new ArrayItem( + $property['accessor'](new PhpExprDataAccessor($propertyValueStmt))->toPhpExpr(), + $this->builder->val($property['name']), + ); + } + } + + $params = $decodeFromStream + ? [new Param($this->builder->var('stream')), new Param($this->builder->var('offset')), new Param($this->builder->var('length'))] + : [new Param($this->builder->var('data'))]; + + $prepareDataStmts = $decodeFromStream ? [ + new Expression(new Assign($this->builder->var('data'), $this->builder->staticCall( + new FullyQualified(Splitter::class), + 'splitDict', + [$this->builder->var('stream'), $this->builder->var('offset'), $this->builder->var('length')], + ))), + ] : []; + + if ($decodeFromStream) { + $instantiateStmts = [ + new Expression(new Assign($this->builder->var('properties'), new Array_([], ['kind' => Array_::KIND_SHORT]))), + new Foreach_($this->builder->var('data'), $this->builder->var('v'), [ + 'keyVar' => $this->builder->var('k'), + 'stmts' => [new Expression(new Match_( + $this->builder->var('k'), + [...$streamPropertiesValuesStmts, new MatchArm(null, $this->builder->val(null))], + ))], + ]), + new Return_($this->builder->methodCall($this->builder->var('instantiator'), 'instantiate', [ + new ClassConstFetch(new FullyQualified($node->getType()->getClassName()), 'class'), + $this->builder->var('properties'), + ])), + ]; + } else { + $instantiateStmts = [ + new Return_($this->builder->methodCall($this->builder->var('instantiator'), 'instantiate', [ + new ClassConstFetch(new FullyQualified($node->getType()->getClassName()), 'class'), + $this->builder->funcCall('\array_filter', [ + new Array_($stringPropertiesValuesStmts, ['kind' => Array_::KIND_SHORT]), + new Closure([ + 'static' => true, + 'params' => [new Param($this->builder->var('v'))], + 'stmts' => [new Return_(new NotIdentical($this->builder->val('_symfony_missing_value'), $this->builder->var('v')))], + ]), + ]), + ])), + ]; + } + + return [ + new Expression(new Assign( + new ArrayDimFetch($this->builder->var('providers'), $this->builder->val($node->getIdentifier())), + new Closure([ + 'static' => true, + 'params' => $params, + 'uses' => [ + new ClosureUse($this->builder->var('options')), + new ClosureUse($this->builder->var('denormalizers')), + new ClosureUse($this->builder->var('instantiator')), + new ClosureUse($this->builder->var('providers'), byRef: true), + ], + 'stmts' => [ + ...$prepareDataStmts, + ...$instantiateStmts, + ], + ]), + )), + ...$propertyValueProvidersStmts, + ]; + } + + private function nodeOnlyNeedsDecode(DataModelNodeInterface $node, bool $decodeFromStream): bool + { + if ($node instanceof CompositeNode) { + foreach ($node->getNodes() as $n) { + if (!$this->nodeOnlyNeedsDecode($n, $decodeFromStream)) { + return false; + } + } + + return true; + } + + if ($node instanceof CollectionNode) { + if ($decodeFromStream) { + return false; + } + + return $this->nodeOnlyNeedsDecode($node->getItemNode(), $decodeFromStream); + } + + if ($node instanceof ObjectNode) { + return false; + } + + if ($node instanceof BackedEnumNode) { + return false; + } + + if ($node instanceof ScalarNode) { + return !$node->getType()->isIdentifiedBy(TypeIdentifier::OBJECT); + } + + return true; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Decode/Splitter.php b/src/Symfony/Component/JsonEncoder/Decode/Splitter.php new file mode 100644 index 0000000000000..186d58241eb2f --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Decode/Splitter.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Decode; + +use Symfony\Component\JsonEncoder\Exception\UnexpectedValueException; + +/** + * Splits collections to retrieve the offset and length of each element. + * + * @author Mathias Arlaud + * + * @internal + */ +final class Splitter +{ + private const NESTING_CHARS = ['{' => true, '[' => true]; + private const UNNESTING_CHARS = ['}' => true, ']' => true]; + + private static ?Lexer $lexer = null; + + /** + * @var array{key: array} + */ + private static array $cache = [ + 'key' => [], + ]; + + /** + * @param resource $stream + */ + public static function splitList($stream, int $offset = 0, ?int $length = null): ?\Iterator + { + $lexer = self::$lexer ??= new Lexer(); + $tokens = $lexer->getTokens($stream, $offset, $length); + + if ('null' === $tokens->current()[0] && 1 === iterator_count($tokens)) { + return null; + } + + return self::createListBoundaries($tokens); + } + + /** + * @param resource $stream + */ + public static function splitDict($stream, int $offset = 0, ?int $length = null): ?\Iterator + { + $lexer = self::$lexer ??= new Lexer(); + $tokens = $lexer->getTokens($stream, $offset, $length); + + if ('null' === $tokens->current()[0] && 1 === iterator_count($tokens)) { + return null; + } + + return self::createDictBoundaries($tokens); + } + + /** + * @param \Iterator $tokens + * + * @return \Iterator + */ + private static function createListBoundaries(\Iterator $tokens): \Iterator + { + $level = 0; + + foreach ($tokens as $i => $token) { + if (0 === $i) { + continue; + } + + [$value, $position] = $token; + $offset = $offset ?? $position; + + if (isset(self::NESTING_CHARS[$value])) { + ++$level; + + continue; + } + + if (isset(self::UNNESTING_CHARS[$value])) { + --$level; + + continue; + } + + if (0 !== $level) { + continue; + } + + if (',' === $value) { + if (($length = $position - $offset) > 0) { + yield [$offset, $length]; + } + + $offset = null; + } + } + + if (-1 !== $level || !isset($value, $offset, $position) || ']' !== $value) { + throw new UnexpectedValueException('JSON is not valid.'); + } + + if (($length = $position - $offset) > 0) { + yield [$offset, $length]; + } + } + + /** + * @param \Iterator $tokens + * + * @return \Iterator + */ + private static function createDictBoundaries(\Iterator $tokens): \Iterator + { + $level = 0; + $offset = 0; + $firstValueToken = false; + $key = null; + + foreach ($tokens as $i => $token) { + if (0 === $i) { + continue; + } + + $value = $token[0]; + $position = $token[1]; + + if ($firstValueToken) { + $firstValueToken = false; + $offset = $position; + } + + if (isset(self::NESTING_CHARS[$value])) { + ++$level; + + continue; + } + + if (isset(self::UNNESTING_CHARS[$value])) { + --$level; + + continue; + } + + if (0 !== $level) { + continue; + } + + if (':' === $value) { + $firstValueToken = true; + + continue; + } + + if (',' === $value) { + if (null !== $key && ($length = $position - $offset) > 0) { + yield $key => [$offset, $length]; + } + + $key = null; + + continue; + } + + if (null === $key) { + $key = self::$cache['key'][$value] ??= json_decode($value); + } + } + + if (-1 !== $level || !isset($value, $position) || '}' !== $value) { + throw new UnexpectedValueException('JSON is not valid.'); + } + + if (null !== $key && ($length = $position - $offset) > 0) { + yield $key => [$offset, $length]; + } + } +} diff --git a/src/Symfony/Component/JsonEncoder/DecoderInterface.php b/src/Symfony/Component/JsonEncoder/DecoderInterface.php new file mode 100644 index 0000000000000..6639e5e638ecc --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/DecoderInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder; + +use Symfony\Component\TypeInfo\Type; + +/** + * Decodes an $input into a given $type according to $options. + * + * @author Mathias Arlaud + * + * @experimental + * + * @template T of array + */ +interface DecoderInterface +{ + /** + * @param resource|string $input + * @param T $options + */ + public function decode($input, Type $type, array $options = []): mixed; +} diff --git a/src/Symfony/Component/JsonEncoder/Encode/EncoderGenerator.php b/src/Symfony/Component/JsonEncoder/Encode/EncoderGenerator.php new file mode 100644 index 0000000000000..e1abbb130b905 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Encode/EncoderGenerator.php @@ -0,0 +1,208 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Encode; + +use PhpParser\PhpVersion; +use PhpParser\PrettyPrinter; +use PhpParser\PrettyPrinter\Standard; +use Symfony\Component\Filesystem\Exception\IOException; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\JsonEncoder\DataModel\DataAccessorInterface; +use Symfony\Component\JsonEncoder\DataModel\Encode\BackedEnumNode; +use Symfony\Component\JsonEncoder\DataModel\Encode\CollectionNode; +use Symfony\Component\JsonEncoder\DataModel\Encode\CompositeNode; +use Symfony\Component\JsonEncoder\DataModel\Encode\DataModelNodeInterface; +use Symfony\Component\JsonEncoder\DataModel\Encode\ExceptionNode; +use Symfony\Component\JsonEncoder\DataModel\Encode\ObjectNode; +use Symfony\Component\JsonEncoder\DataModel\Encode\ScalarNode; +use Symfony\Component\JsonEncoder\DataModel\FunctionDataAccessor; +use Symfony\Component\JsonEncoder\DataModel\PropertyDataAccessor; +use Symfony\Component\JsonEncoder\DataModel\ScalarDataAccessor; +use Symfony\Component\JsonEncoder\DataModel\VariableDataAccessor; +use Symfony\Component\JsonEncoder\Exception\MaxDepthException; +use Symfony\Component\JsonEncoder\Exception\RuntimeException; +use Symfony\Component\JsonEncoder\Exception\UnsupportedException; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadata; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BackedEnumType; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\EnumType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; + +/** + * Generates and write encoders PHP files. + * + * @author Mathias Arlaud + * + * @internal + */ +final class EncoderGenerator +{ + private const MAX_DEPTH = 512; + + private ?PhpAstBuilder $phpAstBuilder = null; + private ?PhpOptimizer $phpOptimizer = null; + private ?PrettyPrinter $phpPrinter = null; + private ?Filesystem $fs = null; + + /** + * @param bool $forceEncodeChunks enforces chunking the JSON string even if a simple `json_encode` is enough + */ + public function __construct( + private PropertyMetadataLoaderInterface $propertyMetadataLoader, + private string $encodersDir, + private bool $forceEncodeChunks, + ) { + } + + /** + * Generates and writes an encoder PHP file and return its path. + * + * @param array $options + */ + public function generate(Type $type, array $options = []): string + { + $path = $this->getPath($type); + if (is_file($path)) { + return $path; + } + + $this->phpAstBuilder ??= new PhpAstBuilder($this->forceEncodeChunks); + $this->phpOptimizer ??= new PhpOptimizer(); + $this->phpPrinter ??= new Standard(['phpVersion' => PhpVersion::fromComponents(8, 2)]); + $this->fs ??= new Filesystem(); + + $dataModel = $this->createDataModel($type, new VariableDataAccessor('data'), $options); + + $nodes = $this->phpAstBuilder->build($dataModel, $options); + $nodes = $this->phpOptimizer->optimize($nodes); + + $content = $this->phpPrinter->prettyPrintFile($nodes)."\n"; + + if (!$this->fs->exists($this->encodersDir)) { + $this->fs->mkdir($this->encodersDir); + } + + $tmpFile = $this->fs->tempnam(\dirname($path), basename($path)); + + try { + $this->fs->dumpFile($tmpFile, $content); + $this->fs->rename($tmpFile, $path); + $this->fs->chmod($path, 0666 & ~umask()); + } catch (IOException $e) { + throw new RuntimeException(\sprintf('Failed to write "%s" encoder file.', $path), previous: $e); + } + + return $path; + } + + private function getPath(Type $type): string + { + return \sprintf('%s%s%s.json%s.php', $this->encodersDir, \DIRECTORY_SEPARATOR, hash('xxh128', (string) $type), $this->forceEncodeChunks ? '.stream' : ''); + } + + /** + * @param array $options + * @param array $context + */ + 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) { + return new CompositeNode($accessor, array_map(fn (Type $t): DataModelNodeInterface => $this->createDataModel($t, $accessor, $options, $context), $type->getTypes())); + } + + if ($type instanceof BuiltinType) { + return new ScalarNode($accessor, $type); + } + + if ($type instanceof BackedEnumType) { + return new BackedEnumNode($accessor, $type); + } + + if ($type instanceof ObjectType && !$type instanceof EnumType) { + ++$context['depth']; + + $transformed = false; + $className = $type->getClassName(); + $propertiesMetadata = $this->propertyMetadataLoader->load($className, $options, ['original_type' => $type] + $context); + + try { + $classReflection = new \ReflectionClass($className); + } catch (\ReflectionException $e) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + + if (\count($classReflection->getProperties()) !== \count($propertiesMetadata) + || array_values(array_map(fn (PropertyMetadata $m): string => $m->getName(), $propertiesMetadata)) !== array_keys($propertiesMetadata) + ) { + $transformed = true; + } + + $propertiesNodes = []; + + foreach ($propertiesMetadata as $encodedName => $propertyMetadata) { + $propertyAccessor = new PropertyDataAccessor($accessor, $propertyMetadata->getName()); + + foreach ($propertyMetadata->getNormalizers() as $normalizer) { + $transformed = true; + + if (\is_string($normalizer)) { + $normalizerServiceAccessor = new FunctionDataAccessor('get', [new ScalarDataAccessor($normalizer)], new VariableDataAccessor('normalizers')); + $propertyAccessor = new FunctionDataAccessor('normalize', [$propertyAccessor, new VariableDataAccessor('options')], $normalizerServiceAccessor); + + continue; + } + + try { + $functionReflection = new \ReflectionFunction($normalizer); + } catch (\ReflectionException $e) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + + $functionName = !$functionReflection->getClosureScopeClass() + ? $functionReflection->getName() + : \sprintf('%s::%s', $functionReflection->getClosureScopeClass()->getName(), $functionReflection->getName()); + $arguments = $functionReflection->isUserDefined() ? [$propertyAccessor, new VariableDataAccessor('options')] : [$propertyAccessor]; + + $propertyAccessor = new FunctionDataAccessor($functionName, $arguments); + } + + $propertiesNodes[$encodedName] = $this->createDataModel($propertyMetadata->getType(), $propertyAccessor, $options, $context); + } + + return new ObjectNode($accessor, $type, $propertiesNodes, $transformed); + } + + if ($type instanceof CollectionType) { + ++$context['depth']; + + return new CollectionNode( + $accessor, + $type, + $this->createDataModel($type->getCollectionValueType(), new VariableDataAccessor('value'), $options, $context), + ); + } + + throw new UnsupportedException(\sprintf('"%s" type is not supported.', (string) $type)); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Encode/MergingStringVisitor.php b/src/Symfony/Component/JsonEncoder/Encode/MergingStringVisitor.php new file mode 100644 index 0000000000000..4c045ba01afcb --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Encode/MergingStringVisitor.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Encode; + +use PhpParser\Node; +use PhpParser\Node\Expr\Yield_; +use PhpParser\Node\Scalar\String_; +use PhpParser\Node\Stmt\Expression; +use PhpParser\NodeVisitor; +use PhpParser\NodeVisitorAbstract; + +/** + * Merges strings that are yielded consequently + * to reduce the call instructions amount. + * + * @author Mathias Arlaud + * + * @internal + */ +final class MergingStringVisitor extends NodeVisitorAbstract +{ + private string $buffer = ''; + + public function leaveNode(Node $node): int|Node|array|null + { + if (!$this->isMergeableNode($node)) { + return null; + } + + /** @var Node|null $next */ + $next = $node->getAttribute('next'); + + if ($next && $this->isMergeableNode($next)) { + $this->buffer .= $node->expr->value->value; + + return NodeVisitor::REMOVE_NODE; + } + + $string = $this->buffer.$node->expr->value->value; + $this->buffer = ''; + + return new Expression(new Yield_(new String_($string))); + } + + private function isMergeableNode(Node $node): bool + { + return $node instanceof Expression + && $node->expr instanceof Yield_ + && $node->expr->value instanceof String_; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Encode/Normalizer/DateTimeNormalizer.php b/src/Symfony/Component/JsonEncoder/Encode/Normalizer/DateTimeNormalizer.php new file mode 100644 index 0000000000000..35aca2d95951a --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Encode/Normalizer/DateTimeNormalizer.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Encode\Normalizer; + +use Symfony\Component\JsonEncoder\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Casts DateTimeInterface to string. + * + * @author Mathias Arlaud + * + * @internal + */ +final class DateTimeNormalizer implements NormalizerInterface +{ + public const FORMAT_KEY = 'date_time_format'; + + public function normalize(mixed $denormalized, array $options = []): string + { + if (!$denormalized instanceof \DateTimeInterface) { + throw new InvalidArgumentException('The denormalized data must implement the "\DateTimeInterface".'); + } + + return $denormalized->format($options[self::FORMAT_KEY] ?? \DateTimeInterface::RFC3339); + } + + /** + * @return BuiltinType + */ + public static function getNormalizedType(): BuiltinType + { + return Type::string(); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Encode/Normalizer/NormalizerInterface.php b/src/Symfony/Component/JsonEncoder/Encode/Normalizer/NormalizerInterface.php new file mode 100644 index 0000000000000..49c4b25a5811a --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Encode/Normalizer/NormalizerInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Encode\Normalizer; + +use Symfony\Component\TypeInfo\Type; + +/** + * Normalizes data during the encoding process. + * + * @author Mathias Arlaud + * + * @experimental + */ +interface NormalizerInterface +{ + /** + * @param array $options + */ + public function normalize(mixed $denormalized, array $options = []): mixed; + + public static function getNormalizedType(): Type; +} diff --git a/src/Symfony/Component/JsonEncoder/Encode/PhpAstBuilder.php b/src/Symfony/Component/JsonEncoder/Encode/PhpAstBuilder.php new file mode 100644 index 0000000000000..20a60ec50baa8 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Encode/PhpAstBuilder.php @@ -0,0 +1,307 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Encode; + +use PhpParser\BuilderFactory; +use PhpParser\Node\Expr; +use PhpParser\Node\Expr\Assign; +use PhpParser\Node\Expr\BinaryOp\Identical; +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\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\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 Psr\Container\ContainerInterface; +use Symfony\Component\JsonEncoder\DataModel\Encode\BackedEnumNode; +use Symfony\Component\JsonEncoder\DataModel\Encode\CollectionNode; +use Symfony\Component\JsonEncoder\DataModel\Encode\CompositeNode; +use Symfony\Component\JsonEncoder\DataModel\Encode\DataModelNodeInterface; +use Symfony\Component\JsonEncoder\DataModel\Encode\ExceptionNode; +use Symfony\Component\JsonEncoder\DataModel\Encode\ObjectNode; +use Symfony\Component\JsonEncoder\DataModel\Encode\ScalarNode; +use Symfony\Component\JsonEncoder\Exception\LogicException; +use Symfony\Component\JsonEncoder\Exception\RuntimeException; +use Symfony\Component\JsonEncoder\Exception\UnexpectedValueException; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\TypeIdentifier; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; + +/** + * Builds a PHP syntax tree that encodes data to JSON. + * + * @author Mathias Arlaud + * + * @internal + */ +final class PhpAstBuilder +{ + private BuilderFactory $builder; + + public function __construct( + private bool $forceEncodeChunks = false, + ) { + $this->builder = new BuilderFactory(); + } + + /** + * @param array $options + * @param array $context + * + * @return list + */ + public function build(DataModelNodeInterface $dataModel, array $options = [], array $context = []): array + { + $closureStmts = $this->buildClosureStatements($dataModel, $options, $context); + + return [new Return_(new Closure([ + 'static' => true, + 'params' => [ + new Param($this->builder->var('data'), type: new Identifier('mixed')), + new Param($this->builder->var('normalizers'), type: new FullyQualified(ContainerInterface::class)), + new Param($this->builder->var('options'), type: new Identifier('array')), + ], + 'returnType' => new FullyQualified(\Traversable::class), + 'stmts' => $closureStmts, + ]))]; + } + + /** + * @param array $options + * @param array $context + * + * @return list + */ + private function buildClosureStatements(DataModelNodeInterface $dataModelNode, array $options, array $context): array + { + $accessor = $dataModelNode->getAccessor()->toPhpExpr(); + + if ($dataModelNode instanceof ExceptionNode) { + return [ + new Expression(new Throw_($accessor)), + ]; + } + + if (!$this->forceEncodeChunks && $this->nodeOnlyNeedsEncode($dataModelNode)) { + return [ + new Expression(new Yield_($this->encodeValue($accessor))), + ]; + } + + if ($dataModelNode instanceof ScalarNode) { + $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), + }; + + return [ + new Expression(new Yield_($scalarAccessor)), + ]; + } + + if ($dataModelNode instanceof BackedEnumNode) { + return [ + new Expression(new Yield_($this->encodeValue(new PropertyFetch($accessor, 'value')))), + ]; + } + + if ($dataModelNode instanceof CompositeNode) { + $nodeCondition = function (DataModelNodeInterface $node): Expr { + $accessor = $node->getAccessor()->toPhpExpr(); + $type = $node->getType(); + + if ($type->isIdentifiedBy(TypeIdentifier::NULL, TypeIdentifier::NEVER, TypeIdentifier::VOID)) { + return new Identical($this->builder->val(null), $accessor); + } + + if ($type->isIdentifiedBy(TypeIdentifier::TRUE)) { + return new Identical($this->builder->val(true), $accessor); + } + + if ($type->isIdentifiedBy(TypeIdentifier::FALSE)) { + return new Identical($this->builder->val(false), $accessor); + } + + if ($type->isIdentifiedBy(TypeIdentifier::MIXED)) { + return $this->builder->val(true); + } + + while ($type instanceof WrappingTypeInterface) { + $type = $type->getWrappedType(); + } + + if ($type instanceof ObjectType) { + return new Instanceof_($accessor, new FullyQualified($type->getClassName())); + } + + return $this->builder->funcCall('\is_'.$type->getTypeIdentifier()->value, [$accessor]); + }; + + $stmtsAndConditions = array_map(fn (DataModelNodeInterface $n): array => [ + 'condition' => $nodeCondition($n), + 'stmts' => $this->buildClosureStatements($n, $options, $context), + ], $dataModelNode->getNodes()); + + $if = $stmtsAndConditions[0]; + unset($stmtsAndConditions[0]); + + return [ + new If_($if['condition'], [ + 'stmts' => $if['stmts'], + 'elseifs' => array_map(fn (array $s): ElseIf_ => new ElseIf_($s['condition'], $s['stmts']), $stmtsAndConditions), + 'else' => new Else_([ + new Expression(new Throw_($this->builder->new(new FullyQualified(UnexpectedValueException::class), [$this->builder->funcCall('\sprintf', [ + $this->builder->val('Unexpected "%s" value.'), + $this->builder->funcCall('\get_debug_type', [$accessor]), + ])]))), + ]), + ]), + ]; + } + + if ($dataModelNode instanceof CollectionNode) { + if ($dataModelNode->getType()->isList()) { + return [ + new Expression(new Yield_($this->builder->val('['))), + new Expression(new Assign($this->builder->var('prefix'), $this->builder->val(''))), + new Foreach_($accessor, $dataModelNode->getItemNode()->getAccessor()->toPhpExpr(), [ + 'stmts' => [ + new Expression(new Yield_($this->builder->var('prefix'))), + ...$this->buildClosureStatements($dataModelNode->getItemNode(), $options, $context), + new Expression(new Assign($this->builder->var('prefix'), $this->builder->val(','))), + ], + ]), + new Expression(new Yield_($this->builder->val(']'))), + ]; + } + + return [ + new Expression(new Yield_($this->builder->val('{'))), + new Expression(new Assign($this->builder->var('prefix'), $this->builder->val(''))), + new Foreach_($accessor, $dataModelNode->getItemNode()->getAccessor()->toPhpExpr(), [ + 'keyVar' => $this->builder->var('key'), + 'stmts' => [ + new Expression(new Assign($this->builder->var('key'), $this->escapeString($this->builder->var('key')))), + new Expression(new Yield_(new Encapsed([ + $this->builder->var('prefix'), + new EncapsedStringPart('"'), + $this->builder->var('key'), + new EncapsedStringPart('":'), + ]))), + ...$this->buildClosureStatements($dataModelNode->getItemNode(), $options, $context), + new Expression(new Assign($this->builder->var('prefix'), $this->builder->val(','))), + ], + ]), + new Expression(new Yield_($this->builder->val('}'))), + ]; + } + + if ($dataModelNode instanceof ObjectNode) { + $objectStmts = [new Expression(new Yield_($this->builder->val('{')))]; + $separator = ''; + + foreach ($dataModelNode->getProperties() as $name => $propertyNode) { + $encodedName = json_encode($name); + if (false === $encodedName) { + throw new RuntimeException(\sprintf('Cannot encode "%s"', $name)); + } + + $encodedName = substr($encodedName, 1, -1); + + $objectStmts = [ + ...$objectStmts, + new Expression(new Yield_($this->builder->val($separator))), + 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), + ]; + + $separator = ','; + } + + $objectStmts[] = new Expression(new Yield_($this->builder->val('}'))); + + return $objectStmts; + } + + throw new LogicException(\sprintf('Unexpected "%s" node', $dataModelNode::class)); + } + + private function encodeValue(Expr $value): Expr + { + return $this->builder->funcCall('\json_encode', [$value]); + } + + private function escapeString(Expr $string): Expr + { + return $this->builder->funcCall('\substr', [$this->encodeValue($string), $this->builder->val(1), $this->builder->val(-1)]); + } + + private function nodeOnlyNeedsEncode(DataModelNodeInterface $node, int $nestingLevel = 0): bool + { + if ($node instanceof CompositeNode) { + foreach ($node->getNodes() as $n) { + if (!$this->nodeOnlyNeedsEncode($n, $nestingLevel + 1)) { + return false; + } + } + + return true; + } + + if ($node instanceof CollectionNode) { + return $this->nodeOnlyNeedsEncode($node->getItemNode(), $nestingLevel + 1); + } + + if ($node instanceof ObjectNode && !$node->isTransformed()) { + foreach ($node->getProperties() as $property) { + if (!$this->nodeOnlyNeedsEncode($property, $nestingLevel + 1)) { + return false; + } + } + + return true; + } + + 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 + if ($type->isIdentifiedBy(TypeIdentifier::NULL) || $type->isIdentifiedBy(TypeIdentifier::BOOL)) { + return $nestingLevel > 0; + } + + return true; + } + + if ($node instanceof ExceptionNode) { + return true; + } + + return false; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Encode/PhpOptimizer.php b/src/Symfony/Component/JsonEncoder/Encode/PhpOptimizer.php new file mode 100644 index 0000000000000..5202aa893e219 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Encode/PhpOptimizer.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Encode; + +use PhpParser\Node; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor\NodeConnectingVisitor; + +/** + * Optimizes a PHP syntax tree. + * + * @author Mathias Arlaud + * + * @internal + */ +final class PhpOptimizer +{ + /** + * @param list $nodes + * + * @return list + */ + public function optimize(array $nodes): array + { + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NodeConnectingVisitor()); + $nodes = $traverser->traverse($nodes); + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new MergingStringVisitor()); + + return $traverser->traverse($nodes); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Encoded.php b/src/Symfony/Component/JsonEncoder/Encoded.php new file mode 100644 index 0000000000000..cb9796820515e --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Encoded.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder; + +/** + * Represents an encoding result. + * Can be iterated or casted to string. + * + * @author Mathias Arlaud + * + * @experimental + * + * @implements \IteratorAggregate + */ +final class Encoded implements \IteratorAggregate, \Stringable +{ + /** + * @param \Traversable $chunks + */ + public function __construct( + private \Traversable $chunks, + ) { + } + + public function getIterator(): \Traversable + { + return $this->chunks; + } + + public function __toString(): string + { + $encoded = ''; + foreach ($this->chunks as $chunk) { + $encoded .= $chunk; + } + + return $encoded; + } +} diff --git a/src/Symfony/Component/JsonEncoder/EncoderInterface.php b/src/Symfony/Component/JsonEncoder/EncoderInterface.php new file mode 100644 index 0000000000000..ae6f4d200b8db --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/EncoderInterface.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder; + +use Symfony\Component\TypeInfo\Type; + +/** + * Encodes $data into a specific format according to $options. + * + * @author Mathias Arlaud + * + * @experimental + * + * @template T of array + */ +interface EncoderInterface +{ + /** + * @param T $options + * + * @return \Traversable&\Stringable + */ + public function encode(mixed $data, Type $type, array $options = []): \Traversable&\Stringable; +} diff --git a/src/Symfony/Component/JsonEncoder/Exception/ExceptionInterface.php b/src/Symfony/Component/JsonEncoder/Exception/ExceptionInterface.php new file mode 100644 index 0000000000000..b14a6e33d9a94 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Exception/ExceptionInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Exception; + +/** + * @author Mathias Arlaud + * + * @experimental + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/JsonEncoder/Exception/InvalidArgumentException.php b/src/Symfony/Component/JsonEncoder/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000000..d4a98a8d4a130 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Exception/InvalidArgumentException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Exception; + +/** + * @author Mathias Arlaud + * + * @experimental + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/JsonEncoder/Exception/InvalidStreamException.php b/src/Symfony/Component/JsonEncoder/Exception/InvalidStreamException.php new file mode 100644 index 0000000000000..f3cfb18f8cfcd --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Exception/InvalidStreamException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Exception; + +/** + * @author Mathias Arlaud + * + * @experimental + */ +final class InvalidStreamException extends UnexpectedValueException +{ +} diff --git a/src/Symfony/Component/JsonEncoder/Exception/LogicException.php b/src/Symfony/Component/JsonEncoder/Exception/LogicException.php new file mode 100644 index 0000000000000..513f9451ad658 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Exception/LogicException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Exception; + +/** + * @author Mathias Arlaud + * + * @experimental + */ +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/JsonEncoder/Exception/MaxDepthException.php b/src/Symfony/Component/JsonEncoder/Exception/MaxDepthException.php new file mode 100644 index 0000000000000..10742c95277a9 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Exception/MaxDepthException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Exception; + +/** + * @author Mathias Arlaud + * + * @experimental + */ +final class MaxDepthException extends RuntimeException +{ + public function __construct() + { + parent::__construct('Max depth of 512 has been reached.'); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Exception/RuntimeException.php b/src/Symfony/Component/JsonEncoder/Exception/RuntimeException.php new file mode 100644 index 0000000000000..747caee07a88c --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Exception/RuntimeException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Exception; + +/** + * @author Mathias Arlaud + * + * @experimental + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/JsonEncoder/Exception/UnexpectedValueException.php b/src/Symfony/Component/JsonEncoder/Exception/UnexpectedValueException.php new file mode 100644 index 0000000000000..40c2aae292ec8 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Exception/UnexpectedValueException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Exception; + +/** + * @author Mathias Arlaud + * + * @experimental + */ +class UnexpectedValueException extends \UnexpectedValueException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/JsonEncoder/Exception/UnsupportedException.php b/src/Symfony/Component/JsonEncoder/Exception/UnsupportedException.php new file mode 100644 index 0000000000000..9bd9710a44fce --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Exception/UnsupportedException.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Exception; + +/** + * @author Mathias Arlaud + * + * @experimental + */ +class UnsupportedException extends InvalidArgumentException +{ +} diff --git a/src/Symfony/Component/JsonEncoder/JsonDecoder.php b/src/Symfony/Component/JsonEncoder/JsonDecoder.php new file mode 100644 index 0000000000000..6e317fb9f1f7b --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/JsonDecoder.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder; + +use PHPStan\PhpDocParser\Parser\PhpDocParser; +use Psr\Container\ContainerInterface; +use Symfony\Component\JsonEncoder\Decode\DecoderGenerator; +use Symfony\Component\JsonEncoder\Decode\Denormalizer\DateTimeDenormalizer; +use Symfony\Component\JsonEncoder\Decode\Denormalizer\DenormalizerInterface; +use Symfony\Component\JsonEncoder\Decode\Instantiator; +use Symfony\Component\JsonEncoder\Decode\LazyInstantiator; +use Symfony\Component\JsonEncoder\Mapping\Decode\AttributePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\Decode\DateTimeTypePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\GenericTypePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; + +/** + * @author Mathias Arlaud + * + * @implements DecoderInterface> + * + * @experimental + */ +final class JsonDecoder implements DecoderInterface +{ + private DecoderGenerator $decoderGenerator; + private Instantiator $instantiator; + private LazyInstantiator $lazyInstantiator; + + public function __construct( + private ContainerInterface $denormalizers, + PropertyMetadataLoaderInterface $propertyMetadataLoader, + string $decodersDir, + string $lazyGhostsDir, + ) { + $this->decoderGenerator = new DecoderGenerator($propertyMetadataLoader, $decodersDir); + $this->instantiator = new Instantiator(); + $this->lazyInstantiator = new LazyInstantiator($lazyGhostsDir); + } + + public function decode($input, Type $type, array $options = []): mixed + { + $isStream = \is_resource($input); + $path = $this->decoderGenerator->generate($type, $isStream, $options); + + return (require $path)($input, $this->denormalizers, $isStream ? $this->lazyInstantiator : $this->instantiator, $options); + } + + /** + * @param array $denormalizers + */ + public static function create(array $denormalizers = [], ?string $decodersDir = null, ?string $lazyGhostsDir = null): self + { + $decodersDir ??= sys_get_temp_dir().'/json_encoder/decoder'; + $lazyGhostsDir ??= sys_get_temp_dir().'/json_encoder/lazy_ghost'; + $denormalizers += [ + 'json_encoder.denormalizer.date_time' => new DateTimeDenormalizer(immutable: false), + 'json_encoder.denormalizer.date_time_immutable' => new DateTimeDenormalizer(immutable: true), + ]; + + $denormalizersContainer = new class($denormalizers) implements ContainerInterface { + public function __construct( + private array $denormalizers, + ) { + } + + public function has(string $id): bool + { + return isset($this->denormalizers[$id]); + } + + public function get(string $id): DenormalizerInterface + { + return $this->denormalizers[$id]; + } + }; + + $typeContextFactory = new TypeContextFactory(class_exists(PhpDocParser::class) ? new StringTypeResolver() : null); + + $propertyMetadataLoader = new GenericTypePropertyMetadataLoader( + new DateTimeTypePropertyMetadataLoader( + new AttributePropertyMetadataLoader( + new PropertyMetadataLoader(TypeResolver::create()), + $denormalizersContainer, + TypeResolver::create(), + ), + ), + $typeContextFactory, + ); + + return new self($denormalizersContainer, $propertyMetadataLoader, $decodersDir, $lazyGhostsDir); + } +} diff --git a/src/Symfony/Component/JsonEncoder/JsonEncoder.php b/src/Symfony/Component/JsonEncoder/JsonEncoder.php new file mode 100644 index 0000000000000..be9301d808ac6 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/JsonEncoder.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder; + +use PHPStan\PhpDocParser\Parser\PhpDocParser; +use Psr\Container\ContainerInterface; +use Symfony\Component\JsonEncoder\Encode\EncoderGenerator; +use Symfony\Component\JsonEncoder\Encode\Normalizer\DateTimeNormalizer; +use Symfony\Component\JsonEncoder\Encode\Normalizer\NormalizerInterface; +use Symfony\Component\JsonEncoder\Mapping\Encode\AttributePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\Encode\DateTimeTypePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\GenericTypePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; + +/** + * @author Mathias Arlaud + * + * @implements EncoderInterface> + * + * @experimental + */ +final class JsonEncoder implements EncoderInterface +{ + private EncoderGenerator $encoderGenerator; + + /** + * @param bool $forceEncodeChunks enforces chunking the JSON string even if a simple `json_encode` is enough + */ + public function __construct( + private ContainerInterface $normalizers, + PropertyMetadataLoaderInterface $propertyMetadataLoader, + string $encodersDir, + bool $forceEncodeChunks = false, + ) { + $this->encoderGenerator = new EncoderGenerator($propertyMetadataLoader, $encodersDir, $forceEncodeChunks); + } + + public function encode(mixed $data, Type $type, array $options = []): \Traversable&\Stringable + { + $path = $this->encoderGenerator->generate($type, $options); + + return new Encoded((require $path)($data, $this->normalizers, $options)); + } + + /** + * @param array $normalizers + * @param bool $forceEncodeChunks enforces chunking the JSON string even if a simple `json_encode` is enough + */ + public static function create(array $normalizers = [], ?string $encodersDir = null, bool $forceEncodeChunks = false): self + { + $encodersDir ??= sys_get_temp_dir().'/json_encoder/encoder'; + $normalizers += [ + 'json_encoder.normalizer.date_time' => new DateTimeNormalizer(), + ]; + + $normalizersContainer = new class($normalizers) implements ContainerInterface { + public function __construct( + private array $normalizers, + ) { + } + + public function has(string $id): bool + { + return isset($this->normalizers[$id]); + } + + public function get(string $id): NormalizerInterface + { + return $this->normalizers[$id]; + } + }; + + $typeContextFactory = new TypeContextFactory(class_exists(PhpDocParser::class) ? new StringTypeResolver() : null); + + $propertyMetadataLoader = new GenericTypePropertyMetadataLoader( + new DateTimeTypePropertyMetadataLoader( + new AttributePropertyMetadataLoader( + new PropertyMetadataLoader(TypeResolver::create()), + $normalizersContainer, + TypeResolver::create(), + ), + ), + $typeContextFactory, + ); + + return new self($normalizersContainer, $propertyMetadataLoader, $encodersDir, $forceEncodeChunks); + } +} diff --git a/src/Symfony/Component/JsonEncoder/LICENSE b/src/Symfony/Component/JsonEncoder/LICENSE new file mode 100644 index 0000000000000..e374a5c8339d3 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2024-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/JsonEncoder/Mapping/Decode/AttributePropertyMetadataLoader.php b/src/Symfony/Component/JsonEncoder/Mapping/Decode/AttributePropertyMetadataLoader.php new file mode 100644 index 0000000000000..511182b37148c --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Mapping/Decode/AttributePropertyMetadataLoader.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Mapping\Decode; + +use Psr\Container\ContainerInterface; +use Symfony\Component\JsonEncoder\Attribute\Denormalizer; +use Symfony\Component\JsonEncoder\Attribute\EncodedName; +use Symfony\Component\JsonEncoder\Decode\Denormalizer\DenormalizerInterface; +use Symfony\Component\JsonEncoder\Exception\InvalidArgumentException; +use Symfony\Component\JsonEncoder\Exception\RuntimeException; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; + +/** + * Enhances properties decoding metadata based on properties' attributes. + * + * @author Mathias Arlaud + * + * @internal + */ +final class AttributePropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + public function __construct( + private PropertyMetadataLoaderInterface $decorated, + private ContainerInterface $denormalizers, + private TypeResolverInterface $typeResolver, + ) { + } + + public function load(string $className, array $options = [], array $context = []): array + { + $initialResult = $this->decorated->load($className, $options, $context); + $result = []; + + foreach ($initialResult as $initialEncodedName => $initialMetadata) { + try { + $propertyReflection = new \ReflectionProperty($className, $initialMetadata->getName()); + } catch (\ReflectionException $e) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + + $attributesMetadata = $this->getPropertyAttributesMetadata($propertyReflection); + $encodedName = $attributesMetadata['name'] ?? $initialEncodedName; + + if (null === $denormalizer = $attributesMetadata['denormalizer'] ?? null) { + $result[$encodedName] = $initialMetadata; + + continue; + } + + if (\is_string($denormalizer)) { + $denormalizerService = $this->getAndValidateDenormalizerService($denormalizer); + $normalizedType = $denormalizerService::getNormalizedType(); + + $result[$encodedName] = $initialMetadata + ->withType($normalizedType) + ->withAdditionalDenormalizer($denormalizer); + + continue; + } + + try { + $denormalizerReflection = new \ReflectionFunction($denormalizer); + } catch (\ReflectionException $e) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + + if (null === ($parameterReflection = $denormalizerReflection->getParameters()[0] ?? null)) { + throw new InvalidArgumentException(\sprintf('"%s" property\'s denormalizer callable has no parameter.', $initialEncodedName)); + } + + $normalizedType = $this->typeResolver->resolve($parameterReflection); + + $result[$encodedName] = $initialMetadata + ->withType($normalizedType) + ->withAdditionalDenormalizer($denormalizer); + } + + return $result; + } + + /** + * @return array{name?: string, denormalizer?: string|\Closure} + */ + private function getPropertyAttributesMetadata(\ReflectionProperty $reflectionProperty): array + { + $metadata = []; + + $reflectionAttribute = $reflectionProperty->getAttributes(EncodedName::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; + if (null !== $reflectionAttribute) { + $metadata['name'] = $reflectionAttribute->newInstance()->getName(); + } + + $reflectionAttribute = $reflectionProperty->getAttributes(Denormalizer::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; + if (null !== $reflectionAttribute) { + $metadata['denormalizer'] = $reflectionAttribute->newInstance()->getDenormalizer(); + } + + return $metadata; + } + + private function getAndValidateDenormalizerService(string $denormalizerId): DenormalizerInterface + { + if (!$this->denormalizers->has($denormalizerId)) { + throw new InvalidArgumentException(\sprintf('You have requested a non-existent denormalizer service "%s". Did you implement "%s"?', $denormalizerId, DenormalizerInterface::class)); + } + + $denormalizer = $this->denormalizers->get($denormalizerId); + if (!$denormalizer instanceof DenormalizerInterface) { + throw new InvalidArgumentException(\sprintf('The "%s" denormalizer service does not implement "%s".', $denormalizerId, DenormalizerInterface::class)); + } + + return $denormalizer; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Mapping/Decode/DateTimeTypePropertyMetadataLoader.php b/src/Symfony/Component/JsonEncoder/Mapping/Decode/DateTimeTypePropertyMetadataLoader.php new file mode 100644 index 0000000000000..719df2914574f --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Mapping/Decode/DateTimeTypePropertyMetadataLoader.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Mapping\Decode; + +use Symfony\Component\JsonEncoder\Decode\Denormalizer\DateTimeDenormalizer; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; + +/** + * Casts DateTime properties to string properties. + * + * @author Mathias Arlaud + * + * @internal + */ +final class DateTimeTypePropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + public function __construct( + private PropertyMetadataLoaderInterface $decorated, + ) { + } + + public function load(string $className, array $options = [], array $context = []): array + { + $result = $this->decorated->load($className, $options, $context); + + foreach ($result as &$metadata) { + $type = $metadata->getType(); + + if ($type instanceof ObjectType && is_a($type->getClassName(), \DateTimeInterface::class, true)) { + $dateTimeDenormalizer = match ($type->getClassName()) { + \DateTimeInterface::class, \DateTimeImmutable::class => 'json_encoder.denormalizer.date_time_immutable', + default => 'json_encoder.denormalizer.date_time', + }; + $metadata = $metadata + ->withType(DateTimeDenormalizer::getNormalizedType()) + ->withAdditionalDenormalizer($dateTimeDenormalizer); + } + } + + return $result; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Mapping/Encode/AttributePropertyMetadataLoader.php b/src/Symfony/Component/JsonEncoder/Mapping/Encode/AttributePropertyMetadataLoader.php new file mode 100644 index 0000000000000..47a3ff4a2d200 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Mapping/Encode/AttributePropertyMetadataLoader.php @@ -0,0 +1,120 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Mapping\Encode; + +use Psr\Container\ContainerInterface; +use Symfony\Component\JsonEncoder\Attribute\EncodedName; +use Symfony\Component\JsonEncoder\Attribute\Normalizer; +use Symfony\Component\JsonEncoder\Encode\Normalizer\NormalizerInterface; +use Symfony\Component\JsonEncoder\Exception\InvalidArgumentException; +use Symfony\Component\JsonEncoder\Exception\RuntimeException; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; + +/** + * Enhances properties encoding metadata based on properties' attributes. + * + * @author Mathias Arlaud + * + * @internal + */ +final class AttributePropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + public function __construct( + private PropertyMetadataLoaderInterface $decorated, + private ContainerInterface $normalizers, + private TypeResolverInterface $typeResolver, + ) { + } + + public function load(string $className, array $options = [], array $context = []): array + { + $initialResult = $this->decorated->load($className, $options, $context); + $result = []; + + foreach ($initialResult as $initialEncodedName => $initialMetadata) { + try { + $propertyReflection = new \ReflectionProperty($className, $initialMetadata->getName()); + } catch (\ReflectionException $e) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + + $attributesMetadata = $this->getPropertyAttributesMetadata($propertyReflection); + $encodedName = $attributesMetadata['name'] ?? $initialEncodedName; + + if (null === $normalizer = $attributesMetadata['normalizer'] ?? null) { + $result[$encodedName] = $initialMetadata; + + continue; + } + + if (\is_string($normalizer)) { + $normalizerService = $this->getAndValidateNormalizerService($normalizer); + $normalizedType = $normalizerService::getNormalizedType(); + + $result[$encodedName] = $initialMetadata + ->withType($normalizedType) + ->withAdditionalNormalizer($normalizer); + + continue; + } + + try { + $normalizerReflection = new \ReflectionFunction($normalizer); + } catch (\ReflectionException $e) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + + $normalizedType = $this->typeResolver->resolve($normalizerReflection); + + $result[$encodedName] = $initialMetadata + ->withType($normalizedType) + ->withAdditionalNormalizer($normalizer); + } + + return $result; + } + + /** + * @return array{name?: string, normalizer?: string|\Closure} + */ + private function getPropertyAttributesMetadata(\ReflectionProperty $reflectionProperty): array + { + $metadata = []; + + $reflectionAttribute = $reflectionProperty->getAttributes(EncodedName::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; + if (null !== $reflectionAttribute) { + $metadata['name'] = $reflectionAttribute->newInstance()->getName(); + } + + $reflectionAttribute = $reflectionProperty->getAttributes(Normalizer::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null; + if (null !== $reflectionAttribute) { + $metadata['normalizer'] = $reflectionAttribute->newInstance()->getNormalizer(); + } + + return $metadata; + } + + private function getAndValidateNormalizerService(string $normalizerId): NormalizerInterface + { + if (!$this->normalizers->has($normalizerId)) { + throw new InvalidArgumentException(\sprintf('You have requested a non-existent normalizer service "%s". Did you implement "%s"?', $normalizerId, NormalizerInterface::class)); + } + + $normalizer = $this->normalizers->get($normalizerId); + if (!$normalizer instanceof NormalizerInterface) { + throw new InvalidArgumentException(\sprintf('The "%s" normalizer service does not implement "%s".', $normalizerId, NormalizerInterface::class)); + } + + return $normalizer; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Mapping/Encode/DateTimeTypePropertyMetadataLoader.php b/src/Symfony/Component/JsonEncoder/Mapping/Encode/DateTimeTypePropertyMetadataLoader.php new file mode 100644 index 0000000000000..5fa327765f1a0 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Mapping/Encode/DateTimeTypePropertyMetadataLoader.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Mapping\Encode; + +use Symfony\Component\JsonEncoder\Encode\Normalizer\DateTimeNormalizer; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\TypeInfo\Type\ObjectType; + +/** + * Casts DateTime properties to string properties. + * + * @author Mathias Arlaud + * + * @internal + */ +final class DateTimeTypePropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + public function __construct( + private PropertyMetadataLoaderInterface $decorated, + ) { + } + + public function load(string $className, array $options = [], array $context = []): array + { + $result = $this->decorated->load($className, $options, $context); + + foreach ($result as &$metadata) { + $type = $metadata->getType(); + + if ($type instanceof ObjectType && is_a($type->getClassName(), \DateTimeInterface::class, true)) { + $metadata = $metadata + ->withType(DateTimeNormalizer::getNormalizedType()) + ->withAdditionalNormalizer('json_encoder.normalizer.date_time'); + } + } + + return $result; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Mapping/GenericTypePropertyMetadataLoader.php b/src/Symfony/Component/JsonEncoder/Mapping/GenericTypePropertyMetadataLoader.php new file mode 100644 index 0000000000000..7ca5749670496 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Mapping/GenericTypePropertyMetadataLoader.php @@ -0,0 +1,145 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Mapping; + +use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\GenericType; +use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; + +/** + * Enhances properties encoding/decoding metadata based on properties' generic type. + * + * @author Mathias Arlaud + * + * @internal + */ +final class GenericTypePropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + public function __construct( + private PropertyMetadataLoaderInterface $decorated, + private TypeContextFactory $typeContextFactory, + ) { + } + + public function load(string $className, array $options = [], array $context = []): array + { + $result = $this->decorated->load($className, $options, $context); + $variableTypes = $this->getClassVariableTypes($className, $context['original_type']); + + foreach ($result as &$metadata) { + $type = $metadata->getType(); + + if (isset($variableTypes[(string) $type])) { + $metadata = $metadata->withType($this->replaceVariableTypes($type, $variableTypes)); + } + } + + return $result; + } + + /** + * @param class-string $className + * + * @return array + */ + private function getClassVariableTypes(string $className, Type $type): array + { + $findTypeWithClassName = static function (string $className, Type $type) use (&$findTypeWithClassName): ?Type { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + foreach ($type->getTypes() as $t) { + if (null !== $classType = $findTypeWithClassName($className, $t)) { + return $classType; + } + } + + return null; + } + + while ($type instanceof WrappingTypeInterface) { + $baseType = $type; + + if ($type instanceof GenericType) { + foreach ($type->getVariableTypes() as $t) { + if (null !== $classType = $findTypeWithClassName($className, $t)) { + return $classType; + } + } + } + + $type = $type->getWrappedType(); + + if ($type instanceof ObjectType && $type->getClassName() === $className) { + return $baseType; + } + } + + return null; + }; + + if (null === $classType = $findTypeWithClassName($className, $type)) { + return []; + } + + $variableTypes = $classType instanceof GenericType ? $classType->getVariableTypes() : []; + $templates = $this->typeContextFactory->createFromClassName($className)->templates; + + if (\count($templates) !== \count($variableTypes)) { + throw new InvalidArgumentException(\sprintf('Given %d variable types in "%s", but %d templates are defined in "%2$s".', \count($variableTypes), $className, \count($templates))); + } + + $templates = array_keys($templates); + $classVariableTypes = []; + + foreach ($variableTypes as $i => $variableType) { + $classVariableTypes[$templates[$i]] = $variableType; + } + + return $classVariableTypes; + } + + /** + * @param array $variableTypes + */ + private function replaceVariableTypes(Type $type, array $variableTypes): Type + { + if (isset($variableTypes[(string) $type])) { + return $variableTypes[(string) $type]; + } + + if ($type instanceof UnionType) { + return new UnionType(...array_map(fn (Type $t): Type => $this->replaceVariableTypes($t, $variableTypes), $type->getTypes())); + } + + if ($type instanceof IntersectionType) { + return new IntersectionType(...array_map(fn (Type $t): Type => $this->replaceVariableTypes($t, $variableTypes), $type->getTypes())); + } + + if ($type instanceof CollectionType) { + return new CollectionType($this->replaceVariableTypes($type->getWrappedType(), $variableTypes), $type->isList()); + } + + if ($type instanceof GenericType) { + return new GenericType( + $this->replaceVariableTypes($type->getWrappedType(), $variableTypes), + ...array_map(fn (Type $t): Type => $this->replaceVariableTypes($t, $variableTypes), $type->getVariableTypes()), + ); + } + + return $type; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Mapping/PropertyMetadata.php b/src/Symfony/Component/JsonEncoder/Mapping/PropertyMetadata.php new file mode 100644 index 0000000000000..af129d55626c0 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Mapping/PropertyMetadata.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Mapping; + +use Symfony\Component\TypeInfo\Type; + +/** + * Holds encoding/decoding metadata about a given property. + * + * @author Mathias Arlaud + * + * @experimental + */ +final class PropertyMetadata +{ + /** + * @param list $normalizers + * @param list $denormalizers + */ + public function __construct( + private string $name, + private Type $type, + private array $normalizers = [], + private array $denormalizers = [], + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function withName(string $name): self + { + return new self($name, $this->type, $this->normalizers, $this->denormalizers); + } + + public function getType(): Type + { + return $this->type; + } + + public function withType(Type $type): self + { + return new self($this->name, $type, $this->normalizers, $this->denormalizers); + } + + /** + * @return list + */ + public function getNormalizers(): array + { + return $this->normalizers; + } + + /** + * @param list $normalizers + */ + public function withNormalizers(array $normalizers): self + { + return new self($this->name, $this->type, $normalizers, $this->denormalizers); + } + + public function withAdditionalNormalizer(string|\Closure $normalizer): self + { + $normalizers = $this->normalizers; + + $normalizers[] = $normalizer; + $normalizers = array_values(array_unique($normalizers)); + + return $this->withNormalizers($normalizers); + } + + /** + * @return list + */ + public function getDenormalizers(): array + { + return $this->denormalizers; + } + + /** + * @param list $denormalizers + */ + public function withDenormalizers(array $denormalizers): self + { + return new self($this->name, $this->type, $this->normalizers, $denormalizers); + } + + public function withAdditionalDenormalizer(string|\Closure $denormalizer): self + { + $denormalizers = $this->denormalizers; + + $denormalizers[] = $denormalizer; + $denormalizers = array_values(array_unique($denormalizers)); + + return $this->withDenormalizers($denormalizers); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Mapping/PropertyMetadataLoader.php b/src/Symfony/Component/JsonEncoder/Mapping/PropertyMetadataLoader.php new file mode 100644 index 0000000000000..5658aa3fa40c3 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Mapping/PropertyMetadataLoader.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Mapping; + +use Symfony\Component\JsonEncoder\Exception\RuntimeException; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface; + +/** + * Loads basic properties encoding/decoding metadata for a given $className. + * + * @author Mathias Arlaud + * + * @internal + */ +final class PropertyMetadataLoader implements PropertyMetadataLoaderInterface +{ + public function __construct( + private TypeResolverInterface $typeResolver, + ) { + } + + public function load(string $className, array $options = [], array $context = []): array + { + $result = []; + + try { + $classReflection = new \ReflectionClass($className); + } catch (\ReflectionException $e) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + + foreach ($classReflection->getProperties() as $reflectionProperty) { + if (!$reflectionProperty->isPublic()) { + continue; + } + + $name = $encodedName = $reflectionProperty->getName(); + $type = $this->typeResolver->resolve($reflectionProperty); + + $result[$encodedName] = new PropertyMetadata($name, $type); + } + + return $result; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Mapping/PropertyMetadataLoaderInterface.php b/src/Symfony/Component/JsonEncoder/Mapping/PropertyMetadataLoaderInterface.php new file mode 100644 index 0000000000000..a2d0ce8dc092d --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Mapping/PropertyMetadataLoaderInterface.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Mapping; + +/** + * Loads properties encoding/decoding metadata for a given $className. + * + * These metadata can be used by the DataModelBuilder to create + * an appropriate ObjectNode. + * + * @author Mathias Arlaud + * + * @experimental + */ +interface PropertyMetadataLoaderInterface +{ + /** + * @param class-string $className + * @param array $options Implementation-specific options + * @param array $context + * + * @return array + */ + public function load(string $className, array $options = [], array $context = []): array; +} diff --git a/src/Symfony/Component/JsonEncoder/README.md b/src/Symfony/Component/JsonEncoder/README.md new file mode 100644 index 0000000000000..4b3de3ee0198a --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/README.md @@ -0,0 +1,18 @@ +JsonEncoder component +==================== + +Provides powerful methods to encode/decode data structures into/from JSON. + +**This Component is experimental**. +[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +Resources +--------- + + * [Documentation](https://symfony.com/doc/current/components/ser-des.html) + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/JsonEncoder/Tests/CacheWarmer/EncoderDecoderCacheWarmerTest.php b/src/Symfony/Component/JsonEncoder/Tests/CacheWarmer/EncoderDecoderCacheWarmerTest.php new file mode 100644 index 0000000000000..142d1ef09d1fa --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/CacheWarmer/EncoderDecoderCacheWarmerTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\CacheWarmer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\CacheWarmer\EncoderDecoderCacheWarmer; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; + +class EncoderDecoderCacheWarmerTest extends TestCase +{ + private string $encodersDir; + private string $decodersDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->encodersDir = \sprintf('%s/symfony_json_encoder_test/json_encoder/encoder', sys_get_temp_dir()); + $this->decodersDir = \sprintf('%s/symfony_json_encoder_test/json_encoder/decoder', sys_get_temp_dir()); + + if (is_dir($this->encodersDir)) { + array_map('unlink', glob($this->encodersDir.'/*')); + rmdir($this->encodersDir); + } + + if (is_dir($this->decodersDir)) { + array_map('unlink', glob($this->decodersDir.'/*')); + rmdir($this->decodersDir); + } + } + + public function testWarmUp() + { + $this->cacheWarmer([ClassicDummy::class])->warmUp('useless'); + + $this->assertSame([ + \sprintf('%s/d147026bb5d25e5012afcdc1543cf097.json.php', $this->encodersDir), + ], glob($this->encodersDir.'/*')); + + $this->assertSame([ + \sprintf('%s/d147026bb5d25e5012afcdc1543cf097.json.php', $this->decodersDir), + \sprintf('%s/d147026bb5d25e5012afcdc1543cf097.json.stream.php', $this->decodersDir), + ], glob($this->decodersDir.'/*')); + } + + /** + * @param list $encodable + */ + private function cacheWarmer(array $encodable): EncoderDecoderCacheWarmer + { + $typeResolver = TypeResolver::create(); + + return new EncoderDecoderCacheWarmer( + $encodable, + new PropertyMetadataLoader($typeResolver), + new PropertyMetadataLoader($typeResolver), + $this->encodersDir, + $this->decodersDir, + ); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/CacheWarmer/LazyGhostCacheWarmerTest.php b/src/Symfony/Component/JsonEncoder/Tests/CacheWarmer/LazyGhostCacheWarmerTest.php new file mode 100644 index 0000000000000..f4544f3762671 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/CacheWarmer/LazyGhostCacheWarmerTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\CacheWarmer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\CacheWarmer\LazyGhostCacheWarmer; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy; + +class LazyGhostCacheWarmerTest extends TestCase +{ + private string $lazyGhostsDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->lazyGhostsDir = \sprintf('%s/symfony_json_encoder_test/json_encoder/lazy_ghost', sys_get_temp_dir()); + + if (is_dir($this->lazyGhostsDir)) { + array_map('unlink', glob($this->lazyGhostsDir.'/*')); + rmdir($this->lazyGhostsDir); + } + } + + public function testWarmUpLazyGhost() + { + (new LazyGhostCacheWarmer([ClassicDummy::class], $this->lazyGhostsDir))->warmUp('useless'); + + $this->assertSame( + array_map(fn (string $c): string => \sprintf('%s/%s.php', $this->lazyGhostsDir, hash('xxh128', $c)), [ClassicDummy::class]), + glob($this->lazyGhostsDir.'/*'), + ); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/DataModel/Decode/CompositeNodeTest.php b/src/Symfony/Component/JsonEncoder/Tests/DataModel/Decode/CompositeNodeTest.php new file mode 100644 index 0000000000000..6a6899aa7e147 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/DataModel/Decode/CompositeNodeTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\DataModel\Decode; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\DataModel\Decode\CollectionNode; +use Symfony\Component\JsonEncoder\DataModel\Decode\CompositeNode; +use Symfony\Component\JsonEncoder\DataModel\Decode\ObjectNode; +use Symfony\Component\JsonEncoder\DataModel\Decode\ScalarNode; +use Symfony\Component\JsonEncoder\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; + +class CompositeNodeTest extends TestCase +{ + public function testCannotCreateWithOnlyOneType() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('"%s" expects at least 2 nodes.', CompositeNode::class)); + + new CompositeNode([new ScalarNode(Type::int())]); + } + + public function testCannotCreateWithCompositeNodeParts() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Cannot set "%s" as a "%s" node.', CompositeNode::class, CompositeNode::class)); + + new CompositeNode([ + new CompositeNode([ + new ScalarNode(Type::int()), + new ScalarNode(Type::int()), + ]), + new ScalarNode(Type::int()), + ]); + } + + public function testSortNodesOnCreation() + { + $composite = new CompositeNode([ + $scalar = new ScalarNode(Type::int()), + $object = new ObjectNode(Type::object(self::class), [], false), + $collection = new CollectionNode(Type::list(), new ScalarNode(Type::int())), + ]); + + $this->assertSame([$collection, $object, $scalar], $composite->getNodes()); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/DataModel/Encode/CompositeNodeTest.php b/src/Symfony/Component/JsonEncoder/Tests/DataModel/Encode/CompositeNodeTest.php new file mode 100644 index 0000000000000..bf11dcb1a0d48 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/DataModel/Encode/CompositeNodeTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\DataModel\Encode; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\DataModel\Encode\CollectionNode; +use Symfony\Component\JsonEncoder\DataModel\Encode\CompositeNode; +use Symfony\Component\JsonEncoder\DataModel\Encode\ObjectNode; +use Symfony\Component\JsonEncoder\DataModel\Encode\ScalarNode; +use Symfony\Component\JsonEncoder\DataModel\VariableDataAccessor; +use Symfony\Component\JsonEncoder\Exception\InvalidArgumentException; +use Symfony\Component\TypeInfo\Type; + +class CompositeNodeTest extends TestCase +{ + public function testCannotCreateWithOnlyOneType() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('"%s" expects at least 2 nodes.', CompositeNode::class)); + + new CompositeNode(new VariableDataAccessor('data'), [new ScalarNode(new VariableDataAccessor('data'), Type::int())]); + } + + public function testCannotCreateWithCompositeNodeParts() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Cannot set "%s" as a "%s" node.', CompositeNode::class, CompositeNode::class)); + + new CompositeNode(new VariableDataAccessor('data'), [ + new CompositeNode(new VariableDataAccessor('data'), [ + new ScalarNode(new VariableDataAccessor('data'), Type::int()), + new ScalarNode(new VariableDataAccessor('data'), Type::int()), + ]), + new ScalarNode(new VariableDataAccessor('data'), Type::int()), + ]); + } + + public function testSortNodesOnCreation() + { + $composite = new CompositeNode(new VariableDataAccessor('data'), [ + $scalar = new ScalarNode(new VariableDataAccessor('data'), Type::int()), + $object = new ObjectNode(new VariableDataAccessor('data'), Type::object(self::class), [], false), + $collection = new CollectionNode(new VariableDataAccessor('data'), Type::list(), new ScalarNode(new VariableDataAccessor('data'), Type::int())), + ]); + + $this->assertSame([$collection, $object, $scalar], $composite->getNodes()); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Decode/DecoderGeneratorTest.php b/src/Symfony/Component/JsonEncoder/Tests/Decode/DecoderGeneratorTest.php new file mode 100644 index 0000000000000..a298343c95fe5 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Decode/DecoderGeneratorTest.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Decode; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Decode\DecoderGenerator; +use Symfony\Component\JsonEncoder\Exception\UnsupportedException; +use Symfony\Component\JsonEncoder\Mapping\Decode\AttributePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\Decode\DateTimeTypePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\GenericTypePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Denormalizer\BooleanStringDenormalizer; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Denormalizer\DivideStringAndCastToIntDenormalizer; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyEnum; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNullableProperties; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithOtherDummies; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithUnionProperties; +use Symfony\Component\JsonEncoder\Tests\ServiceContainer; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; + +class DecoderGeneratorTest extends TestCase +{ + private string $decodersDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->decodersDir = \sprintf('%s/symfony_json_encoder_test/decoder', sys_get_temp_dir()); + + if (is_dir($this->decodersDir)) { + array_map('unlink', glob($this->decodersDir.'/*')); + rmdir($this->decodersDir); + } + } + + /** + * @dataProvider generatedDecoderDataProvider + */ + public function testGeneratedDecoder(string $fixture, Type $type) + { + $propertyMetadataLoader = new GenericTypePropertyMetadataLoader( + new DateTimeTypePropertyMetadataLoader(new AttributePropertyMetadataLoader( + new PropertyMetadataLoader(TypeResolver::create()), + new ServiceContainer([ + DivideStringAndCastToIntDenormalizer::class => new DivideStringAndCastToIntDenormalizer(), + BooleanStringDenormalizer::class => new BooleanStringDenormalizer(), + ]), + TypeResolver::create(), + )), + new TypeContextFactory(new StringTypeResolver()), + ); + + $generator = new DecoderGenerator($propertyMetadataLoader, $this->decodersDir); + + $this->assertStringEqualsFile( + \sprintf('%s/Fixtures/decoder/%s.php', \dirname(__DIR__), $fixture), + file_get_contents($generator->generate($type, false)), + ); + + $this->assertStringEqualsFile( + \sprintf('%s/Fixtures/decoder/%s.stream.php', \dirname(__DIR__), $fixture), + file_get_contents($generator->generate($type, true)), + ); + } + + /** + * @return iterable + */ + public static function generatedDecoderDataProvider(): iterable + { + yield ['scalar', Type::int()]; + yield ['mixed', Type::mixed()]; + yield ['null', Type::null()]; + yield ['backed_enum', Type::enum(DummyBackedEnum::class)]; + yield ['nullable_backed_enum', Type::nullable(Type::enum(DummyBackedEnum::class))]; + + yield ['list', Type::list()]; + yield ['object_list', Type::list(Type::object(ClassicDummy::class))]; + yield ['nullable_object_list', Type::nullable(Type::list(Type::object(ClassicDummy::class)))]; + yield ['iterable_list', Type::iterable(key: Type::int(), asList: true)]; + + yield ['dict', Type::dict()]; + yield ['object_dict', Type::dict(Type::object(ClassicDummy::class))]; + yield ['nullable_object_dict', Type::nullable(Type::dict(Type::object(ClassicDummy::class)))]; + yield ['iterable_dict', Type::iterable(key: Type::string())]; + + yield ['object', Type::object(ClassicDummy::class)]; + yield ['nullable_object', Type::nullable(Type::object(ClassicDummy::class))]; + yield ['object_in_object', Type::object(DummyWithOtherDummies::class)]; + yield ['object_with_nullable_properties', Type::object(DummyWithNullableProperties::class)]; + yield ['object_with_denormalizer', Type::object(DummyWithNormalizerAttributes::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)]; + } + + public function testDoNotSupportIntersectionType() + { + $generator = new DecoderGenerator(new PropertyMetadataLoader(TypeResolver::create()), $this->decodersDir); + + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('"Stringable&Traversable" type is not supported.'); + + $generator->generate(Type::intersection(Type::object(\Traversable::class), Type::object(\Stringable::class)), false); + } + + public function testDoNotSupportEnumType() + { + $generator = new DecoderGenerator(new PropertyMetadataLoader(TypeResolver::create()), $this->decodersDir); + + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage(\sprintf('"%s" type is not supported.', DummyEnum::class)); + + $generator->generate(Type::enum(DummyEnum::class), false); + } + + public function testCallPropertyMetadataLoaderWithProperContext() + { + $type = Type::object(self::class); + + $propertyMetadataLoader = $this->createMock(PropertyMetadataLoaderInterface::class); + $propertyMetadataLoader->expects($this->once()) + ->method('load') + ->with(self::class, [], [ + 'original_type' => $type, + 'generated_classes' => [(string) $type => true], + ]) + ->willReturn([]); + + $generator = new DecoderGenerator($propertyMetadataLoader, $this->decodersDir); + $generator->generate($type, false); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Decode/Denormalizer/DateTimeDenormalizerTest.php b/src/Symfony/Component/JsonEncoder/Tests/Decode/Denormalizer/DateTimeDenormalizerTest.php new file mode 100644 index 0000000000000..60fae8423bb58 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Decode/Denormalizer/DateTimeDenormalizerTest.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Decode\Denormalizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Decode\Denormalizer\DateTimeDenormalizer; +use Symfony\Component\JsonEncoder\Exception\InvalidArgumentException; + +class DateTimeDenormalizerTest extends TestCase +{ + public function testDenormalizeImmutable() + { + $denormalizer = new DateTimeDenormalizer(immutable: true); + + $this->assertEquals( + new \DateTimeImmutable('2023-07-26'), + $denormalizer->denormalize('2023-07-26', []), + ); + + $this->assertEquals( + (new \DateTimeImmutable('2023-07-26'))->setTime(0, 0), + $denormalizer->denormalize('26/07/2023 00:00:00', [DateTimeDenormalizer::FORMAT_KEY => 'd/m/Y H:i:s']), + ); + } + + public function testDenormalizeMutable() + { + $denormalizer = new DateTimeDenormalizer(immutable: false); + + $this->assertEquals( + new \DateTime('2023-07-26'), + $denormalizer->denormalize('2023-07-26', []), + ); + + $this->assertEquals( + (new \DateTime('2023-07-26'))->setTime(0, 0), + $denormalizer->denormalize('26/07/2023 00:00:00', [DateTimeDenormalizer::FORMAT_KEY => 'd/m/Y H:i:s']), + ); + } + + public function testThrowWhenInvalidNormalized() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The normalized data is either not an string, or an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.'); + + (new DateTimeDenormalizer(immutable: true))->denormalize(true, []); + } + + public function testThrowWhenInvalidDateTimeString() + { + $denormalizer = new DateTimeDenormalizer(immutable: true); + + try { + $denormalizer->denormalize('0', []); + $this->fail(\sprintf('A "%s" exception must have been thrown.', InvalidArgumentException::class)); + } catch (InvalidArgumentException $e) { + $this->assertEquals("Parsing datetime string \"0\" resulted in 1 errors: \nat position 0: Unexpected character", $e->getMessage()); + } + + try { + $denormalizer->denormalize('0', [DateTimeDenormalizer::FORMAT_KEY => 'Y-m-d']); + $this->fail(\sprintf('A "%s" exception must have been thrown.', InvalidArgumentException::class)); + } catch (InvalidArgumentException $e) { + $this->assertEquals("Parsing datetime string \"0\" using format \"Y-m-d\" resulted in 1 errors: \nat position 1: Not enough data available to satisfy format", $e->getMessage()); + } + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Decode/InstantiatorTest.php b/src/Symfony/Component/JsonEncoder/Tests/Decode/InstantiatorTest.php new file mode 100644 index 0000000000000..c51298ce6b734 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Decode/InstantiatorTest.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Decode; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Decode\Instantiator; +use Symfony\Component\JsonEncoder\Exception\UnexpectedValueException; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy; + +class InstantiatorTest extends TestCase +{ + public function testInstantiate() + { + $expected = new ClassicDummy(); + $expected->id = 100; + $expected->name = 'dummy'; + + $properties = [ + 'id' => 100, + 'name' => 'dummy', + ]; + + $this->assertEquals($expected, (new Instantiator())->instantiate(ClassicDummy::class, $properties)); + } + + public function testThrowOnInvalidProperty() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage(\sprintf('Cannot assign array to property %s::$id of type int', ClassicDummy::class)); + + (new Instantiator())->instantiate(ClassicDummy::class, [ + 'id' => ['an', 'array'], + ]); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Decode/LazyInstantiatorTest.php b/src/Symfony/Component/JsonEncoder/Tests/Decode/LazyInstantiatorTest.php new file mode 100644 index 0000000000000..b040c53f21ad9 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Decode/LazyInstantiatorTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Decode; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Decode\LazyInstantiator; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes; + +class LazyInstantiatorTest extends TestCase +{ + private string $lazyGhostsDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->lazyGhostsDir = \sprintf('%s/symfony_json_encoder_test/lazy_ghost', sys_get_temp_dir()); + + if (is_dir($this->lazyGhostsDir)) { + array_map('unlink', glob($this->lazyGhostsDir.'/*')); + rmdir($this->lazyGhostsDir); + } + } + + public function testCreateLazyGhost() + { + $ghost = (new LazyInstantiator($this->lazyGhostsDir))->instantiate(ClassicDummy::class, []); + + $this->assertArrayHasKey(\sprintf("\0%sGhost\0lazyObjectState", preg_replace('/\\\\/', '', ClassicDummy::class)), (array) $ghost); + } + + public function testCreateCacheFile() + { + (new LazyInstantiator($this->lazyGhostsDir))->instantiate(DummyWithNormalizerAttributes::class, []); + + $this->assertCount(1, glob($this->lazyGhostsDir.'/*')); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Decode/LexerTest.php b/src/Symfony/Component/JsonEncoder/Tests/Decode/LexerTest.php new file mode 100644 index 0000000000000..1ac997d62ed4f --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Decode/LexerTest.php @@ -0,0 +1,398 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Decode; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Decode\Lexer; +use Symfony\Component\JsonEncoder\Exception\InvalidStreamException; + +class LexerTest extends TestCase +{ + public function testTokens() + { + $this->assertTokens([['1', 0]], '1'); + $this->assertTokens([['false', 0]], 'false'); + $this->assertTokens([['null', 0]], 'null'); + $this->assertTokens([['"string"', 0]], '"string"'); + $this->assertTokens([['[', 0], [']', 1]], '[]'); + $this->assertTokens([['[', 0], ['10', 2], [',', 4], ['20', 6], [']', 9]], '[ 10, 20 ]'); + $this->assertTokens([['[', 0], ['1', 1], [',', 2], ['[', 4], ['2', 5], [']', 6], [']', 8]], '[1, [2] ]'); + $this->assertTokens([['{', 0], ['}', 1]], '{}'); + $this->assertTokens([['{', 0], ['"foo"', 1], [':', 6], ['{', 8], ['"bar"', 9], [':', 14], ['"baz"', 15], ['}', 20], ['}', 21]], '{"foo": {"bar":"baz"}}'); + } + + public function testTokensSubset() + { + $this->assertTokens([['false', 7]], '[1, 2, false]', 7, 5); + } + + public function testTokenizeOverflowingBuffer() + { + $veryLongString = \sprintf('"%s"', str_repeat('.', 20000)); + + $this->assertTokens([[$veryLongString, 0]], $veryLongString); + } + + /** + * Ensures that the lexer is compliant with RFC 8259. + * + * @dataProvider jsonDataProvider + */ + public function testValidJson(string $name, string $json, bool $valid) + { + $resource = fopen('php://temp', 'w'); + fwrite($resource, $json); + rewind($resource); + + try { + iterator_to_array((new Lexer())->getTokens($resource, 0, null)); + fclose($resource); + + if (!$valid) { + $this->fail(\sprintf('"%s" should not be parseable.', $name)); + } + + $this->addToAssertionCount(1); + } catch (InvalidStreamException) { + fclose($resource); + + if ($valid) { + $this->fail(\sprintf('"%s" should be parseable.', $name)); + } + + $this->addToAssertionCount(1); + } + } + + /** + * Pulled from https://github.com/nst/JSONTestSuite. + * + * @return iterable + */ + public static function jsonDataProvider(): iterable + { + yield ['array_1_true_without_comma', '[1 true]', false]; + yield ['array_a_invalid_utf8', '[aå]', false]; + yield ['array_colon_instead_of_comma', '["": 1]', false]; + yield ['array_comma_after_close', '[""],', false]; + yield ['array_comma_and_number', '[,1]', false]; + yield ['array_double_comma', '[1,,2]', false]; + yield ['array_double_extra_comma', '["x",,]', false]; + yield ['array_extra_close', '["x"]]', false]; + yield ['array_extra_comma', '["",]', false]; + yield ['array_incomplete', '["x"', false]; + yield ['array_incomplete_invalid_value', '[x', false]; + yield ['array_inner_array_no_comma', '[3[4]]', false]; + yield ['array_invalid_utf8', '[ÿ]', false]; + yield ['array_items_separated_by_semicolon', '[1:2]', false]; + yield ['array_just_comma', '[,]', false]; + yield ['array_just_minus', '[-]', false]; + yield ['array_missing_value', '[ , ""]', false]; + yield ['array_newlines_unclosed', <<', false]; + yield ['structure_angle_bracket_null', '[]', false]; + yield ['structure_array_trailing_garbage', '[1]x', false]; + yield ['structure_array_with_extra_array_close', '[1]]', false]; + yield ['structure_array_with_unclosed_string', '["asd]', false]; + yield ['structure_ascii-unicode-identifier', 'aå', false]; + yield ['structure_capitalized_True', '[True]', false]; + yield ['structure_close_unopened_array', '1]', false]; + yield ['structure_comma_instead_of_closing_brace', '{"x": true,', false]; + yield ['structure_double_array', '[][]', false]; + yield ['structure_end_array', ']', false]; + yield ['structure_incomplete_UTF8_BOM', 'ï»{}', false]; + yield ['structure_lone-invalid-utf-8', 'å', false]; + yield ['structure_lone-open-bracket', '[', false]; + yield ['structure_no_data', '', false]; + yield ['structure_null-byte-outside-string', '[\\u0000]', false]; + yield ['structure_number_with_trailing_garbage', '2@', false]; + yield ['structure_object_followed_by_closing_object', '{}}', false]; + yield ['structure_object_unclosed_no_value', '{"":', false]; + yield ['structure_object_with_comment', '{"a":/*comment*/"b"}', false]; + yield ['structure_object_with_trailing_garbage', '{"a": true} "x"', false]; + yield ['structure_open_array_apostrophe', '[\'', false]; + yield ['structure_open_array_comma', '[,', false]; + yield ['structure_open_array_object', '[{', false]; + yield ['structure_open_array_open_object', '[{"":[{"":', false]; + yield ['structure_open_array_open_string', '["a', false]; + yield ['structure_open_array_string', '["a"', false]; + yield ['structure_open_object', '{', false]; + yield ['structure_open_object_close_array', '{]', false]; + yield ['structure_open_object_comma', '{,', false]; + yield ['structure_open_object_open_array', '{[', false]; + yield ['structure_open_object_open_string', '{"a', false]; + yield ['structure_open_object_string_with_apostrophes', '{\'a\'', false]; + yield ['structure_open_open', '["\\{["\\{["\\{["\\{', false]; + yield ['structure_single_eacute', 'é', false]; + yield ['structure_single_star', '*', false]; + yield ['structure_trailing_#', '{"a":"b"}#{}', false]; + yield ['structure_U+2060_word_joined', '[\\u2060]', false]; + yield ['structure_uescaped_LF_before_string', '[\\u000A""]', false]; + yield ['structure_unclosed_array', '[1', false]; + yield ['structure_unclosed_array_partial_null', '[ false, nul', false]; + yield ['structure_unclosed_array_unfinished_false', '[ true, fals', false]; + yield ['structure_unclosed_array_unfinished_true', '[ false, tru', false]; + yield ['structure_unclosed_object', '{"asd":"asd"', false]; + yield ['structure_whitespace_formfeed', '[\\u000c]', false]; + + yield ['array_arraysWithSpaces', '[[] ]', true]; + yield ['array_empty-string', '[""]', true]; + yield ['array_empty', '[]', true]; + yield ['array_ending_with_newline', '["a"]', true]; + yield ['array_false', '[false]', true]; + yield ['array_heterogeneous', '[null, 1, "1", {}]', true]; + yield ['array_null', '[null]', true]; + yield ['array_with_1_and_newline', <<assertSame($tokens, iterator_to_array((new Lexer())->getTokens($resource, $offset, $length))); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Decode/NativeDecoderTest.php b/src/Symfony/Component/JsonEncoder/Tests/Decode/NativeDecoderTest.php new file mode 100644 index 0000000000000..a5ea8b86de1b4 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Decode/NativeDecoderTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Decode; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Decode\NativeDecoder; +use Symfony\Component\JsonEncoder\Exception\UnexpectedValueException; + +class NativeDecoderTest extends TestCase +{ + public function testDecode() + { + $this->assertDecoded('foo', '"foo"'); + } + + public function testDecodeSubset() + { + $this->assertDecoded('bar', '["foo","bar","baz"]', 7, 5); + } + + public function testDecodeThrowOnInvalidJsonString() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('JSON is not valid: Syntax error'); + + NativeDecoder::decodeString('foo"'); + } + + public function testDecodeThrowOnInvalidJsonStream() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('JSON is not valid: Syntax error'); + + $resource = fopen('php://temp', 'w'); + fwrite($resource, 'foo"'); + rewind($resource); + + NativeDecoder::decodeStream($resource); + } + + private function assertDecoded(mixed $decoded, string $encoded, int $offset = 0, ?int $length = null): void + { + if (0 === $offset && null === $length) { + $this->assertEquals($decoded, NativeDecoder::decodeString($encoded)); + } + + $resource = fopen('php://temp', 'w'); + fwrite($resource, $encoded); + rewind($resource); + + $this->assertEquals($decoded, NativeDecoder::decodeStream($resource, $offset, $length)); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Decode/SplitterTest.php b/src/Symfony/Component/JsonEncoder/Tests/Decode/SplitterTest.php new file mode 100644 index 0000000000000..929f250bc79f5 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Decode/SplitterTest.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Decode; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Decode\Splitter; +use Symfony\Component\JsonEncoder\Exception\InvalidStreamException; + +class SplitterTest extends TestCase +{ + public function testSplitNull() + { + $this->assertListBoundaries(null, 'null'); + $this->assertDictBoundaries(null, 'null'); + } + + public function testSplitList() + { + $this->assertListBoundaries([], '[]'); + $this->assertListBoundaries([[1, 3]], '[100]'); + $this->assertListBoundaries([[1, 3], [5, 3]], '[100,200]'); + $this->assertListBoundaries([[1, 1], [3, 5]], '[1,[2,3]]'); + $this->assertListBoundaries([[1, 1], [3, 7]], '[1,{"2":3}]'); + } + + public function testSplitDict() + { + $this->assertDictBoundaries([], '{}'); + $this->assertDictBoundaries(['k' => [5, 2]], '{"k":10}'); + $this->assertDictBoundaries(['k' => [5, 4]], '{"k":[10]}'); + } + + /** + * @dataProvider splitDictInvalidDataProvider + */ + public function testSplitDictInvalidThrowException(string $expectedMessage, string $content) + { + $this->expectException(InvalidStreamException::class); + $this->expectExceptionMessage($expectedMessage); + + $resource = fopen('php://temp', 'w'); + fwrite($resource, $content); + rewind($resource); + + iterator_to_array((new Splitter())->splitDict($resource)); + } + + /** + * @return iterable}> + */ + public static function splitDictInvalidDataProvider(): iterable + { + yield ['Unterminated JSON.', '{"foo":1']; + yield ['Unexpected "{" token.', '{{}']; + yield ['Unexpected "}" token.', '}']; + yield ['Unexpected "}" token.', '{}}']; + yield ['Unexpected "," token.', ',']; + yield ['Unexpected "," token.', '{"foo",}']; + yield ['Unexpected ":" token.', ':']; + yield ['Unexpected ":" token.', '{:']; + yield ['Unexpected "0" token.', '{"foo" 0}']; + yield ['Expected scalar value, but got "_".', '{"foo":_']; + yield ['Expected dict key, but got "100".', '{100']; + yield ['Got "foo" dict key twice.', '{"foo":1,"foo"']; + yield ['Expected end, but got ""x"".', '{"a": true} "x"']; + } + + /** + * @dataProvider splitListInvalidDataProvider + */ + public function testSplitListInvalidThrowException(string $expectedMessage, string $content) + { + $this->expectException(InvalidStreamException::class); + $this->expectExceptionMessage($expectedMessage); + + $resource = fopen('php://temp', 'w'); + fwrite($resource, $content); + rewind($resource); + + iterator_to_array((new Splitter())->splitList($resource)); + } + + /** + * @return iterable + */ + public static function splitListInvalidDataProvider(): iterable + { + yield ['Unterminated JSON.', '[100']; + yield ['Unexpected "[" token.', '[][']; + yield ['Unexpected "]" token.', ']']; + yield ['Unexpected "]" token.', '[]]']; + yield ['Unexpected "," token.', ',']; + yield ['Unexpected "," token.', '[100,,]']; + yield ['Unexpected ":" token.', ':']; + yield ['Unexpected ":" token.', '[100:']; + yield ['Unexpected "0" token.', '[1 0]']; + yield ['Expected scalar value, but got "_".', '[_']; + yield ['Expected end, but got "100".', '{"a": true} 100']; + } + + private function assertListBoundaries(?array $expectedBoundaries, string $content, int $offset = 0, ?int $length = null): void + { + $resource = fopen('php://temp', 'w'); + fwrite($resource, $content); + rewind($resource); + + $boundaries = (new Splitter())->splitList($resource, $offset, $length); + $boundaries = null !== $boundaries ? iterator_to_array($boundaries) : null; + + $this->assertSame($expectedBoundaries, $boundaries); + + $resource = fopen('php://temp', 'w'); + fwrite($resource, $content); + rewind($resource); + + $boundaries = (new Splitter())->splitList($resource, $offset, $length); + $boundaries = null !== $boundaries ? iterator_to_array($boundaries) : null; + + $this->assertSame($expectedBoundaries, $boundaries); + } + + private function assertDictBoundaries(?array $expectedBoundaries, string $content, int $offset = 0, ?int $length = null): void + { + $resource = fopen('php://temp', 'w'); + fwrite($resource, $content); + rewind($resource); + + $boundaries = (new Splitter())->splitDict($resource, $offset, $length); + $boundaries = null !== $boundaries ? iterator_to_array($boundaries) : null; + + $this->assertSame($expectedBoundaries, $boundaries); + + $resource = fopen('php://temp', 'w'); + fwrite($resource, $content); + rewind($resource); + + $boundaries = (new Splitter())->splitDict($resource, $offset, $length); + $boundaries = null !== $boundaries ? iterator_to_array($boundaries) : null; + + $this->assertSame($expectedBoundaries, $boundaries); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Encode/EncoderGeneratorTest.php b/src/Symfony/Component/JsonEncoder/Tests/Encode/EncoderGeneratorTest.php new file mode 100644 index 0000000000000..34c6433329b4f --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Encode/EncoderGeneratorTest.php @@ -0,0 +1,154 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Encode; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Encode\EncoderGenerator; +use Symfony\Component\JsonEncoder\Exception\UnsupportedException; +use Symfony\Component\JsonEncoder\Mapping\Encode\AttributePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\Encode\DateTimeTypePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\GenericTypePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyEnum; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithOtherDummies; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithUnionProperties; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Normalizer\BooleanStringNormalizer; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Normalizer\DoubleIntAndCastToStringNormalizer; +use Symfony\Component\JsonEncoder\Tests\ServiceContainer; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; + +class EncoderGeneratorTest extends TestCase +{ + private string $encodersDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->encodersDir = \sprintf('%s/symfony_json_encoder_test/encoder', sys_get_temp_dir()); + + if (is_dir($this->encodersDir)) { + array_map('unlink', glob($this->encodersDir.'/*')); + rmdir($this->encodersDir); + } + } + + /** + * @dataProvider generatedEncoderDataProvider + */ + public function testGeneratedEncoder(string $fixture, Type $type) + { + $propertyMetadataLoader = new GenericTypePropertyMetadataLoader( + new DateTimeTypePropertyMetadataLoader(new AttributePropertyMetadataLoader( + new PropertyMetadataLoader(TypeResolver::create()), + new ServiceContainer([ + DoubleIntAndCastToStringNormalizer::class => new DoubleIntAndCastToStringNormalizer(), + BooleanStringNormalizer::class => new BooleanStringNormalizer(), + ]), + TypeResolver::create(), + )), + new TypeContextFactory(new StringTypeResolver()), + ); + + $generator = new EncoderGenerator($propertyMetadataLoader, $this->encodersDir, forceEncodeChunks: false); + + $this->assertStringEqualsFile( + \sprintf('%s/Fixtures/encoder/%s.php', \dirname(__DIR__), $fixture), + file_get_contents($generator->generate($type)), + ); + + $generator = new EncoderGenerator($propertyMetadataLoader, $this->encodersDir, forceEncodeChunks: true); + + $this->assertStringEqualsFile( + \sprintf('%s/Fixtures/encoder/%s.stream.php', \dirname(__DIR__), $fixture), + file_get_contents($generator->generate($type)), + ); + } + + /** + * @return iterable + */ + public static function generatedEncoderDataProvider(): iterable + { + yield ['scalar', Type::int()]; + yield ['null', Type::null()]; + yield ['bool', Type::bool()]; + yield ['mixed', Type::mixed()]; + yield ['backed_enum', Type::enum(DummyBackedEnum::class, Type::string())]; + yield ['nullable_backed_enum', Type::nullable(Type::enum(DummyBackedEnum::class, Type::string()))]; + + yield ['list', Type::list()]; + yield ['bool_list', Type::list(Type::bool())]; + yield ['null_list', Type::list(Type::null())]; + yield ['object_list', Type::list(Type::object(DummyWithNameAttributes::class))]; + yield ['nullable_object_list', Type::nullable(Type::list(Type::object(DummyWithNameAttributes::class)))]; + + yield ['iterable_list', Type::iterable(key: Type::int(), asList: true)]; + + yield ['dict', Type::dict()]; + yield ['object_dict', Type::dict(Type::object(DummyWithNameAttributes::class))]; + yield ['nullable_object_dict', Type::nullable(Type::dict(Type::object(DummyWithNameAttributes::class)))]; + yield ['iterable_dict', Type::iterable(key: Type::string())]; + + yield ['object', Type::object(DummyWithNameAttributes::class)]; + yield ['nullable_object', Type::nullable(Type::object(DummyWithNameAttributes::class))]; + yield ['object_in_object', Type::object(DummyWithOtherDummies::class)]; + yield ['object_with_normalizer', Type::object(DummyWithNormalizerAttributes::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)]; + } + + public function testDoNotSupportIntersectionType() + { + $generator = new EncoderGenerator(new PropertyMetadataLoader(TypeResolver::create()), $this->encodersDir, false); + + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage('"Stringable&Traversable" type is not supported.'); + + $generator->generate(Type::intersection(Type::object(\Traversable::class), Type::object(\Stringable::class))); + } + + public function testDoNotSupportEnumType() + { + $generator = new EncoderGenerator(new PropertyMetadataLoader(TypeResolver::create()), $this->encodersDir, false); + + $this->expectException(UnsupportedException::class); + $this->expectExceptionMessage(\sprintf('"%s" type is not supported.', DummyEnum::class)); + + $generator->generate(Type::enum(DummyEnum::class)); + } + + public function testCallPropertyMetadataLoaderWithProperContext() + { + $type = Type::object(self::class); + + $propertyMetadataLoader = $this->createMock(PropertyMetadataLoaderInterface::class); + $propertyMetadataLoader->expects($this->once()) + ->method('load') + ->with(self::class, [], [ + 'original_type' => $type, + 'depth' => 1, + ]) + ->willReturn([]); + + $generator = new EncoderGenerator($propertyMetadataLoader, $this->encodersDir, false); + $generator->generate($type); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Encode/Normalizer/DateTimeNormalizerTest.php b/src/Symfony/Component/JsonEncoder/Tests/Encode/Normalizer/DateTimeNormalizerTest.php new file mode 100644 index 0000000000000..fa8766110a045 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Encode/Normalizer/DateTimeNormalizerTest.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Encode\Normalizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Encode\Normalizer\DateTimeNormalizer; +use Symfony\Component\JsonEncoder\Exception\InvalidArgumentException; + +class DateTimeNormalizerTest extends TestCase +{ + public function testNormalize() + { + $normalizer = new DateTimeNormalizer(); + + $this->assertEquals( + '2023-07-26T00:00:00+00:00', + $normalizer->normalize(new \DateTimeImmutable('2023-07-26'), []), + ); + + $this->assertEquals( + '26/07/2023 00:00:00', + $normalizer->normalize((new \DateTimeImmutable('2023-07-26'))->setTime(0, 0), [DateTimeNormalizer::FORMAT_KEY => 'd/m/Y H:i:s']), + ); + } + + public function testThrowWhenInvalidDenormalized() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The denormalized data must implement the "\DateTimeInterface".'); + + (new DateTimeNormalizer())->normalize(true, []); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/EncodedTest.php b/src/Symfony/Component/JsonEncoder/Tests/EncodedTest.php new file mode 100644 index 0000000000000..cb194d7d40bb0 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/EncodedTest.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Encoded; + +class EncodedTest extends TestCase +{ + public function testEncodedAsTraversable() + { + $this->assertSame(['foo', 'bar', 'baz'], iterator_to_array(new Encoded(new \ArrayIterator(['foo', 'bar', 'baz'])))); + } + + public function testEncodedAsString() + { + $this->assertSame('foobarbaz', (string) new Encoded(new \ArrayIterator(['foo', 'bar', 'baz']))); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/Attribute/BooleanStringDenormalizer.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/Attribute/BooleanStringDenormalizer.php new file mode 100644 index 0000000000000..5be4206abe4f0 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/Attribute/BooleanStringDenormalizer.php @@ -0,0 +1,15 @@ + + */ + public array $dummies = []; +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithMethods.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithMethods.php new file mode 100644 index 0000000000000..d3acc57ea945a --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithMethods.php @@ -0,0 +1,13 @@ + (int) $v, explode('..', $range)); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithNullableProperties.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithNullableProperties.php new file mode 100644 index 0000000000000..4ee3c37148010 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithNullableProperties.php @@ -0,0 +1,11 @@ + + */ + public mixed $arrayOfDummies = []; + + /** + * @var list + */ + public array $array = []; + + /** + * @param array $arrayOfDummies + * + * @return array + */ + public static function castArrayOfDummiesToArrayOfStrings(mixed $arrayOfDummies): mixed + { + return array_column('name', $arrayOfDummies); + } + + public static function countArray(array $array): int + { + return count($array); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithUnionProperties.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithUnionProperties.php new file mode 100644 index 0000000000000..2da7714df64cb --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/Model/DummyWithUnionProperties.php @@ -0,0 +1,10 @@ +'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitDict($stream, $offset, $length); + $iterable = static function ($stream, $data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + } + }; + return \iterator_to_array($iterable($stream, $data)); + }; + return $providers['array']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/iterable_dict.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/iterable_dict.php new file mode 100644 index 0000000000000..f0645e3f291d1 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/iterable_dict.php @@ -0,0 +1,5 @@ +'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitDict($stream, $offset, $length); + $iterable = static function ($stream, $data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + } + }; + return $iterable($stream, $data); + }; + return $providers['iterable']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/iterable_list.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/iterable_list.php new file mode 100644 index 0000000000000..f0645e3f291d1 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/iterable_list.php @@ -0,0 +1,5 @@ +'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitList($stream, $offset, $length); + $iterable = static function ($stream, $data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + } + }; + return $iterable($stream, $data); + }; + return $providers['iterable']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/list.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/list.php new file mode 100644 index 0000000000000..f0645e3f291d1 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/list.php @@ -0,0 +1,5 @@ +'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitList($stream, $offset, $length); + $iterable = static function ($stream, $data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + } + }; + return \iterator_to_array($iterable($stream, $data)); + }; + return $providers['array']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/mixed.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/mixed.php new file mode 100644 index 0000000000000..f0645e3f291d1 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/mixed.php @@ -0,0 +1,5 @@ +instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, \array_filter(['id' => $data['id'] ?? '_symfony_missing_value', 'name' => $data['name'] ?? '_symfony_missing_value'], static function ($v) { + return '_symfony_missing_value' !== $v; + })); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy|null'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + if (\is_array($data)) { + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy']($data); + } + if (null === $data) { + return null; + } + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value for "Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy|null".', \get_debug_type($data))); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy|null'](\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeString((string) $string)); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object.stream.php new file mode 100644 index 0000000000000..511c27f1b37e3 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object.stream.php @@ -0,0 +1,31 @@ + $v) { + match ($k) { + 'id' => $properties['id'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + 'name' => $properties['name'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + default => null, + }; + } + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, $properties); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy|null'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $offset, $length); + if (\is_array($data)) { + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy']($data); + } + if (null === $data) { + return null; + } + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value for "Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy|null".', \get_debug_type($data))); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy|null']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_dict.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_dict.php new file mode 100644 index 0000000000000..21990e4bacaa8 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_dict.php @@ -0,0 +1,27 @@ +'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + $iterable = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy']($v); + } + }; + return \iterator_to_array($iterable($data)); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, \array_filter(['id' => $data['id'] ?? '_symfony_missing_value', 'name' => $data['name'] ?? '_symfony_missing_value'], static function ($v) { + return '_symfony_missing_value' !== $v; + })); + }; + $providers['array|null'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + if (\is_array($data)) { + return $providers['array']($data); + } + if (null === $data) { + return null; + } + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value for "array|null".', \get_debug_type($data))); + }; + return $providers['array|null'](\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeString((string) $string)); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_dict.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_dict.stream.php new file mode 100644 index 0000000000000..94cb55d48913b --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_dict.stream.php @@ -0,0 +1,40 @@ +'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitDict($stream, $offset, $length); + $iterable = static function ($stream, $data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy']($stream, $v[0], $v[1]); + } + }; + return \iterator_to_array($iterable($stream, $data)); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitDict($stream, $offset, $length); + $properties = []; + foreach ($data as $k => $v) { + match ($k) { + 'id' => $properties['id'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + 'name' => $properties['name'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + default => null, + }; + } + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, $properties); + }; + $providers['array|null'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $offset, $length); + if (\is_array($data)) { + return $providers['array']($data); + } + if (null === $data) { + return null; + } + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value for "array|null".', \get_debug_type($data))); + }; + return $providers['array|null']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_list.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_list.php new file mode 100644 index 0000000000000..5e30d62fa5b9c --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_list.php @@ -0,0 +1,27 @@ +'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + $iterable = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy']($v); + } + }; + return \iterator_to_array($iterable($data)); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, \array_filter(['id' => $data['id'] ?? '_symfony_missing_value', 'name' => $data['name'] ?? '_symfony_missing_value'], static function ($v) { + return '_symfony_missing_value' !== $v; + })); + }; + $providers['array|null'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + if (\is_array($data) && \array_is_list($data)) { + return $providers['array']($data); + } + if (null === $data) { + return null; + } + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value for "array|null".', \get_debug_type($data))); + }; + return $providers['array|null'](\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeString((string) $string)); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_list.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_list.stream.php new file mode 100644 index 0000000000000..4ca2a5b54393b --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/nullable_object_list.stream.php @@ -0,0 +1,40 @@ +'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitList($stream, $offset, $length); + $iterable = static function ($stream, $data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy']($stream, $v[0], $v[1]); + } + }; + return \iterator_to_array($iterable($stream, $data)); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitDict($stream, $offset, $length); + $properties = []; + foreach ($data as $k => $v) { + match ($k) { + 'id' => $properties['id'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + 'name' => $properties['name'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + default => null, + }; + } + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, $properties); + }; + $providers['array|null'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $offset, $length); + if (\is_array($data) && \array_is_list($data)) { + return $providers['array']($data); + } + if (null === $data) { + return null; + } + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value for "array|null".', \get_debug_type($data))); + }; + return $providers['array|null']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object.php new file mode 100644 index 0000000000000..9214a4ac7b60c --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object.php @@ -0,0 +1,10 @@ +instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, \array_filter(['id' => $data['id'] ?? '_symfony_missing_value', 'name' => $data['name'] ?? '_symfony_missing_value'], static function ($v) { + return '_symfony_missing_value' !== $v; + })); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy'](\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeString((string) $string)); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object.stream.php new file mode 100644 index 0000000000000..8d9e7c9c87b6c --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object.stream.php @@ -0,0 +1,21 @@ + $v) { + match ($k) { + 'id' => $properties['id'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + 'name' => $properties['name'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + default => null, + }; + } + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, $properties); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_dict.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_dict.php new file mode 100644 index 0000000000000..f5f9805ade493 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_dict.php @@ -0,0 +1,18 @@ +'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + $iterable = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy']($v); + } + }; + return \iterator_to_array($iterable($data)); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, \array_filter(['id' => $data['id'] ?? '_symfony_missing_value', 'name' => $data['name'] ?? '_symfony_missing_value'], static function ($v) { + return '_symfony_missing_value' !== $v; + })); + }; + return $providers['array'](\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeString((string) $string)); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_dict.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_dict.stream.php new file mode 100644 index 0000000000000..fcfe59241f5bf --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_dict.stream.php @@ -0,0 +1,30 @@ +'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitDict($stream, $offset, $length); + $iterable = static function ($stream, $data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy']($stream, $v[0], $v[1]); + } + }; + return \iterator_to_array($iterable($stream, $data)); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitDict($stream, $offset, $length); + $properties = []; + foreach ($data as $k => $v) { + match ($k) { + 'id' => $properties['id'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + 'name' => $properties['name'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + default => null, + }; + } + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, $properties); + }; + return $providers['array']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_in_object.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_in_object.php new file mode 100644 index 0000000000000..59b3e7e1f38da --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_in_object.php @@ -0,0 +1,20 @@ +instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithOtherDummies::class, \array_filter(['name' => $data['name'] ?? '_symfony_missing_value', 'otherDummyOne' => \array_key_exists('otherDummyOne', $data) ? $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes']($data['otherDummyOne']) : '_symfony_missing_value', 'otherDummyTwo' => \array_key_exists('otherDummyTwo', $data) ? $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy']($data['otherDummyTwo']) : '_symfony_missing_value'], static function ($v) { + return '_symfony_missing_value' !== $v; + })); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes::class, \array_filter(['id' => $data['@id'] ?? '_symfony_missing_value', 'name' => $data['name'] ?? '_symfony_missing_value'], static function ($v) { + return '_symfony_missing_value' !== $v; + })); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, \array_filter(['id' => $data['id'] ?? '_symfony_missing_value', 'name' => $data['name'] ?? '_symfony_missing_value'], static function ($v) { + return '_symfony_missing_value' !== $v; + })); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithOtherDummies'](\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeString((string) $string)); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_in_object.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_in_object.stream.php new file mode 100644 index 0000000000000..1ba3364db4b67 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_in_object.stream.php @@ -0,0 +1,56 @@ + $v) { + match ($k) { + 'name' => $properties['name'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + 'otherDummyOne' => $properties['otherDummyOne'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes']($stream, $v[0], $v[1]); + }, + 'otherDummyTwo' => $properties['otherDummyTwo'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy']($stream, $v[0], $v[1]); + }, + default => null, + }; + } + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithOtherDummies::class, $properties); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitDict($stream, $offset, $length); + $properties = []; + foreach ($data as $k => $v) { + match ($k) { + '@id' => $properties['id'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + 'name' => $properties['name'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + default => null, + }; + } + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes::class, $properties); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitDict($stream, $offset, $length); + $properties = []; + foreach ($data as $k => $v) { + match ($k) { + 'id' => $properties['id'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + 'name' => $properties['name'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + default => null, + }; + } + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, $properties); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithOtherDummies']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_list.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_list.php new file mode 100644 index 0000000000000..bb0aa363dc887 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_list.php @@ -0,0 +1,18 @@ +'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + $iterable = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy']($v); + } + }; + return \iterator_to_array($iterable($data)); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, \array_filter(['id' => $data['id'] ?? '_symfony_missing_value', 'name' => $data['name'] ?? '_symfony_missing_value'], static function ($v) { + return '_symfony_missing_value' !== $v; + })); + }; + return $providers['array'](\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeString((string) $string)); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_list.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_list.stream.php new file mode 100644 index 0000000000000..3fcbe053e1fe5 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_list.stream.php @@ -0,0 +1,30 @@ +'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitList($stream, $offset, $length); + $iterable = static function ($stream, $data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy']($stream, $v[0], $v[1]); + } + }; + return \iterator_to_array($iterable($stream, $data)); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitDict($stream, $offset, $length); + $properties = []; + foreach ($data as $k => $v) { + match ($k) { + 'id' => $properties['id'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + 'name' => $properties['name'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + default => null, + }; + } + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy::class, $properties); + }; + return $providers['array']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_denormalizer.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_denormalizer.php new file mode 100644 index 0000000000000..ea31bddf19f61 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_denormalizer.php @@ -0,0 +1,10 @@ +instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes::class, \array_filter(['id' => $denormalizers->get('Symfony\Component\JsonEncoder\Tests\Fixtures\Denormalizer\DivideStringAndCastToIntDenormalizer')->denormalize($data['id'] ?? '_symfony_missing_value', $options), 'active' => $denormalizers->get('Symfony\Component\JsonEncoder\Tests\Fixtures\Denormalizer\BooleanStringDenormalizer')->denormalize($data['active'] ?? '_symfony_missing_value', $options), 'name' => strtoupper($data['name'] ?? '_symfony_missing_value'), 'range' => Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes::explodeRange($data['range'] ?? '_symfony_missing_value', $options)], static function ($v) { + return '_symfony_missing_value' !== $v; + })); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes'](\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeString((string) $string)); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_denormalizer.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_denormalizer.stream.php new file mode 100644 index 0000000000000..b1db54117f06a --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_denormalizer.stream.php @@ -0,0 +1,27 @@ + $v) { + match ($k) { + 'id' => $properties['id'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return $denormalizers->get('Symfony\Component\JsonEncoder\Tests\Fixtures\Denormalizer\DivideStringAndCastToIntDenormalizer')->denormalize(\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]), $options); + }, + 'active' => $properties['active'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return $denormalizers->get('Symfony\Component\JsonEncoder\Tests\Fixtures\Denormalizer\BooleanStringDenormalizer')->denormalize(\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]), $options); + }, + 'name' => $properties['name'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return strtoupper(\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1])); + }, + 'range' => $properties['range'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes::explodeRange(\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]), $options); + }, + default => null, + }; + } + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes::class, $properties); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_nullable_properties.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_nullable_properties.php new file mode 100644 index 0000000000000..d6c1669323a38 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_nullable_properties.php @@ -0,0 +1,22 @@ +instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNullableProperties::class, \array_filter(['name' => $data['name'] ?? '_symfony_missing_value', 'enum' => \array_key_exists('enum', $data) ? $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum|null']($data['enum']) : '_symfony_missing_value'], static function ($v) { + return '_symfony_missing_value' !== $v; + })); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum'] = static function ($data) { + return \Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum::from($data); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum|null'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + if (\is_int($data)) { + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum']($data); + } + if (null === $data) { + return null; + } + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value for "Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum|null".', \get_debug_type($data))); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNullableProperties'](\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeString((string) $string)); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_nullable_properties.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_nullable_properties.stream.php new file mode 100644 index 0000000000000..def42f303dab1 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_nullable_properties.stream.php @@ -0,0 +1,34 @@ + $v) { + match ($k) { + 'name' => $properties['name'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + 'enum' => $properties['enum'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum|null']($stream, $v[0], $v[1]); + }, + default => null, + }; + } + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNullableProperties::class, $properties); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum'] = static function ($stream, $offset, $length) { + return \Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum::from(\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $offset, $length)); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum|null'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $offset, $length); + if (\is_int($data)) { + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum']($data); + } + if (null === $data) { + return null; + } + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value for "Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum|null".', \get_debug_type($data))); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNullableProperties']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_union.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_union.php new file mode 100644 index 0000000000000..b75387cfc5c3d --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_union.php @@ -0,0 +1,25 @@ +instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithUnionProperties::class, \array_filter(['value' => \array_key_exists('value', $data) ? $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum|null|string']($data['value']) : '_symfony_missing_value'], static function ($v) { + return '_symfony_missing_value' !== $v; + })); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum'] = static function ($data) { + return \Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum::from($data); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum|null|string'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + if (\is_int($data)) { + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum']($data); + } + if (null === $data) { + return null; + } + if (\is_string($data)) { + return $data; + } + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value for "Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum|null|string".', \get_debug_type($data))); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithUnionProperties'](\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeString((string) $string)); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_union.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_union.stream.php new file mode 100644 index 0000000000000..6b2fb975408b2 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/object_with_union.stream.php @@ -0,0 +1,34 @@ + $v) { + match ($k) { + 'value' => $properties['value'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum|null|string']($stream, $v[0], $v[1]); + }, + default => null, + }; + } + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithUnionProperties::class, $properties); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum'] = static function ($stream, $offset, $length) { + return \Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum::from(\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $offset, $length)); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum|null|string'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $offset, $length); + if (\is_int($data)) { + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum']($data); + } + if (null === $data) { + return null; + } + if (\is_string($data)) { + return $data; + } + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value for "Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum|null|string".', \get_debug_type($data))); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithUnionProperties']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/scalar.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/scalar.php new file mode 100644 index 0000000000000..f0645e3f291d1 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/scalar.php @@ -0,0 +1,5 @@ +'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + $iterable = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum']($v); + } + }; + return \iterator_to_array($iterable($data)); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum'] = static function ($data) { + return \Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum::from($data); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes::class, \array_filter(['id' => $data['@id'] ?? '_symfony_missing_value', 'name' => $data['name'] ?? '_symfony_missing_value'], static function ($v) { + return '_symfony_missing_value' !== $v; + })); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes|array|int'] = static function ($data) use ($options, $denormalizers, $instantiator, &$providers) { + if (\is_array($data) && \array_is_list($data)) { + return $providers['array']($data); + } + if (\is_array($data)) { + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes']($data); + } + if (\is_int($data)) { + return $data; + } + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value for "Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes|array|int".', \get_debug_type($data))); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes|array|int'](\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeString((string) $string)); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/union.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/union.stream.php new file mode 100644 index 0000000000000..2505729a456a2 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/decoder/union.stream.php @@ -0,0 +1,46 @@ +'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitList($stream, $offset, $length); + $iterable = static function ($stream, $data) use ($options, $denormalizers, $instantiator, &$providers) { + foreach ($data as $k => $v) { + yield $k => $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum']($stream, $v[0], $v[1]); + } + }; + return \iterator_to_array($iterable($stream, $data)); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum'] = static function ($stream, $offset, $length) { + return \Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum::from(\Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $offset, $length)); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\Splitter::splitDict($stream, $offset, $length); + $properties = []; + foreach ($data as $k => $v) { + match ($k) { + '@id' => $properties['id'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + 'name' => $properties['name'] = static function () use ($stream, $v, $options, $denormalizers, $instantiator, &$providers) { + return \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $v[0], $v[1]); + }, + default => null, + }; + } + return $instantiator->instantiate(\Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes::class, $properties); + }; + $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes|array|int'] = static function ($stream, $offset, $length) use ($options, $denormalizers, $instantiator, &$providers) { + $data = \Symfony\Component\JsonEncoder\Decode\NativeDecoder::decodeStream($stream, $offset, $length); + if (\is_array($data) && \array_is_list($data)) { + return $providers['array']($data); + } + if (\is_array($data)) { + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes']($data); + } + if (\is_int($data)) { + return $data; + } + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value for "Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes|array|int".', \get_debug_type($data))); + }; + return $providers['Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes|array|int']($stream, 0, null); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/backed_enum.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/backed_enum.php new file mode 100644 index 0000000000000..a1a44fe635a11 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/backed_enum.php @@ -0,0 +1,5 @@ +value); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/backed_enum.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/backed_enum.stream.php new file mode 100644 index 0000000000000..a1a44fe635a11 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/backed_enum.stream.php @@ -0,0 +1,5 @@ +value); +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/bool.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/bool.php new file mode 100644 index 0000000000000..2695b4beea962 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/bool.php @@ -0,0 +1,5 @@ + $value) { + $key = \substr(\json_encode($key), 1, -1); + yield "{$prefix}\"{$key}\":"; + yield \json_encode($value); + $prefix = ','; + } + yield '}'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/iterable_dict.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/iterable_dict.php new file mode 100644 index 0000000000000..6eec711284d61 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/iterable_dict.php @@ -0,0 +1,5 @@ + $value) { + $key = \substr(\json_encode($key), 1, -1); + yield "{$prefix}\"{$key}\":"; + yield \json_encode($value); + $prefix = ','; + } + yield '}'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/iterable_list.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/iterable_list.php new file mode 100644 index 0000000000000..6eec711284d61 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/iterable_list.php @@ -0,0 +1,5 @@ +value); + } elseif (null === $data) { + yield 'null'; + } else { + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_backed_enum.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_backed_enum.stream.php new file mode 100644 index 0000000000000..ce558d91ce987 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_backed_enum.stream.php @@ -0,0 +1,11 @@ +value); + } elseif (null === $data) { + yield 'null'; + } else { + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object.php new file mode 100644 index 0000000000000..69cc96454706f --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object.php @@ -0,0 +1,15 @@ +id); + yield ',"name":'; + yield \json_encode($data->name); + yield '}'; + } elseif (null === $data) { + yield 'null'; + } else { + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object.stream.php new file mode 100644 index 0000000000000..69cc96454706f --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object.stream.php @@ -0,0 +1,15 @@ +id); + yield ',"name":'; + yield \json_encode($data->name); + yield '}'; + } elseif (null === $data) { + yield 'null'; + } else { + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_dict.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_dict.php new file mode 100644 index 0000000000000..d52de84897efc --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_dict.php @@ -0,0 +1,23 @@ + $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); + yield '}'; + $prefix = ','; + } + yield '}'; + } elseif (null === $data) { + yield 'null'; + } else { + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_dict.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_dict.stream.php new file mode 100644 index 0000000000000..d52de84897efc --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_dict.stream.php @@ -0,0 +1,23 @@ + $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); + yield '}'; + $prefix = ','; + } + yield '}'; + } elseif (null === $data) { + yield 'null'; + } else { + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_list.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_list.php new file mode 100644 index 0000000000000..e610ff442f855 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_list.php @@ -0,0 +1,22 @@ +id); + yield ',"name":'; + yield \json_encode($value->name); + yield '}'; + $prefix = ','; + } + yield ']'; + } elseif (null === $data) { + yield 'null'; + } else { + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_list.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_list.stream.php new file mode 100644 index 0000000000000..e610ff442f855 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/nullable_object_list.stream.php @@ -0,0 +1,22 @@ +id); + yield ',"name":'; + yield \json_encode($value->name); + yield '}'; + $prefix = ','; + } + yield ']'; + } elseif (null === $data) { + yield 'null'; + } else { + throw new \Symfony\Component\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object.php new file mode 100644 index 0000000000000..5ceace515fe7c --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object.php @@ -0,0 +1,9 @@ +id); + yield ',"name":'; + yield \json_encode($data->name); + yield '}'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object.stream.php new file mode 100644 index 0000000000000..5ceace515fe7c --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object.stream.php @@ -0,0 +1,9 @@ +id); + yield ',"name":'; + yield \json_encode($data->name); + yield '}'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_dict.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_dict.php new file mode 100644 index 0000000000000..7297d6eee139b --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_dict.php @@ -0,0 +1,17 @@ + $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); + yield '}'; + $prefix = ','; + } + yield '}'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_dict.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_dict.stream.php new file mode 100644 index 0000000000000..7297d6eee139b --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_dict.stream.php @@ -0,0 +1,17 @@ + $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); + yield '}'; + $prefix = ','; + } + yield '}'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_in_object.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_in_object.php new file mode 100644 index 0000000000000..b2472d17bb843 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_in_object.php @@ -0,0 +1,13 @@ +name); + yield ',"otherDummyOne":{"@id":'; + yield \json_encode($data->otherDummyOne->id); + yield ',"name":'; + yield \json_encode($data->otherDummyOne->name); + yield '},"otherDummyTwo":'; + yield \json_encode($data->otherDummyTwo); + yield '}'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_in_object.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_in_object.stream.php new file mode 100644 index 0000000000000..8815a1c2d2f63 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_in_object.stream.php @@ -0,0 +1,15 @@ +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 '}}'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_list.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_list.php new file mode 100644 index 0000000000000..73c8517f7b755 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_list.php @@ -0,0 +1,16 @@ +id); + yield ',"name":'; + yield \json_encode($value->name); + yield '}'; + $prefix = ','; + } + yield ']'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_list.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_list.stream.php new file mode 100644 index 0000000000000..73c8517f7b755 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_list.stream.php @@ -0,0 +1,16 @@ +id); + yield ',"name":'; + yield \json_encode($value->name); + yield '}'; + $prefix = ','; + } + yield ']'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_normalizer.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_normalizer.php new file mode 100644 index 0000000000000..194dbfa14d8ad --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_normalizer.php @@ -0,0 +1,13 @@ +get('Symfony\Component\JsonEncoder\Tests\Fixtures\Normalizer\DoubleIntAndCastToStringNormalizer')->normalize($data->id, $options)); + yield ',"active":'; + yield \json_encode($normalizers->get('Symfony\Component\JsonEncoder\Tests\Fixtures\Normalizer\BooleanStringNormalizer')->normalize($data->active, $options)); + yield ',"name":'; + yield \json_encode(strtolower($data->name)); + yield ',"range":'; + yield \json_encode(Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes::concatRange($data->range, $options)); + yield '}'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_normalizer.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_normalizer.stream.php new file mode 100644 index 0000000000000..194dbfa14d8ad --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_normalizer.stream.php @@ -0,0 +1,13 @@ +get('Symfony\Component\JsonEncoder\Tests\Fixtures\Normalizer\DoubleIntAndCastToStringNormalizer')->normalize($data->id, $options)); + yield ',"active":'; + yield \json_encode($normalizers->get('Symfony\Component\JsonEncoder\Tests\Fixtures\Normalizer\BooleanStringNormalizer')->normalize($data->active, $options)); + yield ',"name":'; + yield \json_encode(strtolower($data->name)); + yield ',"range":'; + yield \json_encode(Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes::concatRange($data->range, $options)); + yield '}'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_union.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_union.php new file mode 100644 index 0000000000000..b1dd0c6480b2a --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_union.php @@ -0,0 +1,15 @@ +value instanceof \Symfony\Component\JsonEncoder\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\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data->value))); + } + yield '}'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_union.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_union.stream.php new file mode 100644 index 0000000000000..b1dd0c6480b2a --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/object_with_union.stream.php @@ -0,0 +1,15 @@ +value instanceof \Symfony\Component\JsonEncoder\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\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data->value))); + } + yield '}'; +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/scalar.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/scalar.php new file mode 100644 index 0000000000000..6eec711284d61 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/scalar.php @@ -0,0 +1,5 @@ +value); + $prefix = ','; + } + yield ']'; + } elseif ($data instanceof \Symfony\Component\JsonEncoder\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\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/union.stream.php b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/union.stream.php new file mode 100644 index 0000000000000..5b74ee3f83066 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Fixtures/encoder/union.stream.php @@ -0,0 +1,24 @@ +value); + $prefix = ','; + } + yield ']'; + } elseif ($data instanceof \Symfony\Component\JsonEncoder\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\JsonEncoder\Exception\UnexpectedValueException(\sprintf('Unexpected "%s" value.', \get_debug_type($data))); + } +}; diff --git a/src/Symfony/Component/JsonEncoder/Tests/JsonDecoderTest.php b/src/Symfony/Component/JsonEncoder/Tests/JsonDecoderTest.php new file mode 100644 index 0000000000000..b0a1b3d12ed1e --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/JsonDecoderTest.php @@ -0,0 +1,192 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\JsonDecoder; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Denormalizer\BooleanStringDenormalizer; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Denormalizer\DivideStringAndCastToIntDenormalizer; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithDateTimes; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNullableProperties; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithPhpDoc; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeIdentifier; + +class JsonDecoderTest extends TestCase +{ + private string $decodersDir; + private string $lazyGhostsDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->decodersDir = \sprintf('%s/symfony_json_encoder_test/decoder', sys_get_temp_dir()); + $this->lazyGhostsDir = \sprintf('%s/symfony_json_encoder_test/lazy_ghost', sys_get_temp_dir()); + + if (is_dir($this->decodersDir)) { + array_map('unlink', glob($this->decodersDir.'/*')); + rmdir($this->decodersDir); + } + + if (is_dir($this->lazyGhostsDir)) { + array_map('unlink', glob($this->lazyGhostsDir.'/*')); + rmdir($this->lazyGhostsDir); + } + } + + public function testDecodeScalar() + { + $decoder = JsonDecoder::create(decodersDir: $this->decodersDir, lazyGhostsDir: $this->lazyGhostsDir); + + $this->assertDecoded($decoder, null, 'null', Type::nullable(Type::int())); + $this->assertDecoded($decoder, true, 'true', Type::bool()); + $this->assertDecoded($decoder, [['foo' => 1, 'bar' => 2], ['foo' => 3]], '[{"foo": 1, "bar": 2}, {"foo": 3}]', Type::builtin(TypeIdentifier::ARRAY)); + $this->assertDecoded($decoder, [['foo' => 1, 'bar' => 2], ['foo' => 3]], '[{"foo": 1, "bar": 2}, {"foo": 3}]', Type::builtin(TypeIdentifier::ITERABLE)); + $this->assertDecoded($decoder, (object) ['foo' => 'bar'], '{"foo": "bar"}', Type::object()); + $this->assertDecoded($decoder, DummyBackedEnum::ONE, '1', Type::enum(DummyBackedEnum::class, Type::string())); + } + + public function testDecodeCollection() + { + $decoder = JsonDecoder::create(decodersDir: $this->decodersDir, lazyGhostsDir: $this->lazyGhostsDir); + + $this->assertDecoded($decoder, [['foo' => 1, 'bar' => 2], ['foo' => 3]], '[{"foo": 1, "bar": 2}, {"foo": 3}]', Type::list(Type::dict(Type::int()))); + $this->assertDecoded($decoder, function (mixed $decoded) { + $this->assertIsIterable($decoded); + $array = []; + foreach ($decoded as $item) { + $array[] = iterator_to_array($item); + } + + $this->assertSame([['foo' => 1, 'bar' => 2], ['foo' => 3]], $array); + }, '[{"foo": 1, "bar": 2}, {"foo": 3}]', Type::iterable(Type::iterable(Type::int()), Type::int(), asList: true)); + } + + public function testDecodeObject() + { + $decoder = JsonDecoder::create(decodersDir: $this->decodersDir, lazyGhostsDir: $this->lazyGhostsDir); + + $this->assertDecoded($decoder, function (mixed $decoded) { + $this->assertInstanceOf(ClassicDummy::class, $decoded); + $this->assertSame(10, $decoded->id); + $this->assertSame('dummy name', $decoded->name); + }, '{"id": 10, "name": "dummy name"}', Type::object(ClassicDummy::class)); + } + + public function testDecodeObjectWithEncodedName() + { + $decoder = JsonDecoder::create(decodersDir: $this->decodersDir, lazyGhostsDir: $this->lazyGhostsDir); + + $this->assertDecoded($decoder, function (mixed $decoded) { + $this->assertInstanceOf(DummyWithNameAttributes::class, $decoded); + $this->assertSame(10, $decoded->id); + }, '{"@id": 10}', Type::object(DummyWithNameAttributes::class)); + } + + public function testDecodeObjectWithDenormalizer() + { + $decoder = JsonDecoder::create( + denormalizers: [ + BooleanStringDenormalizer::class => new BooleanStringDenormalizer(), + DivideStringAndCastToIntDenormalizer::class => new DivideStringAndCastToIntDenormalizer(), + ], + decodersDir: $this->decodersDir, + lazyGhostsDir: $this->lazyGhostsDir, + ); + + $this->assertDecoded($decoder, function (mixed $decoded) { + $this->assertInstanceOf(DummyWithNormalizerAttributes::class, $decoded); + $this->assertSame(10, $decoded->id); + $this->assertTrue($decoded->active); + $this->assertSame('LOWERCASE NAME', $decoded->name); + $this->assertSame([0, 1], $decoded->range); + }, '{"id": "20", "active": "true", "name": "lowercase name", "range": "0..1"}', Type::object(DummyWithNormalizerAttributes::class), ['scale' => 1]); + } + + public function testDecodeObjectWithPhpDoc() + { + $decoder = JsonDecoder::create(decodersDir: $this->decodersDir, lazyGhostsDir: $this->lazyGhostsDir); + + $this->assertDecoded($decoder, function (mixed $decoded) { + $this->assertInstanceOf(DummyWithPhpDoc::class, $decoded); + $this->assertIsArray($decoded->arrayOfDummies); + $this->assertContainsOnlyInstancesOf(DummyWithNameAttributes::class, $decoded->arrayOfDummies); + $this->assertArrayHasKey('key', $decoded->arrayOfDummies); + }, '{"arrayOfDummies":{"key":{"@id":10,"name":"dummy"}}}', Type::object(DummyWithPhpDoc::class)); + } + + public function testDecodeObjectWithNullableProperties() + { + $decoder = JsonDecoder::create(decodersDir: $this->decodersDir, lazyGhostsDir: $this->lazyGhostsDir); + + $this->assertDecoded($decoder, function (mixed $decoded) { + $this->assertInstanceOf(DummyWithNullableProperties::class, $decoded); + $this->assertNull($decoded->name); + $this->assertNull($decoded->enum); + }, '{"name":null,"enum":null}', Type::object(DummyWithNullableProperties::class)); + } + + public function testDecodeObjectWithDateTimes() + { + $decoder = JsonDecoder::create(decodersDir: $this->decodersDir, lazyGhostsDir: $this->lazyGhostsDir); + + $this->assertDecoded($decoder, function (mixed $decoded) { + $this->assertInstanceOf(DummyWithDateTimes::class, $decoded); + $this->assertEquals(new \DateTimeImmutable('2024-11-20'), $decoded->interface); + $this->assertEquals(new \DateTimeImmutable('2025-11-20'), $decoded->immutable); + $this->assertEquals(new \DateTime('2024-10-05'), $decoded->mutable); + }, '{"interface":"2024-11-20","immutable":"2025-11-20","mutable":"2024-10-05"}', Type::object(DummyWithDateTimes::class)); + } + + public function testCreateDecoderFile() + { + $decoder = JsonDecoder::create(decodersDir: $this->decodersDir, lazyGhostsDir: $this->lazyGhostsDir); + + $decoder->decode('true', Type::bool()); + + $this->assertFileExists($this->decodersDir); + $this->assertCount(1, glob($this->decodersDir.'/*')); + } + + public function testCreateDecoderFileOnlyIfNotExists() + { + $decoder = JsonDecoder::create(decodersDir: $this->decodersDir, lazyGhostsDir: $this->lazyGhostsDir); + + if (!file_exists($this->decodersDir)) { + mkdir($this->decodersDir, recursive: true); + } + + file_put_contents( + \sprintf('%s%s%s.json.php', $this->decodersDir, \DIRECTORY_SEPARATOR, hash('xxh128', (string) Type::bool())), + 'assertSame('CACHED', $decoder->decode('true', Type::bool())); + } + + private function assertDecoded(JsonDecoder $decoder, mixed $decodedOrAssert, string $encoded, Type $type, array $options = []): void + { + $assert = \is_callable($decodedOrAssert, syntax_only: true) ? $decodedOrAssert : fn (mixed $decoded) => $this->assertEquals($decodedOrAssert, $decoded); + + $assert($decoder->decode($encoded, $type, $options)); + + $resource = fopen('php://temp', 'w'); + fwrite($resource, $encoded); + rewind($resource); + $assert($decoder->decode($resource, $type, $options)); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/JsonEncoderTest.php b/src/Symfony/Component/JsonEncoder/Tests/JsonEncoderTest.php new file mode 100644 index 0000000000000..34e3373f6d332 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/JsonEncoderTest.php @@ -0,0 +1,209 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Encode\Normalizer\DateTimeNormalizer; +use Symfony\Component\JsonEncoder\Encode\Normalizer\NormalizerInterface; +use Symfony\Component\JsonEncoder\Exception\MaxDepthException; +use Symfony\Component\JsonEncoder\JsonEncoder; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Enum\DummyBackedEnum; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithDateTimes; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNullableProperties; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithPhpDoc; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithUnionProperties; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\SelfReferencingDummy; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Normalizer\BooleanStringNormalizer; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Normalizer\DoubleIntAndCastToStringNormalizer; +use Symfony\Component\TypeInfo\Type; + +class JsonEncoderTest extends TestCase +{ + private string $encodersDir; + + protected function setUp(): void + { + parent::setUp(); + + $this->encodersDir = \sprintf('%s/symfony_json_encoder_test/encoder', sys_get_temp_dir()); + + if (is_dir($this->encodersDir)) { + array_map('unlink', glob($this->encodersDir.'/*')); + rmdir($this->encodersDir); + } + } + + public function testReturnTraversableStringableEncoded() + { + $encoder = JsonEncoder::create(encodersDir: $this->encodersDir); + + $this->assertSame(['true'], iterator_to_array($encoder->encode(true, Type::bool()))); + $this->assertSame('true', (string) $encoder->encode(true, Type::bool())); + } + + public function testEncodeScalar() + { + $this->assertEncoded('null', null, Type::null()); + $this->assertEncoded('true', true, Type::bool()); + $this->assertEncoded('[{"foo":1,"bar":2},{"foo":3}]', [['foo' => 1, 'bar' => 2], ['foo' => 3]], Type::list()); + $this->assertEncoded('{"foo":"bar"}', (object) ['foo' => 'bar'], Type::object()); + $this->assertEncoded('1', DummyBackedEnum::ONE, Type::enum(DummyBackedEnum::class)); + } + + public function testEncodeUnion() + { + $this->assertEncoded( + '[1,true,["foo","bar"]]', + [DummyBackedEnum::ONE, true, ['foo', 'bar']], + Type::list(Type::union(Type::enum(DummyBackedEnum::class), Type::bool(), Type::list(Type::string()))), + ); + + $dummy = new DummyWithUnionProperties(); + $dummy->value = DummyBackedEnum::ONE; + $this->assertEncoded('{"value":1}', $dummy, Type::object(DummyWithUnionProperties::class)); + + $dummy->value = 'foo'; + $this->assertEncoded('{"value":"foo"}', $dummy, Type::object(DummyWithUnionProperties::class)); + + $dummy->value = null; + $this->assertEncoded('{"value":null}', $dummy, Type::object(DummyWithUnionProperties::class)); + } + + public function testEncodeObject() + { + $dummy = new ClassicDummy(); + $dummy->id = 10; + $dummy->name = 'dummy name'; + + $this->assertEncoded('{"id":10,"name":"dummy name"}', $dummy, Type::object(ClassicDummy::class)); + } + + public function testEncodeObjectWithEncodedName() + { + $dummy = new DummyWithNameAttributes(); + $dummy->id = 10; + $dummy->name = 'dummy name'; + + $this->assertEncoded('{"@id":10,"name":"dummy name"}', $dummy, Type::object(DummyWithNameAttributes::class)); + } + + public function testEncodeObjectWithNormalizer() + { + $dummy = new DummyWithNormalizerAttributes(); + $dummy->id = 10; + $dummy->active = true; + + $this->assertEncoded( + '{"id":"20","active":"true","name":"dummy","range":"10..20"}', + $dummy, + Type::object(DummyWithNormalizerAttributes::class), + options: ['scale' => 1], + normalizers: [ + BooleanStringNormalizer::class => new BooleanStringNormalizer(), + DoubleIntAndCastToStringNormalizer::class => new DoubleIntAndCastToStringNormalizer(), + ], + ); + } + + public function testEncodeObjectWithPhpDoc() + { + $dummy = new DummyWithPhpDoc(); + $dummy->arrayOfDummies = ['key' => new DummyWithNameAttributes()]; + + $this->assertEncoded('{"arrayOfDummies":{"key":{"@id":1,"name":"dummy"}},"array":[]}', $dummy, Type::object(DummyWithPhpDoc::class)); + } + + public function testEncodeObjectWithNullableProperties() + { + $dummy = new DummyWithNullableProperties(); + + $this->assertEncoded('{"name":null,"enum":null}', $dummy, Type::object(DummyWithNullableProperties::class)); + } + + public function testEncodeObjectWithDateTimes() + { + $mutableDate = new \DateTime('2024-11-20'); + $immutableDate = \DateTimeImmutable::createFromMutable($mutableDate); + + $dummy = new DummyWithDateTimes(); + $dummy->interface = $immutableDate; + $dummy->immutable = $immutableDate; + $dummy->mutable = $mutableDate; + + $this->assertEncoded( + '{"interface":"2024-11-20","immutable":"2024-11-20","mutable":"2024-11-20"}', + $dummy, + Type::object(DummyWithDateTimes::class), + options: [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'], + ); + } + + public function testThrowWhenMaxDepthIsReached() + { + $encoder = JsonEncoder::create(encodersDir: $this->encodersDir); + + $dummy = new SelfReferencingDummy(); + for ($i = 0; $i < 512; ++$i) { + $tmp = new SelfReferencingDummy(); + $tmp->self = $dummy; + + $dummy = $tmp; + } + + $this->expectException(MaxDepthException::class); + $this->expectExceptionMessage('Max depth of 512 has been reached.'); + + (string) $encoder->encode($dummy, Type::object(SelfReferencingDummy::class)); + } + + public function testCreateEncoderFile() + { + $encoder = JsonEncoder::create(encodersDir: $this->encodersDir); + + $encoder->encode(true, Type::bool()); + + $this->assertFileExists($this->encodersDir); + $this->assertCount(1, glob($this->encodersDir.'/*')); + } + + public function testCreateEncoderFileOnlyIfNotExists() + { + $encoder = JsonEncoder::create(encodersDir: $this->encodersDir); + + if (!file_exists($this->encodersDir)) { + mkdir($this->encodersDir, recursive: true); + } + + file_put_contents( + \sprintf('%s%s%s.json.php', $this->encodersDir, \DIRECTORY_SEPARATOR, hash('xxh128', (string) Type::bool())), + 'assertSame('CACHED', (string) $encoder->encode(true, Type::bool())); + } + + /** + * @param array $options + * @param array $normalizers + */ + private function assertEncoded(string $expected, mixed $data, Type $type, array $options = [], array $normalizers = []): void + { + $encoder = JsonEncoder::create(encodersDir: $this->encodersDir, normalizers: $normalizers); + $this->assertSame($expected, (string) $encoder->encode($data, $type, $options)); + + $encoder = JsonEncoder::create(encodersDir: $this->encodersDir, normalizers: $normalizers, forceEncodeChunks: true); + $this->assertSame($expected, (string) $encoder->encode($data, $type, $options)); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Mapping/Decode/AttributePropertyMetadataLoaderTest.php b/src/Symfony/Component/JsonEncoder/Tests/Mapping/Decode/AttributePropertyMetadataLoaderTest.php new file mode 100644 index 0000000000000..7925a610a1cc3 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Mapping/Decode/AttributePropertyMetadataLoaderTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Mapping\Decode; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Decode\Denormalizer\DenormalizerInterface; +use Symfony\Component\JsonEncoder\Exception\InvalidArgumentException; +use Symfony\Component\JsonEncoder\Mapping\Decode\AttributePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadata; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Denormalizer\BooleanStringDenormalizer; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Denormalizer\DivideStringAndCastToIntDenormalizer; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes; +use Symfony\Component\JsonEncoder\Tests\ServiceContainer; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; + +class AttributePropertyMetadataLoaderTest extends TestCase +{ + public function testRetrieveEncodedName() + { + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader(TypeResolver::create()), new ServiceContainer(), TypeResolver::create()); + + $this->assertSame(['@id', 'name'], array_keys($loader->load(DummyWithNameAttributes::class))); + } + + public function testRetrieveDenormalizer() + { + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader(TypeResolver::create()), new ServiceContainer([ + DivideStringAndCastToIntDenormalizer::class => new DivideStringAndCastToIntDenormalizer(), + BooleanStringDenormalizer::class => new BooleanStringDenormalizer(), + ]), TypeResolver::create()); + + $this->assertEquals([ + 'id' => new PropertyMetadata('id', Type::string(), [], [DivideStringAndCastToIntDenormalizer::class]), + 'active' => new PropertyMetadata('active', Type::string(), [], [BooleanStringDenormalizer::class]), + 'name' => new PropertyMetadata('name', Type::string(), [], [\Closure::fromCallable('strtolower')]), + 'range' => new PropertyMetadata('range', Type::string(), [], [\Closure::fromCallable(DummyWithNormalizerAttributes::concatRange(...))]), + ], $loader->load(DummyWithNormalizerAttributes::class)); + } + + public function testThrowWhenCannotRetrieveDenormalizer() + { + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader(TypeResolver::create()), new ServiceContainer(), TypeResolver::create()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('You have requested a non-existent denormalizer service "%s". Did you implement "%s"?', DivideStringAndCastToIntDenormalizer::class, DenormalizerInterface::class)); + + $loader->load(DummyWithNormalizerAttributes::class); + } + + public function testThrowWhenInvaliDenormalizer() + { + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader(TypeResolver::create()), new ServiceContainer([ + DivideStringAndCastToIntDenormalizer::class => true, + BooleanStringDenormalizer::class => new BooleanStringDenormalizer(), + ]), TypeResolver::create()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The "%s" denormalizer service does not implement "%s".', DivideStringAndCastToIntDenormalizer::class, DenormalizerInterface::class)); + + $loader->load(DummyWithNormalizerAttributes::class); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Mapping/Decode/DateTimeTypePropertyMetadataLoaderTest.php b/src/Symfony/Component/JsonEncoder/Tests/Mapping/Decode/DateTimeTypePropertyMetadataLoaderTest.php new file mode 100644 index 0000000000000..223eb053e85ef --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Mapping/Decode/DateTimeTypePropertyMetadataLoaderTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Mapping\Decode; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Mapping\Decode\DateTimeTypePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadata; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\TypeInfo\Type; + +class DateTimeTypePropertyMetadataLoaderTest extends TestCase +{ + public function testAddDateTimeDenormalizer() + { + $loader = new DateTimeTypePropertyMetadataLoader(self::propertyMetadataLoader([ + 'interface' => new PropertyMetadata('interface', Type::object(\DateTimeInterface::class)), + 'immutable' => new PropertyMetadata('immutable', Type::object(\DateTimeImmutable::class)), + 'mutable' => new PropertyMetadata('mutable', Type::object(\DateTime::class)), + 'other' => new PropertyMetadata('other', Type::object(self::class)), + ])); + + $this->assertEquals([ + 'interface' => new PropertyMetadata('interface', Type::string(), [], ['json_encoder.denormalizer.date_time_immutable']), + 'immutable' => new PropertyMetadata('immutable', Type::string(), [], ['json_encoder.denormalizer.date_time_immutable']), + 'mutable' => new PropertyMetadata('mutable', Type::string(), [], ['json_encoder.denormalizer.date_time']), + 'other' => new PropertyMetadata('other', Type::object(self::class)), + ], $loader->load(self::class)); + } + + /** + * @param array $propertiesMetadata + */ + private static function propertyMetadataLoader(array $propertiesMetadata = []): PropertyMetadataLoaderInterface + { + return new class($propertiesMetadata) implements PropertyMetadataLoaderInterface { + public function __construct(private array $propertiesMetadata) + { + } + + public function load(string $className, array $options = [], array $context = []): array + { + return $this->propertiesMetadata; + } + }; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Mapping/Encode/AttributePropertyMetadataLoaderTest.php b/src/Symfony/Component/JsonEncoder/Tests/Mapping/Encode/AttributePropertyMetadataLoaderTest.php new file mode 100644 index 0000000000000..0567d7456a296 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Mapping/Encode/AttributePropertyMetadataLoaderTest.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Mapping\Encode; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Encode\Normalizer\NormalizerInterface; +use Symfony\Component\JsonEncoder\Exception\InvalidArgumentException; +use Symfony\Component\JsonEncoder\Mapping\Encode\AttributePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadata; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNameAttributes; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithNormalizerAttributes; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Normalizer\BooleanStringNormalizer; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Normalizer\DoubleIntAndCastToStringNormalizer; +use Symfony\Component\JsonEncoder\Tests\ServiceContainer; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; + +class AttributePropertyMetadataLoaderTest extends TestCase +{ + public function testRetrieveEncodedName() + { + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader(TypeResolver::create()), new ServiceContainer(), TypeResolver::create()); + + $this->assertSame(['@id', 'name'], array_keys($loader->load(DummyWithNameAttributes::class))); + } + + public function testRetrieveNormalizer() + { + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader(TypeResolver::create()), new ServiceContainer([ + DoubleIntAndCastToStringNormalizer::class => new DoubleIntAndCastToStringNormalizer(), + BooleanStringNormalizer::class => new BooleanStringNormalizer(), + ]), TypeResolver::create()); + + $this->assertEquals([ + 'id' => new PropertyMetadata('id', Type::string(), [DoubleIntAndCastToStringNormalizer::class]), + 'active' => new PropertyMetadata('active', Type::string(), [BooleanStringNormalizer::class]), + 'name' => new PropertyMetadata('name', Type::string(), [\Closure::fromCallable('strtolower')]), + 'range' => new PropertyMetadata('range', Type::string(), [\Closure::fromCallable(DummyWithNormalizerAttributes::concatRange(...))]), + ], $loader->load(DummyWithNormalizerAttributes::class)); + } + + public function testThrowWhenCannotRetrieveNormalizer() + { + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader(TypeResolver::create()), new ServiceContainer(), TypeResolver::create()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('You have requested a non-existent normalizer service "%s". Did you implement "%s"?', DoubleIntAndCastToStringNormalizer::class, NormalizerInterface::class)); + + $loader->load(DummyWithNormalizerAttributes::class); + } + + public function testThrowWhenInvalidNormalizer() + { + $loader = new AttributePropertyMetadataLoader(new PropertyMetadataLoader(TypeResolver::create()), new ServiceContainer([ + DoubleIntAndCastToStringNormalizer::class => true, + BooleanStringNormalizer::class => new BooleanStringNormalizer(), + ]), TypeResolver::create()); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The "%s" normalizer service does not implement "%s".', DoubleIntAndCastToStringNormalizer::class, NormalizerInterface::class)); + + $loader->load(DummyWithNormalizerAttributes::class); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Mapping/Encode/DateTimeTypePropertyMetadataLoaderTest.php b/src/Symfony/Component/JsonEncoder/Tests/Mapping/Encode/DateTimeTypePropertyMetadataLoaderTest.php new file mode 100644 index 0000000000000..580f48f11ee26 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Mapping/Encode/DateTimeTypePropertyMetadataLoaderTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Mapping\Encode; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Mapping\Encode\DateTimeTypePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadata; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\TypeInfo\Type; + +class DateTimeTypePropertyMetadataLoaderTest extends TestCase +{ + public function testAddDateTimeNormalizer() + { + $loader = new DateTimeTypePropertyMetadataLoader(self::propertyMetadataLoader([ + 'dateTime' => new PropertyMetadata('dateTime', Type::object(\DateTimeImmutable::class)), + 'other' => new PropertyMetadata('other', Type::object(self::class)), + ])); + + $this->assertEquals([ + 'dateTime' => new PropertyMetadata('dateTime', Type::string(), ['json_encoder.normalizer.date_time']), + 'other' => new PropertyMetadata('other', Type::object(self::class)), + ], $loader->load(self::class)); + } + + /** + * @param array $propertiesMetadata + */ + private static function propertyMetadataLoader(array $propertiesMetadata = []): PropertyMetadataLoaderInterface + { + return new class($propertiesMetadata) implements PropertyMetadataLoaderInterface { + public function __construct(private array $propertiesMetadata) + { + } + + public function load(string $className, array $options = [], array $context = []): array + { + return $this->propertiesMetadata; + } + }; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Mapping/GenericTypePropertyMetadataLoaderTest.php b/src/Symfony/Component/JsonEncoder/Tests/Mapping/GenericTypePropertyMetadataLoaderTest.php new file mode 100644 index 0000000000000..2bab9f1b04d57 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Mapping/GenericTypePropertyMetadataLoaderTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Mapping; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Mapping\GenericTypePropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadata; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoaderInterface; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\DummyWithGenerics; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeContext\TypeContextFactory; +use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; + +class GenericTypePropertyMetadataLoaderTest extends TestCase +{ + public function testReplaceGenerics() + { + $loader = new GenericTypePropertyMetadataLoader(self::propertyMetadataLoader([ + 'foo' => new PropertyMetadata('foo', Type::template('T')), + ]), new TypeContextFactory(new StringTypeResolver())); + + $metadata = $loader->load(DummyWithGenerics::class, context: ['original_type' => Type::generic(Type::object(DummyWithGenerics::class), Type::int())]); + $this->assertEquals(['foo' => new PropertyMetadata('foo', Type::int())], $metadata); + + $metadata = $loader->load(DummyWithGenerics::class, context: ['original_type' => Type::generic(Type::object(\stdClass::class), Type::generic(Type::object(DummyWithGenerics::class), Type::int()))]); + $this->assertEquals(['foo' => new PropertyMetadata('foo', Type::int())], $metadata); + + $metadata = $loader->load(DummyWithGenerics::class, context: ['original_type' => Type::list(Type::generic(Type::object(DummyWithGenerics::class), Type::int()))]); + $this->assertEquals(['foo' => new PropertyMetadata('foo', Type::int())], $metadata); + + $metadata = $loader->load(DummyWithGenerics::class, context: ['original_type' => Type::union(Type::string(), Type::generic(Type::object(DummyWithGenerics::class), Type::int()))]); + $this->assertEquals(['foo' => new PropertyMetadata('foo', Type::int())], $metadata); + + $metadata = $loader->load(DummyWithGenerics::class, context: ['original_type' => Type::intersection(Type::object(\stdClass::class), Type::generic(Type::object(DummyWithGenerics::class), Type::int()))]); + $this->assertEquals(['foo' => new PropertyMetadata('foo', Type::int())], $metadata); + } + + /** + * @param array $propertiesMetadata + */ + private static function propertyMetadataLoader(array $propertiesMetadata = []): PropertyMetadataLoaderInterface + { + return new class($propertiesMetadata) implements PropertyMetadataLoaderInterface { + public function __construct(private array $propertiesMetadata) + { + } + + public function load(string $className, array $options = [], array $context = []): array + { + return $this->propertiesMetadata; + } + }; + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/Mapping/PropertyMetadataLoaderTest.php b/src/Symfony/Component/JsonEncoder/Tests/Mapping/PropertyMetadataLoaderTest.php new file mode 100644 index 0000000000000..00c8294ae701f --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/Mapping/PropertyMetadataLoaderTest.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests\Mapping; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadata; +use Symfony\Component\JsonEncoder\Mapping\PropertyMetadataLoader; +use Symfony\Component\JsonEncoder\Tests\Fixtures\Model\ClassicDummy; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; + +class PropertyMetadataLoaderTest extends TestCase +{ + public function testReadPropertyType() + { + $loader = new PropertyMetadataLoader(TypeResolver::create()); + + $this->assertEquals([ + 'id' => new PropertyMetadata('id', Type::int()), + 'name' => new PropertyMetadata('name', Type::string()), + ], $loader->load(ClassicDummy::class)); + } +} diff --git a/src/Symfony/Component/JsonEncoder/Tests/ServiceContainer.php b/src/Symfony/Component/JsonEncoder/Tests/ServiceContainer.php new file mode 100644 index 0000000000000..27a7944bf688e --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/Tests/ServiceContainer.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\JsonEncoder\Tests; + +use Psr\Container\ContainerInterface; + +/** + * A basic container implementation. + */ +class ServiceContainer implements ContainerInterface +{ + /** + * @param array $services + */ + public function __construct( + private array $services = [], + ) { + } + + public function has(string $id): bool + { + return isset($this->services[$id]); + } + + public function get(string $id): mixed + { + return $this->services[$id]; + } +} diff --git a/src/Symfony/Component/JsonEncoder/composer.json b/src/Symfony/Component/JsonEncoder/composer.json new file mode 100644 index 0000000000000..5189af90a923a --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/composer.json @@ -0,0 +1,36 @@ +{ + "name": "symfony/json-encoder", + "type": "library", + "description": "Provides powerful methods to encode/decode data structures into/from JSON.", + "keywords": ["encoding", "decoding", "json"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "nikic/php-parser": "^5.3", + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "psr/log": "^1|^2|^3", + "symfony/filesystem": "^7.2", + "symfony/type-info": "^7.2", + "symfony/var-exporter": "^7.2" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.0", + "symfony/http-kernel": "^7.2" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\JsonEncoder\\": "" }, + "exclude-from-classmap": [ "Tests/" ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/JsonEncoder/phpunit.xml.dist b/src/Symfony/Component/JsonEncoder/phpunit.xml.dist new file mode 100644 index 0000000000000..91cb9a7aaee58 --- /dev/null +++ b/src/Symfony/Component/JsonEncoder/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + +