Skip to content

Commit 81c951d

Browse files
author
Temo Nikolaishvili
committed
TimeOnly xml deserialization bug fix
1 parent 1337707 commit 81c951d

5 files changed

Lines changed: 133 additions & 37 deletions

File tree

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
<Product>Domain Primitives</Product>
1212
<Company>ALTA Software llc.</Company>
1313
<Copyright>Copyright © 2024 ALTA Software llc.</Copyright>
14-
<Version>7.1.0</Version>
14+
<Version>7.1.1</Version>
1515
</PropertyGroup>
1616

1717
<PropertyGroup>

src/AltaSoft.DomainPrimitives.Generator/Helpers/MethodGeneratorHelper.cs

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -872,20 +872,20 @@ public static void GenerateParsable(GeneratorData data, SourceCodeBuilder builde
872872
builder.Append("s");
873873
}
874874
else
875-
if (isChar)
876-
{
877-
builder.Append("char.Parse(s)");
878-
}
879-
else
880-
if (isBool)
881-
{
882-
builder.Append("bool.Parse(s)");
883-
}
884-
else
885-
{
886-
builder.Append($"{underlyingType}.")
887-
.AppendIfElse(format is null, "Parse(s, provider)", $"ParseExact(s, {QuoteAndEscape(format!)}, provider)");
888-
}
875+
if (isChar)
876+
{
877+
builder.Append("char.Parse(s)");
878+
}
879+
else
880+
if (isBool)
881+
{
882+
builder.Append("bool.Parse(s)");
883+
}
884+
else
885+
{
886+
builder.Append($"{underlyingType}.")
887+
.AppendIfElse(format is null, "Parse(s, provider)", $"ParseExact(s, {QuoteAndEscape(format!)}, provider)");
888+
}
889889

890890
builder.AppendLine(!data.GenerateImplicitOperators ? ");" : ";");
891891

@@ -900,26 +900,26 @@ public static void GenerateParsable(GeneratorData data, SourceCodeBuilder builde
900900
builder.AppendLine("if (s is null)");
901901
}
902902
else
903-
if (isChar)
904-
{
905-
builder.AppendLine("if (!char.TryParse(s, out var value))");
906-
}
907-
else
908-
if (isBool)
909-
{
910-
builder.AppendLine("if (!bool.TryParse(s, out var value))");
911-
}
912-
else
913-
{
914-
var style = "";
915-
if (format is not null)
903+
if (isChar)
916904
{
917-
style = data.UnderlyingType == DomainPrimitiveUnderlyingType.TimeSpan ? "TimeSpanStyles.None" : "DateTimeStyles.None";
905+
builder.AppendLine("if (!char.TryParse(s, out var value))");
918906
}
907+
else
908+
if (isBool)
909+
{
910+
builder.AppendLine("if (!bool.TryParse(s, out var value))");
911+
}
912+
else
913+
{
914+
var style = "";
915+
if (format is not null)
916+
{
917+
style = data.UnderlyingType == DomainPrimitiveUnderlyingType.TimeSpan ? "TimeSpanStyles.None" : "DateTimeStyles.None";
918+
}
919919

920-
builder.AppendIf(format is null, $"if (!{underlyingType}.TryParse(s, provider, out var value))")
921-
.AppendIf(format is not null, $"if (!{underlyingType}.TryParseExact(s, {QuoteAndEscape(format)}, provider, {style}, out var value))");
922-
}
920+
builder.AppendIf(format is null, $"if (!{underlyingType}.TryParse(s, provider, out var value))")
921+
.AppendIf(format is not null, $"if (!{underlyingType}.TryParseExact(s, {QuoteAndEscape(format)}, provider, {style}, out var value))");
922+
}
923923

924924
builder.OpenBracket()
925925
.AppendLine("result = default;")
@@ -1110,6 +1110,7 @@ public static void GenerateIXmlSerializableMethods(GeneratorData data, SourceCod
11101110
"string" => "ReadElementContentAsString",
11111111
"bool" => "ReadElementContentAsBoolean",
11121112
"DateOnly" => "ReadElementContentAsDateOnly",
1113+
"TimeOnly" => "ReadElementContentAsTimeOnly",
11131114
_ => $"ReadElementContentAs<{data.PrimitiveTypeFriendlyName}>"
11141115
};
11151116
}
@@ -1130,10 +1131,10 @@ public static void GenerateIXmlSerializableMethods(GeneratorData data, SourceCod
11301131
if (string.Equals(data.PrimitiveTypeFriendlyName, "string", System.StringComparison.Ordinal))
11311132
builder.AppendLine($"public void WriteXml(XmlWriter writer) => writer.WriteString({data.FieldName});");
11321133
else
1133-
if (data.SerializationFormat is null)
1134-
builder.AppendLine($"public void WriteXml(XmlWriter writer) => writer.WriteValue((({data.PrimitiveTypeFriendlyName}){data.FieldName}).ToXmlString());");
1135-
else
1136-
builder.AppendLine($"public void WriteXml(XmlWriter writer) => writer.WriteString({data.FieldName}.ToString({QuoteAndEscape(data.SerializationFormat)}));");
1134+
if (data.SerializationFormat is null)
1135+
builder.AppendLine($"public void WriteXml(XmlWriter writer) => writer.WriteValue((({data.PrimitiveTypeFriendlyName}){data.FieldName}).ToXmlString());");
1136+
else
1137+
builder.AppendLine($"public void WriteXml(XmlWriter writer) => writer.WriteString({data.FieldName}.ToString({QuoteAndEscape(data.SerializationFormat)}));");
11371138
builder.NewLine();
11381139
}
11391140

