Skip to content

Commit 832be1d

Browse files
floberndgithub-actions[bot]
authored andcommitted
Fix custom floating-point JSON converters (#7854)
1 parent cacd1be commit 832be1d

8 files changed

+230
-139
lines changed

Directory.Build.props

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
</PropertyGroup>
2929

3030
<PropertyGroup>
31-
<LangVersion>latest</LangVersion>
31+
<LangVersion>preview</LangVersion>
3232
<!-- Default Version numbers -->
3333
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
3434
<IsPackable>false</IsPackable>

src/Elastic.Clients.Elasticsearch/Serialization/DefaultSourceSerializer.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public class DefaultSourceSerializer : SystemTextJsonSerializer
2222
{
2323
new JsonStringEnumConverter(),
2424
new DoubleWithFractionalPortionConverter(),
25-
new FloatWithFractionalPortionConverter()
25+
new SingleWithFractionalPortionConverter()
2626
};
2727

2828
private readonly JsonSerializerOptions _jsonSerializerOptions;

src/Elastic.Clients.Elasticsearch/Serialization/DoubleWithFractionalPortionConverter.cs

+73-41
Original file line numberDiff line numberDiff line change
@@ -2,89 +2,121 @@
22
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information.
44

5-
#pragma warning disable IDE0005
65
using System;
6+
7+
#if NETCOREAPP
8+
79
using System.Buffers.Text;
10+
11+
#endif
12+
813
using System.Globalization;
14+
15+
#if !NETCOREAPP
16+
917
using System.Text;
18+
19+
#endif
20+
1021
using System.Text.Json;
1122
using System.Text.Json.Serialization;
12-
using static Elastic.Clients.Elasticsearch.Serialization.JsonConstants;
13-
#pragma warning restore IDE0005
1423

1524
namespace Elastic.Clients.Elasticsearch.Serialization;
1625

1726
internal sealed class DoubleWithFractionalPortionConverter : JsonConverter<double>
1827
{
19-
// We don't handle floating point literals (NaN, etc.) because for source serialization because Elasticsearch only support finite values for numeric fields.
20-
// We must handle the possibility of numbers as strings in the source however.
21-
2228
public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
2329
{
24-
if (reader.TokenType == JsonTokenType.String && options.NumberHandling.HasFlag(JsonNumberHandling.AllowReadingFromString))
30+
if (reader.TokenType != JsonTokenType.String)
31+
return reader.GetDouble();
32+
33+
if (options.NumberHandling.HasFlag(JsonNumberHandling.AllowNamedFloatingPointLiterals))
34+
{
35+
// TODO: Handle 'reader.HasValueSequence'
36+
if (reader.ValueSpan.SequenceEqual(JsonConstants.LiteralNaN))
37+
return float.NaN;
38+
if (reader.ValueSpan.SequenceEqual(JsonConstants.LiteralPositiveInfinity))
39+
return float.PositiveInfinity;
40+
if (reader.ValueSpan.SequenceEqual(JsonConstants.LiteralNegativeInfinity))
41+
return float.NegativeInfinity;
42+
}
43+
44+
if (options.NumberHandling.HasFlag(JsonNumberHandling.AllowReadingFromString))
2545
{
2646
var value = reader.GetString();
2747

28-
if (!double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedValue))
48+
if (!double.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out var result))
2949
ThrowHelper.ThrowJsonException($"Unable to parse '{value}' as a double.");
3050

31-
return parsedValue;
51+
return result;
3252
}
3353

3454
return reader.GetDouble();
3555
}
3656

