Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ var elasticRange = DateTimeRange.Parse("2025-01-01T01:25:35Z||+3d/d", DateTime.N
// Bracket notation support [start TO end]
var bracketRange = DateTimeRange.Parse("[2023-01-01 TO 2023-12-31]", DateTime.Now);

// Calendar months, including leap years, use the base time's offset
var january = DateTimeRange.Parse("2025-01", DateTimeOffset.Now);
var firstQuarter = DateTimeRange.Parse("[2025-01 TO 2025-03]", DateTimeOffset.Now);

// Wildcard support for open-ended ranges
var wildcardRange = DateTimeRange.Parse("[2023-01-01 TO *]", DateTime.Now); // From date to infinity
```
Expand Down Expand Up @@ -203,6 +207,10 @@ var baseTime = DateTimeOffset.Now;
var result = DateMath.Parse("now+1h", baseTime);
var rounded = DateMath.Parse("now-1d/d", baseTime, isUpperLimit: false); // Start of yesterday

// Bare YYYY-MM values resolve to calendar month boundaries
var monthStart = DateMath.Parse("2025-01", baseTime); // January 1, midnight
var monthEnd = DateMath.Parse("2025-01", baseTime, isUpperLimit: true); // January 31, 23:59:59.999

// TryParse method - returns bool for success/failure
if (DateMath.TryParse("2023.06.15||+1M/d", baseTime, false, out var parsed)) {
// Successfully parsed: June 15, 2023 + 1 month, rounded to start of day
Expand Down
14 changes: 14 additions & 0 deletions src/Exceptionless.DateTimeExtensions/DateMath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,20 @@ private static bool TryParseFallbackDateCore(string expression, bool isUpperLimi
{
result = default;

if (DateTime.TryParseExact(expression.AsSpan().Trim(), "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out var month))
{
var boundary = isUpperLimit ? month.ChangeDay(DateTime.DaysInMonth(month.Year, month.Month)).EndOfDay() : month;
try
{
result = new DateTimeOffset(boundary, offsetResolver(boundary));
return true;
}
catch (ArgumentException)
{
return false;
}
}

if (OffsetRegex().IsMatch(expression) && DateTimeOffset.TryParse(expression, out DateTimeOffset explicitDate))
{
result = explicitDate;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace Exceptionless.DateTimeExtensions.FormatParsers;
[Priority(30)]
public partial class ExplicitDateFormatParser : IFormatParser
{
[GeneratedRegex(@"^\s*(?<date>\d{4}-\d{2}-\d{2}(?:T(?:\d{2}\:\d{2}\:\d{2}(?:\.\d{3})?|\d{2}\:\d{2}|\d{2})Z?)?)\s*$")]
[GeneratedRegex(@"^\s*(?<date>\d{4}-\d{2}(?:-\d{2}(?:T(?:\d{2}\:\d{2}\:\d{2}(?:\.\d{3})?|\d{2}\:\d{2}|\d{2})Z?)?)?)\s*$")]
private static partial Regex Parser();

public DateTimeRange? Parse(string content, DateTimeOffset relativeBaseTime)
Expand All @@ -17,6 +17,15 @@ public partial class ExplicitDateFormatParser : IFormatParser
return null;

string value = m.Groups["date"].Value;
if (value.Length == 7)
{
if (!DateMath.TryParse(value, relativeBaseTime, false, out var start) ||
!DateMath.TryParse(value, relativeBaseTime, true, out var end))
return null;

return new DateTimeRange(start, end);
}

if (value.Length == 13)
value += ":00:00";
if (value.Length == 16)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ public partial class ExplicitDatePartParser : IPartParser
public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit)
{
string value = match.Groups["date"].Value;
if (value.Length == 7)
return DateMath.TryParse(value, relativeBaseTime, isUpperLimit, out var month) ? month : null;

if (value.Length == 13)
value += ":00:00";
if (value.Length == 16)
Expand All @@ -35,6 +38,6 @@ public partial class ExplicitDatePartParser : IPartParser
};
}

[GeneratedRegex(@"\G(?<date>\d{4}-\d{2}-\d{2}(?:T(?:\d{2}\:\d{2}\:\d{2}(?:\.\d{3})?|\d{2}\:\d{2}|\d{2})Z?)?)")]
[GeneratedRegex(@"\G(?<date>\d{4}-\d{2}(?:-\d{2}(?:T(?:\d{2}\:\d{2}\:\d{2}(?:\.\d{3})?|\d{2}\:\d{2}|\d{2})Z?)?)?)(?!\d)")]
private static partial Regex Parser();
}
56 changes: 56 additions & 0 deletions tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,62 @@ public void TryParse_NullExpression_ReturnsFalse()
Assert.Equal(default, result);
}

[Theory]
[InlineData("2025-01", false, 2025, 1, 1)]
[InlineData("2025-01", true, 2025, 1, 31)]
[InlineData(" 2024-02 ", true, 2024, 2, 29)]
[InlineData("2025-02", true, 2025, 2, 28)]
[InlineData("2025-04", true, 2025, 4, 30)]
[InlineData("2025-12", true, 2025, 12, 31)]
public void TryParse_YearMonth_ResolvesCalendarBoundary(string expression, bool isUpperLimit, int year, int month, int day)
{
var expected = isUpperLimit
? new DateTimeOffset(year, month, day, 23, 59, 59, 999, _baseTime.Offset)
: new DateTimeOffset(year, month, day, 0, 0, 0, _baseTime.Offset);

Assert.True(DateMath.TryParse(expression, _baseTime, isUpperLimit, out var result));
Assert.Equal(expected, result);
Assert.Equal(expected.Offset, result.Offset);
Assert.Equal(result, DateMath.Parse(expression, _baseTime, isUpperLimit));
}

[Theory]
[InlineData(false, -5)]
[InlineData(true, -4)]
public void TryParse_YearMonthWithTimeZone_UsesOffsetAtResolvedBoundary(bool isUpperLimit, int offsetHours)
{
var zone = TimeZoneInfo.FindSystemTimeZoneById("America/New_York");
var expected = isUpperLimit
? new DateTimeOffset(2025, 3, 31, 23, 59, 59, 999, TimeSpan.FromHours(offsetHours))
: new DateTimeOffset(2025, 3, 1, 0, 0, 0, TimeSpan.FromHours(offsetHours));

Assert.True(DateMath.TryParse("2025-03", zone, isUpperLimit, out var result));
Assert.Equal(expected, result);
Assert.Equal(expected.Offset, result.Offset);
}

[Theory]
[InlineData("0001-01", false)]
[InlineData("9999-12", true)]
public void TryParse_YearMonthAtDateLimits_ReturnsRepresentableBoundary(string expression, bool isUpperLimit)
{
var expected = isUpperLimit ? DateTimeOffset.MaxValue : DateTimeOffset.MinValue;

Assert.True(DateMath.TryParse(expression, TimeZoneInfo.Utc, isUpperLimit, out var result));
Assert.Equal(expected, result);
}

[Theory]
[InlineData("0001-01", false, 5)]
[InlineData("9999-12", true, -5)]
public void TryParse_YearMonthOutsideUtcLimits_ReturnsFalse(string expression, bool isUpperLimit, int offsetHours)
{
var baseTime = _baseTime.ToOffset(TimeSpan.FromHours(offsetHours));

Assert.False(DateMath.TryParse(expression, baseTime, isUpperLimit, out var result));
Assert.Equal(default, result);
}

[Fact]
public void TryParse_FallbackExplicitDate_AppliesBaseOffset()
{
Expand Down
121 changes: 121 additions & 0 deletions tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,127 @@ public class DateTimeRangeTests
{
private static readonly DateTime _now = new(2014, 11, 6, 4, 32, 56, 78);

[Theory]
[InlineData("2025-01", 2025, 1, 31)]
[InlineData("[2025-01 TO 2025-01]", 2025, 1, 31)]
[InlineData(" [ 2024-02 to 2024-02 ] ", 2024, 2, 29)]
[InlineData("2025-02", 2025, 2, 28)]
[InlineData("2025-04", 2025, 4, 30)]
[InlineData("2025-12", 2025, 12, 31)]
public void Parse_YearMonth_ReturnsFullMonthWithBaseOffset(string input, int year, int month, int days)
{
foreach (int offsetMinutes in new[] { -300, 0, 330 })
{
var offset = TimeSpan.FromMinutes(offsetMinutes);
var baseTime = new DateTimeOffset(2026, 10, 15, 12, 30, 0, offset);
var start = new DateTimeOffset(year, month, 1, 0, 0, 0, offset);
var end = new DateTimeOffset(year, month, days, 23, 59, 59, 999, offset);

var range = DateTimeRange.Parse(input, baseTime);

Assert.Equal(start.DateTime, range.Start);
Assert.Equal(end.DateTime, range.End);
Assert.Equal(start.UtcDateTime, range.UtcStart);
Assert.Equal(end.UtcDateTime, range.UtcEnd);
}
}

[Theory]
[InlineData("[2024-02 TO 2024-03]", false, true)]
[InlineData("[2024-02 TO 2024-03}", false, false)]
[InlineData("{2024-02 TO 2024-03]", true, true)]
[InlineData("{2024-02 TO 2024-03}", true, false)]
[InlineData("2024-02 TO 2024-03", false, true)]
[InlineData("2024-02 - 2024-03", false, true)]
[InlineData("2024-02-2024-03", false, true)]
public void Parse_YearMonthRange_RespectsBoundaryInclusivity(string input, bool startAtEndOfMonth, bool endAtEndOfMonth)
{
var baseTime = new DateTimeOffset(2026, 10, 15, 12, 0, 0, TimeSpan.FromHours(-5));
var start = startAtEndOfMonth ? new DateTime(2024, 2, 29, 23, 59, 59, 999) : new DateTime(2024, 2, 1);
var end = endAtEndOfMonth ? new DateTime(2024, 3, 31, 23, 59, 59, 999) : new DateTime(2024, 3, 1);

var range = DateTimeRange.Parse(input, baseTime);

Assert.Equal(start, range.Start);
Assert.Equal(end, range.End);
Assert.Equal(new DateTimeOffset(start, baseTime.Offset).UtcDateTime, range.UtcStart);
Assert.Equal(new DateTimeOffset(end, baseTime.Offset).UtcDateTime, range.UtcEnd);
}

[Theory]
[InlineData(">=2025-01", true, false)]
[InlineData(">2025-01", true, true)]
[InlineData("<2025-01", false, false)]
[InlineData("<=2025-01", false, true)]
[InlineData("[2025-01 TO *]", true, false)]
[InlineData("[* TO 2025-01]", false, true)]
public void Parse_YearMonthOpenRange_ResolvesMonthBoundary(string input, bool isLowerBound, bool endOfMonth)
{
var boundary = endOfMonth ? new DateTime(2025, 1, 31, 23, 59, 59, 999) : new DateTime(2025, 1, 1);

var range = DateTimeRange.Parse(input, DateTimeOffset.UtcNow);

Assert.Equal(isLowerBound ? boundary : DateTime.MinValue, range.Start);
Assert.Equal(isLowerBound ? DateTime.MaxValue : boundary, range.End);
}

[Theory]
[InlineData("[2025-01 TO 2025-02-15]")]
[InlineData("[2025-01 TO 2025-02-15||/d]")]
public void Parse_YearMonthWithDayBound_ReturnsMixedPrecisionRange(string input)
{
var range = DateTimeRange.Parse(input, DateTimeOffset.UtcNow);

Assert.Equal(new DateTime(2025, 1, 1), range.Start);
Assert.Equal(new DateTime(2025, 2, 15, 23, 59, 59, 999), range.End);
}

[Theory]
[InlineData("2025-00")]
[InlineData("2025-13")]
[InlineData("2025-1")]
[InlineData("2025-01junk")]
[InlineData("2025-01T05")]
[InlineData("[2025-00 TO 2025-01]")]
[InlineData("[2025-01 TO 2025-13]")]
[InlineData("[2025-01 TO 2025-02-30]")]
public void Parse_InvalidYearMonth_ReturnsEmpty(string input)
{
Assert.Equal(DateTimeRange.Empty, DateTimeRange.Parse(input, DateTimeOffset.UtcNow));
}

[Theory]
[InlineData("0001-01", 1, 1)]
[InlineData("9999-12", 9999, 12)]
public void Parse_YearMonthAtDateLimits_ReturnsRepresentableRange(string input, int year, int month)
{
var start = new DateTime(year, month, 1);
var end = year == 9999 ? DateTime.MaxValue : new DateTime(year, month, 31, 23, 59, 59, 999);

var range = DateTimeRange.Parse(input, DateTimeOffset.UtcNow);

Assert.Equal(start, range.Start);
Assert.Equal(end, range.End);
}

[Fact]
public void Parse_DashSeparatedYears_PreservesYearRange()
{
var range = DateTimeRange.Parse("1000-1100", DateTimeOffset.UtcNow);

Assert.Equal(new DateTime(1000, 1, 1), range.Start);
Assert.Equal(new DateTime(1100, 12, 31, 23, 59, 59, 999), range.End);
}

[Fact]
public void Parse_ExclusiveSameMonthRange_CollapsesToStartOfMonth()
{
var range = DateTimeRange.Parse("{2025-01 TO 2025-01}", DateTimeOffset.UtcNow);

Assert.Equal(new DateTime(2025, 1, 1), range.Start);
Assert.Equal(range.Start, range.End);
}

[Fact]
public void CanCompareForEquality()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ public static IEnumerable<object[]> Inputs
{
return
[
["2014-02", _now.Change(null, 2, 1).StartOfDay(), _now.Change(null, 2, 28).EndOfDay()],
[" 2014-02 ", _now.Change(null, 2, 1).StartOfDay(), _now.Change(null, 2, 28).EndOfDay()],
["2014-00", null, null],
["2014-13", null, null],
["2014-2", null, null],
["2014-02T05", null, null],
["2014-02-01", _now.Change(null, 2, 1).StartOfDay(), _now.Change(null, 2, 1).EndOfDay()],
["2014-02-01T05", _now.Change(null, 2, 1, 5).StartOfHour(), _now.Change(null, 2, 1, 5).EndOfHour()],
["2014-02-01T05:30", _now.Change(null, 2, 1, 5, 30).StartOfMinute(), _now.Change(null, 2, 1, 5, 30).EndOfMinute()],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ public static IEnumerable<object[]> Inputs
{
return
[
["2014-02", false, _now.Change(null, 2, 1).StartOfDay()],
["2014-02", true, _now.Change(null, 2, 28).EndOfDay()],
["2014-00", false, null],
["2014-13", true, null],
["2014-2", false, null],
["2014-1015", false, null],
["2014-02-01", false, _now.Change(null, 2, 1).StartOfDay()],
["2014-02-01", true, _now.Change(null, 2, 1).EndOfDay()],
["2014-02-01T05", false, _now.Change(null, 2, 1, 5).StartOfHour()],
Expand Down
Loading