Skip to content

Commit 9f5e9ce

Browse files
authored
Clarify tag-only YamlDerivedType does not set default derived type (#134)
Update doc comments on YamlDerivedType(Type) and YamlDerivedTypeAttribute(Type) constructors to clearly document that a tag-only entry (Discriminator=null, Tag!=null) does NOT become the default derived type. The default is only set when both Discriminator and Tag are null. Add 10 comprehensive tests covering: - Attribute-based tag-only entries: untagged values deserialize as base type - Runtime tag-only entries: untagged values deserialize as base type - Dictionary with mixed tagged/untagged values - Explicit default with tag-only entries coexisting - Roundtrip serialization for both attribute and runtime paths
1 parent c96d42d commit 9f5e9ce

3 files changed

Lines changed: 311 additions & 3 deletions

File tree

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
#nullable enable
2+
3+
using System.Collections.Generic;
4+
using Microsoft.VisualStudio.TestTools.UnitTesting;
5+
using SharpYaml.Serialization;
6+
7+
namespace SharpYaml.Tests.Serialization;
8+
9+
[TestClass]
10+
public class YamlTagOnlyDerivedTypeTests
11+
{
12+
// ---- Model types: tag-only entries (no discriminator) via attributes ----
13+
14+
[YamlPolymorphic(DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag)]
15+
[YamlDerivedType(typeof(AttrCheckin), Tag = "!checkin")]
16+
[YamlDerivedType(typeof(AttrDns), Tag = "!dns")]
17+
[YamlDerivedType(typeof(AttrHttp), Tag = "!http")]
18+
private class AttrMonitor
19+
{
20+
public string Interval { get; set; } = string.Empty;
21+
}
22+
23+
private sealed class AttrCheckin : AttrMonitor
24+
{
25+
public string Endpoint { get; set; } = string.Empty;
26+
}
27+
28+
private sealed class AttrDns : AttrMonitor
29+
{
30+
public string Host { get; set; } = string.Empty;
31+
}
32+
33+
private sealed class AttrHttp : AttrMonitor
34+
{
35+
public string Url { get; set; } = string.Empty;
36+
}
37+
38+
// ---- Model types: runtime tag-only entries ----
39+
40+
private class RuntimeMonitor
41+
{
42+
public string Interval { get; set; } = string.Empty;
43+
}
44+
45+
private sealed class RuntimeCheckin : RuntimeMonitor
46+
{
47+
public string Endpoint { get; set; } = string.Empty;
48+
}
49+
50+
private sealed class RuntimeDns : RuntimeMonitor
51+
{
52+
public string Host { get; set; } = string.Empty;
53+
}
54+
55+
private sealed class RuntimeHttp : RuntimeMonitor
56+
{
57+
public string Url { get; set; } = string.Empty;
58+
}
59+
60+
// ---- Attribute-based: tag-only entries should NOT set default ----
61+
62+
[TestMethod]
63+
public void TagOnlyAttribute_NoTagDeserializesAsBaseType()
64+
{
65+
// All derived types have tags but no discriminators.
66+
// An untagged mapping should deserialize as the base type, not the first entry.
67+
var yaml = "Interval: 00:00:10\n";
68+
var value = YamlSerializer.Deserialize<AttrMonitor>(yaml);
69+
70+
Assert.IsNotNull(value);
71+
Assert.IsInstanceOfType<AttrMonitor>(value);
72+
Assert.IsFalse(value is AttrCheckin, "Should not be AttrCheckin — tag-only entries must not become default");
73+
Assert.IsFalse(value is AttrDns);
74+
Assert.IsFalse(value is AttrHttp);
75+
Assert.AreEqual("00:00:10", value.Interval);
76+
}
77+
78+
[TestMethod]
79+
public void TagOnlyAttribute_TaggedDeserializesAsDerivedType()
80+
{
81+
var yaml = "!dns\nHost: google.com\nInterval: 00:01:00\n";
82+
var value = YamlSerializer.Deserialize<AttrMonitor>(yaml);
83+
84+
Assert.IsNotNull(value);
85+
Assert.IsInstanceOfType<AttrDns>(value);
86+
Assert.AreEqual("google.com", ((AttrDns)value).Host);
87+
}
88+
89+
[TestMethod]
90+
public void TagOnlyAttribute_AllTagsWork()
91+
{
92+
var checkinYaml = "!checkin\nEndpoint: /health\nInterval: 00:00:30\n";
93+
var dnsYaml = "!dns\nHost: dns.google\nInterval: 00:01:00\n";
94+
var httpYaml = "!http\nUrl: https://example.com\nInterval: 00:05:00\n";
95+
96+
var checkin = YamlSerializer.Deserialize<AttrMonitor>(checkinYaml);
97+
var dns = YamlSerializer.Deserialize<AttrMonitor>(dnsYaml);
98+
var http = YamlSerializer.Deserialize<AttrMonitor>(httpYaml);
99+
100+
Assert.IsInstanceOfType<AttrCheckin>(checkin);
101+
Assert.IsInstanceOfType<AttrDns>(dns);
102+
Assert.IsInstanceOfType<AttrHttp>(http);
103+
Assert.AreEqual("/health", ((AttrCheckin)checkin).Endpoint);
104+
Assert.AreEqual("dns.google", ((AttrDns)dns).Host);
105+
Assert.AreEqual("https://example.com", ((AttrHttp)http).Url);
106+
}
107+
108+
[TestMethod]
109+
public void TagOnlyAttribute_DictionaryWithMixedTagsAndUntagged()
110+
{
111+
var yaml = """
112+
google: !http
113+
Url: https://google.com
114+
Interval: 00:05:00
115+
router: !dns
116+
Host: 192.168.1.1
117+
Interval: 00:01:00
118+
base:
119+
Interval: 00:00:10
120+
""";
121+
122+
var dict = YamlSerializer.Deserialize<Dictionary<string, AttrMonitor>>(yaml);
123+
124+
Assert.IsNotNull(dict);
125+
Assert.AreEqual(3, dict.Count);
126+
Assert.IsInstanceOfType<AttrHttp>(dict["google"]);
127+
Assert.IsInstanceOfType<AttrDns>(dict["router"]);
128+
Assert.IsInstanceOfType<AttrMonitor>(dict["base"]);
129+
Assert.IsFalse(dict["base"] is AttrCheckin, "Untagged entries must not resolve to first tag-only entry");
130+
}
131+
132+
// ---- Runtime: tag-only entries should NOT set default ----
133+
134+
[TestMethod]
135+
public void TagOnlyRuntime_NoTagDeserializesAsBaseType()
136+
{
137+
var options = new YamlSerializerOptions
138+
{
139+
PolymorphismOptions = new YamlPolymorphismOptions
140+
{
141+
DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag,
142+
DerivedTypeMappings =
143+
{
144+
[typeof(RuntimeMonitor)] = new List<YamlDerivedType>
145+
{
146+
new YamlDerivedType(typeof(RuntimeCheckin)) { Tag = "!checkin" },
147+
new YamlDerivedType(typeof(RuntimeDns)) { Tag = "!dns" },
148+
new YamlDerivedType(typeof(RuntimeHttp)) { Tag = "!http" },
149+
}
150+
}
151+
}
152+
};
153+
154+
var yaml = "Interval: 00:00:10\n";
155+
var value = YamlSerializer.Deserialize<RuntimeMonitor>(yaml, options);
156+
157+
Assert.IsNotNull(value);
158+
Assert.IsInstanceOfType<RuntimeMonitor>(value);
159+
Assert.IsFalse(value is RuntimeCheckin, "Should not be RuntimeCheckin — tag-only entries must not become default");
160+
Assert.IsFalse(value is RuntimeDns);
161+
Assert.IsFalse(value is RuntimeHttp);
162+
Assert.AreEqual("00:00:10", value.Interval);
163+
}
164+
165+
[TestMethod]
166+
public void TagOnlyRuntime_TaggedDeserializesAsDerivedType()
167+
{
168+
var options = new YamlSerializerOptions
169+
{
170+
PolymorphismOptions = new YamlPolymorphismOptions
171+
{
172+
DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag,
173+
DerivedTypeMappings =
174+
{
175+
[typeof(RuntimeMonitor)] = new List<YamlDerivedType>
176+
{
177+
new YamlDerivedType(typeof(RuntimeCheckin)) { Tag = "!checkin" },
178+
new YamlDerivedType(typeof(RuntimeHttp)) { Tag = "!http" },
179+
}
180+
}
181+
}
182+
};
183+
184+
var yaml = "!http\nUrl: https://example.com\nInterval: 00:05:00\n";
185+
var value = YamlSerializer.Deserialize<RuntimeMonitor>(yaml, options);
186+
187+
Assert.IsNotNull(value);
188+
Assert.IsInstanceOfType<RuntimeHttp>(value);
189+
Assert.AreEqual("https://example.com", ((RuntimeHttp)value).Url);
190+
}
191+
192+
[TestMethod]
193+
public void TagOnlyRuntime_DictionaryWithMixedTagsAndUntagged()
194+
{
195+
var options = new YamlSerializerOptions
196+
{
197+
PolymorphismOptions = new YamlPolymorphismOptions
198+
{
199+
DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag,
200+
DerivedTypeMappings =
201+
{
202+
[typeof(RuntimeMonitor)] = new List<YamlDerivedType>
203+
{
204+
new YamlDerivedType(typeof(RuntimeCheckin)) { Tag = "!checkin" },
205+
new YamlDerivedType(typeof(RuntimeDns)) { Tag = "!dns" },
206+
}
207+
}
208+
}
209+
};
210+
211+
var yaml = """
212+
health: !checkin
213+
Endpoint: /ping
214+
Interval: 00:00:30
215+
plain:
216+
Interval: 00:00:10
217+
""";
218+
219+
var dict = YamlSerializer.Deserialize<Dictionary<string, RuntimeMonitor>>(yaml, options);
220+
221+
Assert.IsNotNull(dict);
222+
Assert.AreEqual(2, dict.Count);
223+
Assert.IsInstanceOfType<RuntimeCheckin>(dict["health"]);
224+
Assert.IsInstanceOfType<RuntimeMonitor>(dict["plain"]);
225+
Assert.IsFalse(dict["plain"] is RuntimeCheckin);
226+
}
227+
228+
// ---- Explicit default still works when a separate no-tag no-discriminator entry exists ----
229+
230+
[TestMethod]
231+
public void ExplicitDefaultDerivedTypeWithTagOnlyEntries()
232+
{
233+
var options = new YamlSerializerOptions
234+
{
235+
PolymorphismOptions = new YamlPolymorphismOptions
236+
{
237+
DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag,
238+
DerivedTypeMappings =
239+
{
240+
[typeof(RuntimeMonitor)] = new List<YamlDerivedType>
241+
{
242+
new YamlDerivedType(typeof(RuntimeCheckin)) { Tag = "!checkin" },
243+
new YamlDerivedType(typeof(RuntimeDns)) { Tag = "!dns" },
244+
new YamlDerivedType(typeof(RuntimeHttp)), // no tag, no discriminator → explicit default
245+
}
246+
}
247+
}
248+
};
249+
250+
// Untagged entries should resolve to RuntimeHttp (the explicit default)
251+
var yaml = "Url: https://fallback.com\nInterval: 00:01:00\n";
252+
var value = YamlSerializer.Deserialize<RuntimeMonitor>(yaml, options);
253+
254+
Assert.IsNotNull(value);
255+
Assert.IsInstanceOfType<RuntimeHttp>(value);
256+
Assert.AreEqual("https://fallback.com", ((RuntimeHttp)value).Url);
257+
}
258+
259+
// ---- Serialization roundtrip for tag-only entries ----
260+
261+
[TestMethod]
262+
public void TagOnlyAttribute_RoundtripSerialization()
263+
{
264+
AttrMonitor monitor = new AttrDns { Host = "google.com", Interval = "00:01:00" };
265+
var yaml = YamlSerializer.Serialize(monitor, typeof(AttrMonitor));
266+
267+
StringAssert.Contains(yaml, "!dns");
268+
StringAssert.Contains(yaml, "Host: google.com");
269+
270+
var deserialized = YamlSerializer.Deserialize<AttrMonitor>(yaml);
271+
Assert.IsInstanceOfType<AttrDns>(deserialized);
272+
Assert.AreEqual("google.com", ((AttrDns)deserialized).Host);
273+
}
274+
275+
[TestMethod]
276+
public void TagOnlyRuntime_RoundtripSerialization()
277+
{
278+
var options = new YamlSerializerOptions
279+
{
280+
PolymorphismOptions = new YamlPolymorphismOptions
281+
{
282+
DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag,
283+
DerivedTypeMappings =
284+
{
285+
[typeof(RuntimeMonitor)] = new List<YamlDerivedType>
286+
{
287+
new YamlDerivedType(typeof(RuntimeCheckin)) { Tag = "!checkin" },
288+
new YamlDerivedType(typeof(RuntimeDns)) { Tag = "!dns" },
289+
}
290+
}
291+
}
292+
};
293+
294+
RuntimeMonitor monitor = new RuntimeDns { Host = "dns.google", Interval = "00:01:00" };
295+
var yaml = YamlSerializer.Serialize(monitor, typeof(RuntimeMonitor), options);
296+
297+
StringAssert.Contains(yaml, "!dns");
298+
StringAssert.Contains(yaml, "Host: dns.google");
299+
300+
var deserialized = YamlSerializer.Deserialize<RuntimeMonitor>(yaml, options);
301+
Assert.IsInstanceOfType<RuntimeDns>(deserialized);
302+
Assert.AreEqual("dns.google", ((RuntimeDns)deserialized).Host);
303+
}
304+
}

src/SharpYaml/Serialization/YamlDerivedTypeAttribute.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ public sealed class YamlDerivedTypeAttribute : YamlAttribute
1414
{
1515
/// <summary>
1616
/// Initializes a new instance of the <see cref="YamlDerivedTypeAttribute"/> class
17-
/// with no discriminator, marking this derived type as the default when no discriminator matches.
17+
/// with no discriminator. When <see cref="Tag"/> is also <see langword="null"/>, this derived type
18+
/// becomes the default when no discriminator or tag matches. When <see cref="Tag"/> is set, this entry
19+
/// participates only in tag-based dispatch and does not become the default.
1820
/// </summary>
1921
/// <param name="derivedType">The derived CLR type.</param>
2022
/// <exception cref="ArgumentNullException"><paramref name="derivedType"/> is <see langword="null"/>.</exception>

src/SharpYaml/YamlDerivedType.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ namespace SharpYaml;
1313
public sealed class YamlDerivedType
1414
{
1515
/// <summary>
16-
/// Initializes a new instance with no discriminator, marking this derived type
17-
/// as the default when no discriminator matches.
16+
/// Initializes a new instance with no discriminator.
17+
/// When <see cref="Tag"/> is also <see langword="null"/>, this derived type becomes the default
18+
/// when no discriminator or tag matches. When <see cref="Tag"/> is set, this entry participates
19+
/// only in tag-based dispatch and does not become the default.
1820
/// </summary>
1921
/// <param name="derivedType">The derived CLR type.</param>
2022
/// <exception cref="ArgumentNullException"><paramref name="derivedType"/> is <see langword="null"/>.</exception>

0 commit comments

Comments
 (0)