diff --git a/QuickFIXn/Fields/Converters/DateTimeConverter.cs b/QuickFIXn/Fields/Converters/DateTimeConverter.cs index 59a151e04..7785f77ff 100644 --- a/QuickFIXn/Fields/Converters/DateTimeConverter.cs +++ b/QuickFIXn/Fields/Converters/DateTimeConverter.cs @@ -1,6 +1,7 @@ -using System.Collections.Generic; -using System.Text; -using System.Globalization; +using System.Globalization; +using System; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics; namespace QuickFix.Fields.Converters { @@ -9,285 +10,642 @@ namespace QuickFix.Fields.Converters /// public static class DateTimeConverter { - public const int MicrosPerMillis = 1000; - public const int NanosPerMicro = 1000; - public const int TicksPerMicrosecond = 10; public const int NanosecondsPerTick = 100; - public const string DATE_TIME_FORMAT_WITH_NANOSECONDS = "{0:yyyyMMdd-HH:mm:ss}.{1}"; - public const string DATE_TIME_FORMAT_WITH_MICROSECONDS = "{0:yyyyMMdd-HH:mm:ss.ffffff}"; - public const string DATE_TIME_FORMAT_WITH_MILLISECONDS = "{0:yyyyMMdd-HH:mm:ss.fff}"; - public const string DATE_TIME_FORMAT_WITHOUT_MILLISECONDS = "{0:yyyyMMdd-HH:mm:ss}"; - public const string DATE_ONLY_FORMAT = "{0:yyyyMMdd}"; - public const string TIME_ONLY_FORMAT_WITH_NANOSECONDS = "{0:HH:mm:ss}.{1}"; - public const string TIME_ONLY_FORMAT_WITH_MICROSECONDS = "{0:HH:mm:ss.ffffff}"; - public const string TIME_ONLY_FORMAT_WITH_MILLISECONDS = "{0:HH:mm:ss.fff}"; - public const string TIME_ONLY_FORMAT_WITHOUT_MILLISECONDS = "{0:HH:mm:ss}"; - - public const string DATE_TIME_WITH_MICROSECONDS = "yyyyMMdd-HH:mm:ss.ffffff"; - public static int DATE_TIME_MAXLENGTH_WITHOUT_NANOSECONDS = DATE_TIME_WITH_MICROSECONDS.Length; - public static string[] DATE_TIME_FORMATS = { DATE_TIME_WITH_MICROSECONDS, "yyyyMMdd-HH:mm:ss.fff", "yyyyMMdd-HH:mm:ss" }; - public static string[] DATE_ONLY_FORMATS = { "yyyyMMdd" }; - public static string[] TIME_ONLY_FORMATS = { "HH:mm:ss.ffffff", "HH:mm:ss.fff", "HH:mm:ss" }; - public static DateTimeStyles DATE_TIME_STYLES = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal; - public static CultureInfo DATE_TIME_CULTURE_INFO = CultureInfo.InvariantCulture; - private static IDictionary DATE_TIME_PRECISION_TO_FORMAT = new Dictionary - { - {TimeStampPrecision.Second, DATE_TIME_FORMAT_WITHOUT_MILLISECONDS}, - {TimeStampPrecision.Millisecond, DATE_TIME_FORMAT_WITH_MILLISECONDS}, - {TimeStampPrecision.Microsecond, DATE_TIME_FORMAT_WITH_MICROSECONDS}, - }; - private static IDictionary TIME_ONLY_PRECISION_TO_FORMAT = new Dictionary - { - {TimeStampPrecision.Second, TIME_ONLY_FORMAT_WITHOUT_MILLISECONDS}, - {TimeStampPrecision.Millisecond, TIME_ONLY_FORMAT_WITH_MILLISECONDS}, - {TimeStampPrecision.Microsecond, TIME_ONLY_FORMAT_WITH_MICROSECONDS}, - }; - private static System.DateTime TimeOnlyFromNanoString(string str) + /// + /// Converts the specified span to a and, when the span contains + /// UTC offset information, a containing that information. + /// The span must be in the format "yyyyMMdd-HH:mm:ss" optionally followed by fractional seconds + /// and then optionally followed by UTC offset information. + /// + /// A span containing the characters that represent a date and time to convert. + /// + /// When is suffixed with UTC offset information (e.g. Z, +05, -01:30), this parameter + /// is populated with a representing the date, time and offset information in + /// . When does not contain UTC offset information, this parameter + /// is . + /// + /// + /// When does not contain UTC offset information, a value representing the date + /// and time in with equal to + /// . When does contain offset information, + /// then is not and the returned + /// is equal to , that is, adjusted to UTC + /// and with its equal to . + /// + /// + /// This method supports parsing spans containing fractional seconds of arbitrary precision. + /// However, the conversion can be lossy since objects can only retain + /// fractional second precision to 100ns. + /// + /// The conversion cannot be performed successfully. + public static DateTime ConvertToDateTime(ReadOnlySpan str, out DateTimeOffset? dateTimeOffset) { - return ConvertFromNanoString(str, TIME_ONLY_FORMATS); - } + dateTimeOffset = null; - private static System.DateTime DateTimeFromNanoString(string str) - { - return ConvertFromNanoString(str, DATE_TIME_FORMATS); - } + Debug.Assert("yyyyMMdd-HH:mm:ss".Length == 17); - private static System.DateTime ConvertFromNanoString(string str, string[] formats) - { - int i = str.IndexOf('.'); - string dec = str.Substring(i+1); - System.DateTimeKind kind; - int offset = 0; + ReadOnlySpan s = str; + + if (s.Length < 17 || + !DateTime.TryParseExact(s.Slice(0, 17), "yyyyMMdd-HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime dt)) + { + return ThrowDateTime(str); + } + + s = s.Slice(17); + + if (s.IsEmpty) + { + return dt; + } + + if (s[0] == '.') + { + int fractionLength = ParseFraction(s.Slice(1), out double fraction); + + if (fractionLength == 0) + { + return ThrowDateTime(str); + } + + dt = dt.AddTicks((long)(TimeSpan.TicksPerSecond * fraction)); + + s = s.Slice(1 + fractionLength); + + if (s.IsEmpty) + { + return dt; + } + } + + Debug.Assert(!s.IsEmpty); - if (dec.EndsWith("Z")) + if (s.IsWhiteSpace()) { - // UTC - dec = dec.Substring(0, dec.Length - 1); - kind = System.DateTimeKind.Utc; + return ThrowDateTime(str); } - else if (dec.Contains("+") || dec.Contains("-")) + + if (s[0] == ' ') { - // GMT offset - int n = dec.Contains("+") ? dec.IndexOf('+') : dec.IndexOf('-'); - kind = System.DateTimeKind.Unspecified; - offset = int.Parse(dec.Substring(n)); - dec = dec.Substring(0, n); + // Allow a space before the offset + s = s.Slice(1); + Debug.Assert(!s.IsEmpty, "This should be covered by the IsWhiteSpace check"); } - else + + int offsetLength = ParseOffset(s, out int sign, out int hours, out int minutes); + + if (!s.Slice(offsetLength).IsEmpty) { - // local time - kind = System.DateTimeKind.Local; + // Throw if there is something after the last valid offset part + return ThrowDateTime(str); } - long frac = long.Parse(dec); - string tm = str.Substring(0, i); - System.DateTime d = System.DateTime.SpecifyKind(System.DateTime.ParseExact(tm, formats, DATE_TIME_CULTURE_INFO, DATE_TIME_STYLES), kind); - // apply GMT offset - if (offset != 0) + Debug.Assert(offsetLength > 0, "Do not have an offset but going down the offset path"); + Debug.Assert(Math.Abs(sign) == 1, $"Bad {nameof(sign)} value returned from {nameof(ParseOffset)}"); + Debug.Assert((uint)hours <= 14, $"Bad {nameof(hours)} value returned from {nameof(ParseOffset)}"); + Debug.Assert((uint)minutes < 60, $"Bad {nameof(minutes)} value returned from {nameof(ParseOffset)}"); + + TimeSpan offset = new(sign * hours, sign * minutes, 0); + + try + { + dateTimeOffset = new DateTimeOffset(dt, offset); + } + catch (ArgumentOutOfRangeException) { - d = new System.DateTimeOffset(d, System.TimeSpan.FromHours(offset)).UtcDateTime; + return ThrowDateTime(str); } - long ticks = frac / NanosecondsPerTick; - return d.AddTicks(ticks); + return dateTimeOffset.Value.UtcDateTime; } /// - /// Convert string to DateTime + /// Converts the specified string to a . + /// The string must be in the format "yyyyMMdd-HH:mm:ss" optionally followed by fractional seconds + /// and then optionally followed by UTC offset information. /// - /// - public static System.DateTime ConvertToDateTime(string str) + /// A containing the characters that represent a date and time to convert. + /// + /// When does not contain UTC offset information, a value representing the date + /// and time in with equal to + /// . When does contain offset information, + /// then the returned is equal to the date and time in adjusted to UTC + /// and with its equal to . + /// + /// + /// This method supports parsing spans containing fractional seconds of arbitrary precision. + /// However, the conversion can be lossy since objects can only retain + /// fractional second precision to 100ns. + ///

+ /// This method calls + /// which also returns a when UTC offset information is present. + /// Consider calling the latter for flexibility. + ///
+ /// The conversion cannot be performed successfully. + /// + public static DateTime ConvertToDateTime(string str) { - return ConvertToDateTime(str, TimeStampPrecision.Millisecond); + return ConvertToDateTime(str, out _); } /// - /// Convert string to DateTime + /// Converts the specified span to a and, when the span contains + /// UTC offset information, a containing that information. + /// The span must be in the format "HH:mm:ss" optionally followed by fractional seconds + /// and then optionally followed by UTC offset information. /// - /// - public static System.DateTime ConvertToDateTime(string str, TimeStampPrecision precision) + /// A span containing the characters that represent a time to convert. + /// + /// When is suffixed with UTC offset information (e.g. Z, +05, -01:30), this parameter + /// is populated with a representing the offset information in + /// . When does not contain UTC offset information, this parameter + /// is . + /// + /// + /// A value representing the time in . The value is unaffected by the value + /// of . For example, for the string "11:03:15 +05:30" the returned + /// will be equivalent to 11:03:15. This behaviour differs to + /// where the returned + /// is adjusted to UTC when offset information is present (with its + /// equal to ). + /// + /// + /// This method supports parsing spans containing fractional seconds of arbitrary precision. + /// However, the conversion can be lossy since objects can only retain + /// fractional second precision to 100ns. + /// + /// The conversion cannot be performed successfully. + public static TimeOnly ConvertToTimeOnly(ReadOnlySpan str, out TimeSpan? offset) { - try + offset = null; + + Debug.Assert("HH:mm:ss".Length == 8); + + ReadOnlySpan s = str; + + if (s.Length < 8 || + !TimeOnly.TryParseExact(s.Slice(0, 8), "HH:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.None, out TimeOnly time)) + { + return ThrowTimeOnly(str); + } + + s = s.Slice(8); + + if (s.IsEmpty) + { + return time; + } + + if (s[0] == '.') { - //Avoid NanoString Path parsing if not possible from string length - if (str.Length > DATE_TIME_MAXLENGTH_WITHOUT_NANOSECONDS) + int fractionLength = ParseFraction(s.Slice(1), out double fraction); + + if (fractionLength == 0) { - return DateTimeFromNanoString(str); + return ThrowTimeOnly(str); } - else + + time = time.Add(TimeSpan.FromTicks((long)(TimeSpan.TicksPerSecond * fraction))); + + s = s.Slice(1 + fractionLength); + + if (s.IsEmpty) { - return System.DateTime.ParseExact(str, DATE_TIME_FORMATS, DATE_TIME_CULTURE_INFO, DATE_TIME_STYLES); + return time; } } - catch (System.Exception e) + + Debug.Assert(!s.IsEmpty); + + if (s.IsWhiteSpace()) { - throw new FieldConvertError("Could not convert string (" + str + ") to DateTime: " + e.Message, e); + return ThrowTimeOnly(str); } - } - /// - /// Check if string is DateOnly and, if yes, convert to DateTime - /// - /// - /// - /// - public static System.DateTime ConvertToDateOnly(string str) - { - try + if (s[0] == ' ') { - return System.DateTime.ParseExact(str, DATE_ONLY_FORMATS, DATE_TIME_CULTURE_INFO, DATE_TIME_STYLES); + // Allow a space before the offset + s = s.Slice(1); + Debug.Assert(!s.IsEmpty, "This should be covered by the IsWhiteSpace check"); } - catch (System.Exception e) + + int offsetLength = ParseOffset(s, out int sign, out int hours, out int minutes); + + if (!s.Slice(offsetLength).IsEmpty) { - throw new FieldConvertError("Could not convert string (" + str + ") to DateOnly: " + e.Message, e); + // Throw if there is something after the last valid offset part + return ThrowTimeOnly(str); } + + Debug.Assert(offsetLength > 0, "Do not have an offset but going down the offset path"); + Debug.Assert(Math.Abs(sign) == 1, $"Bad {nameof(sign)} value returned from {nameof(ParseOffset)}"); + Debug.Assert((uint)hours <= 14, $"Bad {nameof(hours)} value returned from {nameof(ParseOffset)}"); + Debug.Assert((uint)minutes < 60, $"Bad {nameof(minutes)} value returned from {nameof(ParseOffset)}"); + + offset = new(sign * hours, sign * minutes, 0); + + return time; } /// - /// Check if string is TimeOnly and, if yes, convert to DateTime. Time stamp precision defaults to milliseconds. + /// Converts the specified string to a . + /// The string must be in the format "HH:mm:ss" optionally followed by fractional seconds + /// and then optionally followed by UTC offset information. /// - /// - /// - /// - public static System.DateTime ConvertToTimeOnly(string str) - { - return ConvertToTimeOnly(str, TimeStampPrecision.Millisecond); - } + /// A containing the characters that represent a time to convert. + /// + /// A value representing the time in with its date component equal to 1980-01-01. + /// The value is unaffected by UTC offset information in . + /// For example, for the string "11:03:15 +05:30" the returned will be equivalent + /// to 1980-01-01 11:03:15 with its equal to . + /// + /// + /// This method supports parsing spans containing fractional seconds of arbitrary precision. + /// However, the conversion can be lossy since objects can only retain + /// fractional second precision to 100ns. + ///

+ /// This method calls which returns a + /// and, when UTC offset information is present, a containing that information. + /// Consider calling the latter for flexibility. + ///
+ /// The conversion cannot be performed successfully. + public static DateTime ConvertToTimeOnly(string str) => new DateOnly(1980, 1, 1).ToDateTime(ConvertToTimeOnly(str, out _)); /// - /// Check if string is TimeOnly and, if yes, convert to DateTime. Optional time stamp precision up to + /// Converts the specified string to a . + /// The string must be in the format "HH:mm:ss" optionally followed by fractional seconds + /// and then optionally followed by UTC offset information. /// - /// - /// /// - /// - /// - public static System.DateTime ConvertToTimeOnly(string str, TimeStampPrecision precision) + /// A containing the characters that represent a time to convert. + /// + /// A value representing the time in . + /// The value is unaffected by UTC offset information in . + /// For example, for the string "11:03:15 +05:30" the returned will be equivalent + /// to 11:03:15. + /// + /// + /// This method supports parsing spans containing fractional seconds of arbitrary precision. + /// However, the conversion can be lossy since objects can only retain + /// fractional second precision to 100ns. + ///

+ /// This method calls which returns a + /// and, when UTC offset information is present, a containing that information. + /// Consider calling the latter for flexibility. + ///
+ /// The conversion cannot be performed successfully. + public static TimeSpan ConvertToTimeSpan(string str) => ConvertToTimeOnly(str, out _).ToTimeSpan(); + + /// + /// For the given , read consecutive ASCII digits + /// as a fraction until either a non-digit is found or the end of the span is reached. + /// + /// The to parse + /// + /// The resulting decimal number greater than or equal to 0 and less than 1. + /// For example, for the "0123X", will be 0.0123 + /// + /// The number of elements of the span consumed in parsing the fraction. + /// For example, for the "0123X", the return value will be 4. + /// + private static int ParseFraction(ReadOnlySpan span, out double fraction) { - try + fraction = 0; + double decimalBase = 0.1; + for (int i = 0; i < span.Length; i++) { - System.DateTime d; - if (precision == TimeStampPrecision.Nanosecond) + char c = span[i]; + if ((uint)(c - '0') > (uint)('9' - '0')) // if c is not an ascii digit { - d = TimeOnlyFromNanoString(str); + return i; } - else - { - d = System.DateTime.ParseExact(str, TIME_ONLY_FORMATS, DATE_TIME_CULTURE_INFO, DATE_TIME_STYLES); - } - return new System.DateTime(1980, 1, 1) + d.TimeOfDay; + fraction += (c - '0') * decimalBase; + decimalBase *= 0.1; } - catch (System.Exception e) + + // If we are here then we've consumed the entire span as a fraction + return span.Length; + } + + /// + /// For the given , read consecutive ASCII digits + /// as an integer number until: a non-digit is found; the end of the span is reached; + /// or digits have been read. + /// + /// The to parse. + /// + /// The maximum number of digits to read. + /// The caller is expected to specify a value between 1 and 8. + /// + /// + /// The resulting integral number greater than or equal to 0. + /// For example, for the "0123X", will be 123 + /// + /// The number of elements of the span consumed in parsing the number. + /// For example, for the "0123X", the return value will be 4. + /// + private static int ParseInteger(ReadOnlySpan span, int maxDigits, out int integer) + { + Debug.Assert(maxDigits <= 8); // to avoid overflow + maxDigits = Math.Min(maxDigits, span.Length); + integer = 0; + for (int i = 0; i < maxDigits; i++) { - throw new FieldConvertError("Could not convert string (" + str + ") to TimeOnly: " + e.Message, e); + char c = span[i]; + if ((uint)(c - '0') > (uint)('9' - '0')) // if c is not an ascii digit + { + return i; + } + integer = 10 * integer + (c - '0'); } + + // If we are here then we've consumed digits as an integer from the span + return maxDigits; } /// - /// Check if string is TimeOnly and, if yes, convert to TimeSpan + /// For the given , parse a UTC offset identifier + /// until no valid part of the identifier can be parsed or the end of the span + /// is reached. /// - /// - /// - /// - public static System.TimeSpan ConvertToTimeSpan(string str) + /// The to parse + /// The direction of the offset. Either 1 or -1. + /// The hours component of the offset + /// The minutes component of the offset + /// + /// The number of elements from the start of the span which comprise a valid + /// UTC offset identifier. + /// For example: + /// + /// For the "Z", the return value will be 1 + /// For the "+", the return value will be 0 + /// For the "+04:1", the return value will be 3 + /// For the "+04:12", the return value will be 6 + /// + /// + private static int ParseOffset(ReadOnlySpan span, out int sign, out int hours, out int minutes) { - try + Debug.Assert(span.Length > 0); + + sign = 1; + + switch (span[0]) + { + case 'Z': + hours = minutes = 0; + return 1; + case '+': + break; + case '-': + sign = -1; + break; + default: + hours = minutes = default; + return 0; + } + + Debug.Assert(span[0] == '+' || span[0] == '-'); + + int numHourDigits = ParseInteger(span.Slice(1), maxDigits: 2, out hours); + + if (numHourDigits == 0 || hours > 14) + { + // Invalid hours part. We've consumed no valid offset part + minutes = default; + return 0; + } + + Debug.Assert(numHourDigits == 1 || numHourDigits == 2); + + span = span.Slice(1 + numHourDigits); + + if (span.IsEmpty || span[0] != ':') { - System.DateTime d = ConvertToTimeOnly(str); - return d.TimeOfDay; + minutes = default; + return 1 + numHourDigits; } - catch (System.Exception e) + + int numMinuteDigits = ParseInteger(span.Slice(1), maxDigits: 2, out minutes); + + if (numMinuteDigits != 2 || minutes > 59) { - throw new FieldConvertError("Could not convert string (" + str + ") to TimeSpan: " + e.Message, e); + // Invalid minutes part. We've only consumed a valid hours part + return 1 + numHourDigits; } + + return 1 + numHourDigits + 1 + numMinuteDigits; + } + + [DoesNotReturn] + private static DateTime ThrowDateTime(ReadOnlySpan s) + { + throw new FieldConvertError($"Could not convert \"{s}\" to DateTime"); + } + + [DoesNotReturn] + private static TimeOnly ThrowTimeOnly(ReadOnlySpan s) + { + throw new FieldConvertError($"Could not convert \"{s}\" to TimeOnly"); } /// - /// Convert DateTime to string in FIX Format + /// Converts the specified span to a . + /// The span must be in the format "yyyyMMdd". /// - /// the DateTime to convert - /// if true, include milliseconds in the result - /// FIX-formatted DataTime - public static string Convert( System.DateTime dt, bool includeMilliseconds ) + /// A span containing the characters that represent a date to convert. + /// + /// A value representing the date in . + /// + /// The conversion cannot be performed successfully. + public static DateOnly ConvertToDateOnly(ReadOnlySpan str) { - return includeMilliseconds ? Convert(dt, TimeStampPrecision.Millisecond): Convert( dt, TimeStampPrecision.Second ); + if (!DateOnly.TryParseExact(str, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly dateOnly)) + { + throw new FieldConvertError($"Could not convert string ({str} to DateOnly)"); + } + + return dateOnly; } - + + /// + /// Converts the specified string to a . + /// The string must be in the format "yyyyMMdd". + /// + /// A containing the characters that represent a date to convert. + /// + /// A value representing the date in with its time component equal to 00:00:00 and + /// its equal to . + /// + /// + /// This method calls which returns a . + /// Consider calling the latter for correctness. + /// + /// The conversion cannot be performed successfully. + public static DateTime ConvertToDateOnly(string str) => ConvertToDateOnly((ReadOnlySpan)str).ToDateTime(default); + /// - /// Gets the nanoseconds component of the date represented by this instance truncated to 100 nanosecond resolution + /// Gets the nanoseconds component of the date represented by this instance truncated to 100 nanosecond resolution. /// /// /// The nanoseconds component, expressed as a value between 0 and 99900. - public static int Nanosecond(this System.DateTime dt) + public static int Nanosecond(this DateTime dt) { - int ns = (int)(dt.Ticks % System.TimeSpan.TicksPerMillisecond % (double)TicksPerMicrosecond) * NanosecondsPerTick; - int us = (int)System.Math.Floor((dt.Ticks % System.TimeSpan.TicksPerMillisecond) / (double)TicksPerMicrosecond); - return (us * NanosPerMicro) + ns; + return (int)(dt.Ticks % TimeSpan.TicksPerMillisecond) * NanosecondsPerTick; } - private static long SubsecondAsNanoseconds(this System.DateTime dt) + private static long SubsecondAsNanoseconds(DateTime dt) { - int ns = dt.Nanosecond(); - int ms = dt.Millisecond; - return (ms * NanosPerMicro * MicrosPerMillis) + ns; + return (dt.Ticks % TimeSpan.TicksPerSecond) * NanosecondsPerTick; + } + + private static long SubsecondAsNanoseconds(TimeOnly time) + { + return (time.Ticks % TimeSpan.TicksPerSecond) * NanosecondsPerTick; } /// - /// Converts the specified dt. + /// Converts the specified to a in the format "yyyyMMdd-HH:mm:ss.fff". /// - /// The dt. - /// The precision. - /// - public static string Convert(System.DateTime dt, TimeStampPrecision precision ) + /// The value to convert. + /// A value representing in the format "yyyyMMdd-HH:mm:ss.fff". + public static string Convert(DateTime dt) { - if (precision == TimeStampPrecision.Nanosecond) - { - return string.Format(DATE_TIME_FORMAT_WITH_NANOSECONDS, dt, dt.SubsecondAsNanoseconds()); - } - else - { - var format = DATE_TIME_PRECISION_TO_FORMAT[precision]; - return string.Format(format, dt); - } + return Convert(dt, TimeStampPrecision.Millisecond); + } + + /// + /// Converts the specified to a . + /// + /// The value to convert. + /// Whether fractional seconds (to the millisecond) should be in the returned value. + /// + /// A value representing . If + /// is , the value will be in the format "yyyyMMdd-HH:mm:ss.fff". Otherwise, the value + /// will be in the format "yyyyMMdd-HH:mm:ss". + /// + public static string Convert(DateTime dt, bool includeMilliseconds) + { + return Convert(dt, includeMilliseconds ? TimeStampPrecision.Millisecond : TimeStampPrecision.Second); } /// - /// Convert DateTime to string in FIX Format, with milliseconds + /// Converts the specified to a . /// - /// the DateTime to convert - /// FIX-formatted DateTime - public static string Convert(System.DateTime dt) + /// The value to convert. + /// The level of precision with which to format the fractional seconds component of . + /// + /// A value representing . The value will begin in the format "yyyyMMdd-HH:mm:ss" + /// and end in fractional seconds whose precision is determined by . + /// + /// is an invalid value. + public static string Convert(DateTime dt, TimeStampPrecision precision) { - return DateTimeConverter.Convert(dt, true); + return precision switch + { + TimeStampPrecision.Second => dt.ToString("yyyyMMdd-HH:mm:ss"), + TimeStampPrecision.Millisecond => dt.ToString("yyyyMMdd-HH:mm:ss.fff"), + TimeStampPrecision.Microsecond => dt.ToString("yyyyMMdd-HH:mm:ss.ffffff"), + TimeStampPrecision.Nanosecond => $"{dt:yyyyMMdd-HH:mm:ss}.{SubsecondAsNanoseconds(dt):000000000}", + _ => throw new ArgumentOutOfRangeException(nameof(precision)), + }; } - public static string ConvertDateOnly(System.DateTime dt) + /// + /// Converts the specified to a in the format "yyyyMMdd". + /// + /// The value to convert. + /// A value representing in the format "yyyyMMdd". + public static string ConvertDateOnly(DateOnly date) { - return string.Format(DATE_ONLY_FORMAT, dt); + return date.ToString("yyyyMMdd", CultureInfo.InvariantCulture); } - public static string ConvertTimeOnly(System.DateTime dt) + /// + /// Converts the date component of the specified to a in the format "yyyyMMdd". + /// + /// The value to convert. + /// A value representing the date component of in the format "yyyyMMdd". + public static string ConvertDateOnly(DateTime dt) => ConvertDateOnly(DateOnly.FromDateTime(dt)); + + /// + /// Converts the specified to a in the format "HH:mm:ss.fff". + /// + /// The value to convert. + /// A value representing in the format "HH:mm:ss.fff". + public static string ConvertTimeOnly(TimeOnly time) { - return DateTimeConverter.ConvertTimeOnly(dt, true); + return ConvertTimeOnly(time, TimeStampPrecision.Millisecond); } - public static string ConvertTimeOnly( System.DateTime dt, bool includeMilliseconds ) + /// + /// Converts the time component of the specified to a in the format "HH:mm:ss.fff". + /// + /// The value to convert. + /// A value representing the time component of in the format "HH:mm:ss.fff". + public static string ConvertTimeOnly(DateTime dt) => ConvertTimeOnly(TimeOnly.FromDateTime(dt)); + + /// + /// Converts the specified to a . + /// + /// The value to convert. + /// Whether fractional seconds (to the millisecond) should be in the returned value. + /// + /// A value representing . If + /// is , the value will be in the format "HH:mm:ss.fff". Otherwise, the value + /// will be in the format "HH:mm:ss". + /// + public static string ConvertTimeOnly(TimeOnly time, bool includeMilliseconds) { - return includeMilliseconds ? ConvertTimeOnly( dt, TimeStampPrecision.Millisecond ) : ConvertTimeOnly( dt, TimeStampPrecision.Second ); + return ConvertTimeOnly(time, includeMilliseconds ? TimeStampPrecision.Millisecond : TimeStampPrecision.Second); } - public static string ConvertTimeOnly(System.DateTime dt, TimeStampPrecision precision) + /// + /// Converts the time component of the specified to a . + /// + /// The value to convert. + /// Whether fractional seconds (to the millisecond) should be in the returned value. + /// + /// A value representing the time component of . If + /// is , the value will be in the format "HH:mm:ss.fff". Otherwise, the value + /// will be in the format "HH:mm:ss". + /// + public static string ConvertTimeOnly(DateTime dt, bool includeMilliseconds) + => ConvertTimeOnly(TimeOnly.FromDateTime(dt), includeMilliseconds); + + /// + /// Converts the specified to a . + /// + /// The value to convert. + /// The level of precision with which to format the fractional seconds component of . + /// + /// A value representing . The value will begin in the format "yyyyMMdd-HH:mm:ss" + /// and end in fractional seconds whose precision is determined by . + /// + /// is an invalid value. + public static string ConvertTimeOnly(TimeOnly time, TimeStampPrecision precision) { - if (precision == TimeStampPrecision.Nanosecond) - { - return string.Format(TIME_ONLY_FORMAT_WITH_NANOSECONDS, dt, dt.SubsecondAsNanoseconds()); - } - else + return precision switch { - var format = TIME_ONLY_PRECISION_TO_FORMAT[precision]; - return string.Format(format, dt); - } + TimeStampPrecision.Second => time.ToString("HH:mm:ss", CultureInfo.InvariantCulture), + TimeStampPrecision.Millisecond => time.ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture), + TimeStampPrecision.Microsecond => time.ToString("HH:mm:ss.ffffff", CultureInfo.InvariantCulture), + TimeStampPrecision.Nanosecond => $"{time:HH:mm:ss}.{SubsecondAsNanoseconds(time):000000000}", + _ => throw new ArgumentOutOfRangeException(nameof(precision)), + }; } + + /// + /// Converts the time component of the specified to a . + /// + /// The value to convert. + /// The level of precision with which to format the fractional seconds component of . + /// + /// A value representing the time component of . The value will begin in the format "yyyyMMdd-HH:mm:ss" + /// and end in fractional seconds whose precision is determined by . + /// + /// is an invalid value. + public static string ConvertTimeOnly(DateTime dt, TimeStampPrecision precision) + => ConvertTimeOnly(TimeOnly.FromDateTime(dt), precision); } } diff --git a/UnitTests/ConverterTests.cs b/UnitTests/ConverterTests.cs index 14d138059..a3f67ab06 100644 --- a/UnitTests/ConverterTests.cs +++ b/UnitTests/ConverterTests.cs @@ -90,9 +90,11 @@ public static DateTime makeDateTime(int y, int m, int d, int h, int min, int s, { // already includes ms DateTime dt = new DateTime(y, m, d, h, min, s, ms); - long nanos = (us * DateTimeConverter.NanosPerMicro) + ns; - long ticks = nanos / DateTimeConverter.NanosecondsPerTick; - return dt.AddTicks(ticks); + + const int TicksPerMicrosecond = 10; + const int NanosecondsPerTick = 100; + + return dt.AddTicks((us * TicksPerMicrosecond) + (ns / NanosecondsPerTick)); } public static DateTime makeTimeOnly(int h, int m, int s, int ms, int us, int ns) @@ -169,23 +171,23 @@ public void TestNanosecondPrecision() // convert nanosecond time string to DateTime time portion only DateTime timeOnly = makeTimeOnly(11, 03, 05, 231, 116, 500); - Assert.That(DateTimeConverter.ConvertToTimeOnly("11:03:05.231116500", TimeStampPrecision.Nanosecond), Is.EqualTo(timeOnly)); + Assert.That(DateTimeConverter.ConvertToTimeOnly("11:03:05.231116500"), Is.EqualTo(timeOnly)); // convert nanosecond time string to full DateTime - Assert.That(DateTimeConverter.ConvertToDateTime("20021201-11:03:05.231116500", TimeStampPrecision.Nanosecond), Is.EqualTo(dt)); + Assert.That(DateTimeConverter.ConvertToDateTime("20021201-11:03:05.231116500"), Is.EqualTo(dt)); // convert nanosecond time with UTC time zone to full DateTime - Assert.That(DateTimeConverter.ConvertToDateTime("20021201-11:03:05.231116500Z", TimeStampPrecision.Nanosecond), Is.EqualTo(dt)); + Assert.That(DateTimeConverter.ConvertToDateTime("20021201-11:03:05.231116500Z"), Is.EqualTo(dt)); // convert nanosecond time with non-UTC positive offset time zone to full DateTime - Assert.That(DateTimeConverter.ConvertToDateTime("20021201-16:03:05.231116500+05", TimeStampPrecision.Nanosecond), Is.EqualTo(dt)); + Assert.That(DateTimeConverter.ConvertToDateTime("20021201-16:03:05.231116500+05"), Is.EqualTo(dt)); // convert nanosecond time with non-UTC negative offset time zone to full DateTime - Assert.That(DateTimeConverter.ConvertToDateTime("20021201-08:03:05.231116500-03", TimeStampPrecision.Nanosecond), Is.EqualTo(dt)); + Assert.That(DateTimeConverter.ConvertToDateTime("20021201-08:03:05.231116500-03"), Is.EqualTo(dt)); // convert nanosecond time in local time (no time zone) to full DateTime DateTime local = DateTime.SpecifyKind(makeDateTime(2002, 12, 01, 11, 03, 05, 231, 116, 500), DateTimeKind.Local); - Assert.That(DateTimeConverter.ConvertToDateTime("20021201-11:03:05.231116500", TimeStampPrecision.Nanosecond), Is.EqualTo(local)); + Assert.That(DateTimeConverter.ConvertToDateTime("20021201-11:03:05.231116500"), Is.EqualTo(local)); } } } \ No newline at end of file diff --git a/UnitTests/Fields/Converters/DateTimeConverterMicrosecondTests.cs b/UnitTests/Fields/Converters/DateTimeConverterMicrosecondTests.cs index d5ffbd43a..2e654d501 100644 --- a/UnitTests/Fields/Converters/DateTimeConverterMicrosecondTests.cs +++ b/UnitTests/Fields/Converters/DateTimeConverterMicrosecondTests.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Generic; +using System.Globalization; using NUnit.Framework; +using QuickFix; using QuickFix.Fields.Converters; namespace UnitTests.Fields.Converters @@ -15,7 +18,7 @@ public void CanConvertFromDateTimeStringWithNanosecondsToValidDateTimeObject() var dateTimeStringWithNanosecondsTruncated = "20170305-13:22:12.123456700"; //WHEN - it is converted to a date time - var convertedDateTime = DateTimeConverter.ConvertToDateTime(dateTimeStringWithNanoseconds, TimeStampPrecision.Nanosecond); + var convertedDateTime = DateTimeConverter.ConvertToDateTime(dateTimeStringWithNanoseconds); //THEN - the date time object is setup correctly Assert.AreEqual(2017, convertedDateTime.Year); @@ -28,155 +31,446 @@ public void CanConvertFromDateTimeStringWithNanosecondsToValidDateTimeObject() Assert.AreEqual(456700, convertedDateTime.Nanosecond()); //100 Nanosecond Truncated Resolution Assert.AreEqual(dateTimeStringWithNanosecondsTruncated, DateTimeConverter.Convert(convertedDateTime, TimeStampPrecision.Nanosecond)); } - + [Test] - public void CanConvertFromDateTimeStringWithMicrosecondsToValidDateTimeObject() + [TestCaseSource(nameof(DateTimeData))] + public void DateTimeTests(DateTimeTest t) { - //GIVEN - a datetime string with microseconds - var dateTimeStringWithMicroseconds = "20170305-13:22:12.123456"; - - //WHEN - it is converted to a date time - var convertedDateTime = DateTimeConverter.ConvertToDateTime( dateTimeStringWithMicroseconds ); - - //THEN - the date time object is setup correctly - Assert.AreEqual( 2017, convertedDateTime.Year ); - Assert.AreEqual(3, convertedDateTime.Month); - Assert.AreEqual(5, convertedDateTime.Day); - Assert.AreEqual( 13, convertedDateTime.Hour ); - Assert.AreEqual(22, convertedDateTime.Minute); - Assert.AreEqual(12, convertedDateTime.Second); - Assert.AreEqual(123, convertedDateTime.Millisecond); - Assert.AreEqual( dateTimeStringWithMicroseconds, string.Format( DateTimeConverter.DATE_TIME_FORMAT_WITH_MICROSECONDS, convertedDateTime ) ); + DateTime actualDateTime = DateTimeConverter.ConvertToDateTime(t.InputString, out DateTimeOffset? actualDateTimeOffset); + AssertEqual(t.ExpectedDateTime, actualDateTime); + AssertEqual(t.ExpectedDateTimeOffset, actualDateTimeOffset); + Assert.AreEqual(t.ExpectedStringSeconds, DateTimeConverter.Convert(actualDateTime, TimeStampPrecision.Second)); + Assert.AreEqual(t.ExpectedStringSeconds, DateTimeConverter.Convert(actualDateTime, includeMilliseconds: false)); + Assert.AreEqual(t.ExpectedStringMillis, DateTimeConverter.Convert(actualDateTime, TimeStampPrecision.Millisecond)); + Assert.AreEqual(t.ExpectedStringMillis, DateTimeConverter.Convert(actualDateTime, includeMilliseconds: true)); + Assert.AreEqual(t.ExpectedStringMillis, DateTimeConverter.Convert(actualDateTime)); + Assert.AreEqual(t.ExpectedStringMicros, DateTimeConverter.Convert(actualDateTime, TimeStampPrecision.Microsecond)); + Assert.AreEqual(t.ExpectedStringNanos, DateTimeConverter.Convert(actualDateTime, TimeStampPrecision.Nanosecond)); } + /// + /// Verifies that two objects are equal, including their + /// properties which is not covered by + /// + private static void AssertEqual(DateTime expected, DateTime actual) + { + Assert.AreEqual(expected, actual); + Assert.AreEqual(expected.Kind, actual.Kind); + } - [Test] - public void CanConvertFromDateTimeStringWithMillisecondsToValidDateTimeObject() + /// + /// Verifies that two objects are equal, including their + /// properties by calling + /// + private static void AssertEqual(DateTimeOffset? expected, DateTimeOffset? actual) { - //GIVEN - a datetime string with millisecond precision - var dateTimeStringWithMillisecond = "20170305-13:22:12.123"; + Assert.AreEqual(expected, actual); + if (expected != null) + { + Assert.True(expected.Value.EqualsExact(actual.Value)); + } + } - //WHEN - it is converted to a date time - var convertedDateTime = DateTimeConverter.ConvertToDateTime(dateTimeStringWithMillisecond); + public record DateTimeTest( + string InputString, + DateTime ExpectedDateTime, + DateTimeOffset? ExpectedDateTimeOffset, + string ExpectedStringSeconds, + string ExpectedStringMillis, + string ExpectedStringMicros, + string ExpectedStringNanos); - //THEN - the date time object is setup correctly - Assert.AreEqual(2017, convertedDateTime.Year); - Assert.AreEqual(3, convertedDateTime.Month); - Assert.AreEqual(5, convertedDateTime.Day); - Assert.AreEqual(13, convertedDateTime.Hour); - Assert.AreEqual(22, convertedDateTime.Minute); - Assert.AreEqual(12, convertedDateTime.Second); - Assert.AreEqual(123, convertedDateTime.Millisecond); + private static IEnumerable DateTimeData() + { + yield return new ("20170305-13:22:12", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Unspecified).AddTicks(0), null, + "20170305-13:22:12", "20170305-13:22:12.000", "20170305-13:22:12.000000", "20170305-13:22:12.000000000"); + + yield return new("20170305-13:22:12.1", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Unspecified).AddTicks(1_000_000), null, + "20170305-13:22:12", "20170305-13:22:12.100", "20170305-13:22:12.100000", "20170305-13:22:12.100000000"); + + yield return new("20170305-13:22:12.12", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Unspecified).AddTicks(1_200_000), null, + "20170305-13:22:12", "20170305-13:22:12.120", "20170305-13:22:12.120000", "20170305-13:22:12.120000000"); + + yield return new("20170305-13:22:12.123", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Unspecified).AddTicks(1_230_000), null, + "20170305-13:22:12", "20170305-13:22:12.123", "20170305-13:22:12.123000", "20170305-13:22:12.123000000"); + + yield return new("20170305-13:22:12.1234", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Unspecified).AddTicks(1_234_000), null, + "20170305-13:22:12", "20170305-13:22:12.123", "20170305-13:22:12.123400", "20170305-13:22:12.123400000"); + + yield return new("20170305-13:22:12.12345", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Unspecified).AddTicks(1_234_500), null, + "20170305-13:22:12", "20170305-13:22:12.123", "20170305-13:22:12.123450", "20170305-13:22:12.123450000"); + + yield return new("20170305-13:22:12.123456", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Unspecified).AddTicks(1_234_560), null, + "20170305-13:22:12", "20170305-13:22:12.123", "20170305-13:22:12.123456", "20170305-13:22:12.123456000"); + + yield return new("20170305-13:22:12.1234567", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Unspecified).AddTicks(1_234_567), null, + "20170305-13:22:12", "20170305-13:22:12.123", "20170305-13:22:12.123456", "20170305-13:22:12.123456700"); + + yield return new("20170305-13:22:12.12345678", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Unspecified).AddTicks(1_234_567), null, + "20170305-13:22:12", "20170305-13:22:12.123", "20170305-13:22:12.123456", "20170305-13:22:12.123456700"); + + yield return new("20170305-13:22:12.123456789", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Unspecified).AddTicks(1_234_567), null, + "20170305-13:22:12", "20170305-13:22:12.123", "20170305-13:22:12.123456", "20170305-13:22:12.123456700"); + + yield return new("20170305-13:22:12.12345678912345", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Unspecified).AddTicks(1_234_567), null, + "20170305-13:22:12", "20170305-13:22:12.123", "20170305-13:22:12.123456", "20170305-13:22:12.123456700"); + + yield return new("20170305-13:22:12.000001", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Unspecified).AddTicks(10), null, + "20170305-13:22:12", "20170305-13:22:12.000", "20170305-13:22:12.000001", "20170305-13:22:12.000001000"); + + yield return new("20170305-13:22:12Z", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Utc).AddTicks(0), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(0), TimeSpan.Zero), + "20170305-13:22:12", "20170305-13:22:12.000", "20170305-13:22:12.000000", "20170305-13:22:12.000000000"); + + yield return new("20170305-13:22:12.12345678Z", + new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Utc).AddTicks(1_234_567), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(1_234_567), TimeSpan.Zero), + "20170305-13:22:12", "20170305-13:22:12.123", "20170305-13:22:12.123456", "20170305-13:22:12.123456700"); + + yield return new("20170305-13:22:12+05", + new DateTime(2017, 03, 05, 08, 22, 12, DateTimeKind.Utc).AddTicks(0), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(0), TimeSpan.FromHours(5)), + // Testing the conversion back to string is a bit nonsensical (and already covered above) + // but we do it anyway + "20170305-08:22:12", "20170305-08:22:12.000", "20170305-08:22:12.000000", "20170305-08:22:12.000000000"); + + yield return new("20170305-13:22:12.12345678+05", + new DateTime(2017, 03, 05, 08, 22, 12, DateTimeKind.Utc).AddTicks(1_234_567), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(1_234_567), TimeSpan.FromHours(5)), + "20170305-08:22:12", "20170305-08:22:12.123", "20170305-08:22:12.123456", "20170305-08:22:12.123456700"); + + yield return new("20170305-13:22:12+5", + new DateTime(2017, 03, 05, 08, 22, 12, DateTimeKind.Utc).AddTicks(0), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(0), TimeSpan.FromHours(5)), + "20170305-08:22:12", "20170305-08:22:12.000", "20170305-08:22:12.000000", "20170305-08:22:12.000000000"); + + yield return new("20170305-13:22:12.12345678+5", + new DateTime(2017, 03, 05, 08, 22, 12, DateTimeKind.Utc).AddTicks(1_234_567), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(1_234_567), TimeSpan.FromHours(5)), + "20170305-08:22:12", "20170305-08:22:12.123", "20170305-08:22:12.123456", "20170305-08:22:12.123456700"); + + yield return new("20170305-13:22:12+5:30", + new DateTime(2017, 03, 05, 07, 52, 12, DateTimeKind.Utc).AddTicks(0), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(0), new TimeSpan(05, 30, 0)), + "20170305-07:52:12", "20170305-07:52:12.000", "20170305-07:52:12.000000", "20170305-07:52:12.000000000"); + + yield return new("20170305-13:22:12.12345678+5:30", + new DateTime(2017, 03, 05, 07, 52, 12, DateTimeKind.Utc).AddTicks(1_234_567), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(1_234_567), new TimeSpan(05, 30, 0)), + "20170305-07:52:12", "20170305-07:52:12.123", "20170305-07:52:12.123456", "20170305-07:52:12.123456700"); + + yield return new("20170305-13:22:12 +05:30", + new DateTime(2017, 03, 05, 07, 52, 12, DateTimeKind.Utc).AddTicks(0), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(0), new TimeSpan(05, 30, 0)), + "20170305-07:52:12", "20170305-07:52:12.000", "20170305-07:52:12.000000", "20170305-07:52:12.000000000"); + + yield return new("20170305-13:22:12.12345678 +05:30", + new DateTime(2017, 03, 05, 07, 52, 12, DateTimeKind.Utc).AddTicks(1_234_567), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(1_234_567), new TimeSpan(05, 30, 0)), + "20170305-07:52:12", "20170305-07:52:12.123", "20170305-07:52:12.123456", "20170305-07:52:12.123456700"); + + yield return new("20170305-13:22:12-11", + new DateTime(2017, 03, 06, 00, 22, 12, DateTimeKind.Utc).AddTicks(0), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(0), TimeSpan.FromHours(-11)), + "20170306-00:22:12", "20170306-00:22:12.000", "20170306-00:22:12.000000", "20170306-00:22:12.000000000"); + + yield return new("20170305-13:22:12.12345678-11", + new DateTime(2017, 03, 06, 00, 22, 12, DateTimeKind.Utc).AddTicks(1_234_567), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(1_234_567), TimeSpan.FromHours(-11)), + "20170306-00:22:12", "20170306-00:22:12.123", "20170306-00:22:12.123456", "20170306-00:22:12.123456700"); + + yield return new("20170305-13:22:12-11:45", + new DateTime(2017, 03, 06, 01, 07, 12, DateTimeKind.Utc).AddTicks(0), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(0), -new TimeSpan(11, 45, 0)), + "20170306-01:07:12", "20170306-01:07:12.000", "20170306-01:07:12.000000", "20170306-01:07:12.000000000"); + + yield return new("20170305-13:22:12.12345678-11:45", + new DateTime(2017, 03, 06, 01, 07, 12, DateTimeKind.Utc).AddTicks(1_234_567), + new DateTimeOffset(new DateTime(2017, 03, 05, 13, 22, 12).AddTicks(1_234_567), -new TimeSpan(11, 45, 0)), + "20170306-01:07:12", "20170306-01:07:12.123", "20170306-01:07:12.123456", "20170306-01:07:12.123456700"); } [Test] - public void CanConvertFromDateTimeStringWithSecondsToValidDateTimeObject() + [TestCaseSource(nameof(InvalidStrings))] + [TestCase("20021201")] + [TestCase("02:12:01")] + [TestCase("02:12:01.123")] + [TestCase("02:12:01-01")] + [TestCase("02:12:01.123-01")] + public void InvalidDateTimeString_ThrowsFieldConvertError(string inputString) { - //GIVEN - a datetime string with second precision - var dateTimeStringWithMillisecond = "20170305-13:22:12"; - - //WHEN - it is converted to a date time - var convertedDateTime = DateTimeConverter.ConvertToDateTime(dateTimeStringWithMillisecond); - - //THEN - the date time object is setup correctly - Assert.AreEqual(2017, convertedDateTime.Year); - Assert.AreEqual(3, convertedDateTime.Month); - Assert.AreEqual(5, convertedDateTime.Day); - Assert.AreEqual(13, convertedDateTime.Hour); - Assert.AreEqual(22, convertedDateTime.Minute); - Assert.AreEqual(12, convertedDateTime.Second); - Assert.AreEqual(0, convertedDateTime.Millisecond); + Assert.Throws(() => DateTimeConverter.ConvertToDateTime(inputString, out _)); } [Test] - public void CanConvertFromDateTimeWithSecondsToValidStringWithMicroSecondPrecision() + public void DateOnlyTests() { - //GIVEN - a datetime object with seconds - var dateTime = new DateTime(2017, 03, 05, 13, 22, 12, DateTimeKind.Utc); + DateOnly date = new(1800, 1, 1); + DateOnly maxDate = new(2200, 1, 1); - //WHEN - it is serialised to a string with microsecond precision - var serialisedDateTime = DateTimeConverter.Convert(dateTime, TimeStampPrecision.Microsecond); + while (date < maxDate) + { + string expectedString = date.ToString("yyyyMMdd", CultureInfo.InvariantCulture); + string actualString = DateTimeConverter.ConvertDateOnly(date); - //THEN - the serialised string contains microseconds - Assert.AreEqual("20170305-13:22:12.000000", serialisedDateTime); - } + Assert.AreEqual(expectedString, actualString); - [Test] - public void CanConvertFromDateTimeWithMillisecondsToValidStringWithMicroSecondPrecision() - { - //GIVEN - a datetime object with milliseconds - var dateTime = new DateTime(2017, 03, 05, 13, 22, 12, 123, DateTimeKind.Utc); + DateOnly actualDate = DateTimeConverter.ConvertToDateOnly((ReadOnlySpan)actualString); - //WHEN - it is serialised to a string with microsecond precision - var serialisedDateTime = DateTimeConverter.Convert(dateTime, TimeStampPrecision.Microsecond); + Assert.AreEqual(date, actualDate); - //THEN - the serialised string contains microseconds - Assert.AreEqual("20170305-13:22:12.123000", serialisedDateTime); + date = date.AddDays(1); + } } [Test] - public void CanConvertFromDateTimeWithMicrosecondsToValidStringWithMicroSecondPrecision() + [TestCaseSource(nameof(InvalidStrings))] + [TestCase("20021201-11:03:12")] + [TestCase("20021201-11:03:12.123")] + [TestCase("20021201-11:03:12-01")] + [TestCase("20021201-11:03:12.123-01")] + [TestCase("11:03:12")] + [TestCase("11:03:12.123")] + [TestCase("11:03:12-01")] + [TestCase("11:03:12.123-01")] + public void InvalidDateOnlyString_ThrowsFieldConvertError(string inputString) { - //GIVEN - a datetime object with microseconds - var dateTime = new DateTime( 2017, 03, 05, 13, 22, 12, 123, DateTimeKind.Utc ) + new TimeSpan( 4560 ); - - //WHEN - it is serialised to a string with microsecond precision - var serialisedDateTime = DateTimeConverter.Convert( dateTime, TimeStampPrecision.Microsecond ); + Assert.Throws(() => DateTimeConverter.ConvertToDateOnly((ReadOnlySpan)inputString)); + } - //THEN - the serialised string contains microseconds - Assert.AreEqual("20170305-13:22:12.123456", serialisedDateTime); + /// + /// Strings which should not be parseable to any of , , . + /// + private static IEnumerable InvalidStrings() + { + yield return ""; + yield return " "; + yield return "00000000"; + yield return "20170100"; + yield return "20170001"; + yield return "20170305 "; + yield return " 20170305"; + yield return "-20170305"; + yield return "2017030X"; + yield return "20170305Z"; + yield return "20170305+05"; + yield return "2017"; + yield return "201703"; + yield return "201703-05"; + yield return "2017-0305"; + yield return "05032017"; + yield return "20230229"; + yield return "170305"; + yield return "20021201-11"; yield return "11"; + yield return "20021201-11:"; yield return "11:"; + yield return "20021201-11:03"; yield return "11:03"; + yield return "20021201-11:03+11:03"; yield return "11:03+11:03"; + yield return "20021201-11:03:00-11:03:00"; yield return "11:03:00-11:03:00"; + yield return "20021201-11:03:00-11:03.00"; yield return "11:03:00-11:03.00"; + yield return "20021201-11:03.00"; yield return "11:03.00"; + yield return "20021201-11:03::0"; yield return "11:03::0"; + yield return " 20170305-13:22:12"; yield return " 13:22:12"; + yield return "-20170305-13:22:12"; yield return "-13:22:12"; + yield return "20170305+13:22:12"; yield return "+13:22:12"; + yield return "20171305-13:22:12"; yield return "20171305"; + yield return "20170335-13:22:12"; yield return "20170335"; + yield return "20170305-24:22:12"; yield return "24:22:12"; + yield return "20170305-13:62:12"; yield return "13:62:12"; + yield return "20170305-13:22:62"; yield return "13:22:62"; + yield return "20170305-13:22:12 "; yield return "13:22:12 "; + yield return "20170305-13:22:12+"; yield return "13:22:12+"; + yield return "20170305-13:22:12-"; yield return "13:22:12-"; + yield return "20170305-13:22:12."; yield return "13:22:12."; + yield return "20170305-13:22:12+ "; yield return "13:22:12+ "; + yield return "20170305-13:22:12.4 "; yield return "13:22:12.4 "; + yield return "20170305-13:22:12+05 "; yield return "13:22:12+05 "; + yield return "20170305-13:22:12+05-"; yield return "13:22:12+05-"; + yield return "20170305-13:22:12+05:00:"; yield return "13:22:12+05:00:"; + yield return "20170305-13:22:12+05:"; yield return "13:22:12+05:"; + yield return "20170305-13:22:12-05+"; yield return "13:22:12-05+"; + yield return "20170305-13:22:12+05+"; yield return "13:22:12+05+"; + yield return "20170305-13:22:12+05:0"; yield return "13:22:12+05:0"; + yield return "20170305-13:22:12+05::00"; yield return "13:22:12+05::00"; + yield return "20170305-13:22:12+05:00:00"; yield return "13:22:12+05:00:00"; + yield return "20170305-13:22:12+05: 4"; yield return "13:22:12+05: 4"; + yield return "20170305-13:22:12+05: 40"; yield return "13:22:12+05: 40"; + yield return "20170305-13:22:12+05:-40"; yield return "13:22:12+05:-40"; + yield return "20170305-13:22:12+05:60"; yield return "13:22:12+05:60"; + yield return "20170305-13:22:12 .123"; yield return "13:22:12 .123"; + yield return "20170305-13:22:12..123"; yield return "13:22:12..123"; + yield return "20170305-13:22:12:34"; yield return "13:22:12:34"; + yield return "20170305-13:22:12+ 5"; yield return "13:22:12+ 5"; + yield return "20170305-13:22:12+05:5"; yield return "13:22:12+05:5"; + yield return "20170305-13:22:12.123X5"; yield return "13:22:12.123X5"; + yield return "20170305-13:22:12.123X+5"; yield return "13:22:12.123X+5"; + yield return "20170305-13:22:12+-05"; yield return "13:22:12+-05"; + yield return "20170305-13:22:12-+05"; yield return "13:22:12-+05"; + yield return "20170305-13:22:12Z+05"; yield return "13:22:12Z+05"; + yield return "20170305-13:22:12+5:005"; yield return "13:22:12+5:005"; + yield return "20170305-13:22:12+005"; yield return "13:22:12+005"; + yield return "20170305-13:22:12-15"; yield return "13:22:12-15"; + yield return "20170305-13:22:12+15"; yield return "13:22:12+15"; + yield return "20170305-13:22:12K"; yield return "13:22:12K"; + yield return "20170305-13:22:12+Z"; yield return "13:22:12+Z"; + yield return "20170305-13:22:12+05Z"; yield return "13:22:12+05Z"; + yield return "00010101-00:00:00+1"; yield return "99991231-23:59:00-1"; } [Test] - public void CanConvertFromDateTimeWithMicrosecondsToValidStringWithMilliSecondPrecision() + [TestCaseSource(nameof(TimeOnlyData))] + public void TimeOnlyTests(TimeOnlyTest t) { - //GIVEN - a datetime object with microseconds - var dateTime = new DateTime(2017, 03, 05, 13, 22, 12, 123, DateTimeKind.Utc) + new TimeSpan(4560); - - //WHEN - it is serialised to a string with millisecond precision - var serialisedDateTime = DateTimeConverter.Convert(dateTime, TimeStampPrecision.Millisecond); - - //THEN - the serialised string contains milliseconds - Assert.AreEqual("20170305-13:22:12.123", serialisedDateTime); + TimeOnly actualTimeOnly = DateTimeConverter.ConvertToTimeOnly(t.InputString, out TimeSpan? actualOffset); + Assert.AreEqual(t.ExpectedTimeOnly, actualTimeOnly); + Assert.AreEqual(t.ExpectedOffset, actualOffset); + Assert.AreEqual(t.ExpectedStringSeconds, DateTimeConverter.ConvertTimeOnly(actualTimeOnly, TimeStampPrecision.Second)); + Assert.AreEqual(t.ExpectedStringSeconds, DateTimeConverter.ConvertTimeOnly(actualTimeOnly, includeMilliseconds: false)); + Assert.AreEqual(t.ExpectedStringMillis, DateTimeConverter.ConvertTimeOnly(actualTimeOnly, TimeStampPrecision.Millisecond)); + Assert.AreEqual(t.ExpectedStringMillis, DateTimeConverter.ConvertTimeOnly(actualTimeOnly, includeMilliseconds: true)); + Assert.AreEqual(t.ExpectedStringMillis, DateTimeConverter.ConvertTimeOnly(actualTimeOnly)); + Assert.AreEqual(t.ExpectedStringMicros, DateTimeConverter.ConvertTimeOnly(actualTimeOnly, TimeStampPrecision.Microsecond)); + Assert.AreEqual(t.ExpectedStringNanos, DateTimeConverter.ConvertTimeOnly(actualTimeOnly, TimeStampPrecision.Nanosecond)); } - [Test] - public void CanConvertFromDateTimeWithMicrosecondsToValidStringWithMilliSecondPrecisionUsingOriginalMethod() + public record TimeOnlyTest( + string InputString, + TimeOnly ExpectedTimeOnly, + TimeSpan? ExpectedOffset, + string ExpectedStringSeconds, + string ExpectedStringMillis, + string ExpectedStringMicros, + string ExpectedStringNanos); + + private static IEnumerable TimeOnlyData() { - //GIVEN - a datetime object with microseconds - var dateTime = new DateTime(2017, 03, 05, 13, 22, 12, 123, DateTimeKind.Utc) + new TimeSpan(4560); + yield return new("13:22:12", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(0)), null, + "13:22:12", "13:22:12.000", "13:22:12.000000", "13:22:12.000000000"); - //WHEN - it is serialised to a string with millisecond precision - var serialisedDateTime = DateTimeConverter.Convert(dateTime); + yield return new("13:22:12.1", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_000_000)), null, + "13:22:12", "13:22:12.100", "13:22:12.100000", "13:22:12.100000000"); - //THEN - the serialised string contains milliseconds - Assert.AreEqual("20170305-13:22:12.123", serialisedDateTime); - } + yield return new("13:22:12.12", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_200_000)), null, + "13:22:12", "13:22:12.120", "13:22:12.120000", "13:22:12.120000000"); - [Test] - public void CanConvertFromDateTimeWithMicrosecondsToValidStringWithSecondPrecision() - { - //GIVEN - a datetime object with microseconds - var dateTime = new DateTime(2017, 03, 05, 13, 22, 12, 123, DateTimeKind.Utc) + new TimeSpan(4560); + yield return new("13:22:12.123", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_230_000)), null, + "13:22:12", "13:22:12.123", "13:22:12.123000", "13:22:12.123000000"); + + yield return new("13:22:12.1234", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_000)), null, + "13:22:12", "13:22:12.123", "13:22:12.123400", "13:22:12.123400000"); + + yield return new("13:22:12.12345", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_500)), null, + "13:22:12", "13:22:12.123", "13:22:12.123450", "13:22:12.123450000"); + + yield return new("13:22:12.123456", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_560)), null, + "13:22:12", "13:22:12.123", "13:22:12.123456", "13:22:12.123456000"); + + yield return new("13:22:12.1234567", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_567)), null, + "13:22:12", "13:22:12.123", "13:22:12.123456", "13:22:12.123456700"); + + yield return new("13:22:12.12345678", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_567)), null, + "13:22:12", "13:22:12.123", "13:22:12.123456", "13:22:12.123456700"); + + yield return new("13:22:12.123456789", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_567)), null, + "13:22:12", "13:22:12.123", "13:22:12.123456", "13:22:12.123456700"); + + yield return new("13:22:12.12345678912345", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_567)), null, + "13:22:12", "13:22:12.123", "13:22:12.123456", "13:22:12.123456700"); + + yield return new("13:22:12.000001", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(10)), null, + "13:22:12", "13:22:12.000", "13:22:12.000001", "13:22:12.000001000"); + + yield return new("13:22:12Z", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(0)), TimeSpan.Zero, + "13:22:12", "13:22:12.000", "13:22:12.000000", "13:22:12.000000000"); - //WHEN - it is serialised to a string with second precision - var serialisedDateTime = DateTimeConverter.Convert(dateTime, TimeStampPrecision.Second); + yield return new("13:22:12.12345678Z", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_567)), TimeSpan.Zero, + "13:22:12", "13:22:12.123", "13:22:12.123456", "13:22:12.123456700"); - //THEN - the serialised string contains seconds - Assert.AreEqual("20170305-13:22:12", serialisedDateTime); + yield return new("13:22:12+05", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(0)), TimeSpan.FromHours(5), + "13:22:12", "13:22:12.000", "13:22:12.000000", "13:22:12.000000000"); + + yield return new("13:22:12.12345678+05", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_567)), TimeSpan.FromHours(5), + "13:22:12", "13:22:12.123", "13:22:12.123456", "13:22:12.123456700"); + + yield return new("13:22:12+5", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(0)), TimeSpan.FromHours(5), + "13:22:12", "13:22:12.000", "13:22:12.000000", "13:22:12.000000000"); + + yield return new("13:22:12.12345678+5", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_567)), TimeSpan.FromHours(5), + "13:22:12", "13:22:12.123", "13:22:12.123456", "13:22:12.123456700"); + + yield return new("13:22:12+5:30", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(0)), new TimeSpan(05, 30, 0), + "13:22:12", "13:22:12.000", "13:22:12.000000", "13:22:12.000000000"); + + yield return new("13:22:12.12345678+5:30", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_567)), new TimeSpan(05, 30, 0), + "13:22:12", "13:22:12.123", "13:22:12.123456", "13:22:12.123456700"); + + yield return new("13:22:12 +05:30", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(0)), new TimeSpan(05, 30, 0), + "13:22:12", "13:22:12.000", "13:22:12.000000", "13:22:12.000000000"); + + yield return new("13:22:12.12345678 +05:30", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_567)), new TimeSpan(05, 30, 0), + "13:22:12", "13:22:12.123", "13:22:12.123456", "13:22:12.123456700"); + + yield return new("13:22:12-11", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(0)), TimeSpan.FromHours(-11), + "13:22:12", "13:22:12.000", "13:22:12.000000", "13:22:12.000000000"); + + yield return new("13:22:12.12345678-11", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_567)), TimeSpan.FromHours(-11), + "13:22:12", "13:22:12.123", "13:22:12.123456", "13:22:12.123456700"); + + yield return new("13:22:12-11:45", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(0)), -new TimeSpan(11, 45, 0), + "13:22:12", "13:22:12.000", "13:22:12.000000", "13:22:12.000000000"); + + yield return new("13:22:12.12345678-11:45", + new TimeOnly(13, 22, 12).Add(TimeSpan.FromTicks(1_234_567)), -new TimeSpan(11, 45, 0), + "13:22:12", "13:22:12.123", "13:22:12.123456", "13:22:12.123456700"); } [Test] - public void CanConvertFromDateTimeWithMicrosecondsToValidStringWithSecondPrecisionUsingOriginalMethod() + [TestCaseSource(nameof(InvalidStrings))] + [TestCase("20021201")] + [TestCase("20021201-11:03:12")] + [TestCase("20021201-11:03:12.123")] + [TestCase("20021201-11:03:12-01")] + [TestCase("20021201-11:03:12.123-01")] + public void InvalidTimeOnlyString_ThrowsFieldConvertError(string inputString) { - //GIVEN - a datetime object with microseconds - var dateTime = new DateTime(2017, 03, 05, 13, 22, 12, 123, DateTimeKind.Utc) + new TimeSpan(4560); - - //WHEN - it is serialised to a string with second precision - var serialisedDateTime = DateTimeConverter.Convert(dateTime, false); - - //THEN - the serialised string contains seconds - Assert.AreEqual("20170305-13:22:12", serialisedDateTime); + Assert.Throws(() => DateTimeConverter.ConvertToTimeOnly(inputString, out _)); } @@ -197,7 +491,7 @@ public void CanConvertTimeWithMicroSecondsToDateTimeObject() Assert.AreEqual(22, convertedDateTime.Minute); Assert.AreEqual(12, convertedDateTime.Second); Assert.AreEqual(123, convertedDateTime.Millisecond); - Assert.AreEqual(timeStringWithMicroseconds, string.Format(DateTimeConverter.TIME_ONLY_FORMAT_WITH_MICROSECONDS, convertedDateTime)); + Assert.AreEqual(timeStringWithMicroseconds, DateTimeConverter.ConvertTimeOnly(convertedDateTime, TimeStampPrecision.Microsecond)); } [Test] @@ -274,7 +568,7 @@ public void CanConvertTimeWithMilliSecondsToDateTimeObject() Assert.AreEqual(22, convertedDateTime.Minute); Assert.AreEqual(12, convertedDateTime.Second); Assert.AreEqual(123, convertedDateTime.Millisecond); - Assert.AreEqual(timeStringWithMilliseconds + "000", string.Format(DateTimeConverter.TIME_ONLY_FORMAT_WITH_MICROSECONDS, convertedDateTime)); + Assert.AreEqual(timeStringWithMilliseconds + "000", DateTimeConverter.ConvertTimeOnly(convertedDateTime, TimeStampPrecision.Microsecond)); } [Test] @@ -291,7 +585,7 @@ public void CanConvertTimeWithMicroSecondsToTimeSpanObject() Assert.AreEqual(22, convertedTime.Minutes); Assert.AreEqual(12, convertedTime.Seconds); Assert.AreEqual(123, convertedTime.Milliseconds); - Assert.AreEqual(timeStringWithMicroseconds, string.Format(DateTimeConverter.TIME_ONLY_FORMAT_WITH_MICROSECONDS, new DateTime(convertedTime.Ticks))); + Assert.AreEqual(timeStringWithMicroseconds, DateTimeConverter.ConvertTimeOnly(TimeOnly.FromTimeSpan(convertedTime), TimeStampPrecision.Microsecond)); } [Test] @@ -308,7 +602,17 @@ public void CanConvertTimeWithMilliSecondsToTimeSpanObject() Assert.AreEqual(22, convertedTime.Minutes); Assert.AreEqual(12, convertedTime.Seconds); Assert.AreEqual(123, convertedTime.Milliseconds); - Assert.AreEqual(timeStringWithMilliseconds + "000", string.Format(DateTimeConverter.TIME_ONLY_FORMAT_WITH_MICROSECONDS, new DateTime(convertedTime.Ticks))); + Assert.AreEqual(timeStringWithMilliseconds + "000", DateTimeConverter.ConvertTimeOnly(TimeOnly.FromTimeSpan(convertedTime), TimeStampPrecision.Microsecond)); + } + + + [Test] + public void Invalid_TimeStampPrecision_ThrowsArgumentOutOfRangeException() + { + var invalidPrecision = (TimeStampPrecision)(-1); + Assert.False(Enum.IsDefined(invalidPrecision)); + Assert.Throws(() => DateTimeConverter.Convert(new DateTime(2017, 03, 05, 13, 22, 12), invalidPrecision)); + Assert.Throws(() => DateTimeConverter.ConvertTimeOnly(new TimeOnly(13, 22, 12), invalidPrecision)); } } }