From f28e826627e8d0db7db43a11e6e8d9b568e7870e Mon Sep 17 00:00:00 2001 From: Fred Cox Date: Tue, 4 Sep 2018 20:45:44 +0300 Subject: [PATCH] [Serializer] Encode empty objects as objects, not arrays Allows Normalizers to return a representation of an empty object that the encoder recognizes as such. --- .../Serializer/Encoder/CsvEncoder.php | 8 ++-- .../Serializer/Encoder/YamlEncoder.php | 7 ++++ .../Normalizer/AbstractObjectNormalizer.php | 6 +++ .../Normalizer/NormalizerInterface.php | 2 +- .../Tests/Encoder/CsvEncoderTest.php | 37 +++++++++++++++++++ .../Tests/Encoder/JsonEncodeTest.php | 2 + .../Tests/Encoder/XmlEncoderTest.php | 20 ++++++++++ .../Tests/Encoder/YamlEncoderTest.php | 2 + .../AbstractObjectNormalizerTest.php | 17 +++++++++ .../Serializer/Tests/SerializerTest.php | 13 +++++++ 10 files changed, 109 insertions(+), 5 deletions(-) diff --git a/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php b/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php index 7333e8015a..64626084ee 100644 --- a/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php +++ b/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php @@ -69,7 +69,7 @@ class CsvEncoder implements EncoderInterface, DecoderInterface { $handle = fopen('php://temp,', 'w+'); - if (!\is_array($data)) { + if (!is_iterable($data)) { $data = [[$data]]; } elseif (empty($data)) { $data = [[]]; @@ -210,10 +210,10 @@ class CsvEncoder implements EncoderInterface, DecoderInterface /** * Flattens an array and generates keys including the path. */ - private function flatten(array $array, array &$result, string $keySeparator, string $parentKey = '', bool $escapeFormulas = false) + private function flatten(iterable $array, array &$result, string $keySeparator, string $parentKey = '', bool $escapeFormulas = false) { foreach ($array as $key => $value) { - if (\is_array($value)) { + if (is_iterable($value)) { $this->flatten($value, $result, $keySeparator, $parentKey.$key.$keySeparator, $escapeFormulas); } else { if ($escapeFormulas && \in_array(substr((string) $value, 0, 1), $this->formulasStartCharacters, true)) { @@ -245,7 +245,7 @@ class CsvEncoder implements EncoderInterface, DecoderInterface /** * @return string[] */ - private function extractHeaders(array $data): array + private function extractHeaders(iterable $data): array { $headers = []; $flippedHeaders = []; diff --git a/src/Symfony/Component/Serializer/Encoder/YamlEncoder.php b/src/Symfony/Component/Serializer/Encoder/YamlEncoder.php index dc0bf7fe41..d17ba6b355 100644 --- a/src/Symfony/Component/Serializer/Encoder/YamlEncoder.php +++ b/src/Symfony/Component/Serializer/Encoder/YamlEncoder.php @@ -14,6 +14,7 @@ namespace Symfony\Component\Serializer\Encoder; use Symfony\Component\Serializer\Exception\RuntimeException; use Symfony\Component\Yaml\Dumper; use Symfony\Component\Yaml\Parser; +use Symfony\Component\Yaml\Yaml; /** * Encodes YAML data. @@ -25,6 +26,8 @@ class YamlEncoder implements EncoderInterface, DecoderInterface const FORMAT = 'yaml'; private const ALTERNATIVE_FORMAT = 'yml'; + public const PRESERVE_EMPTY_OBJECTS = 'preserve_empty_objects'; + private $dumper; private $parser; private $defaultContext = ['yaml_inline' => 0, 'yaml_indent' => 0, 'yaml_flags' => 0]; @@ -47,6 +50,10 @@ class YamlEncoder implements EncoderInterface, DecoderInterface { $context = array_merge($this->defaultContext, $context); + if (isset($context[self::PRESERVE_EMPTY_OBJECTS])) { + $context['yaml_flags'] |= Yaml::DUMP_OBJECT_AS_MAP; + } + return $this->dumper->dump($data, $context['yaml_inline'], $context['yaml_indent'], $context['yaml_flags']); } diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index 61311817de..b4195230fb 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -88,6 +88,8 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer */ public const DEEP_OBJECT_TO_POPULATE = 'deep_object_to_populate'; + public const PRESERVE_EMPTY_OBJECTS = 'preserve_empty_objects'; + private $propertyTypeExtractor; private $typesCache = []; private $attributesCache = []; @@ -206,6 +208,10 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer $data = $this->updateData($data, $attribute, $this->serializer->normalize($attributeValue, $format, $this->createChildContext($context, $attribute, $format)), $class, $format, $context); } + if (isset($context[self::PRESERVE_EMPTY_OBJECTS]) && !\count($data)) { + return new \ArrayObject(); + } + return $data; } diff --git a/src/Symfony/Component/Serializer/Normalizer/NormalizerInterface.php b/src/Symfony/Component/Serializer/Normalizer/NormalizerInterface.php index 02a2118584..619f2fee31 100644 --- a/src/Symfony/Component/Serializer/Normalizer/NormalizerInterface.php +++ b/src/Symfony/Component/Serializer/Normalizer/NormalizerInterface.php @@ -30,7 +30,7 @@ interface NormalizerInterface * @param string $format Format the normalization result will be encoded as * @param array $context Context options for the normalizer * - * @return array|string|int|float|bool + * @return array|string|int|float|bool|\ArrayObject \ArrayObject is used to make sure an empty object is encoded as an object not an array * * @throws InvalidArgumentException Occurs when the object given is not an attempted type for the normalizer * @throws CircularReferenceException Occurs when the normalizer detects a circular reference when no circular diff --git a/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php b/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php index 0f93a99cd9..f770535456 100644 --- a/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php @@ -339,6 +339,43 @@ CSV ])); } + public function testEncodeArrayObject() + { + $value = new \ArrayObject(['foo' => 'hello', 'bar' => 'hey ho']); + + $this->assertEquals(<<<'CSV' +foo,bar +hello,"hey ho" + +CSV + , $this->encoder->encode($value, 'csv')); + + $value = new \ArrayObject(); + + $this->assertEquals("\n", $this->encoder->encode($value, 'csv')); + } + + public function testEncodeNestedArrayObject() + { + $value = new \ArrayObject(['foo' => new \ArrayObject(['nested' => 'value']), 'bar' => new \ArrayObject(['another' => 'word'])]); + + $this->assertEquals(<<<'CSV' +foo.nested,bar.another +value,word + +CSV + , $this->encoder->encode($value, 'csv')); + } + + public function testEncodeEmptyArrayObject() + { + $value = new \ArrayObject(); + $this->assertEquals("\n", $this->encoder->encode($value, 'csv')); + + $value = ['foo' => new \ArrayObject()]; + $this->assertEquals("\n\n", $this->encoder->encode($value, 'csv')); + } + public function testSupportsDecoding() { $this->assertTrue($this->encoder->supportsDecoding('csv')); diff --git a/src/Symfony/Component/Serializer/Tests/Encoder/JsonEncodeTest.php b/src/Symfony/Component/Serializer/Tests/Encoder/JsonEncodeTest.php index c79b9bd945..0ddaf79e95 100644 --- a/src/Symfony/Component/Serializer/Tests/Encoder/JsonEncodeTest.php +++ b/src/Symfony/Component/Serializer/Tests/Encoder/JsonEncodeTest.php @@ -46,6 +46,8 @@ class JsonEncodeTest extends TestCase return [ [[], '[]', []], [[], '{}', ['json_encode_options' => JSON_FORCE_OBJECT]], + [new \ArrayObject(), '{}', []], + [new \ArrayObject(['foo' => 'bar']), '{"foo":"bar"}', []], ]; } diff --git a/src/Symfony/Component/Serializer/Tests/Encoder/XmlEncoderTest.php b/src/Symfony/Component/Serializer/Tests/Encoder/XmlEncoderTest.php index 8a48af7d78..55da0933eb 100644 --- a/src/Symfony/Component/Serializer/Tests/Encoder/XmlEncoderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Encoder/XmlEncoderTest.php @@ -48,6 +48,26 @@ class XmlEncoderTest extends TestCase $this->assertEquals($expected, $this->encoder->encode($obj, 'xml')); } + public function testEncodeArrayObject() + { + $obj = new \ArrayObject(['foo' => 'bar']); + + $expected = ''."\n". + 'bar'."\n"; + + $this->assertEquals($expected, $this->encoder->encode($obj, 'xml')); + } + + public function testEncodeEmptyArrayObject() + { + $obj = new \ArrayObject(); + + $expected = ''."\n". + ''."\n"; + + $this->assertEquals($expected, $this->encoder->encode($obj, 'xml')); + } + /** * @group legacy */ diff --git a/src/Symfony/Component/Serializer/Tests/Encoder/YamlEncoderTest.php b/src/Symfony/Component/Serializer/Tests/Encoder/YamlEncoderTest.php index 2c4e2bf112..27b98eabb9 100644 --- a/src/Symfony/Component/Serializer/Tests/Encoder/YamlEncoderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Encoder/YamlEncoderTest.php @@ -28,6 +28,8 @@ class YamlEncoderTest extends TestCase $this->assertEquals('foo', $encoder->encode('foo', 'yaml')); $this->assertEquals('{ foo: 1 }', $encoder->encode(['foo' => 1], 'yaml')); + $this->assertEquals('null', $encoder->encode(new \ArrayObject(['foo' => 1]), 'yaml')); + $this->assertEquals('{ foo: 1 }', $encoder->encode(new \ArrayObject(['foo' => 1]), 'yaml', ['preserve_empty_objects' => true])); } public function testSupportsEncoding() diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php index ed83dc56f2..d4a63ac824 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -198,12 +198,25 @@ class AbstractObjectNormalizerTest extends TestCase 'allow_extra_attributes' => false, ]); } + + public function testNormalizeEmptyObject() + { + $normalizer = new AbstractObjectNormalizerDummy(); + + // This results in objects turning into arrays in some encoders + $normalizedData = $normalizer->normalize(new EmptyDummy()); + $this->assertEquals([], $normalizedData); + + $normalizedData = $normalizer->normalize(new EmptyDummy(), 'any', ['preserve_empty_objects' => true]); + $this->assertEquals(new \ArrayObject(), $normalizedData); + } } class AbstractObjectNormalizerDummy extends AbstractObjectNormalizer { protected function extractAttributes($object, $format = null, array $context = []) { + return []; } protected function getAttributeValue($object, $attribute, $format = null, array $context = []) @@ -233,6 +246,10 @@ class Dummy public $baz; } +class EmptyDummy +{ +} + class AbstractObjectNormalizerWithMetadata extends AbstractObjectNormalizer { public function __construct() diff --git a/src/Symfony/Component/Serializer/Tests/SerializerTest.php b/src/Symfony/Component/Serializer/Tests/SerializerTest.php index 27e89e1f34..aed4842ee0 100644 --- a/src/Symfony/Component/Serializer/Tests/SerializerTest.php +++ b/src/Symfony/Component/Serializer/Tests/SerializerTest.php @@ -194,6 +194,19 @@ class SerializerTest extends TestCase $this->assertEquals(json_encode($data), $result); } + public function testSerializeEmpty() + { + $serializer = new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]); + $data = ['foo' => new \stdClass()]; + + //Old buggy behaviour + $result = $serializer->serialize($data, 'json'); + $this->assertEquals('{"foo":[]}', $result); + + $result = $serializer->serialize($data, 'json', ['preserve_empty_objects' => true]); + $this->assertEquals('{"foo":{}}', $result); + } + public function testSerializeNoEncoder() { $this->expectException('Symfony\Component\Serializer\Exception\UnexpectedValueException');