Skip to content

Commit 00618fb

Browse files
committed
[Serializer] Correct deserialization of scallars in arrays
1 parent 9951559 commit 00618fb

File tree

3 files changed

+565
-0
lines changed

3 files changed

+565
-0
lines changed

src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer
117117
*/
118118
public const PRESERVE_EMPTY_OBJECTS = 'preserve_empty_objects';
119119

120+
/**
121+
* When true, elements of scalar collections (int[], float[], bool[], string[])
122+
* are safely cast to their target builtin type during denormalization.
123+
*/
124+
public const CAST_SCALAR_COLLECTIONS = 'cast_scalar_collections';
125+
120126
protected ?ClassDiscriminatorResolverInterface $classDiscriminatorResolver;
121127

122128
/**
@@ -537,6 +543,19 @@ private function validateAndDenormalize(Type $type, string $currentClass, string
537543
$class = $collectionValueBaseType->getClassName().'[]';
538544
$context['key_type'] = $collectionKeyType;
539545
$context['value_type'] = $collectionValueType;
546+
} elseif (
547+
!empty($context[self::CAST_SCALAR_COLLECTIONS])
548+
&& $type instanceof CollectionType
549+
&& $type->isList()
550+
&& $collectionValueBaseType instanceof BuiltinType
551+
&& \is_array($data)
552+
) {
553+
$data = $this->castScalarCollectionElements(
554+
$data,
555+
$collectionValueType, // original (possibly wrapped) value type
556+
$collectionValueBaseType, // unwrapped BuiltinType: int/float/bool/string
557+
(bool) ($context[self::DISABLE_TYPE_ENFORCEMENT] ?? false) // loose mode
558+
);
540559
} elseif ($collectionValueBaseType instanceof BuiltinType && TypeIdentifier::ARRAY === $collectionValueBaseType->getTypeIdentifier()) {
541560
// get inner type for any nested array
542561
$innerType = $collectionValueType;
@@ -927,4 +946,129 @@ private function getMappedClass(array $data, string $class, array $context): str
927946

928947
return $mappedClass;
929948
}
949+
950+
/**
951+
* Cast each element of a scalar collection (int/float/bool/string) according to the element type.
952+
*
953+
* @param array<int|string,mixed> $data
954+
*
955+
* @return array<int|string,mixed>
956+
*/
957+
private function castScalarCollectionElements(
958+
array $data,
959+
Type $valueType,
960+
BuiltinType $baseType,
961+
bool $isDisabledTypeEnforcement,
962+
): array {
963+
$nullable = false; // Determine element nullability by unwrapping NullableType/WrappingTypeInterface layers
964+
$candidateType = $valueType;
965+
while ($candidateType instanceof WrappingTypeInterface) {
966+
if ($candidateType instanceof NullableType) {
967+
$nullable = true;
968+
break;
969+
}
970+
$candidateType = $candidateType->getWrappedType();
971+
}
972+
$collectionItemTypeIdentifier = $baseType->getTypeIdentifier(); // INT/FLOAT/BOOL/STRING
973+
// Cast function preserves array keys to avoid breaking associative collections
974+
$cast = static function (mixed $v) use (
975+
$collectionItemTypeIdentifier,
976+
$nullable,
977+
$isDisabledTypeEnforcement
978+
) {
979+
// Null handling
980+
if (null === $v) {
981+
if ($nullable || $isDisabledTypeEnforcement) {
982+
return null;
983+
}
984+
985+
throw NotNormalizableValueException::createForUnexpectedDataType('Null is not allowed for a non-nullable collection element.', $v, [$collectionItemTypeIdentifier->name]);
986+
}
987+
988+
switch ($collectionItemTypeIdentifier) {
989+
case TypeIdentifier::INT:
990+
if (\is_int($v)) {
991+
return $v;
992+
}
993+
// Strict, safe integer-string (no spaces, no decimals, optional sign)
994+
if (\is_string($v) && preg_match('/^[+-]?\d+$/', $v)) {
995+
return (int) $v;
996+
}
997+
// Non-strict: allow scalar coercion only (avoid arrays/objects)
998+
if ($isDisabledTypeEnforcement && \is_scalar($v)) {
999+
return (int) $v; // e.g. "12abc"→12, true→1, 3.7→3, "abc"→0
1000+
}
1001+
break;
1002+
1003+
case TypeIdentifier::FLOAT:
1004+
if (\is_float($v)) {
1005+
return $v;
1006+
}
1007+
if (\is_int($v)) {
1008+
return (float) $v;
1009+
}
1010+
// Strict: numeric strings (accepts "1", "2.5", "-0.75", "1e3")
1011+
if (\is_string($v) && is_numeric($v)) {
1012+
return (float) $v;
1013+
}
1014+
// Non-strict: allow scalar coercion only
1015+
if ($isDisabledTypeEnforcement && \is_scalar($v)) {
1016+
return (float) $v; // e.g. "abc"→0.0, true→1.0
1017+
}
1018+
break;
1019+
1020+
case TypeIdentifier::BOOL:
1021+
// Accept real booleans in any mode
1022+
if (\is_bool($v)) {
1023+
return $v;
1024+
}
1025+
1026+
// Accept int 0/1 even in strict mode
1027+
if (0 === $v || 1 === $v) {
1028+
return (bool) $v;
1029+
}
1030+
1031+
// Non-strict mode: allow common string synonyms and scalar coercion
1032+
if ($isDisabledTypeEnforcement) {
1033+
if (\is_string($v)) {
1034+
$v = strtolower(trim($v));
1035+
if (\in_array($v, ['1', 'true', 'on', 'yes'], true)) {
1036+
return true;
1037+
}
1038+
if (\in_array($v, ['0', 'false', 'off', 'no'], true)) {
1039+
return false;
1040+
}
1041+
}
1042+
// Last-chance scalar coercion (e.g., "abc"->true, ""->false)
1043+
if (\is_scalar($v)) {
1044+
return (bool) $v;
1045+
}
1046+
1047+
// Arrays/objects remain invalid even in non-strict mode
1048+
throw NotNormalizableValueException::createForUnexpectedDataType('Cannot coerce non-scalar value to bool.', $v, ['bool', 'int', 'string']);
1049+
}
1050+
break;
1051+
1052+
case TypeIdentifier::STRING:
1053+
if (\is_string($v)) {
1054+
return $v;
1055+
}
1056+
if (!$isDisabledTypeEnforcement) {
1057+
break;
1058+
}
1059+
if (\is_scalar($v)) {
1060+
return (string) $v;
1061+
}
1062+
break;
1063+
}
1064+
1065+
throw NotNormalizableValueException::createForUnexpectedDataType('Failed to denormalize scalar collection element.', $v, [$collectionItemTypeIdentifier->name]);
1066+
};
1067+
1068+
foreach ($data as $k => $elm) {
1069+
$data[$k] = $cast($elm); // preserve keys
1070+
}
1071+
1072+
return $data;
1073+
}
9301074
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Serializer\Tests\Dummy;
13+
14+
class DummyCollectionDTO
15+
{
16+
/**
17+
* @param array<int> $intValues
18+
* @param array<string> $stringValues
19+
* @param array<float> $floatValues
20+
* @param array<bool> $boolValues
21+
*/
22+
public function __construct(
23+
public array $intValues,
24+
public array $mixedValues,
25+
public array $stringValues,
26+
public array $floatValues,
27+
public array $boolValues,
28+
public array $wrappedValues,
29+
public array $mixedWrappedValues,
30+
) {
31+
}
32+
}

0 commit comments

Comments
 (0)