Skip to content

Commit c99decb

Browse files
committed
[Serializer] Correct deserialization of scallars in arrays
1 parent e56a9c9 commit c99decb

File tree

3 files changed

+564
-0
lines changed

3 files changed

+564
-0
lines changed

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

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

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

123129
/**
@@ -796,6 +802,18 @@ private function validateAndDenormalize(Type $type, string $currentClass, string
796802
$class = $collectionValueBaseType->getClassName().'[]';
797803
$context['key_type'] = $collectionKeyType;
798804
$context['value_type'] = $collectionValueType;
805+
} elseif (
806+
!empty($context[self::CAST_SCALAR_COLLECTIONS])
807+
&& $type->isList()
808+
&& $collectionValueBaseType instanceof BuiltinType
809+
&& \is_array($data)
810+
) {
811+
$data = $this->castScalarCollectionElements(
812+
$data,
813+
$collectionValueType, // original (possibly wrapped) value type
814+
$collectionValueBaseType, // unwrapped BuiltinType: int/float/bool/string
815+
(bool) ($context[self::DISABLE_TYPE_ENFORCEMENT] ?? false) // loose mode
816+
);
799817
} elseif (
800818
// BC layer for type-info < 7.2
801819
!class_exists(NullableType::class) && TypeIdentifier::ARRAY === $collectionValueBaseType->getTypeIdentifier()
@@ -1222,4 +1240,129 @@ private function getMappedClass(array $data, string $class, array $context): str
12221240

12231241
return $mappedClass;
12241242
}
1243+
1244+
/**
1245+
* Cast each element of a scalar collection (int/float/bool/string) according to the element type.
1246+
*
1247+
* @param array<int|string,mixed> $data
1248+
*
1249+
* @return array<int|string,mixed>
1250+
*/
1251+
private function castScalarCollectionElements(
1252+
array $data,
1253+
Type $valueType,
1254+
BuiltinType $baseType,
1255+
bool $isDisabledTypeEnforcement,
1256+
): array {
1257+
$nullable = false; // Determine element nullability by unwrapping NullableType/WrappingTypeInterface layers
1258+
$candidateType = $valueType;
1259+
while ($candidateType instanceof WrappingTypeInterface) {
1260+
if ($candidateType instanceof NullableType) {
1261+
$nullable = true;
1262+
break;
1263+
}
1264+
$candidateType = $candidateType->getWrappedType();
1265+
}
1266+
$collectionItemTypeIdentifier = $baseType->getTypeIdentifier(); // INT/FLOAT/BOOL/STRING
1267+
// Cast function preserves array keys to avoid breaking associative collections
1268+
$cast = static function (mixed $v) use (
1269+
$collectionItemTypeIdentifier,
1270+
$nullable,
1271+
$isDisabledTypeEnforcement
1272+
) {
1273+
// Null handling
1274+
if (null === $v) {
1275+
if ($nullable || $isDisabledTypeEnforcement) {
1276+
return null;
1277+
}
1278+
1279+
throw NotNormalizableValueException::createForUnexpectedDataType('Null is not allowed for a non-nullable collection element.', $v, [$collectionItemTypeIdentifier->name]);
1280+
}
1281+
1282+
switch ($collectionItemTypeIdentifier) {
1283+
case TypeIdentifier::INT:
1284+
if (\is_int($v)) {
1285+
return $v;
1286+
}
1287+
// Strict, safe integer-string (no spaces, no decimals, optional sign)
1288+
if (\is_string($v) && preg_match('/^[+-]?\d+$/', $v)) {
1289+
return (int) $v;
1290+
}
1291+
// Non-strict: allow scalar coercion only (avoid arrays/objects)
1292+
if ($isDisabledTypeEnforcement && \is_scalar($v)) {
1293+
return (int) $v; // e.g. "12abc"→12, true→1, 3.7→3, "abc"→0
1294+
}
1295+
break;
1296+
1297+
case TypeIdentifier::FLOAT:
1298+
if (\is_float($v)) {
1299+
return $v;
1300+
}
1301+
if (\is_int($v)) {
1302+
return (float) $v;
1303+
}
1304+
// Strict: numeric strings (accepts "1", "2.5", "-0.75", "1e3")
1305+
if (\is_string($v) && is_numeric($v)) {
1306+
return (float) $v;
1307+
}
1308+
// Non-strict: allow scalar coercion only
1309+
if ($isDisabledTypeEnforcement && \is_scalar($v)) {
1310+
return (float) $v; // e.g. "abc"→0.0, true→1.0
1311+
}
1312+
break;
1313+
1314+
case TypeIdentifier::BOOL:
1315+
// Accept real booleans in any mode
1316+
if (\is_bool($v)) {
1317+
return $v;
1318+
}
1319+
1320+
// Accept int 0/1 even in strict mode
1321+
if (0 === $v || 1 === $v) {
1322+
return (bool) $v;
1323+
}
1324+
1325+
// Non-strict mode: allow common string synonyms and scalar coercion
1326+
if ($isDisabledTypeEnforcement) {
1327+
if (\is_string($v)) {
1328+
$v = strtolower(trim($v));
1329+
if (\in_array($v, ['1', 'true', 'on', 'yes'], true)) {
1330+
return true;
1331+
}
1332+
if (\in_array($v, ['0', 'false', 'off', 'no'], true)) {
1333+
return false;
1334+
}
1335+
}
1336+
// Last-chance scalar coercion (e.g., "abc"->true, ""->false)
1337+
if (\is_scalar($v)) {
1338+
return (bool) $v;
1339+
}
1340+
1341+
// Arrays/objects remain invalid even in non-strict mode
1342+
throw NotNormalizableValueException::createForUnexpectedDataType('Cannot coerce non-scalar value to bool.', $v, ['bool', 'int', 'string']);
1343+
}
1344+
break;
1345+
1346+
case TypeIdentifier::STRING:
1347+
if (\is_string($v)) {
1348+
return $v;
1349+
}
1350+
if (!$isDisabledTypeEnforcement) {
1351+
break;
1352+
}
1353+
if (\is_scalar($v)) {
1354+
return (string) $v;
1355+
}
1356+
break;
1357+
}
1358+
1359+
throw NotNormalizableValueException::createForUnexpectedDataType('Failed to denormalize scalar collection element.', $v, [$collectionItemTypeIdentifier->name]);
1360+
};
1361+
1362+
foreach ($data as $k => $elm) {
1363+
$data[$k] = $cast($elm); // preserve keys
1364+
}
1365+
1366+
return $data;
1367+
}
12251368
}
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)