Skip to content

Commit

Permalink
CsvEncoder handling variable structures and custom header order
Browse files Browse the repository at this point in the history
  • Loading branch information
Oliver Hoff authored and fabpot committed Sep 27, 2017
1 parent aad62c4 commit d173494
Show file tree
Hide file tree
Showing 3 changed files with 96 additions and 18 deletions.
2 changes: 2 additions & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Expand Up @@ -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
-----
Expand Down
72 changes: 59 additions & 13 deletions src/Symfony/Component/Serializer/Encoder/CsvEncoder.php
Expand Up @@ -17,6 +17,7 @@
* Encodes CSV data.
*
* @author Kévin Dunglas <dunglas@gmail.com>
* @author Oliver Hoff <oliver@hofff.com>
*/
class CsvEncoder implements EncoderInterface, DecoderInterface
{
Expand All @@ -25,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;
Expand Down Expand Up @@ -69,21 +71,22 @@ 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);

$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 = array_merge(array_values($headers), array_diff($this->extractHeaders($data), $headers));

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);
Expand Down Expand Up @@ -194,7 +197,50 @@ 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, $headers);
}

/**
* @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 array($delimiter, $enclosure, $escapeChar, $keySeparator);
return $headers;
}
}
40 changes: 35 additions & 5 deletions src/Symfony/Component/Serializer/Tests/Encoder/CsvEncoderTest.php
Expand Up @@ -135,12 +135,42 @@ 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()
{
$value = array(
array('a' => array('foo', 'bar')),
array('a' => array(), 'b' => 'baz'),
array('a' => array('bar', 'foo'), 'c' => 'pong'),
);
$csv = <<<CSV
a.0,a.1,c,b
foo,bar,,
,,,baz
bar,foo,pong,
CSV;

$this->assertEquals($csv, $this->encoder->encode($value, 'csv'));
}

public function testEncodeCustomHeaders()
{
$this->encoder->encode(array(array('a' => array('foo', 'bar')), array('a' => array())), 'csv');
$context = array(
CsvEncoder::HEADERS_KEY => array(
'b',
'c',
),
);
$value = array(
array('a' => 'foo', 'b' => 'bar'),
);
$csv = <<<CSV
b,c,a
bar,,foo
CSV;

$this->assertEquals($csv, $this->encoder->encode($value, 'csv', $context));
}

public function testSupportsDecoding()
Expand Down

0 comments on commit d173494

Please sign in to comment.