diff --git a/README.md b/README.md index 2ced542..9104ed1 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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 diff --git a/src/Exceptionless.DateTimeExtensions/DateMath.cs b/src/Exceptionless.DateTimeExtensions/DateMath.cs index 264f1df..1d2d34a 100644 --- a/src/Exceptionless.DateTimeExtensions/DateMath.cs +++ b/src/Exceptionless.DateTimeExtensions/DateMath.cs @@ -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; diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/ExplicitDateFormatParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/ExplicitDateFormatParser.cs index 7731ea1..78f23c5 100644 --- a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/ExplicitDateFormatParser.cs +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/ExplicitDateFormatParser.cs @@ -6,7 +6,7 @@ namespace Exceptionless.DateTimeExtensions.FormatParsers; [Priority(30)] public partial class ExplicitDateFormatParser : IFormatParser { - [GeneratedRegex(@"^\s*(?\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*(?\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) @@ -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) diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/ExplicitDatePartParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/ExplicitDatePartParser.cs index 6cf90f6..ce03059 100644 --- a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/ExplicitDatePartParser.cs +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/ExplicitDatePartParser.cs @@ -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) @@ -35,6 +38,6 @@ public partial class ExplicitDatePartParser : IPartParser }; } - [GeneratedRegex(@"\G(?\d{4}-\d{2}-\d{2}(?:T(?:\d{2}\:\d{2}\:\d{2}(?:\.\d{3})?|\d{2}\:\d{2}|\d{2})Z?)?)")] + [GeneratedRegex(@"\G(?\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(); } diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs index 8273148..ec41caf 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs @@ -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() { diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs index c6a8efb..ed1a9d7 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs @@ -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() { diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/ExplicitDateFormatParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/ExplicitDateFormatParserTests.cs index 17ff61d..0b3c561 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/ExplicitDateFormatParserTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/ExplicitDateFormatParserTests.cs @@ -20,6 +20,12 @@ public static IEnumerable 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()], diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/ExplicitDatePartParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/ExplicitDatePartParserTests.cs index eefcd91..ed35ae6 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/ExplicitDatePartParserTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/ExplicitDatePartParserTests.cs @@ -20,6 +20,12 @@ public static IEnumerable 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()],