Skip to content

Commit cb555ae

Browse files
committed
Add support for (de)serializing into nullables
1 parent f90aae1 commit cb555ae

File tree

4 files changed

+278
-6
lines changed

4 files changed

+278
-6
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
"object"
2+
{
3+
"Name" "TestName"
4+
"Description" "TestDescription"
5+
"Age" "42"
6+
"Numbers"
7+
{
8+
"0" "1"
9+
"1" "2"
10+
"2" "3"
11+
}
12+
}
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
namespace ValveKeyValue.Test
2+
{
3+
class NullablePropertyDeserializationTestCase
4+
{
5+
#region Deserialization - present values
6+
7+
[Test]
8+
public void NullableStringPropertiesAreDeserialized()
9+
{
10+
using var stream = TestDataHelper.OpenResource("Text.nullable_types.vdf");
11+
var obj = KVSerializer.Create(KVSerializationFormat.KeyValues1Text).Deserialize<NullableObject>(stream);
12+
13+
Assert.That(obj.Name, Is.EqualTo("TestName"));
14+
Assert.That(obj.Description, Is.EqualTo("TestDescription"));
15+
}
16+
17+
[Test]
18+
public void NullableIntPropertyIsDeserializedWhenPresent()
19+
{
20+
using var stream = TestDataHelper.OpenResource("Text.nullable_types.vdf");
21+
var obj = KVSerializer.Create(KVSerializationFormat.KeyValues1Text).Deserialize<NullableObject>(stream);
22+
23+
Assert.That(obj.Age, Is.EqualTo(42));
24+
}
25+
26+
[Test]
27+
public void NullableListPropertyIsDeserializedWhenPresent()
28+
{
29+
using var stream = TestDataHelper.OpenResource("Text.nullable_types.vdf");
30+
var obj = KVSerializer.Create(KVSerializationFormat.KeyValues1Text).Deserialize<NullableObject>(stream);
31+
32+
Assert.That(obj.Numbers, Is.Not.Null);
33+
Assert.That(obj.Numbers, Has.Count.EqualTo(3));
34+
Assert.That(obj.Numbers![0], Is.EqualTo(1));
35+
Assert.That(obj.Numbers[1], Is.EqualTo(2));
36+
Assert.That(obj.Numbers[2], Is.EqualTo(3));
37+
}
38+
39+
#endregion
40+
41+
#region Deserialization - missing values remain null
42+
43+
[Test]
44+
public void NullableStringPropertyRemainsNullWhenMissing()
45+
{
46+
using var stream = TestDataHelper.OpenResource("Text.nullable_types.vdf");
47+
var obj = KVSerializer.Create(KVSerializationFormat.KeyValues1Text).Deserialize<NullableObject>(stream);
48+
49+
Assert.That(obj.MissingProperty, Is.Null);
50+
}
51+
52+
[Test]
53+
public void NullableIntPropertyRemainsNullWhenMissing()
54+
{
55+
using var stream = TestDataHelper.OpenResource("Text.nullable_types.vdf");
56+
var obj = KVSerializer.Create(KVSerializationFormat.KeyValues1Text).Deserialize<NullableObject>(stream);
57+
58+
Assert.That(obj.MissingInt, Is.Null);
59+
}
60+
61+
[Test]
62+
public void NullableListPropertyRemainsNullWhenMissing()
63+
{
64+
using var stream = TestDataHelper.OpenResource("Text.nullable_types.vdf");
65+
var obj = KVSerializer.Create(KVSerializationFormat.KeyValues1Text).Deserialize<NullableObject>(stream);
66+
67+
Assert.That(obj.MissingList, Is.Null);
68+
}
69+
70+
#endregion
71+
72+
#region Deserialization - required properties missing from data
73+
74+
[Test]
75+
public void RequiredStringPropertyIsDefaultWhenMissingFromData()
76+
{
77+
// VDF only has Name/Description/Age/Numbers, but RequiredObject expects RequiredName
78+
var vdf = "\"object\"\n{\n\t\"Name\"\t\"hello\"\n}";
79+
var obj = KVSerializer.Create(KVSerializationFormat.KeyValues1Text).Deserialize<RequiredObject>(vdf);
80+
81+
// required is bypassed by GetUninitializedObject - property stays at default
82+
Assert.That(obj.RequiredName, Is.Null);
83+
Assert.That(obj.Name, Is.EqualTo("hello"));
84+
}
85+
86+
#endregion
87+
88+
#region Serialization - nullable properties
89+
90+
[Test]
91+
public void SerializesNullableStringProperty()
92+
{
93+
var obj = new NullableObject
94+
{
95+
Name = "TestName",
96+
Description = null,
97+
};
98+
99+
string text;
100+
using (var ms = new MemoryStream())
101+
{
102+
KVSerializer.Create(KVSerializationFormat.KeyValues1Text).Serialize(ms, obj, "object");
103+
ms.Seek(0, SeekOrigin.Begin);
104+
using var reader = new StreamReader(ms);
105+
text = reader.ReadToEnd();
106+
}
107+
108+
// Present nullable string should be serialized
109+
Assert.That(text, Does.Contain("\"Name\""));
110+
Assert.That(text, Does.Contain("\"TestName\""));
111+
112+
// Null string should be omitted
113+
Assert.That(text, Does.Not.Contain("\"Description\""));
114+
}
115+
116+
[Test]
117+
public void SerializesNullableIntProperty()
118+
{
119+
var obj = new NullableObject
120+
{
121+
Age = 42,
122+
MissingInt = null,
123+
};
124+
125+
string text;
126+
using (var ms = new MemoryStream())
127+
{
128+
KVSerializer.Create(KVSerializationFormat.KeyValues1Text).Serialize(ms, obj, "object");
129+
ms.Seek(0, SeekOrigin.Begin);
130+
using var reader = new StreamReader(ms);
131+
text = reader.ReadToEnd();
132+
}
133+
134+
// Present nullable int should be serialized
135+
Assert.That(text, Does.Contain("\"Age\""));
136+
Assert.That(text, Does.Contain("\"42\""));
137+
138+
// Null int? should be omitted
139+
Assert.That(text, Does.Not.Contain("\"MissingInt\""));
140+
}
141+
142+
[Test]
143+
public void SerializesNullableListProperty()
144+
{
145+
var obj = new NullableObject
146+
{
147+
Numbers = [10, 20],
148+
MissingList = null,
149+
};
150+
151+
string text;
152+
using (var ms = new MemoryStream())
153+
{
154+
KVSerializer.Create(KVSerializationFormat.KeyValues1Text).Serialize(ms, obj, "object");
155+
ms.Seek(0, SeekOrigin.Begin);
156+
using var reader = new StreamReader(ms);
157+
text = reader.ReadToEnd();
158+
}
159+
160+
Assert.That(text, Does.Contain("\"Numbers\""));
161+
Assert.That(text, Does.Contain("\"10\""));
162+
Assert.That(text, Does.Contain("\"20\""));
163+
Assert.That(text, Does.Not.Contain("\"MissingList\""));
164+
}
165+
166+
#endregion
167+
168+
#region Round-trip
169+
170+
[Test]
171+
public void NullablePropertiesRoundTrip()
172+
{
173+
var original = new NullableObject
174+
{
175+
Name = "RoundTrip",
176+
Age = 99,
177+
Numbers = [1, 2, 3],
178+
};
179+
180+
var serializer = KVSerializer.Create(KVSerializationFormat.KeyValues1Text);
181+
using var ms = new MemoryStream();
182+
serializer.Serialize(ms, original, "object");
183+
ms.Seek(0, SeekOrigin.Begin);
184+
var deserialized = serializer.Deserialize<NullableObject>(ms);
185+
186+
Assert.That(deserialized.Name, Is.EqualTo("RoundTrip"));
187+
Assert.That(deserialized.Age, Is.EqualTo(99));
188+
Assert.That(deserialized.Numbers, Has.Count.EqualTo(3));
189+
Assert.That(deserialized.Description, Is.Null);
190+
Assert.That(deserialized.MissingInt, Is.Null);
191+
Assert.That(deserialized.MissingList, Is.Null);
192+
}
193+
194+
#endregion
195+
196+
#region Test classes
197+
198+
class NullableObject
199+
{
200+
public string? Name { get; set; }
201+
public string? Description { get; set; }
202+
public int? Age { get; set; }
203+
public List<int>? Numbers { get; set; }
204+
public string? MissingProperty { get; set; }
205+
public int? MissingInt { get; set; }
206+
public List<string>? MissingList { get; set; }
207+
}
208+
209+
class RequiredObject
210+
{
211+
public required string RequiredName { get; set; }
212+
public string? Name { get; set; }
213+
}
214+
215+
#endregion
216+
}
217+
}

