From ee0c328238df7cfc8d0b76862b642f7b7bfdcec5 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 27 Sep 2025 21:46:14 -0500 Subject: [PATCH 1/3] Fallback to using DateTimeOffset.TryParse if normal parsing doesn't work --- .../DateMath.cs | 90 ++++++++++++++++++- .../DateMathTests.cs | 67 +++++++++++++- 2 files changed, 150 insertions(+), 7 deletions(-) diff --git a/src/Exceptionless.DateTimeExtensions/DateMath.cs b/src/Exceptionless.DateTimeExtensions/DateMath.cs index e2a5bf3..6e6e030 100644 --- a/src/Exceptionless.DateTimeExtensions/DateMath.cs +++ b/src/Exceptionless.DateTimeExtensions/DateMath.cs @@ -64,7 +64,9 @@ public static bool TryParse(string expression, DateTimeOffset relativeBaseTime, var match = Parser.Match(expression); if (!match.Success) - return false; + { + return TryParseFallbackDate(expression, relativeBaseTime.Offset, isUpperLimit, out result); + } return TryParseFromMatch(match, relativeBaseTime, isUpperLimit, out result); } @@ -110,7 +112,9 @@ public static bool TryParse(string expression, TimeZoneInfo timeZone, bool isUpp var match = Parser.Match(expression); if (!match.Success) - return false; + { + return TryParseFallbackDate(expression, timeZone, isUpperLimit, out result); + } return TryParseFromMatch(match, timeZone, isUpperLimit, out result); } @@ -211,7 +215,87 @@ public static bool IsValidExpression(string expression) if (String.IsNullOrEmpty(expression)) return false; - return Parser.IsMatch(expression); + if (Parser.IsMatch(expression)) + return true; + + // Fallback: Check if it's a valid explicit date + return TryParseFallbackDate(expression, TimeZoneInfo.Local, false, out _); + } + + /// + /// Attempts to parse the expression as an explicit date when date math parsing fails, using the provided timezone for missing offsets. + /// + /// The original expression to interpret as an explicit date. + /// The timezone applied when the expression lacks explicit offset information. + /// Whether the value should be treated as an upper bound, rounding end-of-day when applicable. + /// Receives the parsed when parsing succeeds. + /// when the expression is successfully parsed as an explicit date; otherwise, . + private static bool TryParseFallbackDate(string expression, TimeZoneInfo defaultTimeZone, bool isUpperLimit, out DateTimeOffset result) + { + if (Regex.IsMatch(expression, @"(Z|[+-]\d{2}:\d{2})$") && DateTimeOffset.TryParse(expression, out DateTimeOffset explicitDate)) + { + result = explicitDate; + + if (result.TimeOfDay == TimeSpan.Zero && isUpperLimit) + { + // If time is exactly midnight, and it's an upper limit, set to end of day + result = result.EndOfDay(); + } + + return true; + } + + if (DateTime.TryParse(expression, out DateTime dt)) + { + result = new DateTimeOffset(dt, defaultTimeZone.GetUtcOffset(dt)); + + if (result.TimeOfDay == TimeSpan.Zero && isUpperLimit) + { + // If time is exactly midnight, and it's an upper limit, set to end of day + result = result.EndOfDay(); + } + return true; + } + + return false; + } + + /// + /// Attempts to parse the expression as an explicit date when date math parsing fails, using the provided offset for missing timezone information. + /// + /// The original expression to interpret as an explicit date. + /// The fallback UTC offset applied when the expression omits timezone data. + /// Whether the value should be treated as an upper bound, rounding to the end of day when appropriate. + /// Receives the parsed when parsing succeeds. + /// when the expression is successfully parsed as an explicit date; otherwise, . + private static bool TryParseFallbackDate(string expression, TimeSpan offset, bool isUpperLimit, out DateTimeOffset result) + { + if (Regex.IsMatch(expression, @"(Z|[+-]\d{2}:\d{2})$") && DateTimeOffset.TryParse(expression, out DateTimeOffset explicitDate)) + { + result = explicitDate; + + if (result.TimeOfDay == TimeSpan.Zero && isUpperLimit) + { + // If time is exactly midnight, and it's an upper limit, set to end of day + result = result.EndOfDay(); + } + + return true; + } + + if (DateTime.TryParse(expression, out DateTime dt)) + { + result = new DateTimeOffset(dt, offset); + + if (result.TimeOfDay == TimeSpan.Zero && isUpperLimit) + { + // If time is exactly midnight, and it's an upper limit, set to end of day + result = result.EndOfDay(); + } + return true; + } + + return false; } /// diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs index 667f648..c424ea7 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs @@ -311,7 +311,6 @@ public void Parse_ExplicitDateWithOperations_ReturnsCorrectResult(string express [InlineData(" ")] [InlineData("invalid")] [InlineData("now+1x")] // Invalid unit - [InlineData("2023-01-01")] // Missing || [InlineData("||+1d")] // Missing anchor [InlineData("now/x")] // Invalid rounding unit [InlineData("2023-13-01||")] // Invalid month @@ -344,6 +343,7 @@ public void Parse_NullExpression_ThrowsArgumentException() [InlineData("now")] [InlineData("now+1h")] [InlineData("now-1d/d")] + [InlineData("2023-06-15")] [InlineData("2023-06-15||")] [InlineData("2023-06-15||+1M/d")] [InlineData("2025-01-01T01:25:35Z||+3d/d")] @@ -368,7 +368,6 @@ public void TryParse_ValidExpressions_ReturnsTrueAndCorrectResult(string express [InlineData("")] [InlineData("invalid")] [InlineData("now+")] - [InlineData("2023-01-01")] // Missing || [InlineData("||+1d")] // Missing anchor [InlineData("2001.02.01||")] // Dotted format no longer supported [InlineData("now/d+1h")] // Rounding must be final operation @@ -398,9 +397,69 @@ public void TryParse_NullExpression_ReturnsFalse() Assert.Equal(default, result); } + [Fact] + public void TryParse_FallbackExplicitDate_AppliesBaseOffset() + { + const string expression = "2023-04-01"; + _logger.LogDebug("Testing TryParse fallback with expression: '{Expression}', BaseTime: {BaseTime}", expression, _baseTime); + + bool success = DateMath.TryParse(expression, _baseTime, false, out var result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.True(success); + var expected = new DateTimeOffset(2023, 4, 1, 0, 0, 0, _baseTime.Offset); + Assert.Equal(expected, result); + } + + [Fact] + public void TryParse_FallbackExplicitDateUpperLimit_AdjustsToEndOfDay() + { + const string expression = "2023-07-10"; + _logger.LogDebug("Testing TryParse fallback upper limit with expression: '{Expression}', BaseTime: {BaseTime}", expression, _baseTime); + + bool success = DateMath.TryParse(expression, _baseTime, true, out var result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.True(success); + var expected = new DateTimeOffset(2023, 7, 10, 23, 59, 59, 999, _baseTime.Offset); + Assert.Equal(expected, result); + } + + [Fact] + public void TryParse_FallbackExplicitDateWithTimezone_PreservesOffset() + { + const string expression = "2023-05-05T18:45:00-07:00"; + _logger.LogDebug("Testing TryParse fallback with explicit offset expression: '{Expression}'", expression); + + bool success = DateMath.TryParse(expression, _baseTime, false, out var result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.True(success); + Assert.Equal(new DateTimeOffset(2023, 5, 5, 18, 45, 0, TimeSpan.FromHours(-7)), result); + } + + [Fact] + public void TryParse_FallbackExplicitDateWithTimeZoneInfo_UsesProvidedOffset() + { + const string expression = "2023-09-15"; + var customZone = TimeZoneInfo.CreateCustomTimeZone("TestPlusThree", TimeSpan.FromHours(3), "Test +3", "Test +3"); + _logger.LogDebug("Testing TryParse fallback with TimeZoneInfo: '{Expression}', TimeZone: {TimeZone}", expression, customZone); + + bool success = DateMath.TryParse(expression, customZone, false, out var result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.True(success); + Assert.Equal(new DateTimeOffset(2023, 9, 15, 0, 0, 0, customZone.BaseUtcOffset), result); + } + [Theory] [InlineData("now+1h", false)] [InlineData("now-1d/d", true)] + [InlineData("2023-06-15", false)] [InlineData("2023-06-15||+1M", false)] [InlineData("2025-01-01T01:25:35Z||+3d/d", true)] public void Parse_And_TryParse_ReturnSameResults(string expression, bool isUpperLimit) @@ -556,7 +615,7 @@ public void ParseTimeZone_Now_ReturnsCorrectTimezone(string timeZoneId, int expe public void ParseTimeZone_ExplicitDateWithoutTimezone_UsesSpecifiedTimezone() { var easternTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Eastern"); - const string expression = "2023-06-15T14:30:00||"; + const string expression = "2023-06-15T14:30:00"; _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}", expression, easternTimeZone.Id); @@ -581,7 +640,7 @@ public void ParseTimeZone_ExplicitDateWithoutTimezone_UsesSpecifiedTimezone() public void ParseTimeZone_ExplicitDateWithTimezone_PreservesOriginalTimezone() { var pacificTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Pacific"); - const string expression = "2023-06-15T14:30:00+05:00||"; // Explicit +05:00 timezone + const string expression = "2023-06-15T14:30:00+05:00"; // Explicit +05:00 timezone _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}", expression, pacificTimeZone.Id); From 4e312daebb8bddbd2df929ac1d9c5c3bbd13762f Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 27 Sep 2025 21:50:53 -0500 Subject: [PATCH 2/3] Use shared regex --- src/Exceptionless.DateTimeExtensions/DateMath.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.DateTimeExtensions/DateMath.cs b/src/Exceptionless.DateTimeExtensions/DateMath.cs index 6e6e030..a1f2329 100644 --- a/src/Exceptionless.DateTimeExtensions/DateMath.cs +++ b/src/Exceptionless.DateTimeExtensions/DateMath.cs @@ -31,6 +31,9 @@ public static class DateMath // Pre-compiled regex for operation parsing to avoid repeated compilation private static readonly Regex _operationRegex = new(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled); + // Pre-compiled regex for offset parsing to avoid repeated compilation + private static readonly Regex _offsetRegex = new(@"(Z|[+-]\d{2}:\d{2})$", RegexOptions.Compiled); + /// /// Parses a date math expression and returns the resulting DateTimeOffset. /// @@ -232,7 +235,7 @@ public static bool IsValidExpression(string expression) /// when the expression is successfully parsed as an explicit date; otherwise, . private static bool TryParseFallbackDate(string expression, TimeZoneInfo defaultTimeZone, bool isUpperLimit, out DateTimeOffset result) { - if (Regex.IsMatch(expression, @"(Z|[+-]\d{2}:\d{2})$") && DateTimeOffset.TryParse(expression, out DateTimeOffset explicitDate)) + if (_offsetRegex.IsMatch(expression) && DateTimeOffset.TryParse(expression, out DateTimeOffset explicitDate)) { result = explicitDate; @@ -270,7 +273,7 @@ private static bool TryParseFallbackDate(string expression, TimeZoneInfo default /// when the expression is successfully parsed as an explicit date; otherwise, . private static bool TryParseFallbackDate(string expression, TimeSpan offset, bool isUpperLimit, out DateTimeOffset result) { - if (Regex.IsMatch(expression, @"(Z|[+-]\d{2}:\d{2})$") && DateTimeOffset.TryParse(expression, out DateTimeOffset explicitDate)) + if (_offsetRegex.IsMatch(expression) && DateTimeOffset.TryParse(expression, out DateTimeOffset explicitDate)) { result = explicitDate; From dc02960466d03b12e96ec50d5ce54cec4b351137 Mon Sep 17 00:00:00 2001 From: "Eric J. Smith" Date: Sat, 27 Sep 2025 21:57:19 -0500 Subject: [PATCH 3/3] Refactor method --- .../DateMath.cs | 53 ++++--------------- 1 file changed, 10 insertions(+), 43 deletions(-) diff --git a/src/Exceptionless.DateTimeExtensions/DateMath.cs b/src/Exceptionless.DateTimeExtensions/DateMath.cs index a1f2329..e3e430f 100644 --- a/src/Exceptionless.DateTimeExtensions/DateMath.cs +++ b/src/Exceptionless.DateTimeExtensions/DateMath.cs @@ -225,54 +225,20 @@ public static bool IsValidExpression(string expression) return TryParseFallbackDate(expression, TimeZoneInfo.Local, false, out _); } - /// - /// Attempts to parse the expression as an explicit date when date math parsing fails, using the provided timezone for missing offsets. - /// - /// The original expression to interpret as an explicit date. - /// The timezone applied when the expression lacks explicit offset information. - /// Whether the value should be treated as an upper bound, rounding end-of-day when applicable. - /// Receives the parsed when parsing succeeds. - /// when the expression is successfully parsed as an explicit date; otherwise, . private static bool TryParseFallbackDate(string expression, TimeZoneInfo defaultTimeZone, bool isUpperLimit, out DateTimeOffset result) { - if (_offsetRegex.IsMatch(expression) && DateTimeOffset.TryParse(expression, out DateTimeOffset explicitDate)) - { - result = explicitDate; - - if (result.TimeOfDay == TimeSpan.Zero && isUpperLimit) - { - // If time is exactly midnight, and it's an upper limit, set to end of day - result = result.EndOfDay(); - } - - return true; - } - - if (DateTime.TryParse(expression, out DateTime dt)) - { - result = new DateTimeOffset(dt, defaultTimeZone.GetUtcOffset(dt)); - - if (result.TimeOfDay == TimeSpan.Zero && isUpperLimit) - { - // If time is exactly midnight, and it's an upper limit, set to end of day - result = result.EndOfDay(); - } - return true; - } - - return false; + return TryParseFallbackDateCore(expression, isUpperLimit, out result, defaultTimeZone.GetUtcOffset); } - /// - /// Attempts to parse the expression as an explicit date when date math parsing fails, using the provided offset for missing timezone information. - /// - /// The original expression to interpret as an explicit date. - /// The fallback UTC offset applied when the expression omits timezone data. - /// Whether the value should be treated as an upper bound, rounding to the end of day when appropriate. - /// Receives the parsed when parsing succeeds. - /// when the expression is successfully parsed as an explicit date; otherwise, . private static bool TryParseFallbackDate(string expression, TimeSpan offset, bool isUpperLimit, out DateTimeOffset result) { + return TryParseFallbackDateCore(expression, isUpperLimit, out result, _ => offset); + } + + private static bool TryParseFallbackDateCore(string expression, bool isUpperLimit, out DateTimeOffset result, Func offsetResolver) + { + result = default; + if (_offsetRegex.IsMatch(expression) && DateTimeOffset.TryParse(expression, out DateTimeOffset explicitDate)) { result = explicitDate; @@ -288,13 +254,14 @@ private static bool TryParseFallbackDate(string expression, TimeSpan offset, boo if (DateTime.TryParse(expression, out DateTime dt)) { - result = new DateTimeOffset(dt, offset); + result = new DateTimeOffset(dt, offsetResolver(dt)); if (result.TimeOfDay == TimeSpan.Zero && isUpperLimit) { // If time is exactly midnight, and it's an upper limit, set to end of day result = result.EndOfDay(); } + return true; }