Skip to content

Commit 178aeff

Browse files
committed
[Serializer] Correct deserialization of scallars in arrays
1 parent a2c62a9 commit 178aeff

File tree

3 files changed

+563
-0
lines changed

3 files changed

+563
-0
lines changed

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

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