[Serializer] Encode empty objects as objects, not arrays

Allows Normalizers to return a representation of an empty object that the encoder recognizes as such.
This commit is contained in:
Fred Cox 2018-09-04 20:45:44 +03:00
parent 1aa41ed918
commit f28e826627
10 changed files with 109 additions and 5 deletions

View File

@ -69,7 +69,7 @@ class CsvEncoder implements EncoderInterface, DecoderInterface
{ {
$handle = fopen('php://temp,', 'w+'); $handle = fopen('php://temp,', 'w+');
if (!\is_array($data)) { if (!is_iterable($data)) {
$data = [[$data]]; $data = [[$data]];
} elseif (empty($data)) { } elseif (empty($data)) {
$data = [[]]; $data = [[]];
@ -210,10 +210,10 @@ class CsvEncoder implements EncoderInterface, DecoderInterface
/** /**
* Flattens an array and generates keys including the path. * 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) { foreach ($array as $key => $value) {
if (\is_array($value)) { if (is_iterable($value)) {
$this->flatten($value, $result, $keySeparator, $parentKey.$key.$keySeparator, $escapeFormulas); $this->flatten($value, $result, $keySeparator, $parentKey.$key.$keySeparator, $escapeFormulas);
} else { } else {
if ($escapeFormulas && \in_array(substr((string) $value, 0, 1), $this->formulasStartCharacters, true)) { if ($escapeFormulas && \in_array(substr((string) $value, 0, 1), $this->formulasStartCharacters, true)) {
@ -245,7 +245,7 @@ class CsvEncoder implements EncoderInterface, DecoderInterface
/** /**
* @return string[] * @return string[]
*/ */
private function extractHeaders(array $data): array private function extractHeaders(iterable $data): array
{ {
$headers = []; $headers = [];
$flippedHeaders = []; $flippedHeaders = [];

View File

@ -14,6 +14,7 @@ namespace Symfony\Component\Serializer\Encoder;
use Symfony\Component\Serializer\Exception\RuntimeException; use Symfony\Component\Serializer\Exception\RuntimeException;
use Symfony\Component\Yaml\Dumper; use Symfony\Component\Yaml\Dumper;
use Symfony\Component\Yaml\Parser; use Symfony\Component\Yaml\Parser;
use Symfony\Component\Yaml\Yaml;
/** /**
* Encodes YAML data. * Encodes YAML data.
@ -25,6 +26,8 @@ class YamlEncoder implements EncoderInterface, DecoderInterface
const FORMAT = 'yaml'; const FORMAT = 'yaml';
private const ALTERNATIVE_FORMAT = 'yml'; private const ALTERNATIVE_FORMAT = 'yml';
public const PRESERVE_EMPTY_OBJECTS = 'preserve_empty_objects';
private $dumper; private $dumper;
private $parser; private $parser;
private $defaultContext = ['yaml_inline' => 0, 'yaml_indent' => 0, 'yaml_flags' => 0]; 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); $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']); return $this->dumper->dump($data, $context['yaml_inline'], $context['yaml_indent'], $context['yaml_flags']);
} }

View File

@ -88,6 +88,8 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer
*/ */
public const DEEP_OBJECT_TO_POPULATE = 'deep_object_to_populate'; public const DEEP_OBJECT_TO_POPULATE = 'deep_object_to_populate';
public const PRESERVE_EMPTY_OBJECTS = 'preserve_empty_objects';
private $propertyTypeExtractor; private $propertyTypeExtractor;
private $typesCache = []; private $typesCache = [];
private $attributesCache = []; 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); $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; return $data;
} }

View File

@ -30,7 +30,7 @@ interface NormalizerInterface
* @param string $format Format the normalization result will be encoded as * @param string $format Format the normalization result will be encoded as
* @param array $context Context options for the normalizer * @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 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 * @throws CircularReferenceException Occurs when the normalizer detects a circular reference when no circular

View File

@ -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() public function testSupportsDecoding()
{ {
$this->assertTrue($this->encoder->supportsDecoding('csv')); $this->assertTrue($this->encoder->supportsDecoding('csv'));

View File

@ -46,6 +46,8 @@ class JsonEncodeTest extends TestCase
return [ return [
[[], '[]', []], [[], '[]', []],
[[], '{}', ['json_encode_options' => JSON_FORCE_OBJECT]], [[], '{}', ['json_encode_options' => JSON_FORCE_OBJECT]],
[new \ArrayObject(), '{}', []],
[new \ArrayObject(['foo' => 'bar']), '{"foo":"bar"}', []],
]; ];
} }

View File

@ -48,6 +48,26 @@ class XmlEncoderTest extends TestCase
$this->assertEquals($expected, $this->encoder->encode($obj, 'xml')); $this->assertEquals($expected, $this->encoder->encode($obj, 'xml'));
} }
public function testEncodeArrayObject()
{
$obj = new \ArrayObject(['foo' => 'bar']);
$expected = '<?xml version="1.0"?>'."\n".
'<response><foo>bar</foo></response>'."\n";
$this->assertEquals($expected, $this->encoder->encode($obj, 'xml'));
}
public function testEncodeEmptyArrayObject()
{
$obj = new \ArrayObject();
$expected = '<?xml version="1.0"?>'."\n".
'<response/>'."\n";
$this->assertEquals($expected, $this->encoder->encode($obj, 'xml'));
}
/** /**
* @group legacy * @group legacy
*/ */

View File

@ -28,6 +28,8 @@ class YamlEncoderTest extends TestCase
$this->assertEquals('foo', $encoder->encode('foo', 'yaml')); $this->assertEquals('foo', $encoder->encode('foo', 'yaml'));
$this->assertEquals('{ foo: 1 }', $encoder->encode(['foo' => 1], '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() public function testSupportsEncoding()

View File

@ -198,12 +198,25 @@ class AbstractObjectNormalizerTest extends TestCase
'allow_extra_attributes' => false, '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 class AbstractObjectNormalizerDummy extends AbstractObjectNormalizer
{ {
protected function extractAttributes($object, $format = null, array $context = []) protected function extractAttributes($object, $format = null, array $context = [])
{ {
return [];
} }
protected function getAttributeValue($object, $attribute, $format = null, array $context = []) protected function getAttributeValue($object, $attribute, $format = null, array $context = [])
@ -233,6 +246,10 @@ class Dummy
public $baz; public $baz;
} }
class EmptyDummy
{
}
class AbstractObjectNormalizerWithMetadata extends AbstractObjectNormalizer class AbstractObjectNormalizerWithMetadata extends AbstractObjectNormalizer
{ {
public function __construct() public function __construct()

View File

@ -194,6 +194,19 @@ class SerializerTest extends TestCase
$this->assertEquals(json_encode($data), $result); $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() public function testSerializeNoEncoder()
{ {
$this->expectException('Symfony\Component\Serializer\Exception\UnexpectedValueException'); $this->expectException('Symfony\Component\Serializer\Exception\UnexpectedValueException');