Skip to content

Commit add4de0

Browse files
authored
Add YamlSerializerContext.CreateOptions for per-call option overrides (#138)
Add CreateOptions(Func<YamlSerializerOptions, YamlSerializerOptions>) to YamlSerializerContext, enabling per-call option variations (e.g. different SourceName per file) while reusing the same source-generated context. Implementation: - YamlSerializerContext.CreateOptions applies a 'with' transform and ensures TypeInfoResolver always points back to the context. - YamlSerializer.ResolveTypeInfo now allows options that reference a context as TypeInfoResolver but are a different instance. It wraps the resolved YamlTypeInfo with YamlTypeInfoWithOptions so the caller's options (SourceName, WriteIndented, etc.) flow through to YamlReader/YamlWriter creation. - YamlTypeInfoWithOptions delegates Read/Write to the inner type info but exposes the caller's options.
1 parent bc166ce commit add4de0

5 files changed

Lines changed: 230 additions & 5 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
#nullable enable
2+
3+
using Microsoft.VisualStudio.TestTools.UnitTesting;
4+
using SharpYaml.Serialization;
5+
6+
namespace SharpYaml.Tests.Serialization;
7+
8+
[TestClass]
9+
public class YamlSerializerContextCreateOptionsTests
10+
{
11+
[TestMethod]
12+
public void CreateOptions_OverridesSourceName()
13+
{
14+
var context = new TestYamlSerializerContext();
15+
var options = context.CreateOptions(o => o with { SourceName = "config.yaml" });
16+
17+
Assert.AreEqual("config.yaml", options.SourceName);
18+
Assert.AreSame(context, options.TypeInfoResolver);
19+
}
20+
21+
[TestMethod]
22+
public void CreateOptions_PreservesTypeInfoResolver()
23+
{
24+
var context = new TestYamlSerializerContext();
25+
var options = context.CreateOptions(o => o with { WriteIndented = false });
26+
27+
Assert.AreSame(context, options.TypeInfoResolver);
28+
Assert.IsFalse(options.WriteIndented);
29+
}
30+
31+
[TestMethod]
32+
public void CreateOptions_OverwritesResolverIfDifferent()
33+
{
34+
var context1 = new TestYamlSerializerContext();
35+
var context2 = new TestYamlSerializerContext();
36+
37+
// Even if configure tries to set a different resolver, CreateOptions overwrites it
38+
var options = context1.CreateOptions(o => o with { TypeInfoResolver = context2 });
39+
40+
Assert.AreSame(context1, options.TypeInfoResolver);
41+
}
42+
43+
[TestMethod]
44+
public void CreateOptions_PreservesOriginalConverters()
45+
{
46+
var converter = new DummyConverter();
47+
var baseOptions = new YamlSerializerOptions { Converters = [converter] };
48+
var context = new TestYamlSerializerContext(baseOptions);
49+
50+
var options = context.CreateOptions(o => o with { SourceName = "test.yaml" });
51+
52+
Assert.AreEqual(1, options.Converters.Count);
53+
Assert.AreSame(converter, options.Converters[0]);
54+
Assert.AreEqual("test.yaml", options.SourceName);
55+
}
56+
57+
[TestMethod]
58+
public void CreateOptions_CanAddConverters()
59+
{
60+
var context = new TestYamlSerializerContext();
61+
var newConverter = new DummyConverter();
62+
63+
var options = context.CreateOptions(o => o with
64+
{
65+
Converters = [newConverter],
66+
SourceName = "extra.yaml"
67+
});
68+
69+
Assert.AreEqual(1, options.Converters.Count);
70+
Assert.AreSame(newConverter, options.Converters[0]);
71+
Assert.AreEqual("extra.yaml", options.SourceName);
72+
Assert.AreSame(context, options.TypeInfoResolver);
73+
}
74+
75+
[TestMethod]
76+
public void CreateOptions_CanOverrideMultipleProperties()
77+
{
78+
var context = new TestYamlSerializerContext();
79+
var options = context.CreateOptions(o => o with
80+
{
81+
SourceName = "multi.yaml",
82+
WriteIndented = false,
83+
PropertyNameCaseInsensitive = true,
84+
Schema = YamlSchemaKind.Extended,
85+
});
86+
87+
Assert.AreEqual("multi.yaml", options.SourceName);
88+
Assert.IsFalse(options.WriteIndented);
89+
Assert.IsTrue(options.PropertyNameCaseInsensitive);
90+
Assert.AreEqual(YamlSchemaKind.Extended, options.Schema);
91+
Assert.AreSame(context, options.TypeInfoResolver);
92+
}
93+
94+
[TestMethod]
95+
public void CreateOptions_IdentityReturnsOptionsWithSameResolver()
96+
{
97+
var context = new TestYamlSerializerContext();
98+
var options = context.CreateOptions(o => o);
99+
100+
// Identity transform preserves the resolver
101+
Assert.AreSame(context, options.TypeInfoResolver);
102+
}
103+
104+
[TestMethod]
105+
public void CreateOptions_WorksWithOptionsBasedContext()
106+
{
107+
var baseOptions = new YamlSerializerOptions
108+
{
109+
SourceName = "original.yaml",
110+
WriteIndented = false,
111+
};
112+
var context = new TestYamlSerializerContext(baseOptions);
113+
114+
// Override just SourceName, keep other base settings
115+
var options = context.CreateOptions(o => o with { SourceName = "override.yaml" });
116+
117+
Assert.AreEqual("override.yaml", options.SourceName);
118+
Assert.IsFalse(options.WriteIndented); // Preserved from base
119+
Assert.AreSame(context, options.TypeInfoResolver);
120+
}
121+
122+
[TestMethod]
123+
public void CreateOptions_ResultCanBeUsedForDeserialization()
124+
{
125+
var context = new TestYamlSerializerContext();
126+
var options = context.CreateOptions(o => o with { SourceName = "test-input.yaml" });
127+
128+
// Verify the options work for actual deserialization
129+
var yaml = "first_name: hello\nAge: 42\n";
130+
var result = YamlSerializer.Deserialize<GeneratedPerson>(yaml, options);
131+
132+
Assert.IsNotNull(result);
133+
Assert.AreEqual("hello", result.FirstName);
134+
}
135+
136+
[TestMethod]
137+
public void CreateOptions_SourceNameAppearsInErrorMessages()
138+
{
139+
var context = new TestYamlSerializerContext();
140+
var options = context.CreateOptions(o => o with { SourceName = "myfile.yaml" });
141+
142+
var yaml = "first_name: hello\nAge: not-a-number\n";
143+
var ex = Assert.Throws<YamlException>(() =>
144+
YamlSerializer.Deserialize<GeneratedPerson>(yaml, options));
145+
146+
StringAssert.Contains(ex.Message, "myfile.yaml");
147+
}
148+
149+
private sealed class DummyConverter : YamlConverter<int>
150+
{
151+
public override int Read(YamlReader reader)
152+
{
153+
reader.Skip();
154+
return 0;
155+
}
156+
157+
public override void Write(YamlWriter writer, int value)
158+
=> writer.WriteScalar("0");
159+
}
160+
}

src/SharpYaml.Tests/Serialization/YamlSerializerSourceGenerationTests.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,15 +1061,20 @@ public void GeneratedContextDefaultAppliesYamlSourceGenerationOptions()
10611061
}
10621062

