Skip to content

Commit 4767256

Browse files
committed
[Serializer] Correct deserialization of scallars in arrays
1 parent 60d760d commit 4767256

File tree

3 files changed

+596
-1
lines changed

3 files changed

+596
-1
lines changed

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

Lines changed: 175 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
/**
@@ -533,6 +539,37 @@ private function validateAndDenormalizeLegacy(array $types, string $currentClass
533539
}
534540

535541
$context['value_type'] = $collectionValueType;
542+
} elseif (
543+
!empty($context[self::CAST_SCALAR_COLLECTIONS]) // opt-in via context flag
544+
&& $type->isCollection()
545+
&& \count($collectionValueType = $type->getCollectionValueTypes()) > 0
546+
&& \in_array(
547+
$collectionValueType[0]->getBuiltinType(),
548+
[
549+
LegacyType::BUILTIN_TYPE_INT,
550+
LegacyType::BUILTIN_TYPE_FLOAT,
551+
LegacyType::BUILTIN_TYPE_BOOL,
552+
LegacyType::BUILTIN_TYPE_STRING,
553+
],
554+
true
555+
)
556+
&& \is_array($data)
557+
&& (\function_exists('array_is_list') ? array_is_list($data) : (array_keys($data) === range(0, \count($data) - 1)))
558+
) {
559+
// Map legacy builtin to the new BuiltinType used by castScalarCollectionElements()
560+
$baseType = match ($collectionValueType[0]->getBuiltinType()) {
561+
LegacyType::BUILTIN_TYPE_INT => Type::int(), // BuiltinType<int>
562+
LegacyType::BUILTIN_TYPE_FLOAT => Type::float(), // BuiltinType<float>
563+
LegacyType::BUILTIN_TYPE_BOOL => Type::bool(), // BuiltinType<bool>
564+
LegacyType::BUILTIN_TYPE_STRING => Type::string(),// BuiltinType<string>
565+
default => Type::mixed(),
566+
};
567+
$data = $this->castScalarCollectionElements(
568+
$data,
569+
$baseType, // original (legacy path: equals base)
570+
$baseType, // unwrapped BuiltinType: int/float/bool/string
571+
(bool) ($context[self::DISABLE_TYPE_ENFORCEMENT] ?? false) // loose mode
572+
);
536573
} elseif ($type->isCollection() && \count($collectionValueType = $type->getCollectionValueTypes()) > 0 && LegacyType::BUILTIN_TYPE_ARRAY === $collectionValueType[0]->getBuiltinType()) {
537574
// get inner type for any nested array
538575
[$innerType] = $collectionValueType;
@@ -773,7 +810,6 @@ private function validateAndDenormalize(Type $type, string $currentClass, string
773810
return (int) $data;
774811
}
775812
}
776-
777813
if ($collectionValueType) {
778814
try {
779815
$collectionValueBaseType = $collectionValueType;
@@ -795,6 +831,19 @@ private function validateAndDenormalize(Type $type, string $currentClass, string
795831
$class = $collectionValueBaseType->getClassName().'[]';
796832
$context['key_type'] = $collectionKeyType;
797833
$context['value_type'] = $collectionValueType;
834+
} elseif (
835+
!empty($context[self::CAST_SCALAR_COLLECTIONS])
836+
&& $type instanceof CollectionType
837+
&& $type->isList()
838+
&& $collectionValueBaseType instanceof BuiltinType
839+
&& \is_array($data)
840+
) {
841+
$data = $this->castScalarCollectionElements(
842+
$data,
843+
$collectionValueType, // original (possibly wrapped) value type
844+
$collectionValueBaseType, // unwrapped BuiltinType: int/float/bool/string
845+
(bool) ($context[self::DISABLE_TYPE_ENFORCEMENT] ?? false) // loose mode
846+
);
798847
} elseif (
799848
// BC layer for type-info < 7.2
800849
!class_exists(NullableType::class) && TypeIdentifier::ARRAY === $collectionValueBaseType->getTypeIdentifier()
@@ -1221,4 +1270,129 @@ private function getMappedClass(array $data, string $class, array $context): str
12211270

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