ValveKeyValue/ValveKeyValue.Test/Text/RequiredInitPropertyTestCase.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,28 @@ public void GetOnlyPropertyThrows()
2424
Throws.ArgumentException.With.Message.EqualTo("Property set method not found."));
2525
}
2626

27+
[Test]
28+
public void MissingRequiredInitStringPropertyIsDefault()
29+
{
30+
// VDF has FirstName/LastName/Age but not City
31+
using var stream = TestDataHelper.OpenResource("Text.required_init_person.vdf");
32+
var person = KVSerializer.Create(KVSerializationFormat.KeyValues1Text).Deserialize<PersonWithExtraRequiredInit>(stream);
33+
34+
Assert.That(person.FirstName, Is.EqualTo("Alice"));
35+
Assert.That(person.City, Is.Null);
36+
}
37+
38+
[Test]
39+
public void MissingRequiredInitIntPropertyIsDefault()
40+
{
41+
// VDF has FirstName/LastName/Age but not ZipCode
42+
using var stream = TestDataHelper.OpenResource("Text.required_init_person.vdf");
43+
var person = KVSerializer.Create(KVSerializationFormat.KeyValues1Text).Deserialize<PersonWithExtraRequiredInit>(stream);
44+
45+
Assert.That(person.Age, Is.EqualTo(30));
46+
Assert.That(person.ZipCode, Is.EqualTo(0));
47+
}
48+
2749
class PersonWithRequiredInit
2850
{
2951
public required string FirstName { get; init; }
@@ -41,5 +63,18 @@ class PersonWithGetOnly
4163

4264
public int Age { get; }
4365
}
66+
67+
class PersonWithExtraRequiredInit
68+
{
69+
public required string FirstName { get; init; }
70+
71+
public required string LastName { get; init; }
72+
73+
public required int Age { get; init; }
74+
75+
public required string City { get; init; }
76+
77+
public required int ZipCode { get; init; }
78+
}
4479
}
4580
}

