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 + + + pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy