Skip to content

Commit

Permalink
fix: Support nanosecond precision as far as possible with DateTimeOffset
Browse files Browse the repository at this point in the history
This fixes #2575, for both parsing and formatting. It does mean
that `x.CreateTimestamp = x.CreateTimestamp` potentially loses
information (2023-10-27T10:32:45.123456789Z would become
2023-10-27T10:32:45.123456700Z) but that's the best we can do.

This doesn't change the DateTime handling at all; it's still
brokenly-culture-sensitive, and only formats to millisecond
precision. The generated DateTime properties are now obsolete
though, so it's probably best to leave them as they are.
  • Loading branch information
jskeet committed Oct 30, 2023
1 parent 0ac3f1c commit a933c3e
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 31 deletions.
96 changes: 79 additions & 17 deletions Src/Support/Google.Apis.Core/Util/Utilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ internal static string ConvertToRFC3339(DateTime date)
{
date = date.ToUniversalTime();
}
// TODO: Handle finer precision than milliseconds?
return date.ToString("yyyy-MM-dd'T'HH:mm:ss.fffK", DateTimeFormatInfo.InvariantInfo);
}

Expand Down Expand Up @@ -211,28 +212,89 @@ public static string GetStringFromDateTime(DateTime? date)
}

/// <summary>
/// Parses the input string and returns <see cref="System.DateTimeOffset"/> if the input is
/// of the format "yyyy-MM-ddTHH:mm:ss.FFFZ" or "yyyy-MM-ddTHH:mm:ssZ". If the input is null,
/// this method returns <c>null</c>. Otherwise, <see cref="FormatException"/> is thrown.
/// Parses the input string and returns <see cref="System.DateTimeOffset"/> if the input has 0, 3, 6 or 9
/// subsecond digits. If the input is null, this method returns <c>null</c>. Otherwise, <see cref="FormatException"/> is thrown.
/// If 9 subsecond digits are present, the result is truncated as if the final two digits are 0 (as the "tick"
/// precision of DateTimeOffset is 100 nanoseconds).
/// </summary>
public static DateTimeOffset? GetDateTimeOffsetFromString(string raw) =>
raw is null
? (DateTimeOffset?) null
: DateTimeOffset.ParseExact(raw, "yyyy-MM-dd'T'HH:mm:ss.FFF'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal);
public static DateTimeOffset? GetDateTimeOffsetFromString(string raw)
{
// Date part is 10 characters; time to seconds is 8.
// We need to take account of the "T" separator, the decimal separator for subseconds, and the trailing 'Z'.
const int SecondsOnlyLength = 20;
const int MillisecondsLength = SecondsOnlyLength + 4;
const int MicrosecondsLength = MillisecondsLength + 3;
const int NanosecondsLength = MicrosecondsLength + 3;
const int NanosecondsBeyondTicksIndex = NanosecondsLength - 3;
if (raw is null)
{
return null;
}
return raw.Length switch
{
SecondsOnlyLength => DateTimeOffset.ParseExact(raw, "yyyy-MM-dd'T'HH:mm:ss'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
MillisecondsLength => DateTimeOffset.ParseExact(raw, "yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
MicrosecondsLength => DateTimeOffset.ParseExact(raw, "yyyy-MM-dd'T'HH:mm:ss.ffffff'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
// Note: deliberately no Z here.
NanosecondsLength => DateTimeOffset.ParseExact(AdjustForNanoseconds(raw), "yyyy-MM-dd'T'HH:mm:ss.fffffff", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal),
_ => ThrowFormatException()
};

// If we receive "2023-10-2713:45:00.123456789Z" we need to parse it
// as "2023-10-2713:45:00.1234567Z" (i.e. to tick precision) as .NET doesn't support
// parsing and automatically truncating nanoseconds.
static string AdjustForNanoseconds(string raw)
{
ValidateDigit(raw[NanosecondsBeyondTicksIndex]);
ValidateDigit(raw[NanosecondsBeyondTicksIndex + 1]);
// This avoids us having to concatenate the substring with "Z".
if (raw[NanosecondsBeyondTicksIndex + 2] != 'Z')
{
ThrowFormatException();
}
return raw.Substring(0, NanosecondsBeyondTicksIndex);
}

static void ValidateDigit(char c)
{
if (c < '0' || c > '9')
{
ThrowFormatException();
}
}

// Note: this returns DateTimeOffset so we can use it in the switch...
static DateTimeOffset ThrowFormatException() =>
throw new FormatException("String was not recognized as a valid DateTime");
}

/// <summary>
/// Returns a string from the input <see cref="DateTimeOffset"/> instance, or <c>null</c> if
/// <paramref name="date"/> is null. The string is always in the format "yyyy-MM-ddTHH:mm:ss.fffZ" or
/// "yyyy-MM-ddTHH:mm:ssZ" - always UTC, always either second or millisecond precision, and always using the
/// invariant culture.
/// <paramref name="date"/> is null. The string is always in the format "yyyy-MM-ddTHH:mm:ssZ" or
/// "yyyy-MM-ddTHH:mm:ss.Z" or
/// "yyyy-MM-ddTHH:mm:ssZ". It is always UTC, always either second, millisecond, microsecond or nanosecond precision,
/// and always using the invariant culture. When using nanosecond precision, the last two digits will always be 0
/// as
/// </summary>
public static string GetStringFromDateTimeOffset(DateTimeOffset? date) =>
date is null
? null
// While FFF sounds like it should work, we really want to produce no subsecond parts or 3 digits.
: date.Value.Millisecond == 0 ? date.Value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
: date.Value.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);

public static string GetStringFromDateTimeOffset(DateTimeOffset? date)
{
const int TicksPerMicrosecond = 10;
if (date is not DateTimeOffset dto)
{
return null;
}
dto = dto.ToUniversalTime();
// Note: DateTimeOffset.Ticks is always non-negative, which makes things simpler.
long tickOfSecond = dto.Ticks % TimeSpan.TicksPerSecond;
string pattern = tickOfSecond switch
{
0 => "yyyy-MM-dd'T'HH:mm:ss'Z'",
_ when tickOfSecond % TimeSpan.TicksPerMillisecond == 0 => "yyyy-MM-dd'T'HH:mm:ss.fff'Z'",
_ when tickOfSecond % TicksPerMicrosecond == 0 => "yyyy-MM-dd'T'HH:mm:ss.ffffff'Z'",
_ => "yyyy-MM-dd'T'HH:mm:ss.fffffff'00Z'"
};
return date.Value.ToUniversalTime().ToString(pattern, CultureInfo.InvariantCulture);
}
/// <summary>
/// Deserializes the given raw value to an object using <see cref="NewtonsoftJsonSerializer.Instance"/>,
/// as if it were a JSON string value.
Expand Down
40 changes: 26 additions & 14 deletions Src/Support/Google.Apis.Tests/Apis/Utils/UtilitiesTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ limitations under the License.
*/

using Google.Apis.Util;
using Newtonsoft.Json.Linq;
using System;
using System.Globalization;
using System.Threading;
Expand Down Expand Up @@ -89,38 +88,51 @@ public void GetDateTimeOffsetFromString_Null() =>
[InlineData("broken")]
[InlineData("")]
[InlineData("2023-13-01T00:00:00Z")]
[InlineData("2023-06-14T12:23:45.5Z")] // Subsecond digit count must be a multiple of 3
[InlineData("2023-13-01T00:00:00+00")] // We require the Z
[InlineData("2023-13-01T00:00:00+01")] // We require the Z
[InlineData("2023-12-01T00:00:00.000000Z")] // We only support millisecond precision
[InlineData("2023-06-14T12:23:45.0000000x0Z")] // Broken past ticks
[InlineData("2023-06-14T12:23:45.00000000xZ")] // Broken past ticks
[InlineData("2023-06-14T12:23:45.000000000z")] // Broken past ticks
public void GetDateTimeOffsetFromString_Invalid(string input) =>
Assert.Throws<FormatException>(() => Utilities.GetDateTimeOffsetFromString(input));

// Note on formatting for tick-of-second for the theory data in the following tests:
// ticks are 100ns, so xxx_yyy_z is "xxx milliseconds, yyy microseconds, z ticks within the microsecond"

[Theory]
[InlineData("2023-12-01T00:00:00Z", 2023, 12, 1, 0, 0, 0, 0)]
[InlineData("0001-01-01T00:00:00Z", 1, 1, 1, 0, 0, 0, 0)]
[InlineData("9999-12-31T23:59:59.999Z", 9999, 12, 31, 23, 59, 59, 999)]
[InlineData("2023-06-14T12:23:45.5Z", 2023, 6, 14, 12,23, 45, 500)] // This is slightly unfortunate, but not actively harmful.
[InlineData("2023-06-14T12:23:45.123Z", 2023, 6, 14, 12, 23, 45, 123)]
[InlineData("9999-12-31T23:59:59.999Z", 9999, 12, 31, 23, 59, 59, 999_000_0)]
[InlineData("9999-12-31T23:59:59.999999Z", 9999, 12, 31, 23, 59, 59, 999_999_0)]
[InlineData("9999-12-31T23:59:59.999999999Z", 9999, 12, 31, 23, 59, 59, 999_999_9)]

[InlineData("2023-06-14T12:23:45.123Z", 2023, 6, 14, 12, 23, 45, 123_000_0)]
[InlineData("2023-06-14T12:23:45.123456Z", 2023, 6, 14, 12, 23, 45, 123_456_0)]
[InlineData("2023-06-14T12:23:45.123456789Z", 2023, 6, 14, 12, 23, 45, 123_456_7)]
[InlineData("2023-06-14T12:23:45.000Z", 2023, 6, 14, 12, 23, 45, 0)] // .000 is redundant but valid
[InlineData("2023-06-14T12:23:45.000000Z", 2023, 6, 14, 12, 23, 45, 0)] // .000 is redundant but valid
[InlineData("2023-06-14T12:23:45.000000000Z", 2023, 6, 14, 12, 23, 45, 0)] // .000 is redundant but valid
[InlineData("2023-06-14T12:23:45Z", 2023, 6, 14, 12, 23, 45, 0)]
public void GetDateTimeOffsetFromString_Valid(string input, int year, int month, int day, int hour, int minute, int second, int millisecond)
public void GetDateTimeOffsetFromString_Valid(string input, int year, int month, int day, int hour, int minute, int second, int tickOfSecond)
{
var actual = Utilities.GetDateTimeOffsetFromString(input).Value;
var expected = new DateTimeOffset(year, month, day, hour, minute, second, millisecond, TimeSpan.Zero);
var expected = new DateTimeOffset(year, month, day, hour, minute, second, TimeSpan.Zero).AddTicks(tickOfSecond);
Assert.Equal(expected, actual);
// Be explicit about this, as DateTimeOffset.Equals only compares instants in time, not offsets.
Assert.Equal(TimeSpan.Zero, actual.Offset);
}

[Theory]
[InlineData(5000000, "2023-06-13T15:54:13.500Z")]
[InlineData(1234567, "2023-06-13T15:54:13.123Z")]
[InlineData(1239999, "2023-06-13T15:54:13.123Z")]
[InlineData(9999999, "2023-06-13T15:54:13.999Z")]
[InlineData(10000, "2023-06-13T15:54:13.001Z")]
[InlineData(100000, "2023-06-13T15:54:13.010Z")]
[InlineData(500_000_0, "2023-06-13T15:54:13.500Z")]
[InlineData(123_456_0, "2023-06-13T15:54:13.123456Z")]
[InlineData(123_456_7, "2023-06-13T15:54:13.123456700Z")]
[InlineData(123_999_9, "2023-06-13T15:54:13.123999900Z")]
[InlineData(999_999_9, "2023-06-13T15:54:13.999999900Z")]
[InlineData(1_000_0, "2023-06-13T15:54:13.001Z")]
[InlineData(10_000_0, "2023-06-13T15:54:13.010Z")]
[InlineData(0, "2023-06-13T15:54:13Z")]
public void GetStringFromDateTimeOffset_MillisecondHandling(int tickOfSecond, string expectedResult)
public void GetStringFromDateTimeOffset_SubsecondHandling(int tickOfSecond, string expectedResult)
{
var value = new DateTimeOffset(2023, 6, 13, 15, 54, 13, TimeSpan.Zero).AddTicks(tickOfSecond);
Assert.Equal(expectedResult, Utilities.GetStringFromDateTimeOffset(value));
Expand Down

0 comments on commit a933c3e

Please sign in to comment.