src/AltaSoft.DomainPrimitives/XmlReaderExt.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,25 @@ public static DateOnly ReadElementContentAsDateOnly(this XmlReader reader)
8181
return DateOnly.FromDateTime(DateTime.Parse(str, CultureInfo.InvariantCulture));
8282
}
8383

84+
/// <summary>
85+
/// Reads the content of the current XML element as a <see cref="TimeOnly"/> value.
86+
/// </summary>
87+
/// <param name="reader">The <see cref="XmlReader"/> instance.</param>
88+
/// <returns>
89+
/// A <see cref="TimeOnly"/> value parsed from the current element's content.
90+
/// </returns>
91+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
92+
public static TimeOnly ReadElementContentAsTimeOnly(this XmlReader reader)
93+
{
94+
var str = reader.ReadElementContentAsString();
95+
if (TimeOnly.TryParse(str, CultureInfo.InvariantCulture, out var result))
96+
return result;
97+
98+
var dt = DateTimeOffset.ParseExact(str, s_acceptedFormats, CultureInfo.InvariantCulture, DateTimeStyles.None);
99+
return TimeOnly.FromTimeSpan(dt.TimeOfDay);
100+
101+
}
102+
84103
/// <summary>
85104
/// Reads the content of the current XML element as a <see cref="DateOnly"/> value.
86105
/// </summary>
@@ -140,4 +159,12 @@ public static TimeSpan ReadElementContentAsTimeSpan(this XmlReader reader, strin
140159

141160
return TimeSpan.Parse(str, CultureInfo.InvariantCulture);
142161
}
162+
163+
private static readonly string[] s_acceptedFormats =
164+
[
165+
"HH:mm:ss",
166+
"HH:mm:sszzz", // 15:00:00+04:00
167+
"HH:mm:ssz", // 15:00:00Z
168+
"HH:mm:ss'+'", // 15:00:00+ (bare plus)
169+
];
143170
}

tests/AltaSoft.DomainPrimitives.UnitTests/DateOnlyConversionTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,4 @@ public void ReadElementContentAsDateOnly_WithTimezoneString_ReturnsDateOnly()
2828

2929
Assert.Equal(new DateOnly(2024, 4, 1), result);
3030
}
31-
}
31+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using System.Xml;
2+
using Xunit;
3+
4+
namespace AltaSoft.DomainPrimitives.UnitTests;
5+
6+
public class ParseTimeOnlyTests
7+
{
8+
// ─── Valid cases ───────────────────────────────────────────────────────────
9+
10+
[Theory]
11+
[InlineData("15:00:00", 15, 0, 0)]
12+
[InlineData("00:00:00", 0, 0, 0)]
13+
[InlineData("23:59:59", 23, 59, 59)]
14+
[InlineData("01:02:03", 1, 2, 3)]
15+
// With positive offset
16+
[InlineData("15:00:00+04:00", 15, 0, 0)]
17+
[InlineData("15:00:00+00:00", 15, 0, 0)]
18+
[InlineData("00:00:00+05:30", 0, 0, 0)]
19+
[InlineData("23:59:59+14:00", 23, 59, 59)]
20+
// With negative offset
21+
[InlineData("15:00:00-04:00", 15, 0, 0)]
22+
[InlineData("15:00:00-00:00", 15, 0, 0)]
23+
[InlineData("00:00:00-05:30", 0, 0, 0)]
24+
25+
// With bare plus
26+
[InlineData("15:00:00+", 15, 0, 0)]
27+
[InlineData("00:00:00+", 0, 0, 0)]
28+
public void Parse_ValidInput_ReturnsExpectedTimeOnly(string input, int hour, int minute, int second)
29+
{
30+
var result = ParseTimeOnly(input);
31+
32+
Assert.Equal(new TimeOnly(hour, minute, second), result);
33+
}
34+
35+
// ─── Invalid cases: has date part ─────────────────────────────────────────
36+
37+
[Theory]
38+
[InlineData("2025/01/11")]
39+
[InlineData("11/01/2025")]
40+
public void Parse_InputWithDatePart_Throws(string input)
41+
{
42+
Assert.Throws<FormatException>(() => ParseTimeOnly(input));
43+
}
44+
45+
// ─── Invalid cases: malformed time ────────────────────────────────────────
46+
47+
[Theory]
48+
[InlineData("99:00:00")] // invalid hour
49+
[InlineData("15:60:00")] // invalid minute
50+
[InlineData("15:00:60")] // invalid second
51+
[InlineData("abc")] // garbage
52+
[InlineData("1500:00")] // malformed
53+
[InlineData("15:00:00++04:00")] // double operator
54+
[InlineData("15:00:00+25:00")] // invalid offset hour
55+
[InlineData("")] // empty
56+
[InlineData(" ")] // whitespace
57+
public void Parse_MalformedInput_Throws(string input)
58+
{
59+
Assert.Throws<FormatException>(() => ParseTimeOnly(input));
60+
}
61+
private static TimeOnly ParseTimeOnly(string content)
62+
{
63+
var xml = $"<root>{content}</root>";
64+
var reader = XmlReader.Create(new StringReader(xml));
65+
reader.ReadToFollowing("root");
66+
return reader.ReadElementContentAsTimeOnly();
67+
}
68+
}

0 commit comments

Comments
 (0)