Skip to content

Commit 6e26a74

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

File tree

3 files changed

+571
-1
lines changed

3 files changed

+571
-1
lines changed

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

Lines changed: 150 additions & 1 deletion
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
/**
@@ -661,6 +667,7 @@ private function validateAndDenormalizeLegacy(array $types, string $currentClass
661667
*/
662668
private function validateAndDenormalize(Type $type, string $currentClass, string $attribute, mixed $data, ?string $format, array $context): mixed
663669
{
670+
var_dump('validateAndDenormalize: true');
664671
$expectedTypes = [];
665672

666673
// BC layer for type-info < 7.2
@@ -774,7 +781,7 @@ private function validateAndDenormalize(Type $type, string $currentClass, string
774781
return (int) $data;
775782
}
776783
}
777-
784+
var_dump('$collectionValueType: '.$collectionValueType);
778785
if ($collectionValueType) {
779786
try {
780787
$collectionValueBaseType = $collectionValueType;
@@ -791,11 +798,28 @@ private function validateAndDenormalize(Type $type, string $currentClass, string
791798
$collectionValueBaseType = Type::mixed();
792799
}
793800

801+
var_dump('CAST_SCALAR_COLLECTIONS: '.!empty($context[self::CAST_SCALAR_COLLECTIONS]),
802+
'TypeList:'.$type->isList(),
803+
'Is built in type:'.$collectionValueBaseType instanceof BuiltinType,
804+
'Is array:'.\is_array($data)
805+
);
794806
if ($collectionValueBaseType instanceof ObjectType) {
795807
$typeIdentifier = TypeIdentifier::OBJECT;
796808
$class = $collectionValueBaseType->getClassName().'[]';
797809
$context['key_type'] = $collectionKeyType;
798810
$context['value_type'] = $collectionValueType;
811+
} elseif (
812+
!empty($context[self::CAST_SCALAR_COLLECTIONS])
813+
&& $type->isList()
814+
&& $collectionValueBaseType instanceof BuiltinType
815+
&& \is_array($data)
816+
) {
817+
$data = $this->castScalarCollectionElements(
818+
$data,
819+
$collectionValueType, // original (possibly wrapped) value type
820+
$collectionValueBaseType, // unwrapped BuiltinType: int/float/bool/string
821+
(bool) ($context[self::DISABLE_TYPE_ENFORCEMENT] ?? false) // loose mode
822+
);
799823
} elseif (
800824
// BC layer for type-info < 7.2
801825
!class_exists(NullableType::class) && TypeIdentifier::ARRAY === $collectionValueBaseType->getTypeIdentifier()
@@ -1222,4 +1246,129 @@ private function getMappedClass(array $data, string $class, array $context): str
12221246

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