Skip to content

Commit

Permalink
feature #24256 CsvEncoder handling variable structures and custom hea…
Browse files Browse the repository at this point in the history
…der order (Oliver Hoff)

This PR was squashed before being merged into the 3.4 branch (closes #24256).

Discussion
----------

CsvEncoder handling variable structures and custom header order

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #23278
| License       | MIT
| Doc PR        | TBD

This PR improves the CsvEncoder to handle variable nesting structures and adds a context option that allows custom csv header order.

Commits
-------

d173494 CsvEncoder handling variable structures and custom header order
  • Loading branch information
fabpot committed Sep 27, 2017
2 parents 250d56b + d173494 commit 65872e8
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 @@ -8,6 +8,8 @@ CHANGELOG
to disable throwing an `UnexpectedValueException` on a type mismatch
* added support for serializing `DateInterval` objects
* added getter for extra attributes in `ExtraAttributesException`
* 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 65872e8

Please sign in to comment.