10631063
[TestMethod]
1064-
public void GeneratedContextThrowsWhenOptionsInstanceDoesNotMatchContext()
1064+
public void GeneratedContextAllowsOptionsWithSameResolverButDifferentInstance()
10651065
{
10661066
var context = TestYamlSerializerContext.Default;
10671067
var options = new YamlSerializerOptions
10681068
{
10691069
TypeInfoResolver = context,
1070+
SourceName = "override.yaml",
10701071
};
10711072

1072-
_ = Assert.Throws<InvalidOperationException>(() => YamlSerializer.Serialize(new GeneratedPerson(), options));
1073+
// Options that reference a context as TypeInfoResolver but are a separate instance
1074+
// now work correctly — the context resolves type info and the caller's options
1075+
// are used for runtime behavior (e.g. SourceName).
1076+
var yaml = YamlSerializer.Serialize(new GeneratedPerson { FirstName = "Alice", Age = 30 }, options);
1077+
Assert.IsTrue(yaml.Contains("Alice", StringComparison.Ordinal));
10731078
}
10741079

10751080
[TestMethod]

src/SharpYaml/Serialization/YamlSerializerContext.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,42 @@ protected YamlSerializerContext(YamlSerializerOptions options)
5656
/// </remarks>
5757
public YamlSerializerOptions Options { get; }
5858

