Skip to content

Commit 681a2de

Browse files
xoofxCopilot
andcommitted
Add unmapped-member handling migration support
Fixes #129 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1e6500b commit 681a2de

File tree

8 files changed

+313
-6
lines changed

8 files changed

+313
-6
lines changed

site/migration.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,46 @@ var model = YamlSerializer.Deserialize(yaml, context.MyType);
103103

104104
- Use the syntax APIs (for example [`YamlSyntaxTree`](xref:SharpYaml.Syntax.YamlSyntaxTree)) for lossless roundtrip and source span tooling.
105105
- [`YamlSerializer`](xref:SharpYaml.YamlSerializer) maps to .NET objects and does not preserve formatting/comments.
106+
107+
## SerializerSettings Migration
108+
109+
Most `SerializerSettings` switches from v2 were removed because v3 follows the `YamlSerializerOptions`/`YamlSerializerContext` model and aligns with `System.Text.Json` behavior where practical.
110+
111+
| v2 setting | v3 equivalent | Notes |
112+
| --- | --- | --- |
113+
| `NamingConvention = new CamelCaseNamingConvention()` | `PropertyNamingPolicy = JsonNamingPolicy.CamelCase` | Use `System.Text.Json.JsonNamingPolicy` for CLR property names, and `DictionaryKeyPolicy` for dictionary keys. |
114+
| `IgnoreUnmatchedProperties = true` | default behavior | SharpYaml v3 skips unmatched YAML members by default, matching `System.Text.Json`. |
115+
| `IgnoreUnmatchedProperties = false` | `UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow` | Use the options-level setting or `[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]` on a specific type to fail on unknown members. |
116+
| `EmitTags = false` | default behavior | v3 does not emit tags for ordinary object serialization unless they are required for polymorphism or explicitly written by a converter. |
117+
| `ResetAlias = true` | default behavior | v3 does not preserve object identity unless `ReferenceHandling = YamlReferenceHandling.Preserve` is enabled. Serializer reuse does not carry aliases between operations. |
118+
119+
### Unmatched members and extension data
120+
121+
If a type has [`YamlExtensionDataAttribute`](xref:SharpYaml.Serialization.YamlExtensionDataAttribute) or [`JsonExtensionDataAttribute`](xref:System.Text.Json.Serialization.JsonExtensionDataAttribute), unmatched YAML members are captured there instead of being rejected.
122+
123+
```csharp
124+
using SharpYaml;
125+
using System.Text.Json.Serialization;
126+
127+
var options = new YamlSerializerOptions
128+
{
129+
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
130+
};
131+
```
132+
133+
```csharp
134+
using System.Collections.Generic;
135+
using System.Text.Json.Serialization;
136+
using SharpYaml.Serialization;
137+
138+
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
139+
public sealed class MyModel
140+
{
141+
public string Name { get; set; } = string.Empty;
142+
143+
[YamlExtensionData]
144+
public Dictionary<string, object?> Extra { get; set; } = new();
145+
}
146+
```
147+
148+
In the example above, unknown YAML members are still stored in `Extra`; extension data takes precedence over `UnmappedMemberHandling`.

