From ad893fba7db06d6ae9c1c2c36faf77e4f22bdf1c Mon Sep 17 00:00:00 2001 From: Oliver Hoff Date: Tue, 19 Sep 2017 11:55:20 +0200 Subject: [PATCH 1/3] allow variable structures for CSV encoder --- .../Serializer/Encoder/CsvEncoder.php | 62 +++++++++++++++---- .../Tests/Encoder/CsvEncoderTest.php | 20 ++++-- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php b/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php index cdbe0eb44e659..974a04a33a909 100644 --- a/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php +++ b/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php @@ -17,6 +17,7 @@ * Encodes CSV data. * * @author Kévin Dunglas + * @author Oliver Hoff */ class CsvEncoder implements EncoderInterface, DecoderInterface { @@ -71,19 +72,20 @@ public function encode($data, $format, array $context = array()) list($delimiter, $enclosure, $escapeChar, $keySeparator) = $this->getCsvOptions($context); - $headers = null; - foreach ($data as $value) { - $result = array(); - $this->flatten($value, $result, $keySeparator); + foreach ($data as &$value) { + $flattened = array(); + $this->flatten($value, $flattened, $keySeparator); + $value = $flattened; + } + unset($value); - if (null === $headers) { - $headers = array_keys($result); - fputcsv($handle, $headers, $delimiter, $enclosure, $escapeChar); - } elseif (array_keys($result) !== $headers) { - throw new InvalidArgumentException('To use the CSV encoder, each line in the data array must have the same structure. You may want to use a custom normalizer class to normalize the data format before passing it to the CSV encoder.'); - } + $headers = $this->extractHeaders($data); + + fputcsv($handle, $headers, $delimiter, $enclosure, $escapeChar); - fputcsv($handle, $result, $delimiter, $enclosure, $escapeChar); + $headers = array_fill_keys($headers, ''); + foreach ($data as $row) { + fputcsv($handle, array_replace($headers, $row), $delimiter, $enclosure, $escapeChar); } rewind($handle); @@ -197,4 +199,42 @@ private function getCsvOptions(array $context) return array($delimiter, $enclosure, $escapeChar, $keySeparator); } + + /** + * @param array $data + * + * @return string[] + */ + private function extractHeaders(array $data) + { + $headers = array(); + $flippedHeaders = array(); + + foreach ($data as $row) { + $previousHeader = null; + + foreach ($row as $header => $_) { + if (isset($flippedHeaders[$header])) { + $previousHeader = $header; + continue; + } + + if (null === $previousHeader) { + $n = count($headers); + } else { + $n = $flippedHeaders[$previousHeader] + 1; + + for ($j = count($headers); $j > $n; --$j) { + ++$flippedHeaders[$headers[$j] = $headers[$j - 1]]; + } + } + + $headers[$n] = $header; + $flippedHeaders[$header] = $n; + $previousHeader = $header; + } + } + + return $headers; + } } diff --git a/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php b/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php index 61cbc03ee6d26..6f8211128012e 100644 --- a/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php @@ -135,12 +135,22 @@ public function testEncodeEmptyArray() $this->assertEquals("\n\n", $this->encoder->encode(array(array()), 'csv')); } - /** - * @expectedException \Symfony\Component\Serializer\Exception\InvalidArgumentException - */ - public function testEncodeNonFlattenableStructure() + public function testEncodeVariableStructure() { - $this->encoder->encode(array(array('a' => array('foo', 'bar')), array('a' => array())), 'csv'); + $value = array( + array('a' => array('foo', 'bar')), + array('a' => array(), 'b' => 'baz'), + array('a' => array('bar', 'foo'), 'c' => 'pong'), + ); + $csv = <<assertEquals($csv, $this->encoder->encode($value, 'csv')); } public function testSupportsDecoding() From 4aad92a944e546f3b2b5a453e47362ac85f89cb8 Mon Sep 17 00:00:00 2001 From: Oliver Hoff Date: Tue, 19 Sep 2017 12:11:28 +0200 Subject: [PATCH 2/3] pass CSV headers via context to CsvEncoder --- .../Serializer/Encoder/CsvEncoder.php | 12 ++++++++--- .../Tests/Encoder/CsvEncoderTest.php | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php b/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php index 974a04a33a909..b4e501d7efab7 100644 --- a/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php +++ b/src/Symfony/Component/Serializer/Encoder/CsvEncoder.php @@ -26,6 +26,7 @@ class CsvEncoder implements EncoderInterface, DecoderInterface const ENCLOSURE_KEY = 'csv_enclosure'; const ESCAPE_CHAR_KEY = 'csv_escape_char'; const KEY_SEPARATOR_KEY = 'csv_key_separator'; + const HEADERS_KEY = 'csv_headers'; private $delimiter; private $enclosure; @@ -70,7 +71,7 @@ public function encode($data, $format, array $context = array()) } } - list($delimiter, $enclosure, $escapeChar, $keySeparator) = $this->getCsvOptions($context); + list($delimiter, $enclosure, $escapeChar, $keySeparator, $headers) = $this->getCsvOptions($context); foreach ($data as &$value) { $flattened = array(); @@ -79,7 +80,7 @@ public function encode($data, $format, array $context = array()) } unset($value); - $headers = $this->extractHeaders($data); + $headers = array_merge(array_values($headers), array_diff($this->extractHeaders($data), $headers)); fputcsv($handle, $headers, $delimiter, $enclosure, $escapeChar); @@ -196,8 +197,13 @@ private function getCsvOptions(array $context) $enclosure = isset($context[self::ENCLOSURE_KEY]) ? $context[self::ENCLOSURE_KEY] : $this->enclosure; $escapeChar = isset($context[self::ESCAPE_CHAR_KEY]) ? $context[self::ESCAPE_CHAR_KEY] : $this->escapeChar; $keySeparator = isset($context[self::KEY_SEPARATOR_KEY]) ? $context[self::KEY_SEPARATOR_KEY] : $this->keySeparator; + $headers = isset($context[self::HEADERS_KEY]) ? $context[self::HEADERS_KEY] : array(); + + if (!is_array($headers)) { + throw new InvalidArgumentException(sprintf('The "%s" context variable must be an array or null, given "%s".', self::HEADERS_KEY, gettype($headers))); + } - return array($delimiter, $enclosure, $escapeChar, $keySeparator); + return array($delimiter, $enclosure, $escapeChar, $keySeparator, $headers); } /** diff --git a/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php b/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php index 6f8211128012e..a5e5c256f34ad 100644 --- a/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php @@ -153,6 +153,26 @@ public function testEncodeVariableStructure() $this->assertEquals($csv, $this->encoder->encode($value, 'csv')); } + public function testEncodeCustomHeaders() + { + $context = array( + CsvEncoder::HEADERS_KEY => array( + 'b', + 'c', + ), + ); + $value = array( + array('a' => 'foo', 'b' => 'bar'), + ); + $csv = <<assertEquals($csv, $this->encoder->encode($value, 'csv', $context)); + } + public function testSupportsDecoding() { $this->assertTrue($this->encoder->supportsDecoding('csv')); From cc465b352b4b092304b1a693f06c57cf7d35d9ea Mon Sep 17 00:00:00 2001 From: Oliver Hoff Date: Wed, 27 Sep 2017 09:12:18 +0200 Subject: [PATCH 3/3] CHANGELOG entries for the CsvEncoder improvements --- src/Symfony/Component/Serializer/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index ef59f22499264..9a6e20d52f96d 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -7,6 +7,8 @@ CHANGELOG * added `AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT` context option to disable throwing an `UnexpectedValueException` on a type mismatch * added support for serializing `DateInterval` objects + * improved `CsvEncoder` to handle variable nested structures + * CSV headers can be passed to the `CsvEncoder` via the `csv_headers` serialization context variable 3.3.0 -----