59+
/// <summary>
60+
/// Creates a new <see cref="YamlSerializerOptions"/> based on this context's options
61+
/// with the specified overrides applied, while preserving the <see cref="YamlSerializerOptions.TypeInfoResolver"/>.
62+
/// </summary>
63+
/// <param name="configure">A function that applies overrides to a copy of this context's options using the <c>with</c> expression.</param>
64+
/// <returns>A new options instance with the configured overrides and this context as the resolver.</returns>
65+
/// <remarks>
66+
/// <para>
67+
/// This is useful when you need per-call option variations (such as a different
68+
/// <see cref="YamlSerializerOptions.SourceName"/> for each file) while reusing the
69+
/// same source-generated context.
70+
/// </para>
71+
/// <para>
72+
/// The returned options always has <see cref="YamlSerializerOptions.TypeInfoResolver"/> set to this context.
73+
/// Any <see cref="YamlSerializerOptions.TypeInfoResolver"/> value set by <paramref name="configure"/> is overwritten.
74+
/// </para>
75+
/// </remarks>
76+
/// <example>
77+
/// <code>
78+
/// var options = context.CreateOptions(o =&gt; o with { SourceName = "config.yaml" });
79+
/// var result = YamlSerializer.Deserialize&lt;Config&gt;(yaml, options);
80+
/// </code>
81+
/// </example>
82+
/// <exception cref="ArgumentNullException"><paramref name="configure"/> is <see langword="null"/>.</exception>
83+
public YamlSerializerOptions CreateOptions(Func<YamlSerializerOptions, YamlSerializerOptions> configure)
84+
{
85+
ArgumentGuard.ThrowIfNull(configure);
86+
var modified = configure(Options);
87+
if (ReferenceEquals(modified.TypeInfoResolver, this))
88+
{
89+
return modified;
90+
}
91+
92+
return modified with { TypeInfoResolver = this };
93+
}
94+
5995
/// <inheritdoc />
6096
public abstract YamlTypeInfo? GetTypeInfo(Type type, YamlSerializerOptions options);
6197
}

src/SharpYaml/YamlSerializer.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,9 +1070,15 @@ private static YamlTypeInfo ResolveTypeInfo(YamlSerializerOptions options, Type
10701070
{
10711071
if (options.TypeInfoResolver is YamlSerializerContext context && !ReferenceEquals(options, context.Options))
10721072
{
1073-
throw new InvalidOperationException(
1074-
$"The provided {nameof(YamlSerializerOptions)} instance does not match the options associated with the source-generated context '{context.GetType()}'. " +
1075-
$"Use the overloads that accept a {nameof(YamlSerializerContext)} or a {nameof(YamlTypeInfo)} directly.");
1073+
// Options were created from context.CreateOptions(); resolve type info from the context
1074+
// but use the caller's options for runtime behavior (SourceName, WriteIndented, etc.).
1075+
var contextTypeInfo = context.GetTypeInfo(requestedType, context.Options);
1076+
if (contextTypeInfo is not null)
1077+
{
1078+
return new YamlTypeInfoWithOptions(contextTypeInfo, options);
1079+
}
1080+
1081+
throw new InvalidOperationException($"No generated metadata is available for '{requestedType}' on context '{context.GetType()}'.");
10761082
}
10771083

10781084
var typeInfo = options.TypeInfoResolver?.GetTypeInfo(requestedType, options);

src/SharpYaml/YamlTypeInfo.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,21 @@ public override void Write(YamlWriter writer, object? value)
9999
}
100100
}
101101

102+
/// <summary>
103+
/// Wraps a <see cref="YamlTypeInfo"/> to use a different <see cref="YamlSerializerOptions"/>.
104+
/// </summary>
105+
internal sealed class YamlTypeInfoWithOptions : YamlTypeInfo
106+
{
107+
private readonly YamlTypeInfo _inner;
108+
109+
internal YamlTypeInfoWithOptions(YamlTypeInfo inner, YamlSerializerOptions options)
110+
: base(inner.Type, options)
111+
{
112+
_inner = inner;
113+
}
114+
115+
public override void Write(YamlWriter writer, object? value) => _inner.Write(writer, value);
116+
117+
public override object? ReadAsObject(YamlReader reader) => _inner.ReadAsObject(reader);
118+
}
119+

0 commit comments

Comments
 (0)