Skip to content

Commit 194ca7e

Browse files
committed
Prefer the exact type when resolving a member converter
GetMemberConverter walked the registrations and took the first whose type was assignable from the declaring type, so a converter registered for a base type or an interface permanently shadowed a more specific one that happened to be registered later. The exact declaring type is now looked up first, with the assignable walk as the fallback, mirroring TryGetScrubOrIgnoreByMemberOfType.
1 parent fd7d700 commit 194ca7e

5 files changed

Lines changed: 78 additions & 1 deletion

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"exact": {
3+
"Value": "TheValue_Exact"
4+
},
5+
"inherited": {
6+
"Value": "TheValue_Base"
7+
}
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
exact: {
3+
Value: TheValue_Exact
4+
},
5+
inherited: {
6+
Value: TheValue_Base
7+
}
8+
}

src/Verify.Tests/Serialization/SerializationTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4649,4 +4649,52 @@ Task SecondsFractionUpperShort() =>
46494649
""")
46504650
.ScrubInlineDateTimes("yyyy-MM-ddTHH:mm:ss.F");
46514651
#endif
4652+
4653+
[ModuleInitializer]
4654+
public static void MemberConverterExactTypeInit()
4655+
{
4656+
// the interface is registered first, so without exact type precedence it wins
4657+
// for every implementation
4658+
VerifierSettings.MemberConverter<IMemberConverterBase, string>(
4659+
expression: _ => _.Value,
4660+
converter: _ => $"{_}_Base");
4661+
4662+
VerifierSettings.MemberConverter<MemberConverterExact, string>(
4663+
expression: _ => _.Value,
4664+
converter: _ => $"{_}_Exact");
4665+
}
4666+
4667+
[Fact]
4668+
public Task MemberConverterExactType() =>
4669+
Verify(
4670+
new
4671+
{
4672+
// the converter for the exact type wins
4673+
exact = new MemberConverterExact
4674+
{
4675+
Value = "TheValue"
4676+
},
4677+
// and an implementation without one still falls back to the interface
4678+
inherited = new MemberConverterInherited
4679+
{
4680+
Value = "TheValue"
4681+
}
4682+
});
4683+
4684+
interface IMemberConverterBase
4685+
{
4686+
string Value { get; set; }
4687+
}
4688+
4689+
class MemberConverterExact :
4690+
IMemberConverterBase
4691+
{
4692+
public string Value { get; set; }
4693+
}
4694+
4695+
class MemberConverterInherited :
4696+
IMemberConverterBase
4697+
{
4698+
public string Value { get; set; }
4699+
}
46524700
}

src/Verify/Serialization/VerifierSettings_MemberConverter.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@ public static partial class VerifierSettings
1212

1313
internal static ConvertTargetMember? GetMemberConverter(Type? declaringType, string name)
1414
{
15+
if (declaringType is null)
16+
{
17+
return null;
18+
}
19+
20+
// The exact type wins, so a converter registered for a base type or interface
21+
// does not shadow a more specific one merely by being registered first
22+
if (membersConverters.TryGetValue(declaringType, out var forType) &&
23+
forType.TryGetValue(name, out var converter))
24+
{
25+
return converter;
26+
}
27+
1528
foreach (var pair in membersConverters)
1629
{
1730
if (pair.Key.IsAssignableFrom(declaringType) &&

src/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ All six resolved 2026-08-16 (five fixed here; the inline item resolved as by-des
4141
- [ ] **Trimmed fraction format collapses into a standard format specifier.**
4242
`Verify/Serialization/Scrubbers/DateMatchers.cs:269-297` (consumed at 118-127) — `ScrubInlineDateTimes("s.F")` builds a secondary scrubber for `"s"`; length-1 formats are standard specifiers, so it scrubs every full sortable date-time in the output. `"H.F"` trims to `"H"` and throws `Invalid format: H` at registration despite passing up-front validation.
4343

44-
- [ ] **`MemberConverter` has no exact-type precedence.**
44+
- [x] **`MemberConverter` has no exact-type precedence.**
4545
`Verify/Serialization/VerifierSettings_MemberConverter.cs:13-25` — first registered entry with `IsAssignableFrom` wins, so a base-interface converter registered earlier permanently shadows a more specific one. Contrast `TryGetScrubOrIgnoreByMemberOfType`, which checks the exact declaring type first.
4646

4747
- [ ] **Registering an `IgnoreInstance` predicate disables empty-collection ignoring for that type.**

0 commit comments

Comments
 (0)