8000 [Form] Fix form value merging involving file upload, collection & checkbox by priyadi · Pull Request #54324 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[Form] Fix form value merging involving file upload, collection & checkbox #54324

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: 5.4
Choose a base branch
from
Open
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,53 @@ public function testMergeZeroIndexedCollection()
$this->assertNotNull($itemsForm->get('0')->get('file'));
}

public function testMergePartialDataFromCollection()
{
$form = $this->createForm('root', 'POST', true);
$form->add('items', CollectionType::class, [
'entry_type' => ItemFileType::class,
'allow_add' => true,
]);

$file = $this->getUploadedFile();
$file2 = $this->getUploadedFile();

$this->setRequestData('POST', [
'root' => [
'items' => [
1 => [
'item' => 'test',
],
],
],
], [
'root' => [
'items' => [
0 => [
'file' => $file,
],
1 => [
'file' => $file2,
],
],
],
]);

$this->requestHandler->handleRequest($form, $this->request);

$itemsForm = $form->get('items');
$data = $itemsForm->getData();
$this->assertTrue($form->isSubmitted());
$this->assertTrue($form->isValid());

$this->assertCount(2, $data);
$this->assertArrayHasKey(0, $data);
$this->assertArrayHasKey(1, $data);

$this->assertEquals('test', $itemsForm->get('1')->get('item')->getData());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this misses assertions on $itemsForm->get('0')->get('name') and $itemsForm->get('1')->get('file')

$this->assertNotNull($itemsForm->get('0')->get('file'));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this assert the form data is not null instead of asserting that the Form instance is not null ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

btw, testMergeZeroIndexedCollection seems to have the same issue.

}

/**
* @dataProvider methodExceptGetProvider
*/
Expand Down
26 changes: 2 additions & 24 deletions src/Symfony/Component/Form/Util/FormUtil.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,6 @@ private function __construct()
* a form and needs to be consistent. PHP keyword `empty` cannot
* be used as it also considers 0 and "0" to be empty.
*
* @param mixed $data
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's keep this one

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fabbot insists that I remove it. I reverted the changes, but it won't pass the test.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the change has not been reverted

*
* @return bool
*/
public static function isEmpty($data)
Expand All @@ -43,30 +41,10 @@ public static function isEmpty($data)
}

/**
* Recursively replaces or appends elements of the first array with elements
* of second array. If the key is an integer, the values will be appended to
* the new array; otherwise, the value from the second array will replace
* the one from the first array.
* Merges query string or post parameters with uploaded files.
*/
public static function mergeParamsAndFiles(array $params, array $files): array
{
$isFilesList = array_is_list($files);

foreach ($params as $key => $value) {
if (\is_array($value) && \is_array($files[$key] ?? null)) {
$params[$key] = self::mergeParamsAndFiles($value, $files[$key]);
unset($files[$key]);
}
}

if (!$isFilesList) {
return array_replace($params, $files);
}

foreach ($files as $value) {
$params[] = $value;
}

return $params;
return (new ParamFilesMerger([], $params, $files))->getResult();
}
}
184 changes: 184 additions & 0 deletions src/Symfony/Component/Form/Util/ParamFilesMerger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Form\Util;

use Symfony\Component\HttpFoundation\File\UploadedFile;

/**
* @author Priyadi Iman Nurcahyo <priyadi@rekalogika.com>
*/
class ParamFilesMerger
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of introducing this new internal class the code should be moved into private methods in FormUtil as the class is only used there.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The class represents a node in the tree. It is used by itself to traverse the entire tree. The task will be very complicated without a dedicated class.

https://github.com/symfony/symfony/pull/54324/files/30c95b8c9396624a98f6e44b0f83889b77112ab7#diff-5983ae1bcb319a7efa00aa62df7a2bcfaeaabd1894959ed7994d30a 9E88 79f2281deR120

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have sent a PR against your fork which inlines the methods from the ParamFilesMerger class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have merged your PR

{
private array $path;
private array $params;
private array $files;

/**
* @param array $path The path to the current element, empty means the root
* @param array $params The parameters
* @param array $files The files
*/
public function __construct(array $path, array $params, array $files)
{
$this->path = $path;
$this->params = $params;
$this->files = $files;
}

public function getResult()
{
$paramsValue = $this->getParamsValue();
$filesValue = $this->getFilesValue();

if (null === $paramsValue) {
if (null === $filesValue) {
return null;
} elseif (self::isFileUpload($filesValue)) {
return $filesValue;
} elseif (\is_array($filesValue)) {
return $filesValue;
} else { // $filesValue has a non-array value
return $filesValue;
}
} elseif (\is_array($paramsValue)) {
if (null === $filesValue) {
return $paramsValue;
} elseif (self::isFileUpload($filesValue)) {
return $filesValue; // if the array is a file upload field, it has the precedence
} elseif (\is_array($filesValue)) {
return $this->getResultArray($paramsValue, $filesValue);
} else { // $filesValue has a non-array value
return $paramsValue; // params has the precedence
}
} else { // $paramsValue has a non-array value
if (null === $filesValue) {
return $paramsValue;
} elseif (self::isFileUpload($filesValue)) {
return $filesValue; // if the array is a file upload field, it has the precedence
} elseif (\is_array($filesValue)) {
return $paramsValue; // params has the precedence
} else { // $filesValue has a non-array value
return $paramsValue; // params has the precedence
}
}
}

/**
* @return bool
*/
private static function isFileUpload($value)
{
if ($value instanceof UploadedFile) {
return true;
}

if (!\is_array($value)) {
return false;
}

$keys = array_keys($value);
sort($keys);

return $keys === ['error', 'name', 'size', 'tmp_name', 'type'];
}

/**
* @param array $array
*
* @return bool
*/
private static function doesNotContainArrayOrFileUpload($array)
{
foreach ($array as $value) {
if (
\is_array($value)
&& !self::isFileUpload($value)
) {
return false;
}
}

return true;
}

/**
* @return array
*/
private function getResultArray(array $paramsValue, array $filesValue)
{
// if both are lists and both does not contains array, then merge them and return
if (
array_is_list($paramsValue)
&& self::doesNotContainArrayOrFileUpload($paramsValue)
&& array_is_list($filesValue)
&& self::doesNotContainArrayOrFileUpload($filesValue)
) {
return array_merge($paramsValue, $filesValue);
}

// heuristics to preserve order, the bigger array wins
if (\count($filesValue) > \count($paramsValue)) {
$keys = array_unique(array_merge(array_keys($filesValue), array_keys($paramsValue)));
} else {
$keys = array_unique(array_merge(array_keys($paramsValue), array_keys($filesValue)));
}

$result = [];

foreach ($keys as $key) {
$path = $this->path;
$path[] = $key;

$node = new self($path, $this->params, $this->files);

$result[$key] = $node->getResult();
}

return $result;
}

/**
* Gets the value of the current element in the params according to the path.
*/
private function getParamsValue()
{
$params = $this->params;

foreach ($this->path as $key) {
$params = $params[$key] ?? null;

if (null === $params) {
return null;
}
}

return $params;
}

/**
* Gets the value of the current element in the files according to the path.
*/
private function getFilesValue()
{
$files = $this->files;

foreach ($this->path as $key) {
$files = $files[$key] ?? null;

if (null === $files) {
return null;
}
}

return $files;
}
}
0