Skip to content

Commit c41cd4c

Browse files
committed
fix csv serialization
1 parent 0f57cab commit c41cd4c

File tree

6 files changed

+142
-67
lines changed

6 files changed

+142
-67
lines changed

src/Symfony/Component/SerDes/Internal/Serialize/Optimizer.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ private function optimizeNodeCollection(array $nodes): array
5555
*/
5656
private function mergeResourceStringFwrites(array $nodes): array
5757
{
58+
if (!array_is_list($nodes)) {
59+
return $nodes;
60+
}
61+
5862
$createFwriteExpression = fn (string $content) => new ExpressionNode(new FunctionNode('\fwrite', [new VariableNode('resource'), new ScalarNode($content)]));
5963

6064
$stringContent = '';

src/Symfony/Component/SerDes/Internal/Serialize/TemplateGenerator/CsvTemplateGenerator.php

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,15 @@ protected function listNodes(Type $type, NodeInterface $accessor, array $context
8484
$collectionValueType = reset($collectionValueTypes);
8585
}
8686

87-
$headersName = $this->scopeVariableName('headers', $context);
88-
$flippedHeadersName = $this->scopeVariableName('flippedHeaders', $context);
8987
$rowName = $this->scopeVariableName('row', $context);
88+
$flippedHeadersName = $this->scopeVariableName('flippedHeaders', $context);
89+
90+
$rowNodes = $this->generate($collectionValueType, new VariableNode($rowName), $context + [
91+
'flipped_headers_accessor' => new VariableNode($flippedHeadersName),
92+
'csv_depth' => $depth + 1,
93+
]);
94+
95+
$headersName = $this->scopeVariableName('headers', $context);
9096

9197
$headerNodes = match (true) {
9298
$collectionValueType->isScalar() || $collectionValueType->isEnum() || $collectionValueType->isNull() => [
@@ -130,6 +136,18 @@ protected function listNodes(Type $type, NodeInterface $accessor, array $context
130136
new BinaryNode('??', new ArrayAccessNode(new VariableNode('context'), new ScalarNode('csv_end_of_line')), new ScalarNode("\n")),
131137
])),
132138
],
139+
$collectionValueType->isObject() && $collectionValueType->hasClass() => [
140+
new ExpressionNode(new AssignNode(
141+
new VariableNode($headersName),
142+
new ArrayNode(array_map(fn (string $n): NodeInterface => new ScalarNode($n), array_keys($rowNodes[1]->node->arguments[1]->arguments[1]->elements))),
143+
)),
144+
new ExpressionNode(new AssignNode(new VariableNode($flippedHeadersName), new FunctionNode('\array_fill_keys', [new VariableNode($headersName), new ScalarNode('')]))),
145+
...$this->fputcsvNodes(new VariableNode($headersName)),
146+
new ExpressionNode(new FunctionNode('\fwrite', [
147+
new VariableNode('resource'),
148+
new BinaryNode('??', new ArrayAccessNode(new VariableNode('context'), new ScalarNode('csv_end_of_line')), new ScalarNode("\n")),
149+
])),
150+
],
133151
$collectionValueType->isObject() => [
134152
new ExpressionNode(new AssignNode(new VariableNode($headersName), new FunctionNode('\array_keys', [new CastNode('array', new FunctionNode('\reset', [$accessor]))]))),
135153
new ExpressionNode(new AssignNode(new VariableNode($flippedHeadersName), new FunctionNode('\array_fill_keys', [new VariableNode($headersName), new ScalarNode('')]))),
@@ -145,10 +163,7 @@ protected function listNodes(Type $type, NodeInterface $accessor, array $context
145163
return [
146164
...$headerNodes,
147165
new ForEachNode($accessor, null, $rowName, [
148-
...$this->generate($collectionValueType, new VariableNode($rowName), $context + [
149-
'flipped_headers_accessor' => new VariableNode($flippedHeadersName),
150-
'csv_depth' => $depth + 1,
151-
]),
166+
...$rowNodes,
152167
new ExpressionNode(new FunctionNode('\fwrite', [
153168
new VariableNode('resource'),
154169
new BinaryNode('??', new ArrayAccessNode(new VariableNode('context'), new ScalarNode('csv_end_of_line')), new ScalarNode("\n")),
@@ -217,27 +232,10 @@ protected function objectNodes(Type $type, array $propertiesInfo, array $context
217232
$indexedPropertiesInfo[$propertyInfo['name']] = $propertyInfo;
218233
}
219234

220-
$nodes = [];
221-
$prefix = '';
222-
223-
$properties = array_map(fn (\ReflectionProperty $p): string => $p->name, (new \ReflectionClass($type->className()))->getProperties());
224-
225-
foreach ($properties as $property) {
226-
$nodes[] = new ExpressionNode(new FunctionNode('\fwrite', [new VariableNode('resource'), new ScalarNode($prefix)]));
227-
$prefix = $context['csv_separator'] ?? ',';
228-
229-
if (null === $propertyInfo = $indexedPropertiesInfo[$property] ?? null) {
230-
continue;
231-
}
232-
233-
if (\is_string($propertyInfo['type'])) {
234-
$propertyInfo['type'] = TypeFactory::createFromString($propertyInfo['type']);
235-
}
236-
237-
array_push($nodes, ...$this->generate($propertyInfo['type'], $propertyInfo['accessor'], $propertyInfo['context']));
238-
}
239-
240-
return $nodes;
235+
return $this->fputcsvNodes(new FunctionNode('\array_replace', [
236+
$context['flipped_headers_accessor'],
237+
new ArrayNode(array_map(fn (array $p): NodeInterface => $p['accessor'], $indexedPropertiesInfo)),
238+
]));
241239
}
242240

243241
throw $this->tooDeepException();

src/Symfony/Component/SerDes/Tests/Internal/Serialize/Csv/SerializeGenerateTest.php

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -456,20 +456,52 @@ public static function serializeGenerateDataProvider(): iterable
456456
<?php
457457
458458
/**
459-
* @param array<int, Symfony\Component\SerDes\Tests\Fixtures\Dto\ClassicDummy> \$data
459+
* @param array<int, object> \$data
460460
* @param resource \$resource
461461
*/
462462
return static function (mixed \$data, mixed \$resource, array \$context): void {
463463
\$headers_0 = \\array_keys((array) (\\reset(\$data)));
464464
\$flippedHeaders_0 = \\array_fill_keys(\$headers_0, "");
465465
\\fputcsv(\$resource, \$headers_0, \$context["csv_separator"] ?? ",", \$context["csv_enclosure"] ?? "\\"", \$context["csv_escape_char"] ?? "\\\\", "");
466466
\\fwrite(\$resource, \$context["csv_end_of_line"] ?? "
467+
");
468+
foreach (\$data as \$row_0) {
469+
if (\\is_iterable(\$row_0)) {
470+
\\fputcsv(\$resource, \array_replace(\$flippedHeaders_0, \$row_0), \$context["csv_separator"] ?? ",", \$context["csv_enclosure"] ?? "\\"", \$context["csv_escape_char"] ?? "\\\\", "");
471+
} elseif (\\is_object(\$row_0) && \\is_subclass_of(\\get_class(\$row_0), "BackedEnum")) {
472+
\\fputcsv(\$resource, [\$row_0->value], \$context["csv_separator"] ?? ",", \$context["csv_enclosure"] ?? "\\"", \$context["csv_escape_char"] ?? "\\\\", "");
473+
} elseif (\\is_object(\$row_0)) {
474+
\\fputcsv(\$resource, (array) (\$row_0), \$context["csv_separator"] ?? ",", \$context["csv_enclosure"] ?? "\\"", \$context["csv_escape_char"] ?? "\\\\", "");
475+
} else {
476+
\\fputcsv(\$resource, [\$row_0], \$context["csv_separator"] ?? ",", \$context["csv_enclosure"] ?? "\\"", \$context["csv_escape_char"] ?? "\\\\", "");
477+
}
478+
\\fwrite(\$resource, \$context["csv_end_of_line"] ?? "
479+
");
480+
}
481+
};
482+
483+
PHP,
484+
'array<int, object>',
485+
[],
486+
];
487+
488+
yield [
489+
<<<PHP
490+
<?php
491+
492+
/**
493+
* @param array<int, Symfony\Component\SerDes\Tests\Fixtures\Dto\ClassicDummy> \$data
494+
* @param resource \$resource
495+
*/
496+
return static function (mixed \$data, mixed \$resource, array \$context): void {
497+
\$headers_0 = ["id", "name"];
498+
\$flippedHeaders_0 = \\array_fill_keys(\$headers_0, "");
499+
\\fputcsv(\$resource, \$headers_0, \$context["csv_separator"] ?? ",", \$context["csv_enclosure"] ?? "\\"", \$context["csv_escape_char"] ?? "\\\\", "");
500+
\\fwrite(\$resource, \$context["csv_end_of_line"] ?? "
467501
");
468502
foreach (\$data as \$row_0) {
469503
\$object_0 = \$row_0;
470-
\\fputcsv(\$resource, [\$object_0->id], \$context["csv_separator"] ?? ",", \$context["csv_enclosure"] ?? "\\"", \$context["csv_escape_char"] ?? "\\\\", "");
471-
\\fwrite(\$resource, ",");
472-
\\fputcsv(\$resource, [\$object_0->name], \$context["csv_separator"] ?? ",", \$context["csv_enclosure"] ?? "\\"", \$context["csv_escape_char"] ?? "\\\\", "");
504+
\\fputcsv(\$resource, \array_replace(\$flippedHeaders_0, ["id" => \$object_0->id, "name" => \$object_0->name]), \$context["csv_separator"] ?? ",", \$context["csv_enclosure"] ?? "\\"", \$context["csv_escape_char"] ?? "\\\\", "");
473505
\\fwrite(\$resource, \$context["csv_end_of_line"] ?? "
474506
");
475507
}

src/Symfony/Component/SerDes/Tests/Internal/Serialize/OptimizerTest.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ public static function mergeStringFwritesDataProvider(): iterable
4646
$createFwriteExpression(new ScalarNode('bar')),
4747
]];
4848

49+
yield [[
50+
'foo' => $createFwriteExpression(new ScalarNode('foo')),
51+
'bar' => $createFwriteExpression(new ScalarNode('bar')),
52+
], [
53+
'foo' => $createFwriteExpression(new ScalarNode('foo')),
54+
'bar' => $createFwriteExpression(new ScalarNode('bar')),
55+
]];
56+
4957
yield [[
5058
$createFwriteExpression(new ScalarNode('foo')),
5159
$createFwriteExpression(new VariableNode('bar')),

src/Symfony/Component/SerDes/Tests/Internal/Serialize/TemplateGenerator/CsvTemplateGeneratorTest.php

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ public function testGenerateObjectList()
234234
$this->assertEquals([
235235
new ExpressionNode(new AssignNode(
236236
new VariableNode('headers_0'),
237-
new FunctionNode('\array_keys', [new CastNode('array', new FunctionNode('\reset', [new VariableNode('accessor')]))]),
237+
new ArrayNode([new ScalarNode('id'), new ScalarNode('name')]),
238238
)),
239239
new ExpressionNode(new AssignNode(
240240
new VariableNode('flippedHeaders_0'),
@@ -254,19 +254,12 @@ public function testGenerateObjectList()
254254
])),
255255
new ForEachNode(new VariableNode('accessor'), null, 'row_0', [
256256
new ExpressionNode(new AssignNode(new VariableNode('object_0'), new VariableNode('row_0'))),
257-
new ExpressionNode(new FunctionNode('\fwrite', [new VariableNode('resource'), new ScalarNode('')])),
258257
new ExpressionNode(new FunctionNode('\fputcsv', [
259258
new VariableNode('resource'),
260-
new ArrayNode([new PropertyNode(new VariableNode('object_0'), 'id')]),
261-
new BinaryNode('??', new ArrayAccessNode(new VariableNode('context'), new ScalarNode('csv_separator')), new ScalarNode(',')),
262-
new BinaryNode('??', new ArrayAccessNode(new VariableNode('context'), new ScalarNode('csv_enclosure')), new ScalarNode('"')),
263-
new BinaryNode('??', new ArrayAccessNode(new VariableNode('context'), new ScalarNode('csv_escape_char')), new ScalarNode('\\')),
264-
new ScalarNode(''),
265-
])),
266-
new ExpressionNode(new FunctionNode('\fwrite', [new VariableNode('resource'), new ScalarNode(',')])),
267-
new ExpressionNode(new FunctionNode('\fputcsv', [
268-
new VariableNode('resource'),
269-
new ArrayNode([new PropertyNode(new VariableNode('object_0'), 'name')]),
259+
new FunctionNode('\array_replace', [
260+
new VariableNode('flippedHeaders_0'),
261+
new ArrayNode(['id' => new PropertyNode(new VariableNode('object_0'), 'id'), 'name' => new PropertyNode(new VariableNode('object_0'), 'name')]),
262+
]),
270263
new BinaryNode('??', new ArrayAccessNode(new VariableNode('context'), new ScalarNode('csv_separator')), new ScalarNode(',')),
271264
new BinaryNode('??', new ArrayAccessNode(new VariableNode('context'), new ScalarNode('csv_enclosure')), new ScalarNode('"')),
272265
new BinaryNode('??', new ArrayAccessNode(new VariableNode('context'), new ScalarNode('csv_escape_char')), new ScalarNode('\\')),

src/Symfony/Component/SerDes/Tests/Internal/SerializeDeserializeTest.php

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -41,35 +41,55 @@ protected function setUp(): void
4141
*
4242
* @param array<string, mixed> $context
4343
*/
44-
public function testSerializeDeserialize(mixed $data, string $type, array $context = [])
44+
public function testSerializeDeserialize(mixed $data, string $type, string $format, array $context = [])
4545
{
4646
/** @var resource $resource */
4747
$resource = fopen('php://memory', 'w+');
4848

49-
serialize($data, $resource, 'json', ['type' => $type] + $context);
49+
serialize($data, $resource, $format, ['type' => $type] + $context);
5050
rewind($resource);
5151

52-
$this->assertEquals($data, deserialize($resource, TypeFactory::createFromString($type), 'json', $context));
52+
$this->assertEquals($data, deserialize($resource, TypeFactory::createFromString($type), $format, $context));
5353
}
5454

5555
/**
56-
* @return iterable<array{0: mixed, 1: string, 2: array<string, mixed>}>
56+
* @return iterable<array{0: mixed, 1: string, 2: string, 3: array<string, mixed>}>
5757
*/
5858
public static function serializeDeserializeDataProvider(): iterable
5959
{
60-
yield [1, 'int'];
61-
yield [null, '?int'];
62-
yield ['foo', 'string'];
63-
yield [[1, 2, null], 'array<int, ?int>'];
64-
yield [['foo' => 1, 'bar' => 2, 'baz' => null], 'array<string, ?int>'];
65-
yield [DummyBackedEnum::ONE, DummyBackedEnum::class];
66-
yield [new ClassicDummy(), ClassicDummy::class];
67-
6860
$dummy = new DummyWithFormatterAttributes();
6961
$dummy->id = 200;
7062
$dummy->name = '200';
7163

72-
yield [$dummy, DummyWithFormatterAttributes::class, ['hooks' => [
64+
yield [1, 'int', 'json'];
65+
yield [null, '?int', 'json'];
66+
yield ['foo', 'string', 'json'];
67+
yield [[1, 2, null], 'array<int, ?int>', 'json'];
68+
yield [['foo' => 1, 'bar' => 2, 'baz' => null], 'array<string, ?int>', 'json'];
69+
yield [DummyBackedEnum::ONE, DummyBackedEnum::class, 'json'];
70+
yield [new ClassicDummy(), ClassicDummy::class, 'json'];
71+
yield [$dummy, DummyWithFormatterAttributes::class, 'json', ['hooks' => [
72+
'serialize' => [
73+
sprintf('%s::$name', DummyWithFormatterAttributes::class) => fn (\ReflectionProperty $p, string $accessor) => [
74+
'name' => '@name',
75+
'accessor' => sprintf('%s::divideAndCastToInt(%s, $context)', DummyWithFormatterAttributes::class, $accessor),
76+
],
77+
],
78+
'deserialize' => [
79+
sprintf('%s[@name]', DummyWithFormatterAttributes::class) => fn (\ReflectionClass $class, string $key, callable $value, array $context) => [
80+
'name' => 'name',
81+
'value_provider' => fn () => DummyWithFormatterAttributes::doubleAndCastToString($value('int', $context)),
82+
],
83+
],
84+
]]];
85+
86+
yield [[1], 'array<int, int>', 'csv'];
87+
yield [[1, 2, null], 'array<int, ?int>', 'csv'];
88+
yield [['foo'], 'array<int, string>', 'csv'];
89+
yield [[['foo' => 1, 'bar' => null], ['foo' => null, 'bar' => 2]], 'array<int, array<string, ?int>>', 'csv'];
90+
yield [[DummyBackedEnum::ONE], sprintf('array<int, %s>', DummyBackedEnum::class), 'csv'];
91+
yield [[new ClassicDummy()], sprintf('array<int, %s>', ClassicDummy::class), 'csv'];
92+
yield [[$dummy], sprintf('array<int, %s>', DummyWithFormatterAttributes::class), 'csv', ['hooks' => [
7393
'serialize' => [
7494
sprintf('%s::$name', DummyWithFormatterAttributes::class) => fn (\ReflectionProperty $p, string $accessor) => [
7595
'name' => '@name',
@@ -90,37 +110,57 @@ public static function serializeDeserializeDataProvider(): iterable
90110
*
91111
* @param array<string, mixed> $context
92112
*/
93-
public function testDeserializeSerialize(string $content, string $type, array $context = [])
113+
public function testDeserializeSerialize(string $content, string $type, string $format, array $context = [])
94114
{
95115
/** @var resource $resource */
96116
$resource = fopen('php://memory', 'w+');
97117

98118
fwrite($resource, $content);
99119
rewind($resource);
100120

101-
$data = deserialize($resource, TypeFactory::createFromString($type), 'json', $context);
121+
$data = deserialize($resource, TypeFactory::createFromString($type), $format, $context);
102122

103123
/** @var resource $resource */
104124
$newResource = fopen('php://memory', 'w+');
105125

106-
serialize($data, $newResource, 'json', ['type' => TypeFactory::createFromString($type)] + $context);
126+
serialize($data, $newResource, $format, ['type' => TypeFactory::createFromString($type)] + $context);
107127
rewind($newResource);
108128

109129
$this->assertEquals($content, stream_get_contents($newResource));
110130
}
111131

112132
/**
113-
* @return iterable<array{0: string, 1: string, 2: array<string, mixed>}>
133+
* @return iterable<array{0: string, 1: string, 2: string, 3: array<string, mixed>}>
114134
*/
115135
public static function deserializeSerializeDataProvider(): iterable
116136
{
117-
yield ['1', 'int'];
118-
yield ['null', '?int'];
119-
yield ['"foo"', 'string'];
120-
yield ['[1,2,null]', 'array<int, ?int>'];
121-
yield ['{"foo":1,"bar":2,"baz":null}', 'array<string, ?int>'];
122-
yield ['{"id":100,"name":"Dummy"}', ClassicDummy::class];
123-
yield ['{"id":200,"@name":100}', DummyWithFormatterAttributes::class, ['hooks' => [
137+
yield ['1', 'int', 'json'];
138+
yield ['null', '?int', 'json'];
139+
yield ['"foo"', 'string', 'json'];
140+
yield ['[1,2,null]', 'array<int, ?int>', 'json'];
141+
yield ['{"foo":1,"bar":2,"baz":null}', 'array<string, ?int>', 'json'];
142+
yield ['{"id":100,"name":"Dummy"}', ClassicDummy::class, 'json'];
143+
yield ['{"id":200,"@name":100}', DummyWithFormatterAttributes::class, 'json', ['hooks' => [
144+
'serialize' => [
145+
sprintf('%s::$name', DummyWithFormatterAttributes::class) => fn (\ReflectionProperty $p, string $accessor) => [
146+
'name' => '@name',
147+
'accessor' => sprintf('%s::divideAndCastToInt(%s, $context)', DummyWithFormatterAttributes::class, $accessor),
148+
],
149+
],
150+
'deserialize' => [
151+
sprintf('%s[@name]', DummyWithFormatterAttributes::class) => fn (\ReflectionClass $class, string $key, callable $value, array $context) => [
152+
'name' => 'name',
153+
'value_provider' => fn () => DummyWithFormatterAttributes::doubleAndCastToString($value('int', $context)),
154+
],
155+
],
156+
]]];
157+
158+
yield ["0\n1\n", 'array<int, int>', 'csv'];
159+
yield ["0\n1\n2\n", 'array<int, ?int>', 'csv'];
160+
yield ["0\nfoo\n", 'array<int, string>', 'csv'];
161+
yield ["foo,bar\n1,\n,2\n", 'array<int, array<string, ?int>>', 'csv'];
162+
yield ["id,name\n100,Dummy\n", sprintf('array<int, %s>', ClassicDummy::class), 'csv'];
163+
yield ["id,@name\n200,100\n", sprintf('array<int, %s>', DummyWithFormatterAttributes::class), 'csv', ['hooks' => [
124164
'serialize' => [
125165
sprintf('%s::$name', DummyWithFormatterAttributes::class) => fn (\ReflectionProperty $p, string $accessor) => [
126166
'name' => '@name',

0 commit comments

Comments
 (0)