Skip to content

Commit e15612b

Browse files
committed
fix: ConvertValue returns default instead of throwing on incompatible string conversions
Convention-matched string properties that name-match numeric or enum destinations (e.g. string MonitorObjectId -> int? MonitorObjectId) no longer throw FormatException or ArgumentException at map-time. ConvertValue now uses Enum.TryParse for string->enum and wraps Convert.ChangeType in a try/catch, returning default(T) on failure.
1 parent 9f6ffa9 commit e15612b

4 files changed

Lines changed: 163 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
66

77
## [Unreleased]
88

9+
## [10.0.16] - 2026-03-31
10+
11+
### Fixed
12+
13+
- `ConvertValue` no longer throws `FormatException`/`ArgumentException` when convention-matched string properties can't convert to numeric or enum destinations - returns `default(T)` instead
14+
915
## [10.0.15] - 2026-03-31
1016

1117
### Added
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using PanoramicData.Mapper.Test.Models;
2+
3+
namespace PanoramicData.Mapper.Test;
4+
5+
public class ConventionMismatchTests
6+
{
7+
// --- string "" -> int (FormatException repro) ---
8+
9+
[Fact]
10+
public void Map_EmptyStringToInt_DefaultsToZero()
11+
{
12+
var config = new MapperConfiguration(cfg =>
13+
cfg.AddProfile(new StringToIntProfile()));
14+
var mapper = config.CreateMapper();
15+
16+
var source = new StringPropertySource { MonitorObjectId = "" };
17+
var dest = mapper.Map<MismatchedNumericDestination>(source);
18+
19+
dest.MonitorObjectId.Should().Be(0);
20+
}
21+
22+
[Fact]
23+
public void Map_ValidStringToInt_ConvertsCorrectly()
24+
{
25+
var config = new MapperConfiguration(cfg =>
26+
cfg.AddProfile(new StringToIntProfile()));
27+
var mapper = config.CreateMapper();
28+
29+
var source = new StringPropertySource { MonitorObjectId = "42", Count = "7" };
30+
var dest = mapper.Map<MismatchedNumericDestination>(source);
31+
32+
dest.MonitorObjectId.Should().Be(42);
33+
dest.Count.Should().Be(7);
34+
}
35+
36+
// --- string "" -> int? (FormatException repro) ---
37+
38+
[Fact]
39+
public void Map_EmptyStringToNullableInt_DefaultsToNull()
40+
{
41+
var config = new MapperConfiguration(cfg =>
42+
cfg.AddProfile(new StringToNullableIntProfile()));
43+
var mapper = config.CreateMapper();
44+
45+
var source = new StringPropertySource { MonitorObjectId = "" };
46+
var dest = mapper.Map<MismatchedNullableIntDestination>(source);
47+
48+
dest.MonitorObjectId.Should().BeNull();
49+
}
50+
51+
// --- string "" -> enum (ArgumentException repro) ---
52+
53+
[Fact]
54+
public void Map_EmptyStringToEnum_DefaultsToFirstValue()
55+
{
56+
var config = new MapperConfiguration(cfg =>
57+
cfg.AddProfile(new StringToEnumMismatchProfile()));
58+
var mapper = config.CreateMapper();
59+
60+
var source = new StringPropertySource { GroupStatus = "" };
61+
var dest = mapper.Map<MismatchedEnumDestination>(source);
62+
63+
dest.GroupStatus.Should().Be(ResourceGroupStatusType.Unknown);
64+
}
65+
66+
[Fact]
67+
public void Map_InvalidStringToEnum_DefaultsToFirstValue()
68+
{
69+
var config = new MapperConfiguration(cfg =>
70+
cfg.AddProfile(new StringToEnumMismatchProfile()));
71+
var mapper = config.CreateMapper();
72+
73+
var source = new StringPropertySource { GroupStatus = "NotAValidEnumMember" };
74+
var dest = mapper.Map<MismatchedEnumDestination>(source);
75+
76+
dest.GroupStatus.Should().Be(ResourceGroupStatusType.Unknown);
77+
}
78+
79+
[Fact]
80+
public void Map_ValidStringToEnum_ConvertsCorrectly()
81+
{
82+
var config = new MapperConfiguration(cfg =>
83+
cfg.AddProfile(new StringToEnumMismatchProfile()));
84+
var mapper = config.CreateMapper();
85+
86+
var source = new StringPropertySource { GroupStatus = "Active" };
87+
var dest = mapper.Map<MismatchedEnumDestination>(source);
88+
89+
dest.GroupStatus.Should().Be(ResourceGroupStatusType.Active);
90+
}
91+
92+
// --- Profiles ---
93+
94+
private class StringToIntProfile : Profile
95+
{
96+
public StringToIntProfile() => CreateMap<StringPropertySource, MismatchedNumericDestination>();
97+
}
98+
99+
private class StringToNullableIntProfile : Profile
100+
{
101+
public StringToNullableIntProfile() => CreateMap<StringPropertySource, MismatchedNullableIntDestination>();
102+
}
103+
104+
private class StringToEnumMismatchProfile : Profile
105+
{
106+
public StringToEnumMismatchProfile() => CreateMap<StringPropertySource, MismatchedEnumDestination>();
107+
}
108+
}

