Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 90 additions & 3 deletions src/Exceptionless.DateTimeExtensions/DateMath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>
/// Parses a date math expression and returns the resulting DateTimeOffset.
/// </summary>
Expand Down Expand Up @@ -64,7 +67,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);
}
Expand Down Expand Up @@ -110,7 +115,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);
}
Expand Down Expand Up @@ -211,7 +218,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 _);
}

/// <summary>
/// Attempts to parse the expression as an explicit date when date math parsing fails, using the provided timezone for missing offsets.
/// </summary>
/// <param name="expression">The original expression to interpret as an explicit date.</param>
/// <param name="defaultTimeZone">The timezone applied when the expression lacks explicit offset information.</param>
/// <param name="isUpperLimit">Whether the value should be treated as an upper bound, rounding end-of-day when applicable.</param>
/// <param name="result">Receives the parsed <see cref="DateTimeOffset"/> when parsing succeeds.</param>
/// <returns><see langword="true"/> when the expression is successfully parsed as an explicit date; otherwise, <see langword="false"/>.</returns>
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;
}

/// <summary>
/// Attempts to parse the expression as an explicit date when date math parsing fails, using the provided offset for missing timezone information.
/// </summary>
/// <param name="expression">The original expression to interpret as an explicit date.</param>
/// <param name="offset">The fallback UTC offset applied when the expression omits timezone data.</param>
/// <param name="isUpperLimit">Whether the value should be treated as an upper bound, rounding to the end of day when appropriate.</param>
/// <param name="result">Receives the parsed <see cref="DateTimeOffset"/> when parsing succeeds.</param>
/// <returns><see langword="true"/> when the expression is successfully parsed as an explicit date; otherwise, <see langword="false"/>.</returns>
private static bool TryParseFallbackDate(string expression, TimeSpan offset, 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, 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;
}

/// <summary>
Expand Down
67 changes: 63 additions & 4 deletions tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")]
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down