@@ -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}
0 commit comments