JamesNK / Newtonsoft.Json

Json.NET is a popular high-performance JSON framework for .NET
https://www.newtonsoft.com/json
MIT License
10.74k stars 3.25k forks source link

Calling JToken.Value<string> on a JTokenType.Date token ignores local culture settings. #2522

Closed JasonRDT closed 3 years ago

JasonRDT commented 3 years ago

There appears to be an issue with the call to the JToken.Value<T> method, when working with JTokenType.Date tokens. I am in the UK and would expect to see dates always formatted in UK style i.e. as "dd/MM/yyyy". When calling the JToken.Value<string> method on a token whose token Type is JTokenType.Date, the returned date is always serialised in US format as "MM/dd/yyyy".

You can reproduce this issue by setting Region->Format to English (United Kingdom) in Control Panel, and then running the following code:

namespace ConsoleApp1
{
    using System;
    using Newtonsoft.Json.Linq;

    class Program
    {
        static void Main(string[] args)
        {
            var anon = new {
                TestDate = new DateTime(2021, 05, 06, 12, 0, 0)
            };

            var obj = JObject.FromObject(anon);

            var token = obj["TestDate"];

            Console.WriteLine($"Calling DateTime.ToString: {anon.TestDate}");
            Console.WriteLine($"Calling JToken.Value<string>: {token.Value<string>()}");

            Console.ReadLine();
        }
    }
}

I would have expected to see the following output in the console window:

Calling DateTime.ToString: 06/05/2021 12:00:00 Calling JToken.Value<string>: 06/05/2021 12:00:00

But what I actually see returned is the following:

Calling DateTime.ToString: 06/05/2021 12:00:00 Calling JToken.Value<string>: 05/06/2021 12:00:00

As you can see, the call to JToken.Value<string> returns the serialised date with the day and month transposed into US format as "MM/dd/yyyy". This call should be preserving local culture settings when returning the serialised date.

UPDATE:

I have traced this behaviour to the following line of code in Newtonsoft.Json/Linq/Extensions.cs, line 297:

return (U)System.Convert.ChangeType(value.Value, targetType, CultureInfo.InvariantCulture);

InvariantCulture uses many en-US formatted date and time strings. Switching this to use CurrentCulture resolves the issue, as you can see by executing the following code:

namespace ConsoleApp1
{
    using System;
    using System.Globalization;
    using Newtonsoft.Json.Linq;

    class Program
    {
        static void Main(string[] args)
        {
            var anon = new {
                TestDate = new DateTime(2021, 05, 06, 12, 0, 0)
            };

            var obj = JObject.FromObject(anon);

            var token = obj["TestDate"];

            Console.WriteLine($"Calling DateTime.ToString: {anon.TestDate}");
            Console.WriteLine($"Calling JToken.Value<string>: {token.Value<string>()}");

            Type targetType = typeof(string);
            JValue value = token as JValue;
            var s = (string)System.Convert.ChangeType(value.Value, targetType, CultureInfo.CurrentCulture);

            Console.WriteLine($"Calling ChangeType with CurrentCulture: {s}");
            Console.ReadLine();
        }
    }
}

Can this be corrected?

JamesNK commented 3 years ago

No, this will break people who depend on existing behavior.

JasonRDT commented 3 years ago

Then why don't you simply create an overload of this method that allows someone to pass in their desired culture? This particular issue caused our company a LOT of problems and time spent investigating what was happening.

JamesNK commented 3 years ago

Like this method? https://www.newtonsoft.com/json/help/html/M_Newtonsoft_Json_Linq_JValue_ToString_1.htm

JValue v = new JValue(DateTime.UtcNow);

string invariant = v.ToString(CultureInfo.InvariantCulture);
// 05/17/2021 08:21:52

string current = v.ToString(new System.Globalization.CultureInfo("en-NZ"));
// 17/05/2021 8:21:52 AM
JasonRDT commented 3 years ago

Yes, except for our data is already an instance of JToken as it is being returned from a call to JToken.SelectTokens.

JToken.ToString() does not support passing in the culture, which is why I suggested passing it into the Value conversion method instead of defaulting this to CultureInfo.InvariantCulture, i.e:

internal static U? Convert<T, U>(this T token, CultureInfo? culture = null) where T : JToken?
{
    if (token == null)
    {
#pragma warning disable CS8653 // A default expression introduces a null value for a type parameter.
                return default;
#pragma warning restore CS8653 // A default expression introduces a null value for a type parameter.
    }

    if (culture == null)
    {
        culture = CultureInfo.InvariantCulture;
    }

    if (token is U castValue
        // don't want to cast JValue to its interfaces, want to get the internal value
        && typeof(U) != typeof(IComparable) && typeof(U) != typeof(IFormattable))
    {
        return castValue;
    }
    else
    {
        if (!(token is JValue value))
        {
            throw new InvalidCastException("Cannot cast {0} to {1}.".FormatWith(culture, token.GetType(), typeof(T)));
        }

        if (value.Value is U u)
        {
            return u;
        }

        Type targetType = typeof(U);

        if (ReflectionUtils.IsNullableType(targetType))
        {
            if (value.Value == null)
            {
#pragma warning disable CS8653 // A default expression introduces a null value for a type parameter.
                return default;
#pragma warning restore CS8653 // A default expression introduces a null value for a type parameter.
            }

            targetType = Nullable.GetUnderlyingType(targetType);
        }

        return (U)System.Convert.ChangeType(value.Value, targetType, culture);
    }
}