PanoramicData.Mapper.Test/Models/TestModels.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,4 +644,36 @@ public class DataSourceGraphStoreItem : IdentifiedStoreItem
644644
public required string Title { get; set; }
645645
public required int Width { get; set; }
646646
public bool IsActive { get; set; }
647+
}
648+
649+
// --- Convention mismatch models (string -> numeric/enum by name) ---
650+
651+
public enum ResourceGroupStatusType
652+
{
653+
Unknown,
654+
Active,
655+
Inactive
656+
}
657+
658+
public class StringPropertySource
659+
{
660+
public string MonitorObjectId { get; set; } = string.Empty;
661+
public string GroupStatus { get; set; } = string.Empty;
662+
public string Count { get; set; } = string.Empty;
663+
}
664+
665+
public class MismatchedNumericDestination
666+
{
667+
public int MonitorObjectId { get; set; }
668+
public int Count { get; set; }
669+
}
670+
671+
public class MismatchedNullableIntDestination
672+
{
673+
public int? MonitorObjectId { get; set; }
674+
}
675+
676+
public class MismatchedEnumDestination
677+
{
678+
public ResourceGroupStatusType GroupStatus { get; set; }
647679
}

PanoramicData.Mapper/Internal/TypeMap.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -674,7 +674,14 @@ private static bool IsAssignableOrConvertible(Type sourceType, Type destType)
674674
// String -> enum
675675
if (valueType == typeof(string) && dstCore.IsEnum)
676676
{
677-
return Enum.Parse(dstCore, (string)value);
677+
var str = (string)value;
678+
if (str.Length > 0 && Enum.TryParse(dstCore, str, out var parsed))
679+
{
680+
return parsed;
681+
}
682+
683+
// Unparseable string - return default for the enum
684+
return Activator.CreateInstance(destType) ?? Activator.CreateInstance(dstCore)!;
678685
}
679686

680687
// Integral -> enum (Convert.ChangeType cannot handle this)
@@ -686,7 +693,15 @@ private static bool IsAssignableOrConvertible(Type sourceType, Type destType)
686693
// Enum -> integral, numeric widening/narrowing, string -> numeric, etc.
687694
if (value is IConvertible)
688695
{
689-
return Convert.ChangeType(value, dstCore);
696+
try
697+
{
698+
return Convert.ChangeType(value, dstCore);
699+
}
700+
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
701+
{
702+
// Conversion failed (e.g. empty string -> int) - return default
703+
return destType.IsValueType ? Activator.CreateInstance(destType) : null;
704+
}
690705
}
691706

692707
return value;

0 commit comments

Comments
 (0)