3757
public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
3858
{
39-
Span<byte> utf8bytes = stackalloc byte[128]; // This is the size used in STJ for future proofing. https://github.com/dotnet/runtime/blob/dae6c2472b699b7cff2efeb5ce06b75c9551bc40/src/libraries/System.Text.Json/src/System/Text/Json/JsonConstants.cs#L79
59+
if (options.NumberHandling.HasFlag(JsonNumberHandling.AllowNamedFloatingPointLiterals))
60+
{
61+
switch (value)
62+
{
63+
case double.NaN:
64+
writer.WriteStringValue(JsonConstants.EncodedNaN);
65+
return;
4066

41-
// NOTE: This code is based on https://github.com/dotnet/runtime/blob/dae6c2472b699b7cff2efeb5ce06b75c9551bc40/src/libraries/System.Text.Json/src/System/Text/Json/JsonConstants.cs#L79
67+
case double.PositiveInfinity:
68+
writer.WriteStringValue(JsonConstants.EncodedPositiveInfinity);
69+
return;
70+
71+
case double.NegativeInfinity:
72+
writer.WriteStringValue(JsonConstants.EncodedNegativeInfinity);
73+
return;
74+
}
75+
}
4276

43-
// Frameworks that are not .NET Core 3.0 or higher do not produce round-trippable strings by
44-
// default. Further, the Utf8Formatter on older frameworks does not support taking a precision
45-
// specifier for 'G' nor does it represent other formats such as 'R'. As such, we duplicate
46-
// the .NET Core 3.0 logic of forwarding to the UTF16 formatter and transcoding it back to UTF8,
47-
// with some additional changes to remove dependencies on Span APIs which don't exist downlevel.
77+
if (options.NumberHandling.HasFlag(JsonNumberHandling.WriteAsString))
78+
{
79+
// TODO: Implement as needed
80+
throw new NotImplementedException("The 'JsonNumberHandling.WriteAsString' is currently not supported.");
81+
}
4882

49-
// PERFORMANCE: This code could be benchmarked and tweaked to make it faster.
83+
// This code is based on:
84+
// https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/Writer/Utf8JsonWriter.WriteValues.Double.cs#L101
5085

5186
#if NETCOREAPP
52-
if (Utf8Formatter.TryFormat(value, utf8bytes, out var bytesWritten))
87+
Span<byte> utf8Text = stackalloc byte[JsonConstants.MaximumFormatDoubleLength];
88+
89+
if (Utf8Formatter.TryFormat(value, utf8Text, out var bytesWritten, JsonConstants.DoubleStandardFormat))
5390
{
54-
if (utf8bytes.IndexOfAny(NonIntegerBytes) == -1)
91+
if (utf8Text.IndexOfAny(JsonConstants.NonIntegerChars) == -1)
5592
{
56-
utf8bytes[bytesWritten++] = (byte)'.';
57-
utf8bytes[bytesWritten++] = (byte)'0';
93+
utf8Text[bytesWritten++] = (byte)'.';
94+
utf8Text[bytesWritten++] = (byte)'0';
5895
}
5996

60-
#pragma warning disable IDE0057 // Use range operator
61-
writer.WriteRawValue(utf8bytes.Slice(0, bytesWritten), skipInputValidation: true);
62-
#pragma warning restore IDE0057 // Use range operator
63-
97+
writer.WriteRawValue(utf8Text[..bytesWritten], true);
6498
return;
6599
}
66100
#else
67-
var utf16Text = value.ToString("G17", CultureInfo.InvariantCulture);
101+
var utf16Text = value.ToString(JsonConstants.DoubleFormatString, CultureInfo.InvariantCulture);
102+
if (utf16Text.IndexOfAny(JsonConstants.NonIntegerChars) == -1)
103+
{
104+
utf16Text += ".0";
105+
}
68106

69-
if (utf16Text.Length < utf8bytes.Length)
107+
try
70108
{
71-
try
72-
{
73-
var bytes = Encoding.UTF8.GetBytes(utf16Text);
109+
var utf8Text = Encoding.UTF8.GetBytes(utf16Text);
74110

75-
if (bytes.Length < utf8bytes.Length)
76-
{
77-
bytes.CopyTo(utf8bytes);
78-
return;
79-
}
80-
}
81-
catch
82-
{
83-
// Swallow this and fall through to our general exception.
84-
}
111+
writer.WriteRawValue(utf8Text, true);
112+
return;
113+
}
114+
catch
115+
{
116+
// Swallow this and fall through to our general exception.
85117
}
86118
#endif
87119

88-
ThrowHelper.ThrowJsonException($"Unable to serialize double value.");
120+
ThrowHelper.ThrowJsonException("Unable to serialize double value.");
89121
}
90122
}

src/Elastic.Clients.Elasticsearch/Serialization/FloatWithFractionalPortionConverter.cs

-90
This file was deleted.

src/Elastic.Clients.Elasticsearch/Serialization/JsonConstants.cs

+30-3
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,39 @@
33
// See the LICENSE file in the project root for more information.
44

55
using System;
6+
using System.Text.Json;
7+
8+
#if NETCOREAPP
9+
10+
using System.Buffers;
11+
12+
#endif
613

714
namespace Elastic.Clients.Elasticsearch.Serialization;
815

916
internal static class JsonConstants
1017
{
11-
#pragma warning disable IDE0230 // Use UTF-8 string literal
12-
public static ReadOnlySpan<byte> NonIntegerBytes => new[] { (byte)'E', (byte)'.' }; // In the future, when we move to the .NET 7 SDK, it would be nice to use u8 literals e.g. "E."u8
13-
#pragma warning restore IDE0230 // Use UTF-8 string literal
18+
public static ReadOnlySpan<byte> LiteralNaN => "NaN"u8;
19+
public static ReadOnlySpan<byte> LiteralPositiveInfinity => "Infinity"u8;
20+
public static ReadOnlySpan<byte> LiteralNegativeInfinity => "-Infinity"u8;
21+
public static JsonEncodedText EncodedNaN => JsonEncodedText.Encode(LiteralNaN);
22+
public static JsonEncodedText EncodedPositiveInfinity => JsonEncodedText.Encode(LiteralPositiveInfinity);
23+
public static JsonEncodedText EncodedNegativeInfinity => JsonEncodedText.Encode(LiteralNegativeInfinity);
24+
25+
#if NETCOREAPP
26+
public static ReadOnlySpan<byte> NonIntegerChars => "E."u8;
27+
#else
28+
public static char[] NonIntegerChars => new[] { 'E', '.' };
29+
#endif
30+
31+
public const string DoubleFormatString = "G17"; // 'R' does not roundtrip correctly in some cases prior to .NET Core 3
32+
public const string SingleFormatString = "G9"; // https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-numeric-format-strings#round-trip-format-specifier-r
33+
34+
#if NETCOREAPP
35+
public static readonly StandardFormat DoubleStandardFormat = StandardFormat.Parse(DoubleFormatString);
36+
public static readonly StandardFormat SingleStandardFormat = StandardFormat.Parse(SingleFormatString);
37+
38+
public const int MaximumFormatDoubleLength = 128; // https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/JsonConstants.cs#L78
39+
public const int MaximumFormatSingleLength = 128; // https://github.com/dotnet/runtime/blob/main/src/libraries/System.Text.Json/src/System/Text/Json/JsonConstants.cs#L79
40+
#endif
1441
}

0 commit comments

Comments
 (0)