ValveKeyValue/ValveKeyValue/ObjectCopier.cs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ static KVObject ConvertObjectToValue(
127127
{
128128
foreach (var member in reflector.GetMembers(objectType, managedObject).OrderBy(p => p.Name, StringComparer.InvariantCulture))
129129
{
130-
if (!member.MemberType.IsValueType && member.Value is null)
130+
if (member.Value is null)
131131
{
132132
continue;
133133
}
@@ -402,6 +402,12 @@ static object ConvertValue(
402402
[DynamicallyAccessedMembers(Trimming.Constructors | Trimming.Properties)] Type valueType,
403403
IObjectReflector reflector)
404404
{
405+
var underlyingType = Nullable.GetUnderlyingType(valueType);
406+
if (underlyingType != null)
407+
{
408+
valueType = underlyingType;
409+
}
410+
405411
if (value is KVObject kvObject)
406412
{
407413
if (kvObject.ValueType == KVValueType.Collection)
@@ -434,19 +440,21 @@ static bool TryConvertValueTo<TValue>(KVObject value, [MaybeNullWhen(false)] out
434440
return true;
435441
}
436442

437-
if (typeof(TValue).IsEnum)
443+
var targetType = Nullable.GetUnderlyingType(typeof(TValue)) ?? typeof(TValue);
444+
445+
if (targetType.IsEnum)
438446
{
439-
var underlyingType = Enum.GetUnderlyingType(typeof(TValue));
447+
var underlyingType = Enum.GetUnderlyingType(targetType);
440448
var underlyingValue = value.ToType(underlyingType, CultureInfo.InvariantCulture);
441-
converted = (TValue)Enum.ToObject(typeof(TValue), underlyingValue);
449+
converted = (TValue)Enum.ToObject(targetType, underlyingValue);
442450
return true;
443451
}
444452

445-
if (CanConvertValueTo(typeof(TValue)))
453+
if (CanConvertValueTo(targetType))
446454
{
447455
try
448456
{
449-
converted = (TValue)value.ToType(typeof(TValue), CultureInfo.InvariantCulture);
457+
converted = (TValue)value.ToType(targetType, CultureInfo.InvariantCulture);
450458
}
451459
catch (Exception e)
452460
{

0 commit comments

Comments
 (0)