src/SharpYaml.SourceGenerator/YamlSerializerContextGenerator.cs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ private sealed class SourceGenerationOptionsModel
8282
public string? MappingOrder { get; set; }
8383
public string? Schema { get; set; }
8484
public bool? UseSchema { get; set; }
85+
public string? UnmappedMemberHandling { get; set; }
8586
public string? DuplicateKeyHandling { get; set; }
8687
public bool? UnsafeAllowDeserializeFromTagTypeName { get; set; }
8788
public string? ReferenceHandling { get; set; }
@@ -104,6 +105,7 @@ public void ApplyFrom(SourceGenerationOptionsModel other)
104105
if (!string.IsNullOrEmpty(other.MappingOrder)) MappingOrder = other.MappingOrder;
105106
if (!string.IsNullOrEmpty(other.Schema)) Schema = other.Schema;
106107
if (other.UseSchema.HasValue) UseSchema = other.UseSchema;
108+
if (!string.IsNullOrEmpty(other.UnmappedMemberHandling)) UnmappedMemberHandling = other.UnmappedMemberHandling;
107109
if (!string.IsNullOrEmpty(other.DuplicateKeyHandling)) DuplicateKeyHandling = other.DuplicateKeyHandling;
108110
if (other.UnsafeAllowDeserializeFromTagTypeName.HasValue) UnsafeAllowDeserializeFromTagTypeName = other.UnsafeAllowDeserializeFromTagTypeName;
109111
if (!string.IsNullOrEmpty(other.ReferenceHandling)) ReferenceHandling = other.ReferenceHandling;
@@ -1334,6 +1336,10 @@ typeSymbol is INamedTypeSymbol lifecycleType &&
13341336
.AppendLine("(global::SharpYaml.Serialization.YamlReader reader)");
13351337
builder.AppendLine(" {");
13361338
builder.AppendLine(" var options = reader.Options;");
1339+
if (extensionData is null)
1340+
{
1341+
builder.Append(" var unmappedMemberHandling = ").Append(GetUnmappedMemberHandlingExpression(typeSymbol)).AppendLine(";");
1342+
}
13371343
builder.AppendLine(" var hasCustomConverters = options.Converters.Count != 0;");
13381344
builder.AppendLine(" if (reader.TokenType != global::SharpYaml.Serialization.YamlTokenType.StartMapping)");
13391345
builder.AppendLine(" {");
@@ -1586,6 +1592,10 @@ typeSymbol is INamedTypeSymbol lifecycleType &&
15861592
}
15871593
else
15881594
{
1595+
builder.AppendLine(" if (unmappedMemberHandling == global::System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow)");
1596+
builder.AppendLine(" {");
1597+
builder.Append(" throw global::SharpYaml.Serialization.YamlThrowHelper.ThrowUnmappedMember(reader, typeof(").Append(typeName).AppendLine("), mergeKey);");
1598+
builder.AppendLine(" }");
15891599
builder.AppendLine(" reader.Skip();");
15901600
}
15911601
builder.AppendLine(" }");
@@ -1651,6 +1661,10 @@ typeSymbol is INamedTypeSymbol lifecycleType &&
16511661
}
16521662
else
16531663
{
1664+
builder.AppendLine(" if (unmappedMemberHandling == global::System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow)");
1665+
builder.AppendLine(" {");
1666+
builder.Append(" throw global::SharpYaml.Serialization.YamlThrowHelper.ThrowUnmappedMember(reader, typeof(").Append(typeName).AppendLine("), key);");
1667+
builder.AppendLine(" }");
16541668
builder.AppendLine(" reader.Skip();");
16551669
}
16561670
builder.AppendLine(" }");
@@ -2008,6 +2022,10 @@ private static void EmitReadObjectCoreWithConstructor(
20082022
}
20092023
else
20102024
{
2025+
builder.AppendLine(" if (unmappedMemberHandling == global::System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow)");
2026+
builder.AppendLine(" {");
2027+
builder.Append(" throw global::SharpYaml.Serialization.YamlThrowHelper.ThrowUnmappedMember(reader, typeof(").Append(typeName).AppendLine("), mergeKey);");
2028+
builder.AppendLine(" }");
20112029
builder.AppendLine(" reader.Skip();");
20122030
}
20132031
builder.AppendLine(" }");
@@ -2140,6 +2158,10 @@ private static void EmitReadObjectCoreWithConstructor(
21402158
}
21412159
else
21422160
{
2161+
builder.AppendLine(" if (unmappedMemberHandling == global::System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow)");
2162+
builder.AppendLine(" {");
2163+
builder.Append(" throw global::SharpYaml.Serialization.YamlThrowHelper.ThrowUnmappedMember(reader, typeof(").Append(typeName).AppendLine("), key);");
2164+
builder.AppendLine(" }");
21432165
builder.AppendLine(" reader.Skip();");
21442166
}
21452167
builder.AppendLine(" }");
@@ -6383,6 +6405,9 @@ private static void ApplyYamlSourceGenerationOptionsAttribute(AttributeData attr
63836405
case "UseSchema":
63846406
model.UseSchema = argument.Value.Value as bool?;
63856407
break;
6408+
case "UnmappedMemberHandling":
6409+
model.UnmappedMemberHandling = NormalizeEnumName(argument.Value.ToCSharpString());
6410+
break;
63866411
case "DuplicateKeyHandling":
63876412
model.DuplicateKeyHandling = NormalizeEnumName(argument.Value.ToCSharpString());
63886413
break;
@@ -6446,6 +6471,46 @@ private static void ApplyYamlSourceGenerationOptionsAttribute(AttributeData attr
64466471
return lastDot >= 0 && lastDot < text.Length - 1 ? text.Substring(lastDot + 1) : text;
64476472
}
64486473

6474+
private static string GetUnmappedMemberHandlingExpression(ITypeSymbol typeSymbol)
6475+
{
6476+
if (typeSymbol is INamedTypeSymbol namedType)
6477+
{
6478+
var overrideValue = TryGetJsonUnmappedMemberHandlingOverride(namedType);
6479+
if (!string.IsNullOrEmpty(overrideValue))
6480+
{
6481+
return "global::System.Text.Json.Serialization.JsonUnmappedMemberHandling." + overrideValue;
6482+
}
6483+
}
6484+
6485+
return "options.UnmappedMemberHandling";
6486+
}
6487+
6488+
private static string? TryGetJsonUnmappedMemberHandlingOverride(INamedTypeSymbol typeSymbol)
6489+
{
6490+
foreach (var attribute in typeSymbol.GetAttributes())
6491+
{
6492+
if (!string.Equals(attribute.AttributeClass?.ToDisplayString(), "System.Text.Json.Serialization.JsonUnmappedMemberHandlingAttribute", StringComparison.Ordinal))
6493+
{
6494+
continue;
6495+
}
6496+
6497+
if (attribute.ConstructorArguments.Length != 0)
6498+
{
6499+
return NormalizeEnumName(attribute.ConstructorArguments[0].ToCSharpString());
6500+
}
6501+
6502+
foreach (var argument in attribute.NamedArguments)
6503+
{
6504+
if (string.Equals(argument.Key, "UnmappedMemberHandling", StringComparison.Ordinal))
6505+
{
6506+
return NormalizeEnumName(argument.Value.ToCSharpString());
6507+
}
6508+
}
6509+
}
6510+
6511+
return null;
6512+
}
6513+
64496514
private static void AppendOptionAssignments(StringBuilder builder, SourceGenerationOptionsModel options)
64506515
{
64516516
if (options.WriteIndented.HasValue)
@@ -6518,6 +6583,13 @@ private static void AppendOptionAssignments(StringBuilder builder, SourceGenerat
65186583
.AppendLine(",");
65196584
}
65206585

6586+
if (!string.IsNullOrEmpty(options.UnmappedMemberHandling))
6587+
{
6588+
builder.Append(" UnmappedMemberHandling = global::System.Text.Json.Serialization.JsonUnmappedMemberHandling.")
6589+
.Append(options.UnmappedMemberHandling)
6590+
.AppendLine(",");
6591+
}
6592+
65216593
if (!string.IsNullOrEmpty(options.DuplicateKeyHandling))
65226594
{
65236595
builder.Append(" DuplicateKeyHandling = global::SharpYaml.YamlDuplicateKeyHandling.")

src/SharpYaml.Tests/Serialization/YamlObjectConverterContractTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#nullable enable
22

33
using System;
4+
using System.Collections.Generic;
45
using System.Text.Json.Serialization;
56
using Microsoft.VisualStudio.TestTools.UnitTesting;
67
using SharpYaml.Serialization;
@@ -47,6 +48,21 @@ private sealed class Person
4748
public string FirstName { get; set; } = string.Empty;
4849
}
4950

51+
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
52+
private sealed class StrictPerson
53+
{
54+
public string FirstName { get; set; } = string.Empty;
55+
}
56+
57+
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
58+
private sealed class StrictExtensionDataPerson
59+
{
60+
public string FirstName { get; set; } = string.Empty;
61+
62+
[YamlExtensionData]
63+
public Dictionary<string, object?> Extra { get; set; } = new();
64+
}
65+
5066
private sealed class NullIgnoreModel
5167
{
5268
public string? Nick { get; set; }
@@ -133,6 +149,45 @@ public void PropertyNameCaseInsensitive_AllowsMismatchedCasing()
133149
Assert.AreEqual("Ada", person.FirstName);
134150
}
135151

152+
[TestMethod]
153+
public void UnmappedMembers_AreSkippedByDefault()
154+
{
155+
var person = YamlSerializer.Deserialize<Person>("FirstName: Ada\nLastName: Lovelace\n");
156+
157+
Assert.IsNotNull(person);
158+
Assert.AreEqual("Ada", person.FirstName);
159+
}
160+
161+
[TestMethod]
162+
public void UnmappedMembers_CanBeDisallowedViaOptions()
163+
{
164+
var options = new YamlSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow };
165+
166+
var exception = Assert.Throws<YamlException>(() => YamlSerializer.Deserialize<Person>("FirstName: Ada\nLastName: Lovelace\n", options));
167+
168+
StringAssert.Contains(exception.Message, "LastName");
169+
StringAssert.Contains(exception.Message, typeof(Person).ToString());
170+
}
171+
172+
[TestMethod]
173+
public void JsonUnmappedMemberHandlingAttribute_CanDisallowUnknownMembers()
174+
{
175+
var exception = Assert.Throws<YamlException>(() => YamlSerializer.Deserialize<StrictPerson>("FirstName: Ada\nLastName: Lovelace\n"));
176+
177+
StringAssert.Contains(exception.Message, "LastName");
178+
StringAssert.Contains(exception.Message, typeof(StrictPerson).ToString());
179+
}
180+
181+
[TestMethod]
182+
public void JsonUnmappedMemberHandling_DoesNotConflictWithExtensionData()
183+
{
184+
var person = YamlSerializer.Deserialize<StrictExtensionDataPerson>("FirstName: Ada\nLastName: Lovelace\n");
185+
186+
Assert.IsNotNull(person);
187+
Assert.AreEqual("Ada", person.FirstName);
188+
Assert.AreEqual("Lovelace", person.Extra["LastName"]);
189+
}
190+
136191
[TestMethod]
137192
public void ContractErrors_AreWrappedInYamlExceptionWithContext()
138193
{

src/SharpYaml.Tests/Serialization/YamlSerializerSourceGenerationTests.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ internal sealed class GeneratedWithDefaultOptions
3030
public string? Optional { get; set; }
3131
}
3232

33+
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
34+
internal sealed class GeneratedAttributedUnmappedPayload
35+
{
36+
public string DisplayName { get; set; } = string.Empty;
37+
}
38+
39+
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
40+
internal sealed class GeneratedAttributedExtensionDataPayload
41+
{
42+
public string DisplayName { get; set; } = string.Empty;
43+
44+
[YamlExtensionData]
45+
public Dictionary<string, object?> Extra { get; set; } = new();
46+
}
47+
3348
internal sealed class GeneratedSchemaAwareScalars
3449
{
3550
public string? NullableText { get; set; }
@@ -542,6 +557,8 @@ internal GeneratedInternalJsonCtorModel(string name, int age)
542557
[YamlSerializable(typeof(GeneratedExtensionDataMappingPayload))]
543558
[YamlSerializable(typeof(GeneratedInitOnlyExtensionDataDictionaryPayload))]
544559
[YamlSerializable(typeof(GeneratedInitOnlyExtensionDataMappingPayload))]
560+
[YamlSerializable(typeof(GeneratedAttributedUnmappedPayload))]
561+
[YamlSerializable(typeof(GeneratedAttributedExtensionDataPayload))]
545562
[YamlSerializable(typeof(GeneratedMemberConverterPayload))]
546563
[YamlSerializable(typeof(GeneratedTypeWithConverter))]
547564
[YamlSerializable(typeof(GeneratedYamlCtorModel))]
@@ -594,6 +611,13 @@ internal partial class TestYamlSerializerContextWithSchema : YamlSerializerConte
594611
{
595612
}
596613

614+
[YamlSourceGenerationOptions(
615+
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)]
616+
[YamlSerializable(typeof(GeneratedWithDefaultOptions))]
617+
internal partial class TestYamlSerializerContextWithStrictUnmappedMembers : YamlSerializerContext
618+
{
619+
}
620+
597621
[YamlSerializable(typeof(GeneratedPerson), TypeInfoPropertyName = "GeneratedPersonTypeInfo")]
598622
[YamlSerializable(typeof(Dictionary<string, int>), TypeInfoPropertyName = "IntMapTypeInfo")]
599623
internal partial class TestYamlSerializerContextWithCustomPropertyNames : YamlSerializerContext
@@ -873,6 +897,7 @@ public void GeneratedContextDefaultAppliesYamlSourceGenerationOptions()
873897
Assert.AreEqual(YamlIgnoreCondition.WhenWritingNull, options.DefaultIgnoreCondition);
874898
Assert.AreSame(JsonNamingPolicy.CamelCase, options.PropertyNamingPolicy);
875899
Assert.AreSame(JsonNamingPolicy.CamelCase, options.DictionaryKeyPolicy);
900+
Assert.AreEqual(JsonUnmappedMemberHandling.Skip, options.UnmappedMemberHandling);
876901
Assert.AreSame(context, options.TypeInfoResolver);
877902

878903
var yaml = YamlSerializer.Serialize(
@@ -924,6 +949,61 @@ public void GeneratedContext_CanUseSchemaAwareScalarResolution()
924949
Assert.AreEqual("yes", value.QuotedFlag);
925950
}
926951

952+
[TestMethod]
953+
public void GeneratedContext_SkipsUnmappedMembersByDefault()
954+
{
955+
var context = TestYamlSerializerContext.Default;
956+
var value = YamlSerializer.Deserialize(
957+
"first_name: Ada\nAge: 37\nUnknown: test\n",
958+
context.GeneratedPerson);
959+
960+
Assert.IsNotNull(value);
961+
Assert.AreEqual("Ada", value.FirstName);
962+
Assert.AreEqual(37, value.Age);
963+
}
964+
965+
[TestMethod]
966+
public void GeneratedContext_CanDisallowUnmappedMembersViaOptions()
967+
{
968+
var context = TestYamlSerializerContextWithStrictUnmappedMembers.Default;
969+
var options = context.GeneratedWithDefaultOptions.Options;
970+
971+
Assert.AreEqual(JsonUnmappedMemberHandling.Disallow, options.UnmappedMemberHandling);
972+
973+
var exception = Assert.Throws<YamlException>(() => YamlSerializer.Deserialize(
974+
"DisplayName: Ada\nUnknown: test\n",
975+
context.GeneratedWithDefaultOptions));
976+
977+
StringAssert.Contains(exception.Message, "Unknown");
978+
StringAssert.Contains(exception.Message, typeof(GeneratedWithDefaultOptions).ToString());
979+
}
980+
981+
[TestMethod]
982+
public void GeneratedContext_HonorsJsonUnmappedMemberHandlingAttribute()
983+
{
984+
var context = TestYamlSerializerContext.Default;
985+
986+
var exception = Assert.Throws<YamlException>(() => YamlSerializer.Deserialize(
987+
"DisplayName: Ada\nUnknown: test\n",
988+
context.GeneratedAttributedUnmappedPayload));
989+
990+
StringAssert.Contains(exception.Message, "Unknown");
991+
StringAssert.Contains(exception.Message, typeof(GeneratedAttributedUnmappedPayload).ToString());
992+
}
993+
994+
[TestMethod]
995+
public void GeneratedContext_UnmappedMemberHandlingDoesNotConflictWithExtensionData()
996+
{
997+
var context = TestYamlSerializerContext.Default;
998+
var value = YamlSerializer.Deserialize(
999+
"DisplayName: Ada\nUnknown: test\n",
1000+
context.GeneratedAttributedExtensionDataPayload);
1001+
1002+
Assert.IsNotNull(value);
1003+
Assert.AreEqual("Ada", value.DisplayName);
1004+
Assert.AreEqual("test", value.Extra["Unknown"]);
1005+
}
1006+
9271007
[TestMethod]
9281008
public void GeneratedContextOptionsCanRegisterConvertersAtBuildTime()
9291009
{

0 commit comments

Comments
 (0)