From 904ea1912f32d2f447e72b36c4c5f76ba7df68d4 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 21 Sep 2025 20:40:33 -0500 Subject: [PATCH 01/13] Adds wildcard support to date range parsing Extends the date range parsing functionality to support wildcard characters, allowing for open-ended date ranges. Implements a new `WildcardPartParser` to handle "*" characters, representing either the minimum or maximum DateTimeOffset value based on the context. Updates the `TwoPartFormatParser` to correctly handle bracketed date ranges and wildcard characters. --- .../PartParsers/WildcardPartParser.cs | 20 +++ .../PartParsers/WildcardPartParserTests.cs | 117 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs create mode 100644 tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs new file mode 100644 index 0000000..d21053a --- /dev/null +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs @@ -0,0 +1,20 @@ +using System; +using System.Text.RegularExpressions; + +namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers; + +[Priority(1)] +public class WildcardPartParser : IPartParser +{ + private static readonly Regex _wildCardRegex = new(@"\G\s*\*(?=\s|\]|\}|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public Regex Regex => _wildCardRegex; + + public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit) + { + if (!match.Success) + return null; + + return isUpperLimit ? DateTimeOffset.MaxValue : DateTimeOffset.MinValue; + } +} diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs new file mode 100644 index 0000000..063d575 --- /dev/null +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Exceptionless.DateTimeExtensions.FormatParsers.PartParsers; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Exceptionless.DateTimeExtensions.Tests.FormatParsers.PartParsers; + +public class WildcardPartParserTests : PartParserTestsBase +{ + public WildcardPartParserTests(ITestOutputHelper output) : base(output) { } + + [Theory] + [MemberData(nameof(ParseInputs))] + public void ParseInput(string input, bool isUpperLimit, DateTimeOffset? expected) + { + var parser = new WildcardPartParser(); + _logger.LogInformation("Testing input: '{Input}', IsUpperLimit: {IsUpperLimit}, Expected: {Expected}", input, isUpperLimit, expected); + + var match = parser.Regex.Match(input); + _logger.LogInformation("Regex match success: {Success}, Value: '{Value}', Index: {Index}, Length: {Length}", match.Success, match.Value, match.Index, match.Length); + + var result = parser.Parse(match, _now, isUpperLimit); + _logger.LogInformation("Parse result: {Result}", result); + + if (expected == null) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + Assert.Equal(expected.Value.DateTime, result.Value.DateTime); + } + } + + public static IEnumerable ParseInputs + { + get + { + return new[] + { + // Valid wildcard inputs + new object[] { "*", false, DateTimeOffset.MinValue }, + new object[] { "*", true, DateTimeOffset.MaxValue }, + new object[] { " * ", false, DateTimeOffset.MinValue }, + new object[] { " * ", true, DateTimeOffset.MaxValue }, + new object[] { " * ", false, DateTimeOffset.MinValue }, + new object[] { " * ", true, DateTimeOffset.MaxValue }, + + // Invalid inputs (patterns that should not match a complete wildcard) + new object[] { "blah", false, null }, + new object[] { "blah", true, null }, + new object[] { "2012", false, null }, + new object[] { "2012", true, null }, + new object[] { "**", false, null }, + + // This should match the first * in a two-part context like "* *" + new object[] { "* *", false, DateTimeOffset.MinValue }, + }; + } + } + + [Fact] + public void RegexPatternTest() + { + var parser = new WildcardPartParser(); + var regex = parser.Regex; + + _logger.LogInformation("Regex pattern: {Pattern}", regex); + + // Test various inputs + var testInputs = new[] { "*", " * ", " * ", "blah", "2012", "**", "* *", "" }; + + foreach (var input in testInputs) + { + var match = regex.Match(input); + _logger.LogInformation("Input: '{Input}' -> Success: {Success}, Value: '{Value}', Index: {Index}, Length: {Length}", input, match.Success, match.Value, match.Index, match.Length); + } + } + + [Fact] + public void TestInTwoPartContext() + { + var parser = new WildcardPartParser(); + + // Test how it behaves in a two-part parsing context + var inputs = new[] { "* TO 2013", "2012 TO *", "[* TO 2013]", "{2012 TO *}" }; + + foreach (var input in inputs) + { + _logger.LogInformation("Testing two-part context for: '{Input}'", input); + + // Test parsing at the beginning + var match = parser.Regex.Match(input, 0); + _logger.LogInformation(" At position 0: Success: {Success}, Value: '{Value}', Index: {Index}, Length: {Length}", match.Success, match.Value, match.Index, match.Length); + + // Test parsing after bracket + if (input.StartsWith("[") || input.StartsWith("{")) + { + match = parser.Regex.Match(input, 1); + _logger.LogInformation(" At position 1: Success: {Success}, Value: '{Value}', Index: {Index}, Length: {Length}", match.Success, match.Value, match.Index, match.Length); + } + + // Find TO and test parsing after it + var toIndex = input.IndexOf(" TO ", StringComparison.OrdinalIgnoreCase); + if (toIndex >= 0) + { + var afterTo = toIndex + 4; + match = parser.Regex.Match(input, afterTo); + _logger.LogInformation(" After TO at position {Position}: Success: {Success}, Value: '{Value}', Index: {Index}, Length: {Length}", afterTo, match.Success, match.Value, match.Index, match.Length); + } + } + } +} From f952c8d9716d6405275a6c7a1dbdd9a7f3a1169a Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 21 Sep 2025 20:40:40 -0500 Subject: [PATCH 02/13] Extends date range parser to support new formats Adds support for "TO" delimiter and bracket syntax to the two-part date range parser. This allows for more flexible date range input formats, including those used by Elasticsearch. --- .../FormatParsers/TwoPartFormatParser.cs | 4 ++-- .../FormatParsers/TwoPartFormatParserTests.cs | 22 ++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs index 4bc4741..c9d08b2 100644 --- a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs @@ -8,9 +8,9 @@ namespace Exceptionless.DateTimeExtensions.FormatParsers; [Priority(25)] public class TwoPartFormatParser : IFormatParser { - private static readonly Regex _beginRegex = new(@"^\s*"); + private static readonly Regex _beginRegex = new(@"^\s*(?:[\[\{])?\s*"); private static readonly Regex _delimiterRegex = new(@"\G(?:\s*-\s*|\s+TO\s+)", RegexOptions.IgnoreCase); - private static readonly Regex _endRegex = new(@"\G\s*$"); + private static readonly Regex _endRegex = new(@"\G\s*(?:[\]\}])?\s*$"); public TwoPartFormatParser() { diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs index 6317860..b32fd9e 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs @@ -22,12 +22,32 @@ public static IEnumerable Inputs get { return new[] { + // Original dash delimiter syntax new object[] { "2012-2013", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear() }, new object[] { "5 days ago - now", _now.SubtractDays(5).StartOfDay(), _now }, new object[] { "jan-feb", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth() }, new object[] { "now-this feb", _now, _now.AddYears(1).ChangeMonth(2).EndOfMonth() }, + + // TO delimiter syntax (case-insensitive) + new object[] { "2012 TO 2013", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear() }, + new object[] { "jan to feb", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth() }, + new object[] { "5 days ago TO now", _now.SubtractDays(5).StartOfDay(), _now }, + + // Elasticsearch bracket syntax + new object[] { "[2012 TO 2013]", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear() }, + new object[] { "{jan TO feb}", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth() }, + new object[] { "[2012-2013]", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear() }, + + // Wildcard support + new object[] { "* TO 2013", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear() }, + new object[] { "2012 TO *", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue}, + new object[] { "[* TO 2013]", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear() }, + new object[] { "{2012 TO *}", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue }, + + // Invalid inputs new object[] { "blah", null, null }, - new object[] { "blah blah", null, null } + new object[] { "[invalid", null, null }, + new object[] { "invalid}", null, null } }; } } From d4ad41fd648ed91b8398d48e1eff55738083d1b5 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 21 Sep 2025 20:43:39 -0500 Subject: [PATCH 03/13] Updates .gitignore to exclude IDE files Adds a new entry to .gitignore to prevent tracking of specific IDE configuration files related to the project. This ensures a cleaner repository by excluding auto-generated files and preventing accidental commits of personal IDE settings. --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 897f2f0..6bc9163 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,5 @@ _NCrunch_* # Rider auto-generates .iml files, and contentModel.xml **/.idea/**/*.iml **/.idea/**/contentModel.xml -**/.idea/**/modules.xml \ No newline at end of file +**/.idea/**/modules.xml +.idea/.idea.Exceptionless.DateTimeExtensions/.idea/ From 46a32f71fbfcb9792bf4ea809fb96dfd0dade0f9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 21 Sep 2025 20:51:53 -0500 Subject: [PATCH 04/13] Improves regex parsing performance Adds `RegexOptions.Compiled` to regex definitions in the `TwoPartFormatParser` to improve performance. --- .../FormatParsers/FormatParsers/TwoPartFormatParser.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs index d819104..afbc314 100644 --- a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs @@ -8,9 +8,9 @@ namespace Exceptionless.DateTimeExtensions.FormatParsers; [Priority(25)] public class TwoPartFormatParser : IFormatParser { - private static readonly Regex _beginRegex = new(@"^\s*(?:[\[\{])?\s*"); - private static readonly Regex _delimiterRegex = new(@"\G(?:\s*-\s*|\s+TO\s+)", RegexOptions.IgnoreCase); - private static readonly Regex _endRegex = new(@"\G\s*(?:[\]\}])?\s*$"); + private static readonly Regex _beginRegex = new(@"^\s*(?:[\[\{])?\s*", RegexOptions.Compiled); + private static readonly Regex _delimiterRegex = new(@"\G(?:\s*-\s*|\s+TO\s+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex _endRegex = new(@"\G\s*(?:[\]\}])?\s*$", RegexOptions.Compiled); public TwoPartFormatParser() { From 1b7ef73980806a3d2eab99a88390f101aa076587 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 21 Sep 2025 20:52:03 -0500 Subject: [PATCH 05/13] Adds two-part format parser tests Introduces tests for parsing date ranges using various delimiters including dashes, "TO", and Elasticsearch bracket syntax. Also, adds tests for wildcard support in date range parsing. Addresses and updates existing wildcard parser tests. --- .../PartParsers/WildcardPartParserTests.cs | 22 ++++++------- .../FormatParsers/TwoPartFormatParserTests.cs | 32 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs index 063d575..ef3bf5e 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs @@ -44,21 +44,21 @@ public static IEnumerable ParseInputs { // Valid wildcard inputs new object[] { "*", false, DateTimeOffset.MinValue }, - new object[] { "*", true, DateTimeOffset.MaxValue }, - new object[] { " * ", false, DateTimeOffset.MinValue }, - new object[] { " * ", true, DateTimeOffset.MaxValue }, - new object[] { " * ", false, DateTimeOffset.MinValue }, - new object[] { " * ", true, DateTimeOffset.MaxValue }, + ["*", true, DateTimeOffset.MaxValue], + [" * ", false, DateTimeOffset.MinValue], + [" * ", true, DateTimeOffset.MaxValue], + [" * ", false, DateTimeOffset.MinValue], + [" * ", true, DateTimeOffset.MaxValue], // Invalid inputs (patterns that should not match a complete wildcard) - new object[] { "blah", false, null }, - new object[] { "blah", true, null }, - new object[] { "2012", false, null }, - new object[] { "2012", true, null }, - new object[] { "**", false, null }, + ["blah", false, null], + ["blah", true, null], + ["2012", false, null], + ["2012", true, null], + ["**", false, null], // This should match the first * in a two-part context like "* *" - new object[] { "* *", false, DateTimeOffset.MinValue }, + ["* *", false, DateTimeOffset.MinValue], }; } } diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs index b32fd9e..3e43974 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs @@ -24,30 +24,30 @@ public static IEnumerable Inputs return new[] { // Original dash delimiter syntax new object[] { "2012-2013", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear() }, - new object[] { "5 days ago - now", _now.SubtractDays(5).StartOfDay(), _now }, - new object[] { "jan-feb", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth() }, - new object[] { "now-this feb", _now, _now.AddYears(1).ChangeMonth(2).EndOfMonth() }, + ["5 days ago - now", _now.SubtractDays(5).StartOfDay(), _now], + ["jan-feb", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth()], + ["now-this feb", _now, _now.AddYears(1).ChangeMonth(2).EndOfMonth()], // TO delimiter syntax (case-insensitive) - new object[] { "2012 TO 2013", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear() }, - new object[] { "jan to feb", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth() }, - new object[] { "5 days ago TO now", _now.SubtractDays(5).StartOfDay(), _now }, + ["2012 TO 2013", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear()], + ["jan to feb", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth()], + ["5 days ago TO now", _now.SubtractDays(5).StartOfDay(), _now], // Elasticsearch bracket syntax - new object[] { "[2012 TO 2013]", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear() }, - new object[] { "{jan TO feb}", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth() }, - new object[] { "[2012-2013]", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear() }, + ["[2012 TO 2013]", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear()], + ["{jan TO feb}", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth()], + ["[2012-2013]", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear()], // Wildcard support - new object[] { "* TO 2013", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear() }, - new object[] { "2012 TO *", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue}, - new object[] { "[* TO 2013]", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear() }, - new object[] { "{2012 TO *}", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue }, + ["* TO 2013", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear()], + ["2012 TO *", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue], + ["[* TO 2013]", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear()], + ["{2012 TO *}", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue], // Invalid inputs - new object[] { "blah", null, null }, - new object[] { "[invalid", null, null }, - new object[] { "invalid}", null, null } + ["blah", null, null], + ["[invalid", null, null], + ["invalid}", null, null] }; } } From b5ebbfeaed042b38c66c5a61b3744d1fd2772ffa Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 21 Sep 2025 21:38:14 -0500 Subject: [PATCH 06/13] Adds Elasticsearch date math expression support Implements parsing of Elasticsearch-style date math expressions within date ranges, offering flexible and precise date calculations. This includes support for: - Anchors (now, explicit dates) - Operations (+, -, /) - Units (y, M, w, d, h, m, s) - Timezone handling Updates the DateTimeRange parsing to support bracket notation and wildcards. --- README.md | 29 +- .../PartParsers/DateMathPartParser.cs | 229 ++++++++++++ .../PartParsers/DateMathPartParserTests.cs | 345 ++++++++++++++++++ 3 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs create mode 100644 tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs diff --git a/README.md b/README.md index fc7d868..033f273 100644 --- a/README.md +++ b/README.md @@ -34,15 +34,42 @@ bool isDay = day.IsBusinessDay(date); ### DateTime Ranges -Quickly work with date ranges. . Check out our [unit tests](https://github.com/exceptionless/Exceptionless.DateTimeExtensions/blob/main/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs) for more usage samples. +Quickly work with date ranges with support for Elasticsearch-style date math expressions and bracket notation. Check out our [unit tests](https://github.com/exceptionless/Exceptionless.DateTimeExtensions/blob/main/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs) for more usage samples. ```csharp +// Basic range parsing var range = DateTimeRange.Parse("yesterday", DateTime.Now); if (range.Contains(DateTime.Now.Subtract(TimeSpan.FromHours(6)))) { //... } + +// Elasticsearch Date Math support with proper timezone handling +var elasticRange = DateTimeRange.Parse("2025-01-01T01:25:35Z||+3d/d", DateTime.Now); +// Supports timezone-aware operations: Z (UTC), +05:00, -08:00 + +// Bracket notation support [start TO end] +var bracketRange = DateTimeRange.Parse("[2023-01-01 TO 2023-12-31]", DateTime.Now); + +// Wildcard support for open-ended ranges +var wildcardRange = DateTimeRange.Parse("[2023-01-01 TO *]", DateTime.Now); // From date to infinity ``` +#### Date Math Features + +Supports full Elasticsearch date math syntax following [official specifications](https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math): + +- **Anchors**: `now`, explicit dates with `||` separator +- **Operations**: `+1d` (add), `-1h` (subtract), `/d` (round down) +- **Units**: `y` (years), `M` (months), `w` (weeks), `d` (days), `h`/`H` (hours), `m` (minutes), `s` (seconds) +- **Timezone Support**: Preserves explicit timezones (`Z`, `+05:00`, `-08:00`) or uses system timezone as fallback + +Examples: + +- `now+1h` - One hour from now +- `now-1d/d` - Start of yesterday +- `2025-01-01T01:25:35Z||+3d/d` - January 4th, 2025 (start of day) in UTC +- `2023-06-15T14:30:00+05:00||+1M-2d` - One month minus 2 days from the specified date/time in +05:00 timezone + ### TimeUnit Quickly work with time units. . Check out our [unit tests](https://github.com/exceptionless/Exceptionless.DateTimeExtensions/blob/main/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs) for more usage samples. diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs new file mode 100644 index 0000000..7fc62e4 --- /dev/null +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs @@ -0,0 +1,229 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers; + +/// +/// Parses Elasticsearch date math expressions with proper timezone support. +/// Supports: now, explicit dates with ||, operations (+, -, /), and time units (y, M, w, d, h, H, m, s). +/// Examples: now+1h, now-1d/d, 2001.02.01||+1M/d, 2025-01-01T01:25:35Z||+3d/d +/// +/// Timezone Handling (following Elasticsearch standards): +/// - Explicit timezone (Z, +05:00, -08:00): Preserved from input +/// - No timezone: Uses current system timezone +/// +/// References: +/// - https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math +/// - https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html#date-math-rounding +/// +[Priority(35)] +public class DateMathPartParser : IPartParser +{ + // Match date math expressions with anchors and operations + private static readonly Regex _parser = new( + @"^(?now|(?\d{4}[-.]?\d{2}[-.]?\d{2}(?:[T\s](?:\d{1,2}(?::?\d{2}(?::?\d{2})?)?(?:\.\d{1,3})?)?(?:[+-]\d{2}:?\d{2}|Z)?)?)\|\|)" + + @"(?(?:[+\-/]\d*[yMwdhHms])*)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public Regex Regex => _parser; + + public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit) + { + if (!match.Success) + return null; + + try + { + // Parse the anchor (now or explicit date) + DateTimeOffset baseTime; + string anchor = match.Groups["anchor"].Value; + + if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase)) + { + baseTime = relativeBaseTime; + } + else + { + // Parse explicit date from the date group + string dateStr = match.Groups["date"].Value; + if (!TryParseExplicitDate(dateStr, relativeBaseTime.Offset, out baseTime)) + return null; + } + + // Parse and apply operations + string operations = match.Groups["operations"].Value; + var result = ApplyOperations(baseTime, operations, isUpperLimit); + + return result; + } + catch + { + // Return null for any parsing errors to maintain robustness + return null; + } + } + + /// + /// Attempts to parse an explicit date string with proper timezone handling. + /// Supports various Elasticsearch-compatible date formats with optional timezone information. + /// + /// Timezone Behavior: + /// - If timezone is specified (Z, +HH:MM, -HH:MM): Preserved from input + /// - If no timezone specified: Uses the provided fallback offset + /// + /// This matches Elasticsearch's behavior where explicit timezone information takes precedence. + /// + /// The date string to parse + /// Fallback timezone offset for dates without explicit timezone + /// The parsed DateTimeOffset with correct timezone + /// True if parsing succeeded, false otherwise + private static bool TryParseExplicitDate(string dateStr, TimeSpan offset, out DateTimeOffset result) + { + result = default; + + if (string.IsNullOrEmpty(dateStr)) + return false; + + // Try various formats that Elasticsearch supports + string[] formats = { + "yyyy-MM-dd", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-ddTHH:mm", + "yyyy-MM-ddTHH", + "yyyy-MM-ddTHH:mm:ssZ", + "yyyy-MM-ddTHH:mm:ss.fff", + "yyyy-MM-ddTHH:mm:ss.fffZ", + "yyyy-MM-ddTHH:mm:sszzz", + "yyyy-MM-ddTHH:mm:ss.fffzzz", + "yyyy.MM.dd", + "yyyy.MM.ddTHH:mm:ss", + "yyyy.MM.ddTHH:mm", + "yyyy.MM.ddTHH", + "yyyy.MM.ddTHH:mm:ssZ", + "yyyy.MM.ddTHH:mm:ss.fff", + "yyyy.MM.ddTHH:mm:ss.fffZ", + "yyyy.MM.ddTHH:mm:sszzz", + "yyyy.MM.ddTHH:mm:ss.fffzzz", + "yyyyMMdd", + "yyyyMMddTHHmmss", + "yyyyMMddTHHmm", + "yyyyMMddTHH", + "yyyyMMddTHHmmssZ", + "yyyyMMddTHHmmss.fff", + "yyyyMMddTHHmmss.fffZ", + "yyyyMMddTHHmmsszzz", + "yyyyMMddTHHmmss.fffzzz" + }; + + foreach (string format in formats) + { + // Handle timezone-aware formats differently from timezone-naive formats + if (format.EndsWith("Z") || format.Contains("zzz")) + { + // Try parsing with timezone information preserved + if (DateTimeOffset.TryParseExact(dateStr, format, CultureInfo.InvariantCulture, + DateTimeStyles.None, out result)) + { + return true; + } + } + else + { + // For formats without timezone, parse as DateTime and treat as if already in target timezone + if (DateTime.TryParseExact(dateStr, format, CultureInfo.InvariantCulture, + DateTimeStyles.None, out DateTime dateTime)) + { + // Treat the parsed DateTime as if it's already in the target timezone + // This avoids any conversion issues + result = new DateTimeOffset(dateTime.Ticks, offset); + return true; + } + } + } + + return false; + } + + private static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string operations, bool isUpperLimit) + { + if (string.IsNullOrEmpty(operations)) + return baseTime; + + var result = baseTime; + var operationRegex = new Regex(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled); + var matches = operationRegex.Matches(operations); + + // Validate that all operations were matched properly + var totalMatchLength = matches.Cast().Sum(m => m.Length); + if (totalMatchLength != operations.Length) + { + // If not all operations were matched, there are invalid operations + throw new ArgumentException("Invalid operations"); + } + + foreach (Match opMatch in matches) + { + string operation = opMatch.Groups[1].Value; + string amountStr = opMatch.Groups[2].Value; + string unit = opMatch.Groups[3].Value; + + // Default amount is 1 if not specified + int amount = string.IsNullOrEmpty(amountStr) ? 1 : int.Parse(amountStr); + + switch (operation) + { + case "+": + result = AddTimeUnit(result, amount, unit); + break; + case "-": + result = AddTimeUnit(result, -amount, unit); + break; + case "/": + result = RoundToUnit(result, unit, isUpperLimit); + break; + } + } + + return result; + } + + private static DateTimeOffset AddTimeUnit(DateTimeOffset dateTime, int amount, string unit) + { + try + { + return unit switch + { + "y" => dateTime.AddYears(amount), + "M" => dateTime.AddMonths(amount), // Capital M for months + "m" => dateTime.AddMinutes(amount), // Lowercase m for minutes + "w" => dateTime.AddDays(amount * 7), + "d" => dateTime.AddDays(amount), + "h" or "H" => dateTime.AddHours(amount), + "s" => dateTime.AddSeconds(amount), + _ => throw new ArgumentException($"Invalid time unit: {unit}") + }; + } + catch (ArgumentOutOfRangeException) + { + // Return original date if operation would overflow + return dateTime; + } + } + + private static DateTimeOffset RoundToUnit(DateTimeOffset dateTime, string unit, bool isUpperLimit) + { + return unit switch + { + "y" => isUpperLimit ? dateTime.EndOfYear() : dateTime.StartOfYear(), + "M" => isUpperLimit ? dateTime.EndOfMonth() : dateTime.StartOfMonth(), + "w" => isUpperLimit ? dateTime.EndOfWeek() : dateTime.StartOfWeek(), + "d" => isUpperLimit ? dateTime.EndOfDay() : dateTime.StartOfDay(), + "h" or "H" => isUpperLimit ? dateTime.EndOfHour() : dateTime.StartOfHour(), + "m" => isUpperLimit ? dateTime.EndOfMinute() : dateTime.StartOfMinute(), + "s" => isUpperLimit ? dateTime.EndOfSecond() : dateTime.StartOfSecond(), + _ => throw new ArgumentException($"Invalid time unit for rounding: {unit}") + }; + } +} diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs new file mode 100644 index 0000000..0678738 --- /dev/null +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using Exceptionless.DateTimeExtensions.FormatParsers.PartParsers; +using Xunit; +using Xunit.Abstractions; + +namespace Exceptionless.DateTimeExtensions.Tests.FormatParsers.PartParsers; + +public class DateMathPartParserTests : PartParserTestsBase +{ + public DateMathPartParserTests(ITestOutputHelper output) : base(output) { } + + [Theory] + [MemberData(nameof(NowInputs))] + public void ParseNowInput(string input, bool isUpperLimit, DateTimeOffset? expected) + { + ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected); + } + + [Theory] + [MemberData(nameof(ExplicitDateInputs))] + public void ParseExplicitDateInput(string input, bool isUpperLimit, DateTimeOffset? expected) + { + ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected); + } + + [Theory] + [MemberData(nameof(InvalidInputs))] + public void ParseInvalidInput(string input, bool isUpperLimit, DateTimeOffset? expected) + { + ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected); + } + + [Theory] + [MemberData(nameof(ComplexOperationInputs))] + public void ParseComplexOperations(string input, bool isUpperLimit, DateTimeOffset? expected) + { + ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected); + } + + [Theory] + [MemberData(nameof(RoundingInputs))] + public void ParseRoundingOperations(string input, bool isUpperLimit, DateTimeOffset? expected) + { + ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected); + } + + [Theory] + [MemberData(nameof(EdgeCaseInputs))] + public void ParseEdgeCases(string input, bool isUpperLimit, DateTimeOffset? expected) + { + ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected); + } + + public static IEnumerable NowInputs + { + get + { + return new[] { + // Basic "now" anchor + new object[] { "now", false, _now }, + ["now", true, _now], + + // Now with single operations + ["now+1h", false, _now.AddHours(1)], + ["now+1h", true, _now.AddHours(1)], + ["now-1h", false, _now.AddHours(-1)], + ["now-1h", true, _now.AddHours(-1)], + ["now+1d", false, _now.AddDays(1)], + ["now+1d", true, _now.AddDays(1)], + ["now-1d", false, _now.AddDays(-1)], + ["now-1d", true, _now.AddDays(-1)], + ["now+1w", false, _now.AddDays(7)], + ["now+1w", true, _now.AddDays(7)], + ["now-1w", false, _now.AddDays(-7)], + ["now-1w", true, _now.AddDays(-7)], + ["now+1M", false, _now.AddMonths(1)], + ["now+1M", true, _now.AddMonths(1)], + ["now-1M", false, _now.AddMonths(-1)], + ["now-1M", true, _now.AddMonths(-1)], + ["now+1y", false, _now.AddYears(1)], + ["now+1y", true, _now.AddYears(1)], + ["now-1y", false, _now.AddYears(-1)], + ["now-1y", true, _now.AddYears(-1)], + ["now+1m", false, _now.AddMinutes(1)], + ["now+1m", true, _now.AddMinutes(1)], + ["now-1m", false, _now.AddMinutes(-1)], + ["now-1m", true, _now.AddMinutes(-1)], + ["now+1s", false, _now.AddSeconds(1)], + ["now+1s", true, _now.AddSeconds(1)], + ["now-1s", false, _now.AddSeconds(-1)], + ["now-1s", true, _now.AddSeconds(-1)], + + // Multi-digit amounts + ["now+10h", false, _now.AddHours(10)], + ["now+24h", true, _now.AddHours(24)], + ["now-365d", false, _now.AddDays(-365)], + ["now+100y", true, _now.AddYears(100)], + + // Capital H for hours + ["now+1H", false, _now.AddHours(1)], + ["now-2H", true, _now.AddHours(-2)], + + // Without amount (defaults to 1) + ["now+h", false, _now.AddHours(1)], + ["now-d", true, _now.AddDays(-1)], + ["now+M", false, _now.AddMonths(1)], + ["now-y", true, _now.AddYears(-1)] + }; + } + } + + public static IEnumerable ExplicitDateInputs + { + get + { + var baseDate = new DateTimeOffset(2001, 2, 1, 0, 0, 0, _now.Offset); + + return new[] { + // Basic explicit date formats + new object[] { "2001-02-01||", false, baseDate }, + ["2001-02-01||", true, baseDate], + ["2001.02.01||", false, baseDate], + ["2001.02.01||", true, baseDate], + ["20010201||", false, baseDate], + ["20010201||", true, baseDate], + + // With time components + ["2001-02-01T12:30:45||", false, new DateTimeOffset(2001, 2, 1, 12, 30, 45, _now.Offset)], + ["2001-02-01T12:30:45||", true, new DateTimeOffset(2001, 2, 1, 12, 30, 45, _now.Offset)], + ["2001-02-01T12:30||", false, new DateTimeOffset(2001, 2, 1, 12, 30, 0, _now.Offset)], + ["2001-02-01T12:30||", true, new DateTimeOffset(2001, 2, 1, 12, 30, 0, _now.Offset)], + ["2001-02-01T12||", false, new DateTimeOffset(2001, 2, 1, 12, 0, 0, _now.Offset)], + ["2001-02-01T12||", true, new DateTimeOffset(2001, 2, 1, 12, 0, 0, _now.Offset)], + + // With operations + ["2001-02-01||+1M", false, baseDate.AddMonths(1)], + ["2001-02-01||+1M", true, baseDate.AddMonths(1)], + ["2001-02-01||+1y", false, baseDate.AddYears(1)], + ["2001-02-01||+1y", true, baseDate.AddYears(1)], + ["2001-02-01||-1d", false, baseDate.AddDays(-1)], + ["2001-02-01||-1d", true, baseDate.AddDays(-1)], + + // Complex example from Elasticsearch docs + ["2001.02.01||+1M/d", false, baseDate.AddMonths(1).StartOfDay()], + ["2001.02.01||+1M/d", true, baseDate.AddMonths(1).EndOfDay()], + + // User's specific test case - UTC date with operations and rounding + ["2025-01-01T01:25:35Z||+3d/d", false, new DateTimeOffset(2025, 1, 4, 0, 0, 0, TimeSpan.Zero)], + ["2025-01-01T01:25:35Z||+3d/d", true, new DateTimeOffset(2025, 1, 4, 23, 59, 59, 999, TimeSpan.Zero)], + + // Timezone variations - should preserve the timezone + ["2023-06-15T14:30:00Z||", false, new DateTimeOffset(2023, 6, 15, 14, 30, 0, TimeSpan.Zero)], + ["2023-06-15T14:30:00Z||", true, new DateTimeOffset(2023, 6, 15, 14, 30, 0, TimeSpan.Zero)], + ["2023-06-15T14:30:00+05:00||", false, new DateTimeOffset(2023, 6, 15, 14, 30, 0, TimeSpan.FromHours(5))], + ["2023-06-15T14:30:00+05:00||", true, new DateTimeOffset(2023, 6, 15, 14, 30, 0, TimeSpan.FromHours(5))], + ["2023-06-15T14:30:00-08:00||", false, new DateTimeOffset(2023, 6, 15, 14, 30, 0, TimeSpan.FromHours(-8))], + ["2023-06-15T14:30:00-08:00||", true, new DateTimeOffset(2023, 6, 15, 14, 30, 0, TimeSpan.FromHours(-8))], + + // Dates without timezone - should use current timezone + ["2023-06-15T14:30:00||", false, new DateTimeOffset(2023, 6, 15, 14, 30, 0, _now.Offset)], + ["2023-06-15T14:30:00||", true, new DateTimeOffset(2023, 6, 15, 14, 30, 0, _now.Offset)], + + // Milliseconds support with and without timezone + ["2023-01-01T12:00:00.123||", false, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, _now.Offset)], + ["2023-01-01T12:00:00.123||", true, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, _now.Offset)], + ["2023-01-01T12:00:00.123Z||", false, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, TimeSpan.Zero)], + ["2023-01-01T12:00:00.123Z||", true, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, TimeSpan.Zero)], + ["2023-01-01T12:00:00.123+02:00||", false, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, TimeSpan.FromHours(2))], + ["2023-01-01T12:00:00.123+02:00||", true, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, TimeSpan.FromHours(2))], + + // Different date separators + ["2023.06.15||", false, new DateTimeOffset(2023, 6, 15, 0, 0, 0, _now.Offset)], + ["2023.06.15||", true, new DateTimeOffset(2023, 6, 15, 0, 0, 0, _now.Offset)], + ["2023.06.15T10:30||", false, new DateTimeOffset(2023, 6, 15, 10, 30, 0, _now.Offset)], + ["2023.06.15T10:30||", true, new DateTimeOffset(2023, 6, 15, 10, 30, 0, _now.Offset)], + + // Basic format variations + ["20230615||", false, new DateTimeOffset(2023, 6, 15, 0, 0, 0, _now.Offset)], + ["20230615||", true, new DateTimeOffset(2023, 6, 15, 0, 0, 0, _now.Offset)], + ["20230615T143000||", false, new DateTimeOffset(2023, 6, 15, 14, 30, 0, _now.Offset)], + ["20230615T143000||", true, new DateTimeOffset(2023, 6, 15, 14, 30, 0, _now.Offset)] + }; + } + } + + public static IEnumerable ComplexOperationInputs + { + get + { + return new[] { + // Multiple operations + new object[] { "now+1d+1h", false, _now.AddDays(1).AddHours(1) }, + ["now+1d+1h", true, _now.AddDays(1).AddHours(1)], + ["now-1d-1h", false, _now.AddDays(-1).AddHours(-1)], + ["now-1d-1h", true, _now.AddDays(-1).AddHours(-1)], + ["now+1M-1d", false, _now.AddMonths(1).AddDays(-1)], + ["now+1M-1d", true, _now.AddMonths(1).AddDays(-1)], + ["now+1y+6M+15d", false, _now.AddYears(1).AddMonths(6).AddDays(15)], + ["now+1y+6M+15d", true, _now.AddYears(1).AddMonths(6).AddDays(15)], + + // Mixed units and operations + ["now+2h+30m+45s", false, _now.AddHours(2).AddMinutes(30).AddSeconds(45)], + ["now+2h+30m+45s", true, _now.AddHours(2).AddMinutes(30).AddSeconds(45)], + ["now-1w+2d", false, _now.AddDays(-7).AddDays(2)], + ["now-1w+2d", true, _now.AddDays(-7).AddDays(2)], + + // Operations with explicit dates and timezones + ["2025-01-01T01:25:35||+3d/d", false, new DateTimeOffset(2025, 1, 4, 0, 0, 0, _now.Offset)], + ["2025-01-01T01:25:35||+3d/d", true, new DateTimeOffset(2025, 1, 4, 23, 59, 59, 999, _now.Offset)], + + // Complex operations with UTC timezone + ["2025-01-01T01:25:35Z||+3d/d", false, new DateTimeOffset(2025, 1, 4, 0, 0, 0, TimeSpan.Zero)], + ["2025-01-01T01:25:35Z||+3d/d", true, new DateTimeOffset(2025, 1, 4, 23, 59, 59, 999, TimeSpan.Zero)], + ["2023-12-31T23:59:59Z||+1d+1h", false, new DateTimeOffset(2024, 1, 2, 0, 59, 59, TimeSpan.Zero)], + ["2023-12-31T23:59:59Z||+1d+1h", true, new DateTimeOffset(2024, 1, 2, 0, 59, 59, TimeSpan.Zero)], + + // Complex operations with timezone offsets + ["2023-06-15T10:00:00+05:00||+1d-2h", false, new DateTimeOffset(2023, 6, 16, 8, 0, 0, TimeSpan.FromHours(5))], + ["2023-06-15T10:00:00+05:00||+1d-2h", true, new DateTimeOffset(2023, 6, 16, 8, 0, 0, TimeSpan.FromHours(5))] + }; + } + } + + public static IEnumerable RoundingInputs + { + get + { + return new[] { + // Rounding to different units + new object[] { "now/d", false, _now.StartOfDay() }, + ["now/d", true, _now.EndOfDay()], + ["now/h", false, _now.StartOfHour()], + ["now/h", true, _now.EndOfHour()], + ["now/H", false, _now.StartOfHour()], + ["now/H", true, _now.EndOfHour()], + ["now/m", false, _now.StartOfMinute()], + ["now/m", true, _now.EndOfMinute()], + ["now/s", false, _now.StartOfSecond()], + ["now/s", true, _now.EndOfSecond()], + ["now/w", false, _now.StartOfWeek()], + ["now/w", true, _now.EndOfWeek()], + ["now/M", false, _now.StartOfMonth()], + ["now/M", true, _now.EndOfMonth()], + ["now/y", false, _now.StartOfYear()], + ["now/y", true, _now.EndOfYear()], + + // Combined operations with rounding (Elasticsearch example) + ["now-1h/d", false, _now.AddHours(-1).StartOfDay()], + ["now-1h/d", true, _now.AddHours(-1).EndOfDay()], + ["now+1d/h", false, _now.AddDays(1).StartOfHour()], + ["now+1d/h", true, _now.AddDays(1).EndOfHour()], + + // Multiple operations ending with rounding + ["now+1M-1d/d", false, _now.AddMonths(1).AddDays(-1).StartOfDay()], + ["now+1M-1d/d", true, _now.AddMonths(1).AddDays(-1).EndOfDay()] + }; + } + } + + public static IEnumerable EdgeCaseInputs + { + get + { + return new[] { + // Case sensitivity + new object[] { "NOW", false, _now }, + ["NOW", true, _now], + ["Now", false, _now], + ["Now", true, _now], + + // Zero amounts (edge case, should work) + ["now+0d", false, _now], + ["now+0d", true, _now], + ["now-0h", false, _now], + ["now-0h", true, _now], + + // Large amounts (but not overflow) + ["now+100y", false, _now.AddYears(100)], + ["now+100y", true, _now.AddYears(100)], + + // Overflow case - should return original date + ["now+9999y", false, _now], // AddYears would overflow, should return original + ["now+9999y", true, _now], + + // Mixed case units + ["now+1D", false, null], // Should fail - lowercase d required + ["now+1D", true, null], + ["now+1HOUR", false, null], // Should fail - single char required + ["now+1HOUR", true, null] + }; + } + } + + public static IEnumerable InvalidInputs + { + get + { + return new[] { + // Invalid formats + new object[] { "invalid", false, null }, + ["invalid", true, null], + ["blah", false, null], + ["blah blah", true, null], + ["", false, null], + ["", true, null], + + // Invalid date formats + ["2001-13-01||", false, null], // Invalid month + ["2001-13-01||", true, null], + ["2001-02-30||", false, null], // Invalid day for February + ["2001-02-30||", true, null], + ["invalid-date||", false, null], + ["invalid-date||", true, null], + + // Invalid operations + ["now+", false, null], // Missing unit + ["now+", true, null], + ["now+1", false, null], // Missing unit + ["now+1", true, null], + ["now+1x", false, null], // Invalid unit + ["now+1x", true, null], + ["now++1d", false, null], // Double operator + ["now++1d", true, null], + ["now+1d+", false, null], // Trailing operator + ["now+1d+", true, null], + + // Invalid anchor + ["yesterday+1d", false, null], // Invalid anchor + ["yesterday+1d", true, null], + ["2001||+1d", false, null], // Incomplete date + ["2001||+1d", true, null], + + // Invalid timezone formats + // Invalid date components + ["2023-00-01||", false, null], // Invalid month + ["2023-00-01||", true, null], + ["2023-01-32||", false, null], // Invalid day + ["2023-01-32||", true, null], + ["2023-02-29||", false, null], // Invalid day for non-leap year + ["2023-02-29||", true, null] + }; + } + } +} From 6c4049dba311d21c3739f46ed7ae66a80d898347 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 21 Sep 2025 21:42:24 -0500 Subject: [PATCH 07/13] pr feedbacj --- .../FormatParsers/PartParsers/WildcardPartParser.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs index d21053a..78a434d 100644 --- a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs @@ -6,9 +6,9 @@ namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers; [Priority(1)] public class WildcardPartParser : IPartParser { - private static readonly Regex _wildCardRegex = new(@"\G\s*\*(?=\s|\]|\}|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex _wildcardRegex = new(@"\G\s*\*(?=\s|\]|\}|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - public Regex Regex => _wildCardRegex; + public Regex Regex => _wildcardRegex; public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit) { From db06e1670a8f2ca7f1dfd9e1e3c932e3e44c0720 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 21 Sep 2025 22:18:19 -0500 Subject: [PATCH 08/13] Improves date math expression parsing. Refactors the DateMathPartParser to enhance performance and accuracy in parsing Elasticsearch date math expressions. - Optimizes explicit date parsing by using length-based format selection and pre-compiled regex. - Improves timezone handling and format ordering for better parsing. - Adds comprehensive tests to ensure correctness. --- .vscode/settings.json | 1 + .../PartParsers/DateMathPartParser.cs | 184 ++++++++++++------ .../PartParsers/DateMathPartParserTests.cs | 24 +-- .../PartParsers/WildcardPartParserTests.cs | 1 - 4 files changed, 135 insertions(+), 75 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index d5cdf52..ac7149a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "millis", "timespan" ] } \ No newline at end of file diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs index 7fc62e4..f44855c 100644 --- a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs @@ -27,6 +27,9 @@ public class DateMathPartParser : IPartParser @"(?(?:[+\-/]\d*[yMwdhHms])*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + // Pre-compiled regex for operation parsing to avoid repeated compilation + private static readonly Regex _operationRegex = new(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled); + public Regex Regex => _parser; public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit) @@ -54,13 +57,10 @@ public class DateMathPartParser : IPartParser // Parse and apply operations string operations = match.Groups["operations"].Value; - var result = ApplyOperations(baseTime, operations, isUpperLimit); - - return result; + return ApplyOperations(baseTime, operations, isUpperLimit); } catch { - // Return null for any parsing errors to maintain robustness return null; } } @@ -69,6 +69,8 @@ public class DateMathPartParser : IPartParser /// Attempts to parse an explicit date string with proper timezone handling. /// Supports various Elasticsearch-compatible date formats with optional timezone information. /// + /// Performance-optimized with length checks and format ordering by likelihood. + /// /// Timezone Behavior: /// - If timezone is specified (Z, +HH:MM, -HH:MM): Preserved from input /// - If no timezone specified: Uses the provided fallback offset @@ -83,64 +85,133 @@ private static bool TryParseExplicitDate(string dateStr, TimeSpan offset, out Da { result = default; - if (string.IsNullOrEmpty(dateStr)) + if (String.IsNullOrEmpty(dateStr)) return false; - // Try various formats that Elasticsearch supports - string[] formats = { - "yyyy-MM-dd", - "yyyy-MM-ddTHH:mm:ss", - "yyyy-MM-ddTHH:mm", - "yyyy-MM-ddTHH", - "yyyy-MM-ddTHH:mm:ssZ", - "yyyy-MM-ddTHH:mm:ss.fff", - "yyyy-MM-ddTHH:mm:ss.fffZ", - "yyyy-MM-ddTHH:mm:sszzz", - "yyyy-MM-ddTHH:mm:ss.fffzzz", - "yyyy.MM.dd", - "yyyy.MM.ddTHH:mm:ss", - "yyyy.MM.ddTHH:mm", - "yyyy.MM.ddTHH", - "yyyy.MM.ddTHH:mm:ssZ", - "yyyy.MM.ddTHH:mm:ss.fff", - "yyyy.MM.ddTHH:mm:ss.fffZ", - "yyyy.MM.ddTHH:mm:sszzz", - "yyyy.MM.ddTHH:mm:ss.fffzzz", - "yyyyMMdd", - "yyyyMMddTHHmmss", - "yyyyMMddTHHmm", - "yyyyMMddTHH", - "yyyyMMddTHHmmssZ", - "yyyyMMddTHHmmss.fff", - "yyyyMMddTHHmmss.fffZ", - "yyyyMMddTHHmmsszzz", - "yyyyMMddTHHmmss.fffzzz" - }; + int len = dateStr.Length; + + // Early exit for obviously invalid lengths + if (len is < 4 or > 29) // Min: yyyy (4), Max: yyyy-MM-ddTHH:mm:ss.fffzzz (29) + return false; - foreach (string format in formats) + // Fast character validation for year digits + if (!Char.IsDigit(dateStr[0]) || !Char.IsDigit(dateStr[1]) || + !Char.IsDigit(dateStr[2]) || !Char.IsDigit(dateStr[3])) + return false; + + // Detect timezone presence for smart format selection + bool hasZ = dateStr[len - 1] == 'Z'; + bool hasTimezone = hasZ; + if (!hasTimezone && len > 10) // Check for +/-HH:mm timezone format { - // Handle timezone-aware formats differently from timezone-naive formats - if (format.EndsWith("Z") || format.Contains("zzz")) + for (int index = Math.Max(10, len - 6); index < len - 1; index++) { - // Try parsing with timezone information preserved - if (DateTimeOffset.TryParseExact(dateStr, format, CultureInfo.InvariantCulture, - DateTimeStyles.None, out result)) + if (dateStr[index] is '+' or '-' && index + 1 < len && Char.IsDigit(dateStr[index + 1])) { - return true; + hasTimezone = true; + break; } } - else + } + + // Length-based format selection for maximum performance + // Only try formats that match the exact length to avoid unnecessary parsing attempts + switch (len) + { + case 4: // Built-in: year (yyyy) + return TryParseWithFormat(dateStr, "yyyy", offset, false, out result); + + case 7: // Built-in: year_month (yyyy-MM) + if (dateStr[4] == '-') + return TryParseWithFormat(dateStr, "yyyy-MM", offset, false, out result); + break; + + case 8: // Built-in: basic_date (yyyyMMdd) + return TryParseWithFormat(dateStr, "yyyyMMdd", offset, false, out result); + + case 10: // Built-in: date (yyyy-MM-dd) + if (dateStr[4] == '-' && dateStr[7] == '-') + return TryParseWithFormat(dateStr, "yyyy-MM-dd", offset, false, out result); + break; + + case 13: // Built-in: date_hour (yyyy-MM-ddTHH) + if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T') + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH", offset, false, out result); + break; + + case 16: // Built-in: date_hour_minute (yyyy-MM-ddTHH:mm) + if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':') + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm", offset, false, out result); + break; + + case 19: // Built-in: date_hour_minute_second (yyyy-MM-ddTHH:mm:ss) + if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':') + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss", offset, false, out result); + break; + + case 20: // Built-in: date_time_no_millis (yyyy-MM-ddTHH:mm:ssZ) + if (hasZ && dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':') + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ssZ", offset, true, out result); + break; + + case 23: // Built-in: date_hour_minute_second_millis (yyyy-MM-ddTHH:mm:ss.fff) + if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.') + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fff", offset, false, out result); + break; + + case 24: // Built-in: date_time (yyyy-MM-ddTHH:mm:ss.fffZ) + if (hasZ && dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.') + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fffZ", offset, true, out result); + break; + } + + // Handle RFC 822 timezone offset formats (variable lengths: +05:00, +0500, etc.) + // Note: .NET uses 'zzz' pattern for timezone offsets like +05:00 + if (hasTimezone && !hasZ) + { + // Only try timezone formats for lengths that make sense + if (len is >= 25 and <= 29) // +05:00 variants { - // For formats without timezone, parse as DateTime and treat as if already in target timezone - if (DateTime.TryParseExact(dateStr, format, CultureInfo.InvariantCulture, - DateTimeStyles.None, out DateTime dateTime)) + if (dateStr.Contains(".")) // with milliseconds { - // Treat the parsed DateTime as if it's already in the target timezone - // This avoids any conversion issues - result = new DateTimeOffset(dateTime.Ticks, offset); - return true; + // Try: yyyy-MM-ddTHH:mm:ss.fff+05:00 + if (TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fffzzz", offset, true, out result)) + return true; } } + + if (len is >= 22 and <= 25) // without milliseconds + { + // Try: yyyy-MM-ddTHH:mm:ss+05:00 + if (TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:sszzz", offset, true, out result)) + return true; + } + } + + return false; + } + + /// + /// Helper method to parse with a specific format, handling timezone appropriately. + /// + private static bool TryParseWithFormat(string dateStr, string format, TimeSpan offset, bool hasTimezone, out DateTimeOffset result) + { + result = default; + + if (hasTimezone) + { + // Try parsing with timezone information preserved + return DateTimeOffset.TryParseExact(dateStr, format, CultureInfo.InvariantCulture, + DateTimeStyles.None, out result); + } + + // For formats without timezone, parse as DateTime and treat as if already in target timezone + if (DateTime.TryParseExact(dateStr, format, CultureInfo.InvariantCulture, + DateTimeStyles.None, out DateTime dateTime)) + { + // Treat the parsed DateTime as if it's already in the target timezone + result = new DateTimeOffset(dateTime.Ticks, offset); + return true; } return false; @@ -148,15 +219,14 @@ private static bool TryParseExplicitDate(string dateStr, TimeSpan offset, out Da private static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string operations, bool isUpperLimit) { - if (string.IsNullOrEmpty(operations)) + if (String.IsNullOrEmpty(operations)) return baseTime; var result = baseTime; - var operationRegex = new Regex(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled); - var matches = operationRegex.Matches(operations); + var matches = _operationRegex.Matches(operations); // Validate that all operations were matched properly - var totalMatchLength = matches.Cast().Sum(m => m.Length); + int totalMatchLength = matches.Cast().Sum(m => m.Length); if (totalMatchLength != operations.Length) { // If not all operations were matched, there are invalid operations @@ -170,7 +240,7 @@ private static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string op string unit = opMatch.Groups[3].Value; // Default amount is 1 if not specified - int amount = string.IsNullOrEmpty(amountStr) ? 1 : int.Parse(amountStr); + int amount = String.IsNullOrEmpty(amountStr) ? 1 : Int32.Parse(amountStr); switch (operation) { @@ -199,7 +269,7 @@ private static DateTimeOffset AddTimeUnit(DateTimeOffset dateTime, int amount, s "M" => dateTime.AddMonths(amount), // Capital M for months "m" => dateTime.AddMinutes(amount), // Lowercase m for minutes "w" => dateTime.AddDays(amount * 7), - "d" => dateTime.AddDays(amount), + "d" => dateTime.AddDays(amount), // Only lowercase d for days "h" or "H" => dateTime.AddHours(amount), "s" => dateTime.AddSeconds(amount), _ => throw new ArgumentException($"Invalid time unit: {unit}") @@ -219,7 +289,7 @@ private static DateTimeOffset RoundToUnit(DateTimeOffset dateTime, string unit, "y" => isUpperLimit ? dateTime.EndOfYear() : dateTime.StartOfYear(), "M" => isUpperLimit ? dateTime.EndOfMonth() : dateTime.StartOfMonth(), "w" => isUpperLimit ? dateTime.EndOfWeek() : dateTime.StartOfWeek(), - "d" => isUpperLimit ? dateTime.EndOfDay() : dateTime.StartOfDay(), + "d" => isUpperLimit ? dateTime.EndOfDay() : dateTime.StartOfDay(), // Only lowercase d for days "h" or "H" => isUpperLimit ? dateTime.EndOfHour() : dateTime.StartOfHour(), "m" => isUpperLimit ? dateTime.EndOfMinute() : dateTime.StartOfMinute(), "s" => isUpperLimit ? dateTime.EndOfSecond() : dateTime.StartOfSecond(), diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs index 0678738..680d640 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs @@ -117,15 +117,13 @@ public static IEnumerable ExplicitDateInputs var baseDate = new DateTimeOffset(2001, 2, 1, 0, 0, 0, _now.Offset); return new[] { - // Basic explicit date formats + // Basic explicit date formats (officially supported by Elasticsearch) new object[] { "2001-02-01||", false, baseDate }, ["2001-02-01||", true, baseDate], - ["2001.02.01||", false, baseDate], - ["2001.02.01||", true, baseDate], ["20010201||", false, baseDate], ["20010201||", true, baseDate], - // With time components + // With time components (ISO 8601 formats) ["2001-02-01T12:30:45||", false, new DateTimeOffset(2001, 2, 1, 12, 30, 45, _now.Offset)], ["2001-02-01T12:30:45||", true, new DateTimeOffset(2001, 2, 1, 12, 30, 45, _now.Offset)], ["2001-02-01T12:30||", false, new DateTimeOffset(2001, 2, 1, 12, 30, 0, _now.Offset)], @@ -141,9 +139,9 @@ public static IEnumerable ExplicitDateInputs ["2001-02-01||-1d", false, baseDate.AddDays(-1)], ["2001-02-01||-1d", true, baseDate.AddDays(-1)], - // Complex example from Elasticsearch docs - ["2001.02.01||+1M/d", false, baseDate.AddMonths(1).StartOfDay()], - ["2001.02.01||+1M/d", true, baseDate.AddMonths(1).EndOfDay()], + // With operations and rounding (basic_date format + operations) + ["20010201||+1M/d", false, baseDate.AddMonths(1).StartOfDay()], + ["20010201||+1M/d", true, baseDate.AddMonths(1).EndOfDay()], // User's specific test case - UTC date with operations and rounding ["2025-01-01T01:25:35Z||+3d/d", false, new DateTimeOffset(2025, 1, 4, 0, 0, 0, TimeSpan.Zero)], @@ -169,17 +167,9 @@ public static IEnumerable ExplicitDateInputs ["2023-01-01T12:00:00.123+02:00||", false, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, TimeSpan.FromHours(2))], ["2023-01-01T12:00:00.123+02:00||", true, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, TimeSpan.FromHours(2))], - // Different date separators - ["2023.06.15||", false, new DateTimeOffset(2023, 6, 15, 0, 0, 0, _now.Offset)], - ["2023.06.15||", true, new DateTimeOffset(2023, 6, 15, 0, 0, 0, _now.Offset)], - ["2023.06.15T10:30||", false, new DateTimeOffset(2023, 6, 15, 10, 30, 0, _now.Offset)], - ["2023.06.15T10:30||", true, new DateTimeOffset(2023, 6, 15, 10, 30, 0, _now.Offset)], - - // Basic format variations + // Basic format variations (yyyyMMdd is officially supported) ["20230615||", false, new DateTimeOffset(2023, 6, 15, 0, 0, 0, _now.Offset)], - ["20230615||", true, new DateTimeOffset(2023, 6, 15, 0, 0, 0, _now.Offset)], - ["20230615T143000||", false, new DateTimeOffset(2023, 6, 15, 14, 30, 0, _now.Offset)], - ["20230615T143000||", true, new DateTimeOffset(2023, 6, 15, 14, 30, 0, _now.Offset)] + ["20230615||", true, new DateTimeOffset(2023, 6, 15, 0, 0, 0, _now.Offset)] }; } } diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs index ef3bf5e..3a9d21e 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Text.RegularExpressions; using Exceptionless.DateTimeExtensions.FormatParsers.PartParsers; using Microsoft.Extensions.Logging; using Xunit; From 5eddd172f0dc255e0bb63e8796cd76207d973c79 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 21 Sep 2025 22:47:43 -0500 Subject: [PATCH 09/13] Introduces DateMath utility for date math parsing Adds a `DateMath` utility class for parsing Elasticsearch date math expressions, offering standalone date math functionality without range capabilities. This utility supports parsing expressions with `now`, explicit dates, operations, and time units, providing a simpler API for direct parsing operations. Includes comprehensive unit tests for various parsing scenarios, edge cases, and timezone handling. --- README.md | 31 ++ .../DateMath.cs | 407 ++++++++++++++ .../PartParsers/DateMathPartParser.cs | 291 +--------- .../DateMathTests.cs | 508 ++++++++++++++++++ 4 files changed, 956 insertions(+), 281 deletions(-) create mode 100644 src/Exceptionless.DateTimeExtensions/DateMath.cs create mode 100644 tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs diff --git a/README.md b/README.md index 033f273..81a0336 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,37 @@ Examples: - `2025-01-01T01:25:35Z||+3d/d` - January 4th, 2025 (start of day) in UTC - `2023-06-15T14:30:00+05:00||+1M-2d` - One month minus 2 days from the specified date/time in +05:00 timezone +### DateMath Utility + +For applications that need standalone date math parsing without the range functionality, the `DateMath` utility class provides direct access to Elasticsearch date math expression parsing. Check out our [unit tests](https://github.com/exceptionless/Exceptionless.DateTimeExtensions/blob/main/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs) for more usage samples. + +```csharp +using Exceptionless.DateTimeExtensions; + +// Parse date math expressions with standard .NET conventions +var baseTime = DateTimeOffset.Now; + +// Parse method - throws ArgumentException on invalid input +var result = DateMath.Parse("now+1h", baseTime); +var rounded = DateMath.Parse("now-1d/d", baseTime, isUpperLimit: false); // Start of yesterday + +// 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 + Console.WriteLine($"Parsed: {parsed:O}"); +} + +// Upper limit behavior affects rounding +var startOfDay = DateMath.Parse("now/d", baseTime, isUpperLimit: false); // 00:00:00 +var endOfDay = DateMath.Parse("now/d", baseTime, isUpperLimit: true); // 23:59:59.999 + +// Explicit dates with timezone preservation +var utcResult = DateMath.Parse("2025-01-01T01:25:35Z||+3d/d", baseTime); +var offsetResult = DateMath.Parse("2023-06-15T14:30:00+05:00||+1M", baseTime); +``` + +The `DateMath` utility supports the same comprehensive syntax as `DateTimeRange` but provides a simpler API for direct parsing operations. + ### TimeUnit Quickly work with time units. . Check out our [unit tests](https://github.com/exceptionless/Exceptionless.DateTimeExtensions/blob/main/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs) for more usage samples. diff --git a/src/Exceptionless.DateTimeExtensions/DateMath.cs b/src/Exceptionless.DateTimeExtensions/DateMath.cs new file mode 100644 index 0000000..26d4208 --- /dev/null +++ b/src/Exceptionless.DateTimeExtensions/DateMath.cs @@ -0,0 +1,407 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Exceptionless.DateTimeExtensions; + +/// +/// Provides Elasticsearch date math parsing functionality. +/// Supports: now, explicit dates with ||, operations (+, -, /), and time units (y, M, w, d, h, H, m, s). +/// Examples: now+1h, now-1d/d, 2001.02.01||+1M/d, 2025-01-01T01:25:35Z||+3d/d +/// +/// Timezone Handling (following Elasticsearch standards): +/// - Explicit timezone (Z, +05:00, -08:00): Preserved from input +/// - No timezone: Uses current system timezone +/// +/// References: +/// - https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math +/// - https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html#date-math-rounding +/// +public static class DateMath +{ + // Match date math expressions with anchors and operations + internal static readonly Regex Parser = new( + @"^(?now|(?\d{4}[-.]?\d{2}[-.]?\d{2}(?:[T\s](?:\d{1,2}(?::?\d{2}(?::?\d{2})?)?(?:\.\d{1,3})?)?(?:[+-]\d{2}:?\d{2}|Z)?)?)\|\|)" + + @"(?(?:[+\-/]\d*[yMwdhHms])*)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // Pre-compiled regex for operation parsing to avoid repeated compilation + private static readonly Regex _operationRegex = new(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled); + + /// + /// Parses a date math expression and returns the resulting DateTimeOffset. + /// + /// The date math expression to parse + /// The base time to use for relative calculations (e.g., 'now') + /// Whether this is for an upper limit (affects rounding behavior) + /// The parsed DateTimeOffset + /// Thrown when the expression is invalid or cannot be parsed + public static DateTimeOffset Parse(string expression, DateTimeOffset relativeBaseTime, bool isUpperLimit = false) + { + if (!TryParse(expression, relativeBaseTime, isUpperLimit, out DateTimeOffset result)) + throw new ArgumentException($"Invalid date math expression: {expression}", nameof(expression)); + + return result; + } + + /// + /// Tries to parse a date math expression and returns the resulting DateTimeOffset. + /// + /// The date math expression to parse + /// The base time to use for relative calculations (e.g., 'now') + /// Whether this is for an upper limit (affects rounding behavior) + /// The parsed DateTimeOffset if successful + /// True if parsing succeeded, false otherwise + public static bool TryParse(string expression, DateTimeOffset relativeBaseTime, bool isUpperLimit, out DateTimeOffset result) + { + result = default; + + if (String.IsNullOrEmpty(expression)) + return false; + + var match = Parser.Match(expression); + if (!match.Success) + return false; + + return TryParseFromMatch(match, relativeBaseTime, isUpperLimit, out result); + } + + /// + /// Tries to parse a date math expression from a regex match and returns the resulting DateTimeOffset. + /// This method bypasses the regex matching for cases where the match is already available. + /// + /// The regex match containing the parsed expression groups + /// The base time to use for relative calculations (e.g., 'now') + /// Whether this is for an upper limit (affects rounding behavior) + /// The parsed DateTimeOffset if successful + /// True if parsing succeeded, false otherwise + public static bool TryParseFromMatch(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit, out DateTimeOffset result) + { + result = default; + + try + { + // Parse the anchor (now or explicit date) + DateTimeOffset baseTime; + string anchor = match.Groups["anchor"].Value; + + if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase)) + { + baseTime = relativeBaseTime; + } + else + { + // Parse explicit date from the date group + string dateStr = match.Groups["date"].Value; + if (!TryParseExplicitDate(dateStr, relativeBaseTime.Offset, out baseTime)) + return false; + } + + // Parse and apply operations + string operations = match.Groups["operations"].Value; + result = ApplyOperations(baseTime, operations, isUpperLimit); + return true; + } + catch + { + return false; + } + } + + /// + /// Checks if the given expression is a valid date math expression. + /// + /// The expression to validate + /// True if the expression is valid date math, false otherwise + public static bool IsValidExpression(string expression) + { + if (String.IsNullOrEmpty(expression)) + return false; + + return Parser.IsMatch(expression); + } + + /// + /// Attempts to parse an explicit date string with proper timezone handling. + /// Supports various Elasticsearch-compatible date formats with optional timezone information. + /// + /// Performance-optimized with length checks and format ordering by likelihood. + /// + /// Timezone Behavior: + /// - If timezone is specified (Z, +HH:MM, -HH:MM): Preserved from input + /// - If no timezone specified: Uses the provided fallback offset + /// + /// This matches Elasticsearch's behavior where explicit timezone information takes precedence. + /// + /// The date string to parse + /// Fallback timezone offset for dates without explicit timezone + /// The parsed DateTimeOffset with correct timezone + /// True if parsing succeeded, false otherwise + private static bool TryParseExplicitDate(string dateStr, TimeSpan offset, out DateTimeOffset result) + { + result = default; + + if (String.IsNullOrEmpty(dateStr)) + return false; + + int len = dateStr.Length; + + // Early exit for obviously invalid lengths + if (len is < 4 or > 29) // Min: yyyy (4), Max: yyyy-MM-ddTHH:mm:ss.fffzzz (29) + return false; + + // Fast character validation for year digits + if (!Char.IsDigit(dateStr[0]) || !Char.IsDigit(dateStr[1]) || + !Char.IsDigit(dateStr[2]) || !Char.IsDigit(dateStr[3])) + return false; + + // Detect timezone presence for smart format selection + bool hasZ = dateStr[len - 1] == 'Z'; + bool hasTimezone = hasZ; + if (!hasTimezone && len > 10) // Check for +/-HH:mm timezone format + { + for (int index = Math.Max(10, len - 6); index < len - 1; index++) + { + if (dateStr[index] is '+' or '-' && index + 1 < len && Char.IsDigit(dateStr[index + 1])) + { + hasTimezone = true; + break; + } + } + } + + // Length-based format selection for maximum performance + // Only try formats that match the exact length to avoid unnecessary parsing attempts + switch (len) + { + case 4: // Built-in: year (yyyy) + return TryParseWithFormat(dateStr, "yyyy", offset, false, out result); + + case 7: // Built-in: year_month (yyyy-MM or yyyy.MM) + if (dateStr[4] is '-' or '.') + return TryParseWithFormat(dateStr, dateStr[4] == '-' ? "yyyy-MM" : "yyyy.MM", offset, false, out result); + break; + + case 8: // Built-in: basic_date (yyyyMMdd) + return TryParseWithFormat(dateStr, "yyyyMMdd", offset, false, out result); + + case 10: // Built-in: date (yyyy-MM-dd or yyyy.MM.dd) + if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.') + { + string format = dateStr[4] == '-' ? "yyyy-MM-dd" : "yyyy.MM.dd"; + return TryParseWithFormat(dateStr, format, offset, false, out result); + } + break; + + case 13: // Built-in: date_hour (yyyy-MM-ddTHH or yyyy.MM.ddTHH) + if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T') + { + string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH" : "yyyy.MM.ddTHH"; + return TryParseWithFormat(dateStr, format, offset, false, out result); + } + break; + + case 16: // Built-in: date_hour_minute (yyyy-MM-ddTHH:mm or yyyy.MM.ddTHH:mm) + if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':') + { + string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm" : "yyyy.MM.ddTHH:mm"; + return TryParseWithFormat(dateStr, format, offset, false, out result); + } + break; + + case 19: // Built-in: date_hour_minute_second (yyyy-MM-ddTHH:mm:ss or yyyy.MM.ddTHH:mm:ss) + if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':') + { + string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm:ss" : "yyyy.MM.ddTHH:mm:ss"; + return TryParseWithFormat(dateStr, format, offset, false, out result); + } + break; + + case 20: // Built-in: date_time_no_millis (yyyy-MM-ddTHH:mm:ssZ or yyyy.MM.ddTHH:mm:ssZ) + if (hasZ && dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':') + { + string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm:ssZ" : "yyyy.MM.ddTHH:mm:ssZ"; + return TryParseWithFormat(dateStr, format, offset, true, out result); + } + break; + + case 23: // Built-in: date_hour_minute_second_millis (yyyy-MM-ddTHH:mm:ss.fff or yyyy.MM.ddTHH:mm:ss.fff) + if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.') + { + string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm:ss.fff" : "yyyy.MM.ddTHH:mm:ss.fff"; + return TryParseWithFormat(dateStr, format, offset, false, out result); + } + break; + + case 24: // Built-in: date_time (yyyy-MM-ddTHH:mm:ss.fffZ or yyyy.MM.ddTHH:mm:ss.fffZ) + if (hasZ && dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.') + { + string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm:ss.fffZ" : "yyyy.MM.ddTHH:mm:ss.fffZ"; + return TryParseWithFormat(dateStr, format, offset, true, out result); + } + break; + } + + // Handle RFC 822 timezone offset formats (variable lengths: +05:00, +0500, etc.) + // Note: .NET uses 'zzz' pattern for timezone offsets like +05:00 + if (hasTimezone && !hasZ) + { + // Determine the date separator for format construction + char dateSeparator = (len > 4 && dateStr[4] == '.') ? '.' : '-'; + + // Only try timezone formats for lengths that make sense + if (len is >= 25 and <= 29) // +05:00 variants + { + if (dateStr.Contains(".")) // with milliseconds + { + // Try both separators: yyyy-MM-ddTHH:mm:ss.fff+05:00 or yyyy.MM.ddTHH:mm:ss.fff+05:00 + string format = dateSeparator == '.' + ? "yyyy.MM.ddTHH:mm:ss.fffzzz" + : "yyyy-MM-ddTHH:mm:ss.fffzzz"; + if (TryParseWithFormat(dateStr, format, offset, true, out result)) + return true; + } + } + + if (len is >= 22 and <= 25) // without milliseconds + { + // Try both separators: yyyy-MM-ddTHH:mm:ss+05:00 or yyyy.MM.ddTHH:mm:ss+05:00 + string format = dateSeparator == '.' + ? "yyyy.MM.ddTHH:mm:sszzz" + : "yyyy-MM-ddTHH:mm:sszzz"; + if (TryParseWithFormat(dateStr, format, offset, true, out result)) + return true; + } + } + + return false; + } + + /// + /// Helper method to parse with a specific format, handling timezone appropriately. + /// + private static bool TryParseWithFormat(string dateStr, string format, TimeSpan offset, bool hasTimezone, out DateTimeOffset result) + { + result = default; + + if (hasTimezone) + { + // Try parsing with timezone information preserved + return DateTimeOffset.TryParseExact(dateStr, format, CultureInfo.InvariantCulture, + DateTimeStyles.None, out result); + } + + // For formats without timezone, parse as DateTime and treat as if already in target timezone + if (DateTime.TryParseExact(dateStr, format, CultureInfo.InvariantCulture, + DateTimeStyles.None, out DateTime dateTime)) + { + // Treat the parsed DateTime as if it's already in the target timezone + result = new DateTimeOffset(dateTime.Ticks, offset); + return true; + } + + return false; + } + + /// + /// Applies date math operations to a base time. + /// + /// The base time to apply operations to + /// The operations string (e.g., "+1d-2h/d") + /// Whether this is for an upper limit (affects rounding) + /// The result after applying all operations + public static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string operations, bool isUpperLimit = false) + { + if (String.IsNullOrEmpty(operations)) + return baseTime; + + var result = baseTime; + var matches = _operationRegex.Matches(operations); + + // Validate that all operations were matched properly + int totalMatchLength = matches.Cast().Sum(m => m.Length); + if (totalMatchLength != operations.Length) + { + // If not all operations were matched, there are invalid operations + throw new ArgumentException("Invalid operations"); + } + + foreach (Match opMatch in matches) + { + string operation = opMatch.Groups[1].Value; + string amountStr = opMatch.Groups[2].Value; + string unit = opMatch.Groups[3].Value; + + // Default amount is 1 if not specified + int amount = String.IsNullOrEmpty(amountStr) ? 1 : Int32.Parse(amountStr); + + switch (operation) + { + case "+": + result = AddTimeUnit(result, amount, unit); + break; + case "-": + result = AddTimeUnit(result, -amount, unit); + break; + case "/": + result = RoundToUnit(result, unit, isUpperLimit); + break; + } + } + + return result; + } + + /// + /// Adds a time unit to a DateTimeOffset. + /// + /// The base date time + /// The amount to add + /// The time unit (y, M, w, d, h, H, m, s) + /// The result after adding the time unit + public static DateTimeOffset AddTimeUnit(DateTimeOffset dateTime, int amount, string unit) + { + try + { + return unit switch + { + "y" => dateTime.AddYears(amount), + "M" => dateTime.AddMonths(amount), // Capital M for months + "m" => dateTime.AddMinutes(amount), // Lowercase m for minutes + "w" => dateTime.AddDays(amount * 7), + "d" => dateTime.AddDays(amount), // Only lowercase d for days + "h" or "H" => dateTime.AddHours(amount), + "s" => dateTime.AddSeconds(amount), + _ => throw new ArgumentException($"Invalid time unit: {unit}") + }; + } + catch (ArgumentOutOfRangeException) + { + // Return original date if operation would overflow + return dateTime; + } + } + + /// + /// Rounds a DateTimeOffset to a time unit. + /// + /// The date time to round + /// The time unit to round to (y, M, w, d, h, H, m, s) + /// Whether to round up (end of period) or down (start of period) + /// The rounded DateTimeOffset + public static DateTimeOffset RoundToUnit(DateTimeOffset dateTime, string unit, bool isUpperLimit = false) + { + return unit switch + { + "y" => isUpperLimit ? dateTime.EndOfYear() : dateTime.StartOfYear(), + "M" => isUpperLimit ? dateTime.EndOfMonth() : dateTime.StartOfMonth(), + "w" => isUpperLimit ? dateTime.EndOfWeek() : dateTime.StartOfWeek(), + "d" => isUpperLimit ? dateTime.EndOfDay() : dateTime.StartOfDay(), // Only lowercase d for days + "h" or "H" => isUpperLimit ? dateTime.EndOfHour() : dateTime.StartOfHour(), + "m" => isUpperLimit ? dateTime.EndOfMinute() : dateTime.StartOfMinute(), + "s" => isUpperLimit ? dateTime.EndOfSecond() : dateTime.StartOfSecond(), + _ => throw new ArgumentException($"Invalid time unit for rounding: {unit}") + }; + } +} diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs index f44855c..7840db4 100644 --- a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs @@ -1,299 +1,28 @@ using System; -using System.Globalization; -using System.Linq; using System.Text.RegularExpressions; namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers; /// -/// Parses Elasticsearch date math expressions with proper timezone support. +/// Parses Elasticsearch date math expressions using the DateMath utility class. +/// This is a thin wrapper that adapts the DateMath utility for use within the format parser framework. +/// /// Supports: now, explicit dates with ||, operations (+, -, /), and time units (y, M, w, d, h, H, m, s). /// Examples: now+1h, now-1d/d, 2001.02.01||+1M/d, 2025-01-01T01:25:35Z||+3d/d /// -/// Timezone Handling (following Elasticsearch standards): -/// - Explicit timezone (Z, +05:00, -08:00): Preserved from input -/// - No timezone: Uses current system timezone -/// -/// References: -/// - https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math -/// - https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html#date-math-rounding +/// For more details about date math functionality, see . /// [Priority(35)] public class DateMathPartParser : IPartParser { - // Match date math expressions with anchors and operations - private static readonly Regex _parser = new( - @"^(?now|(?\d{4}[-.]?\d{2}[-.]?\d{2}(?:[T\s](?:\d{1,2}(?::?\d{2}(?::?\d{2})?)?(?:\.\d{1,3})?)?(?:[+-]\d{2}:?\d{2}|Z)?)?)\|\|)" + - @"(?(?:[+\-/]\d*[yMwdhHms])*)$", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - - // Pre-compiled regex for operation parsing to avoid repeated compilation - private static readonly Regex _operationRegex = new(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled); - - public Regex Regex => _parser; + public Regex Regex => DateMath.Parser; public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit) { - if (!match.Success) - return null; - - try - { - // Parse the anchor (now or explicit date) - DateTimeOffset baseTime; - string anchor = match.Groups["anchor"].Value; - - if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase)) - { - baseTime = relativeBaseTime; - } - else - { - // Parse explicit date from the date group - string dateStr = match.Groups["date"].Value; - if (!TryParseExplicitDate(dateStr, relativeBaseTime.Offset, out baseTime)) - return null; - } - - // Parse and apply operations - string operations = match.Groups["operations"].Value; - return ApplyOperations(baseTime, operations, isUpperLimit); - } - catch - { - return null; - } - } - - /// - /// Attempts to parse an explicit date string with proper timezone handling. - /// Supports various Elasticsearch-compatible date formats with optional timezone information. - /// - /// Performance-optimized with length checks and format ordering by likelihood. - /// - /// Timezone Behavior: - /// - If timezone is specified (Z, +HH:MM, -HH:MM): Preserved from input - /// - If no timezone specified: Uses the provided fallback offset - /// - /// This matches Elasticsearch's behavior where explicit timezone information takes precedence. - /// - /// The date string to parse - /// Fallback timezone offset for dates without explicit timezone - /// The parsed DateTimeOffset with correct timezone - /// True if parsing succeeded, false otherwise - private static bool TryParseExplicitDate(string dateStr, TimeSpan offset, out DateTimeOffset result) - { - result = default; - - if (String.IsNullOrEmpty(dateStr)) - return false; - - int len = dateStr.Length; - - // Early exit for obviously invalid lengths - if (len is < 4 or > 29) // Min: yyyy (4), Max: yyyy-MM-ddTHH:mm:ss.fffzzz (29) - return false; - - // Fast character validation for year digits - if (!Char.IsDigit(dateStr[0]) || !Char.IsDigit(dateStr[1]) || - !Char.IsDigit(dateStr[2]) || !Char.IsDigit(dateStr[3])) - return false; - - // Detect timezone presence for smart format selection - bool hasZ = dateStr[len - 1] == 'Z'; - bool hasTimezone = hasZ; - if (!hasTimezone && len > 10) // Check for +/-HH:mm timezone format - { - for (int index = Math.Max(10, len - 6); index < len - 1; index++) - { - if (dateStr[index] is '+' or '-' && index + 1 < len && Char.IsDigit(dateStr[index + 1])) - { - hasTimezone = true; - break; - } - } - } - - // Length-based format selection for maximum performance - // Only try formats that match the exact length to avoid unnecessary parsing attempts - switch (len) - { - case 4: // Built-in: year (yyyy) - return TryParseWithFormat(dateStr, "yyyy", offset, false, out result); - - case 7: // Built-in: year_month (yyyy-MM) - if (dateStr[4] == '-') - return TryParseWithFormat(dateStr, "yyyy-MM", offset, false, out result); - break; - - case 8: // Built-in: basic_date (yyyyMMdd) - return TryParseWithFormat(dateStr, "yyyyMMdd", offset, false, out result); - - case 10: // Built-in: date (yyyy-MM-dd) - if (dateStr[4] == '-' && dateStr[7] == '-') - return TryParseWithFormat(dateStr, "yyyy-MM-dd", offset, false, out result); - break; - - case 13: // Built-in: date_hour (yyyy-MM-ddTHH) - if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T') - return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH", offset, false, out result); - break; - - case 16: // Built-in: date_hour_minute (yyyy-MM-ddTHH:mm) - if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':') - return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm", offset, false, out result); - break; - - case 19: // Built-in: date_hour_minute_second (yyyy-MM-ddTHH:mm:ss) - if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':') - return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss", offset, false, out result); - break; - - case 20: // Built-in: date_time_no_millis (yyyy-MM-ddTHH:mm:ssZ) - if (hasZ && dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':') - return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ssZ", offset, true, out result); - break; - - case 23: // Built-in: date_hour_minute_second_millis (yyyy-MM-ddTHH:mm:ss.fff) - if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.') - return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fff", offset, false, out result); - break; - - case 24: // Built-in: date_time (yyyy-MM-ddTHH:mm:ss.fffZ) - if (hasZ && dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.') - return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fffZ", offset, true, out result); - break; - } - - // Handle RFC 822 timezone offset formats (variable lengths: +05:00, +0500, etc.) - // Note: .NET uses 'zzz' pattern for timezone offsets like +05:00 - if (hasTimezone && !hasZ) - { - // Only try timezone formats for lengths that make sense - if (len is >= 25 and <= 29) // +05:00 variants - { - if (dateStr.Contains(".")) // with milliseconds - { - // Try: yyyy-MM-ddTHH:mm:ss.fff+05:00 - if (TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fffzzz", offset, true, out result)) - return true; - } - } - - if (len is >= 22 and <= 25) // without milliseconds - { - // Try: yyyy-MM-ddTHH:mm:ss+05:00 - if (TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:sszzz", offset, true, out result)) - return true; - } - } - - return false; - } - - /// - /// Helper method to parse with a specific format, handling timezone appropriately. - /// - private static bool TryParseWithFormat(string dateStr, string format, TimeSpan offset, bool hasTimezone, out DateTimeOffset result) - { - result = default; - - if (hasTimezone) - { - // Try parsing with timezone information preserved - return DateTimeOffset.TryParseExact(dateStr, format, CultureInfo.InvariantCulture, - DateTimeStyles.None, out result); - } - - // For formats without timezone, parse as DateTime and treat as if already in target timezone - if (DateTime.TryParseExact(dateStr, format, CultureInfo.InvariantCulture, - DateTimeStyles.None, out DateTime dateTime)) - { - // Treat the parsed DateTime as if it's already in the target timezone - result = new DateTimeOffset(dateTime.Ticks, offset); - return true; - } - - return false; - } - - private static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string operations, bool isUpperLimit) - { - if (String.IsNullOrEmpty(operations)) - return baseTime; - - var result = baseTime; - var matches = _operationRegex.Matches(operations); - - // Validate that all operations were matched properly - int totalMatchLength = matches.Cast().Sum(m => m.Length); - if (totalMatchLength != operations.Length) - { - // If not all operations were matched, there are invalid operations - throw new ArgumentException("Invalid operations"); - } - - foreach (Match opMatch in matches) - { - string operation = opMatch.Groups[1].Value; - string amountStr = opMatch.Groups[2].Value; - string unit = opMatch.Groups[3].Value; - - // Default amount is 1 if not specified - int amount = String.IsNullOrEmpty(amountStr) ? 1 : Int32.Parse(amountStr); - - switch (operation) - { - case "+": - result = AddTimeUnit(result, amount, unit); - break; - case "-": - result = AddTimeUnit(result, -amount, unit); - break; - case "/": - result = RoundToUnit(result, unit, isUpperLimit); - break; - } - } - - return result; - } - - private static DateTimeOffset AddTimeUnit(DateTimeOffset dateTime, int amount, string unit) - { - try - { - return unit switch - { - "y" => dateTime.AddYears(amount), - "M" => dateTime.AddMonths(amount), // Capital M for months - "m" => dateTime.AddMinutes(amount), // Lowercase m for minutes - "w" => dateTime.AddDays(amount * 7), - "d" => dateTime.AddDays(amount), // Only lowercase d for days - "h" or "H" => dateTime.AddHours(amount), - "s" => dateTime.AddSeconds(amount), - _ => throw new ArgumentException($"Invalid time unit: {unit}") - }; - } - catch (ArgumentOutOfRangeException) - { - // Return original date if operation would overflow - return dateTime; - } - } - - private static DateTimeOffset RoundToUnit(DateTimeOffset dateTime, string unit, bool isUpperLimit) - { - return unit switch - { - "y" => isUpperLimit ? dateTime.EndOfYear() : dateTime.StartOfYear(), - "M" => isUpperLimit ? dateTime.EndOfMonth() : dateTime.StartOfMonth(), - "w" => isUpperLimit ? dateTime.EndOfWeek() : dateTime.StartOfWeek(), - "d" => isUpperLimit ? dateTime.EndOfDay() : dateTime.StartOfDay(), // Only lowercase d for days - "h" or "H" => isUpperLimit ? dateTime.EndOfHour() : dateTime.StartOfHour(), - "m" => isUpperLimit ? dateTime.EndOfMinute() : dateTime.StartOfMinute(), - "s" => isUpperLimit ? dateTime.EndOfSecond() : dateTime.StartOfSecond(), - _ => throw new ArgumentException($"Invalid time unit for rounding: {unit}") - }; + // Since we already have a successful regex match, use the efficient TryParseFromMatch method + // to avoid redundant regex matching and validation + return DateMath.TryParseFromMatch(match, relativeBaseTime, isUpperLimit, out DateTimeOffset result) + ? result + : null; } } diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs new file mode 100644 index 0000000..93227df --- /dev/null +++ b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs @@ -0,0 +1,508 @@ +using System; +using Foundatio.Xunit; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Exceptionless.DateTimeExtensions.Tests; + +/// +/// Comprehensive tests for the DateMath utility class, covering all parsing scenarios, +/// edge cases, timezone handling, and error conditions. +/// +public class DateMathTests : TestWithLoggingBase +{ + private readonly DateTimeOffset _baseTime = new(2023, 6, 15, 14, 30, 45, 123, TimeSpan.FromHours(5)); + + public DateMathTests(ITestOutputHelper output) : base(output) + { + } + + [Theory] + [InlineData("now", false)] + [InlineData("now", true)] + public void Parse_Now_ReturnsBaseTime(string expression, bool isUpperLimit) + { + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, IsUpperLimit: {IsUpperLimit}", + expression, _baseTime, isUpperLimit); + + var result = DateMath.Parse(expression, _baseTime, isUpperLimit); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(_baseTime, result); + } + + [Theory] + [InlineData("now+1h", 1)] + [InlineData("now+2h", 2)] + [InlineData("now+24h", 24)] + [InlineData("now+1H", 1)] // Case insensitive + [InlineData("now-1h", -1)] + [InlineData("now-12h", -12)] + public void Parse_HourOperations_ReturnsCorrectResult(string expression, int hours) + { + var expected = _baseTime.AddHours(hours); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now+1d", 1)] + [InlineData("now+7d", 7)] + [InlineData("now-1d", -1)] + [InlineData("now-30d", -30)] + public void Parse_DayOperations_ReturnsCorrectResult(string expression, int days) + { + var expected = _baseTime.AddDays(days); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now+1M", 1)] + [InlineData("now+6M", 6)] + [InlineData("now-1M", -1)] + [InlineData("now-12M", -12)] + public void Parse_MonthOperations_ReturnsCorrectResult(string expression, int months) + { + var expected = _baseTime.AddMonths(months); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now+1y", 1)] + [InlineData("now+5y", 5)] + [InlineData("now-1y", -1)] + [InlineData("now-10y", -10)] + public void Parse_YearOperations_ReturnsCorrectResult(string expression, int years) + { + var expected = _baseTime.AddYears(years); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now+1w", 7)] + [InlineData("now+2w", 14)] + [InlineData("now-1w", -7)] + [InlineData("now-4w", -28)] + public void Parse_WeekOperations_ReturnsCorrectResult(string expression, int days) + { + var expected = _baseTime.AddDays(days); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now+1m", 1)] + [InlineData("now+30m", 30)] + [InlineData("now-1m", -1)] + [InlineData("now-60m", -60)] + public void Parse_MinuteOperations_ReturnsCorrectResult(string expression, int minutes) + { + var expected = _baseTime.AddMinutes(minutes); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now+1s", 1)] + [InlineData("now+30s", 30)] + [InlineData("now-1s", -1)] + [InlineData("now-3600s", -3600)] + public void Parse_SecondOperations_ReturnsCorrectResult(string expression, int seconds) + { + var expected = _baseTime.AddSeconds(seconds); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now/d", false)] // Start of day + [InlineData("now/d", true)] // End of day + [InlineData("now/h", false)] // Start of hour + [InlineData("now/h", true)] // End of hour + [InlineData("now/m", false)] // Start of minute + [InlineData("now/m", true)] // End of minute + public void Parse_RoundingOperations_ReturnsCorrectResult(string expression, bool isUpperLimit) + { + _logger.LogDebug("Testing Parse with rounding expression: '{Expression}', BaseTime: {BaseTime}, IsUpperLimit: {IsUpperLimit}", + expression, _baseTime, isUpperLimit); + + var result = DateMath.Parse(expression, _baseTime, isUpperLimit); + + _logger.LogDebug("Parse result: {Result}", result); + + if (expression.EndsWith("/d")) + { + if (isUpperLimit) + { + // End of day: 23:59:59.999 + var expectedEnd = new DateTimeOffset(_baseTime.Year, _baseTime.Month, _baseTime.Day, 23, 59, 59, 999, _baseTime.Offset); + Assert.Equal(expectedEnd, result); + } + else + { + // Start of day: 00:00:00.000 + var expectedStart = new DateTimeOffset(_baseTime.Year, _baseTime.Month, _baseTime.Day, 0, 0, 0, 0, _baseTime.Offset); + Assert.Equal(expectedStart, result); + } + } + else if (expression.EndsWith("/h")) + { + var hourStart = new DateTimeOffset(_baseTime.Year, _baseTime.Month, _baseTime.Day, + _baseTime.Hour, 0, 0, 0, _baseTime.Offset); + + Assert.Equal(isUpperLimit ? hourStart.AddHours(1).AddMilliseconds(-1) : hourStart, result); + } + else if (expression.EndsWith("/m")) + { + var minuteStart = new DateTimeOffset(_baseTime.Year, _baseTime.Month, _baseTime.Day, + _baseTime.Hour, _baseTime.Minute, 0, 0, _baseTime.Offset); + + Assert.Equal(isUpperLimit ? minuteStart.AddMinutes(1).AddMilliseconds(-1) : minuteStart, result); + } + } + + [Theory] + [InlineData("now+1d+2h")] + [InlineData("now-1d+12h")] + [InlineData("now+1M+1d")] + [InlineData("now+1y-1M")] + public void Parse_MultipleOperations_ReturnsCorrectResult(string expression) + { + _logger.LogDebug("Testing Parse with multiple operations: '{Expression}', BaseTime: {BaseTime}", + expression, _baseTime); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + + // For simple combinations, we can verify exact results + if (expression == "now+1d+2h") + { + var expected = _baseTime.AddDays(1).AddHours(2); + Assert.Equal(expected, result); + } + else if (expression == "now-1d+12h") + { + var expected = _baseTime.AddDays(-1).AddHours(12); + Assert.Equal(expected, result); + } + else + { + // For month/year operations, just verify the result is reasonable + Assert.NotEqual(_baseTime, result); + } + } + + [Theory] + [InlineData("2023.06.15||")] + [InlineData("2023-06-15||")] + [InlineData("2023-06-15T10:30:00||")] + [InlineData("2023-06-15T10:30:00.123||")] + public void Parse_ExplicitDateFormats_ReturnsCorrectResult(string expression) + { + _logger.LogDebug("Testing Parse with explicit date format: '{Expression}', BaseTime: {BaseTime}", + expression, _baseTime); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + + // All should resolve to June 15, 2023 in the base time's timezone + Assert.Equal(2023, result.Year); + Assert.Equal(6, result.Month); + Assert.Equal(15, result.Day); + Assert.Equal(_baseTime.Offset, result.Offset); + } + + [Theory] + [InlineData("2023-06-15T10:30:00Z||", 0)] + [InlineData("2023-06-15T10:30:00+02:00||", 2)] + [InlineData("2023-06-15T10:30:00-05:00||", -5)] + [InlineData("2023-06-15T10:30:00+09:30||", 9.5)] + public void Parse_ExplicitTimezones_PreservesTimezone(string expression, double offsetHours) + { + _logger.LogDebug("Testing Parse with explicit timezone: '{Expression}', BaseTime: {BaseTime}, Expected offset hours: {OffsetHours}", + expression, _baseTime, offsetHours); + + var result = DateMath.Parse(expression, _baseTime); + var expectedOffset = TimeSpan.FromHours(offsetHours); + + _logger.LogDebug("Parse result: {Result}, Expected offset: {ExpectedOffset}", result, expectedOffset); + + Assert.Equal(2023, result.Year); + Assert.Equal(6, result.Month); + Assert.Equal(15, result.Day); + Assert.Equal(10, result.Hour); + Assert.Equal(30, result.Minute); + Assert.Equal(expectedOffset, result.Offset); + } + + [Theory] + [InlineData("2023.06.15||+1M")] + [InlineData("2023-06-15T10:30:00||+2d")] + [InlineData("2023-06-15T10:30:00Z||+1h")] + [InlineData("2023-06-15T10:30:00+02:00||-1d/d")] + public void Parse_ExplicitDateWithOperations_ReturnsCorrectResult(string expression) + { + _logger.LogDebug("Testing Parse with explicit date and operations: '{Expression}', BaseTime: {BaseTime}", + expression, _baseTime); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + + // Verify it's not the base time and the operation was applied + Assert.NotEqual(_baseTime, result); + + if (expression.Contains("+1M")) + { + Assert.Equal(7, result.Month); // June + 1 month = July + } + else if (expression.Contains("+2d")) + { + Assert.Equal(17, result.Day); // 15 + 2 days = 17 + } + else if (expression.Contains("+1h")) + { + Assert.Equal(11, result.Hour); // 10 + 1 hour = 11 + } + } + + [Theory] + [InlineData("")] + [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 + [InlineData("2023-01-32||")] // Invalid day + public void Parse_InvalidExpressions_ThrowsArgumentException(string expression) + { + _logger.LogDebug("Testing Parse with invalid expression: '{Expression}', expecting ArgumentException", expression); + + var exception = Assert.Throws(() => DateMath.Parse(expression, _baseTime)); + + _logger.LogDebug("Exception thrown as expected: {Message}", exception.Message); + Assert.Contains("Invalid date math expression", exception.Message); + } + + [Fact] + public void Parse_NullExpression_ThrowsArgumentException() + { + _logger.LogDebug("Testing Parse with null expression, expecting ArgumentException"); + + var exception = Assert.Throws(() => DateMath.Parse(null!, _baseTime)); + + _logger.LogDebug("Exception thrown as expected: {Message}", exception.Message); + } + + [Theory] + [InlineData("now")] + [InlineData("now+1h")] + [InlineData("now-1d/d")] + [InlineData("2023.06.15||")] + [InlineData("2023.06.15||+1M/d")] + [InlineData("2025-01-01T01:25:35Z||+3d/d")] + public void TryParse_ValidExpressions_ReturnsTrueAndCorrectResult(string expression) + { + _logger.LogDebug("Testing TryParse with valid 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); + Assert.NotEqual(default, result); + + // Verify TryParse and Parse return the same result + var parseResult = DateMath.Parse(expression, _baseTime, false); + Assert.Equal(parseResult, result); + } + + [Theory] + [InlineData("")] + [InlineData("invalid")] + [InlineData("now+")] + [InlineData("2023-01-01")] // Missing || + [InlineData("||+1d")] // Missing anchor + public void TryParse_InvalidExpressions_ReturnsFalse(string expression) + { + _logger.LogDebug("Testing TryParse with invalid expression: '{Expression}', expecting false", expression); + + bool success = DateMath.TryParse(expression, _baseTime, false, out var result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.False(success); + Assert.Equal(default, result); + } + + [Fact] + public void TryParse_NullExpression_ReturnsFalse() + { + _logger.LogDebug("Testing TryParse with null expression, expecting false"); + + bool success = DateMath.TryParse(null!, _baseTime, false, out var result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.False(success); + Assert.Equal(default, result); + } + + [Theory] + [InlineData("now+1h", false)] + [InlineData("now-1d/d", true)] + [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) + { + _logger.LogDebug("Testing Parse vs TryParse consistency for expression: '{Expression}', IsUpperLimit: {IsUpperLimit}", + expression, isUpperLimit); + + var parseResult = DateMath.Parse(expression, _baseTime, isUpperLimit); + bool tryParseSuccess = DateMath.TryParse(expression, _baseTime, isUpperLimit, out var tryParseResult); + + _logger.LogDebug("Parse result: {ParseResult}, TryParse success: {TryParseSuccess}, TryParse result: {TryParseResult}", + parseResult, tryParseSuccess, tryParseResult); + + Assert.True(tryParseSuccess); + Assert.Equal(parseResult, tryParseResult); + } + + [Theory] + [InlineData("now/d")] + [InlineData("now/h")] + [InlineData("now/m")] + [InlineData("now+1d/d")] + [InlineData("now-1M/d")] + public void Parse_UpperLimitVsLowerLimit_ProducesDifferentResults(string expression) + { + _logger.LogDebug("Testing upper vs lower limit behavior for expression: '{Expression}'", expression); + + var lowerResult = DateMath.Parse(expression, _baseTime, false); + var upperResult = DateMath.Parse(expression, _baseTime, true); + + _logger.LogDebug("Lower limit result: {LowerResult}, Upper limit result: {UpperResult}", + lowerResult, upperResult); + + // Upper limit should be later than lower limit for rounding operations + Assert.True(upperResult > lowerResult, + $"Upper limit ({upperResult}) should be greater than lower limit ({lowerResult})"); + } + + [Fact] + public void Parse_EdgeCase_LeapYear() + { + var leapYearDate = new DateTimeOffset(2024, 2, 28, 12, 0, 0, _baseTime.Offset); + const string expression = "now+1d"; + + _logger.LogDebug("Testing leap year edge case with date: {LeapYearDate}, expression: '{Expression}'", + leapYearDate, expression); + + var result = DateMath.Parse(expression, leapYearDate); + + _logger.LogDebug("Parse result: {Result}", result); + + Assert.Equal(29, result.Day); // Should go to Feb 29 in leap year + Assert.Equal(2, result.Month); + } + + [Fact] + public void Parse_EdgeCase_MonthOverflow() + { + var endOfMonth = new DateTimeOffset(2023, 1, 31, 12, 0, 0, _baseTime.Offset); + const string expression = "now+1M"; + + _logger.LogDebug("Testing month overflow edge case with date: {EndOfMonth}, expression: '{Expression}'", + endOfMonth, expression); + + var result = DateMath.Parse(expression, endOfMonth); + + _logger.LogDebug("Parse result: {Result}", result); + + // January 31 + 1 month should go to February 28 (or 29 in leap year) + Assert.Equal(2, result.Month); + Assert.True(result.Day <= 29); + } + + [Fact] + public void Parse_EdgeCase_YearOverflow() + { + var endOfYear = new DateTimeOffset(2023, 12, 31, 23, 59, 59, _baseTime.Offset); + const string expression = "now+1d"; + + _logger.LogDebug("Testing year overflow edge case with date: {EndOfYear}, expression: '{Expression}'", + endOfYear, expression); + + var result = DateMath.Parse(expression, endOfYear); + + _logger.LogDebug("Parse result: {Result}", result); + + Assert.Equal(2024, result.Year); + Assert.Equal(1, result.Month); + Assert.Equal(1, result.Day); + } + + [Fact] + public void Parse_ComplexExpression_MultipleOperationsWithRounding() + { + const string expression = "now+1M-2d+3h/h"; + + _logger.LogDebug("Testing complex expression: '{Expression}', BaseTime: {BaseTime}", expression, _baseTime); + + var result = DateMath.Parse(expression, _baseTime, false); + + _logger.LogDebug("Parse result: {Result}", result); + + // Should be rounded to start of hour + Assert.Equal(0, result.Minute); + Assert.Equal(0, result.Second); + Assert.Equal(0, result.Millisecond); + + // Should not equal base time + Assert.NotEqual(_baseTime, result); + } +} From 9298e48692356422f2e1b7ae5b46170a9c79831b Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 21 Sep 2025 22:54:54 -0500 Subject: [PATCH 10/13] Refactors date parsing and validates date math. - Improves date parsing by removing support for dotted date formats and enforcing hyphenated formats for consistency. - Adds validation to ensure rounding operations in date math expressions are only used as the final operation, aligning with specification requirements. - Enhances two-part format parser to validate matching brackets, preventing parsing errors due to unbalanced brackets. --- .../DateMath.cs | 96 ++++++++++--------- .../FormatParsers/TwoPartFormatParser.cs | 36 ++++++- .../DateMathTests.cs | 16 +++- .../FormatParsers/TwoPartFormatParserTests.cs | 10 +- 4 files changed, 104 insertions(+), 54 deletions(-) diff --git a/src/Exceptionless.DateTimeExtensions/DateMath.cs b/src/Exceptionless.DateTimeExtensions/DateMath.cs index 26d4208..6c2b838 100644 --- a/src/Exceptionless.DateTimeExtensions/DateMath.cs +++ b/src/Exceptionless.DateTimeExtensions/DateMath.cs @@ -22,7 +22,7 @@ public static class DateMath { // Match date math expressions with anchors and operations internal static readonly Regex Parser = new( - @"^(?now|(?\d{4}[-.]?\d{2}[-.]?\d{2}(?:[T\s](?:\d{1,2}(?::?\d{2}(?::?\d{2})?)?(?:\.\d{1,3})?)?(?:[+-]\d{2}:?\d{2}|Z)?)?)\|\|)" + + @"^(?now|(?\d{4}-?\d{2}-?\d{2}(?:[T\s](?:\d{1,2}(?::?\d{2}(?::?\d{2})?)?(?:\.\d{1,3})?)?(?:[+-]\d{2}:?\d{2}|Z)?)?)\|\|)" + @"(?(?:[+\-/]\d*[yMwdhHms])*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); @@ -178,67 +178,60 @@ private static bool TryParseExplicitDate(string dateStr, TimeSpan offset, out Da case 4: // Built-in: year (yyyy) return TryParseWithFormat(dateStr, "yyyy", offset, false, out result); - case 7: // Built-in: year_month (yyyy-MM or yyyy.MM) - if (dateStr[4] is '-' or '.') - return TryParseWithFormat(dateStr, dateStr[4] == '-' ? "yyyy-MM" : "yyyy.MM", offset, false, out result); + case 7: // Built-in: year_month (yyyy-MM) + if (dateStr[4] == '-') + return TryParseWithFormat(dateStr, "yyyy-MM", offset, false, out result); break; case 8: // Built-in: basic_date (yyyyMMdd) return TryParseWithFormat(dateStr, "yyyyMMdd", offset, false, out result); - case 10: // Built-in: date (yyyy-MM-dd or yyyy.MM.dd) - if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.') + case 10: // Built-in: date (yyyy-MM-dd) + if (dateStr[4] == '-' && dateStr[7] == '-') { - string format = dateStr[4] == '-' ? "yyyy-MM-dd" : "yyyy.MM.dd"; - return TryParseWithFormat(dateStr, format, offset, false, out result); + return TryParseWithFormat(dateStr, "yyyy-MM-dd", offset, false, out result); } break; - case 13: // Built-in: date_hour (yyyy-MM-ddTHH or yyyy.MM.ddTHH) - if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T') + case 13: // Built-in: date_hour (yyyy-MM-ddTHH) + if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T') { - string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH" : "yyyy.MM.ddTHH"; - return TryParseWithFormat(dateStr, format, offset, false, out result); + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH", offset, false, out result); } break; - case 16: // Built-in: date_hour_minute (yyyy-MM-ddTHH:mm or yyyy.MM.ddTHH:mm) - if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':') + case 16: // Built-in: date_hour_minute (yyyy-MM-ddTHH:mm) + if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':') { - string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm" : "yyyy.MM.ddTHH:mm"; - return TryParseWithFormat(dateStr, format, offset, false, out result); + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm", offset, false, out result); } break; - case 19: // Built-in: date_hour_minute_second (yyyy-MM-ddTHH:mm:ss or yyyy.MM.ddTHH:mm:ss) - if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':') + case 19: // Built-in: date_hour_minute_second (yyyy-MM-ddTHH:mm:ss) + if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':') { - string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm:ss" : "yyyy.MM.ddTHH:mm:ss"; - return TryParseWithFormat(dateStr, format, offset, false, out result); + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss", offset, false, out result); } break; - case 20: // Built-in: date_time_no_millis (yyyy-MM-ddTHH:mm:ssZ or yyyy.MM.ddTHH:mm:ssZ) - if (hasZ && dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':') + case 20: // Built-in: date_time_no_millis (yyyy-MM-ddTHH:mm:ssZ) + if (hasZ && dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':') { - string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm:ssZ" : "yyyy.MM.ddTHH:mm:ssZ"; - return TryParseWithFormat(dateStr, format, offset, true, out result); + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ssZ", offset, true, out result); } break; - case 23: // Built-in: date_hour_minute_second_millis (yyyy-MM-ddTHH:mm:ss.fff or yyyy.MM.ddTHH:mm:ss.fff) - if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.') + case 23: // Built-in: date_hour_minute_second_millis (yyyy-MM-ddTHH:mm:ss.fff) + if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.') { - string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm:ss.fff" : "yyyy.MM.ddTHH:mm:ss.fff"; - return TryParseWithFormat(dateStr, format, offset, false, out result); + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fff", offset, false, out result); } break; - case 24: // Built-in: date_time (yyyy-MM-ddTHH:mm:ss.fffZ or yyyy.MM.ddTHH:mm:ss.fffZ) - if (hasZ && dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.') + case 24: // Built-in: date_time (yyyy-MM-ddTHH:mm:ss.fffZ) + if (hasZ && dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.') { - string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm:ss.fffZ" : "yyyy.MM.ddTHH:mm:ss.fffZ"; - return TryParseWithFormat(dateStr, format, offset, true, out result); + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fffZ", offset, true, out result); } break; } @@ -247,30 +240,21 @@ private static bool TryParseExplicitDate(string dateStr, TimeSpan offset, out Da // Note: .NET uses 'zzz' pattern for timezone offsets like +05:00 if (hasTimezone && !hasZ) { - // Determine the date separator for format construction - char dateSeparator = (len > 4 && dateStr[4] == '.') ? '.' : '-'; - // Only try timezone formats for lengths that make sense if (len is >= 25 and <= 29) // +05:00 variants { if (dateStr.Contains(".")) // with milliseconds { - // Try both separators: yyyy-MM-ddTHH:mm:ss.fff+05:00 or yyyy.MM.ddTHH:mm:ss.fff+05:00 - string format = dateSeparator == '.' - ? "yyyy.MM.ddTHH:mm:ss.fffzzz" - : "yyyy-MM-ddTHH:mm:ss.fffzzz"; - if (TryParseWithFormat(dateStr, format, offset, true, out result)) + // yyyy-MM-ddTHH:mm:ss.fff+05:00 + if (TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fffzzz", offset, true, out result)) return true; } } if (len is >= 22 and <= 25) // without milliseconds { - // Try both separators: yyyy-MM-ddTHH:mm:ss+05:00 or yyyy.MM.ddTHH:mm:ss+05:00 - string format = dateSeparator == '.' - ? "yyyy.MM.ddTHH:mm:sszzz" - : "yyyy-MM-ddTHH:mm:sszzz"; - if (TryParseWithFormat(dateStr, format, offset, true, out result)) + // yyyy-MM-ddTHH:mm:ss+05:00 + if (TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:sszzz", offset, true, out result)) return true; } } @@ -327,6 +311,28 @@ public static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string ope throw new ArgumentException("Invalid operations"); } + // Validate that rounding operations (/) are only at the end + // According to Elasticsearch spec, rounding must be the final operation + bool foundRounding = false; + for (int i = 0; i < matches.Count; i++) + { + string operation = matches[i].Groups[1].Value; + if (operation == "/") + { + if (foundRounding) + { + // Multiple rounding operations are not allowed + throw new ArgumentException("Multiple rounding operations are not allowed"); + } + if (i != matches.Count - 1) + { + // Rounding operation must be the last operation + throw new ArgumentException("Rounding operation must be the final operation"); + } + foundRounding = true; + } + } + foreach (Match opMatch in matches) { string operation = opMatch.Groups[1].Value; diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs index afbc314..c20742a 100644 --- a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs @@ -8,9 +8,9 @@ namespace Exceptionless.DateTimeExtensions.FormatParsers; [Priority(25)] public class TwoPartFormatParser : IFormatParser { - private static readonly Regex _beginRegex = new(@"^\s*(?:[\[\{])?\s*", RegexOptions.Compiled); + private static readonly Regex _beginRegex = new(@"^\s*([\[\{])?\s*", RegexOptions.Compiled); private static readonly Regex _delimiterRegex = new(@"\G(?:\s*-\s*|\s+TO\s+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static readonly Regex _endRegex = new(@"\G\s*(?:[\]\}])?\s*$", RegexOptions.Compiled); + private static readonly Regex _endRegex = new(@"\G\s*([\]\}])?\s*$", RegexOptions.Compiled); public TwoPartFormatParser() { @@ -33,6 +33,9 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime) if (!begin.Success) return null; + // Capture the opening bracket if present + string openingBracket = begin.Groups[1].Value; + index += begin.Length; DateTimeOffset? start = null; foreach (var parser in Parsers) @@ -70,9 +73,36 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime) break; } - if (!_endRegex.IsMatch(content, index)) + var endMatch = _endRegex.Match(content, index); + if (!endMatch.Success) + return null; + + // Validate bracket matching + string closingBracket = endMatch.Groups[1].Value; + if (!IsValidBracketPair(openingBracket, closingBracket)) return null; return new DateTimeRange(start ?? DateTime.MinValue, end ?? DateTime.MaxValue); } + + /// + /// Validates that opening and closing brackets are properly matched. + /// + /// The opening bracket character + /// The closing bracket character + /// True if brackets are properly matched, false otherwise + private static bool IsValidBracketPair(string opening, string closing) + { + // Both empty - valid (no brackets) + if (String.IsNullOrEmpty(opening) && String.IsNullOrEmpty(closing)) + return true; + + // One empty, one not - invalid (unbalanced) + if (String.IsNullOrEmpty(opening) || String.IsNullOrEmpty(closing)) + return false; + + // Check for proper matching pairs + return (opening == "[" && closing == "]") || + (opening == "{" && closing == "}"); + } } diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs index 93227df..3e22354 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs @@ -233,7 +233,6 @@ public void Parse_MultipleOperations_ReturnsCorrectResult(string expression) } [Theory] - [InlineData("2023.06.15||")] [InlineData("2023-06-15||")] [InlineData("2023-06-15T10:30:00||")] [InlineData("2023-06-15T10:30:00.123||")] @@ -277,7 +276,7 @@ public void Parse_ExplicitTimezones_PreservesTimezone(string expression, double } [Theory] - [InlineData("2023.06.15||+1M")] + [InlineData("2023-06-15||+1M")] [InlineData("2023-06-15T10:30:00||+2d")] [InlineData("2023-06-15T10:30:00Z||+1h")] [InlineData("2023-06-15T10:30:00+02:00||-1d/d")] @@ -317,6 +316,10 @@ public void Parse_ExplicitDateWithOperations_ReturnsCorrectResult(string express [InlineData("now/x")] // Invalid rounding unit [InlineData("2023-13-01||")] // Invalid month [InlineData("2023-01-32||")] // Invalid day + [InlineData("2001.02.01||")] // Dotted format no longer supported + [InlineData("now/d+1h")] // Rounding must be final operation + [InlineData("now/d/d")] // Multiple rounding operations + [InlineData("now+1h/d+2m")] // Rounding in middle of operations public void Parse_InvalidExpressions_ThrowsArgumentException(string expression) { _logger.LogDebug("Testing Parse with invalid expression: '{Expression}', expecting ArgumentException", expression); @@ -341,8 +344,8 @@ public void Parse_NullExpression_ThrowsArgumentException() [InlineData("now")] [InlineData("now+1h")] [InlineData("now-1d/d")] - [InlineData("2023.06.15||")] - [InlineData("2023.06.15||+1M/d")] + [InlineData("2023-06-15||")] + [InlineData("2023-06-15||+1M/d")] [InlineData("2025-01-01T01:25:35Z||+3d/d")] public void TryParse_ValidExpressions_ReturnsTrueAndCorrectResult(string expression) { @@ -367,6 +370,9 @@ public void TryParse_ValidExpressions_ReturnsTrueAndCorrectResult(string express [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 + [InlineData("now/d/d")] // Multiple rounding operations public void TryParse_InvalidExpressions_ReturnsFalse(string expression) { _logger.LogDebug("Testing TryParse with invalid expression: '{Expression}', expecting false", expression); @@ -395,7 +401,7 @@ public void TryParse_NullExpression_ReturnsFalse() [Theory] [InlineData("now+1h", false)] [InlineData("now-1d/d", true)] - [InlineData("2023.06.15||+1M", 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) { diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs index 3e43974..f44768a 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs @@ -47,7 +47,15 @@ public static IEnumerable Inputs // Invalid inputs ["blah", null, null], ["[invalid", null, null], - ["invalid}", null, null] + ["invalid}", null, null], + + // Mismatched bracket validation + ["{2012 TO 2013]", null, null], // Opening brace with closing bracket + ["[2012 TO 2013}", null, null], // Opening bracket with closing brace + ["}2012 TO 2013{", null, null], // Wrong orientation + ["]2012 TO 2013[", null, null], // Wrong orientation + ["[2012 TO 2013", null, null], // Missing closing bracket + ["2012 TO 2013]", null, null], // Missing opening bracket }; } } From a3c045ed4d84ecd71d7733ac0358bb493e03c653 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 21 Sep 2025 22:56:41 -0500 Subject: [PATCH 11/13] Adds bracket matching validation to parser Adds validation to ensure that brackets and braces are properly matched in the format parser. This prevents incorrect parsing when there are mismatched brackets/braces. --- .../FormatParsers/TwoPartFormatParserTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs index f44768a..0da82d1 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs @@ -48,7 +48,7 @@ public static IEnumerable Inputs ["blah", null, null], ["[invalid", null, null], ["invalid}", null, null], - + // Mismatched bracket validation ["{2012 TO 2013]", null, null], // Opening brace with closing bracket ["[2012 TO 2013}", null, null], // Opening bracket with closing brace From 66bee81300185b71634f5469dfcbfab410a9db64 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 21 Sep 2025 23:02:52 -0500 Subject: [PATCH 12/13] Adds timezone support to DateMath Extends the DateMath utility to support TimeZoneInfo, enabling accurate date parsing and calculations within specific timezones. This enhancement allows for parsing expressions using a specified timezone, ensuring that "now" calculations and dates without explicit timezone information are correctly interpreted. Dates with explicit timezone information are preserved, regardless of the TimeZoneInfo parameter. --- README.md | 28 ++ .../DateMath.cs | 90 +++++++ .../DateMathTests.cs | 246 ++++++++++++++++++ 3 files changed, 364 insertions(+) diff --git a/README.md b/README.md index 81a0336..cc5ab43 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,34 @@ var utcResult = DateMath.Parse("2025-01-01T01:25:35Z||+3d/d", baseTime); var offsetResult = DateMath.Parse("2023-06-15T14:30:00+05:00||+1M", baseTime); ``` +#### TimeZone-Aware DateMath + +The `DateMath` utility also provides overloads that work directly with `TimeZoneInfo` for better timezone handling: + +```csharp +using Exceptionless.DateTimeExtensions; + +// Parse expressions using a specific timezone +var utcTimeZone = TimeZoneInfo.Utc; +var easternTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Eastern"); + +// "now" will use current time in the specified timezone +var utcResult = DateMath.Parse("now+1h", utcTimeZone); +var easternResult = DateMath.Parse("now/d", easternTimeZone, isUpperLimit: false); + +// TryParse with timezone +if (DateMath.TryParse("now+2d-3h", easternTimeZone, false, out var result)) { + Console.WriteLine($"Eastern time result: {result:O}"); +} + +// Dates without explicit timezone use the provided TimeZoneInfo +var localDate = DateMath.Parse("2023-06-15T14:30:00||+1M", easternTimeZone); + +// Dates with explicit timezone are preserved regardless of TimeZoneInfo parameter +var preservedTz = DateMath.Parse("2023-06-15T14:30:00+05:00||+1M", easternTimeZone); +// Result will still have +05:00 offset, not Eastern time offset +``` + The `DateMath` utility supports the same comprehensive syntax as `DateTimeRange` but provides a simpler API for direct parsing operations. ### TimeUnit diff --git a/src/Exceptionless.DateTimeExtensions/DateMath.cs b/src/Exceptionless.DateTimeExtensions/DateMath.cs index 6c2b838..5f7f7ce 100644 --- a/src/Exceptionless.DateTimeExtensions/DateMath.cs +++ b/src/Exceptionless.DateTimeExtensions/DateMath.cs @@ -67,6 +67,52 @@ public static bool TryParse(string expression, DateTimeOffset relativeBaseTime, return TryParseFromMatch(match, relativeBaseTime, isUpperLimit, out result); } + /// + /// Parses a date math expression and returns the resulting DateTimeOffset using the specified timezone. + /// + /// The date math expression to parse + /// The timezone to use for 'now' calculations and dates without explicit timezone information + /// Whether this is for an upper limit (affects rounding behavior) + /// The parsed DateTimeOffset + /// Thrown when the expression is invalid or cannot be parsed + /// Thrown when timeZone is null + public static DateTimeOffset Parse(string expression, TimeZoneInfo timeZone, bool isUpperLimit = false) + { + if (timeZone == null) + throw new ArgumentNullException(nameof(timeZone)); + + if (!TryParse(expression, timeZone, isUpperLimit, out DateTimeOffset result)) + throw new ArgumentException($"Invalid date math expression: {expression}", nameof(expression)); + + return result; + } + + /// + /// Tries to parse a date math expression and returns the resulting DateTimeOffset using the specified timezone. + /// + /// The date math expression to parse + /// The timezone to use for 'now' calculations and dates without explicit timezone information + /// Whether this is for an upper limit (affects rounding behavior) + /// The parsed DateTimeOffset if successful + /// True if parsing succeeded, false otherwise + /// Thrown when timeZone is null + public static bool TryParse(string expression, TimeZoneInfo timeZone, bool isUpperLimit, out DateTimeOffset result) + { + if (timeZone == null) + throw new ArgumentNullException(nameof(timeZone)); + + result = default; + + if (String.IsNullOrEmpty(expression)) + return false; + + var match = Parser.Match(expression); + if (!match.Success) + return false; + + return TryParseFromMatch(match, timeZone, isUpperLimit, out result); + } + /// /// Tries to parse a date math expression from a regex match and returns the resulting DateTimeOffset. /// This method bypasses the regex matching for cases where the match is already available. @@ -109,6 +155,50 @@ public static bool TryParseFromMatch(Match match, DateTimeOffset relativeBaseTim } } + /// + /// Tries to parse a date math expression from a regex match and returns the resulting DateTimeOffset using the specified timezone. + /// This method bypasses the regex matching for cases where the match is already available. + /// + /// The regex match containing the parsed expression groups + /// The timezone to use for 'now' calculations and dates without explicit timezone information + /// Whether this is for an upper limit (affects rounding behavior) + /// The parsed DateTimeOffset if successful + /// True if parsing succeeded, false otherwise + public static bool TryParseFromMatch(Match match, TimeZoneInfo timeZone, bool isUpperLimit, out DateTimeOffset result) + { + result = default; + + try + { + // Parse the anchor (now or explicit date) + DateTimeOffset baseTime; + string anchor = match.Groups["anchor"].Value; + + if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase)) + { + // Use current time in the specified timezone + baseTime = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZone); + } + else + { + // Parse explicit date from the date group + string dateStr = match.Groups["date"].Value; + TimeSpan offset = timeZone.GetUtcOffset(DateTime.UtcNow); + if (!TryParseExplicitDate(dateStr, offset, out baseTime)) + return false; + } + + // Parse and apply operations + string operations = match.Groups["operations"].Value; + result = ApplyOperations(baseTime, operations, isUpperLimit); + return true; + } + catch + { + return false; + } + } + /// /// Checks if the given expression is a valid date math expression. /// diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs index 3e22354..c72cc24 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs @@ -511,4 +511,250 @@ public void Parse_ComplexExpression_MultipleOperationsWithRounding() // Should not equal base time Assert.NotEqual(_baseTime, result); } + + [Fact] + public void Parse_WithTimeZoneInfo_Now_ReturnsCurrentTimeInSpecifiedTimezone() + { + var utcTimeZone = TimeZoneInfo.Utc; + const string expression = "now"; + + _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}", + expression, utcTimeZone.Id); + + var result = DateMath.Parse(expression, utcTimeZone); + + _logger.LogDebug("Parse result: {Result}", result); + + // Should be close to current UTC time + var utcNow = DateTimeOffset.UtcNow; + Assert.True(Math.Abs((result - utcNow).TotalSeconds) < 5, + $"Result {result} should be within 5 seconds of UTC now {utcNow}"); + Assert.Equal(TimeSpan.Zero, result.Offset); // Should be UTC + } + + [Theory] + [InlineData("UTC", 0)] + [InlineData("US/Eastern", -5)] // EST offset (not considering DST for this test) + [InlineData("US/Pacific", -8)] // PST offset (not considering DST for this test) + public void Parse_WithTimeZoneInfo_Now_ReturnsCorrectTimezone(string timeZoneId, int expectedOffsetHours) + { + var timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + const string expression = "now"; + + _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}", + expression, timeZone.Id); + + var result = DateMath.Parse(expression, timeZone); + + _logger.LogDebug("Parse result: {Result}, Expected offset hours: {ExpectedOffsetHours}", result, expectedOffsetHours); + + // Note: This test might need adjustment for DST, but it demonstrates the concept + Assert.Equal(timeZone.GetUtcOffset(DateTime.UtcNow), result.Offset); + } + + [Fact] + public void Parse_WithTimeZoneInfo_ExplicitDateWithoutTimezone_UsesSpecifiedTimezone() + { + var easternTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Eastern"); + const string expression = "2023-06-15T14:30:00||"; + + _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}", + expression, easternTimeZone.Id); + + var result = DateMath.Parse(expression, easternTimeZone); + + _logger.LogDebug("Parse result: {Result}", result); + + Assert.Equal(2023, result.Year); + Assert.Equal(6, result.Month); + Assert.Equal(15, result.Day); + Assert.Equal(14, result.Hour); + Assert.Equal(30, result.Minute); + Assert.Equal(0, result.Second); + + // Should use the timezone offset from Eastern Time + var expectedOffset = easternTimeZone.GetUtcOffset(new DateTime(2023, 6, 15, 14, 30, 0)); + Assert.Equal(expectedOffset, result.Offset); + } + + [Fact] + public void Parse_WithTimeZoneInfo_ExplicitDateWithTimezone_PreservesOriginalTimezone() + { + var pacificTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Pacific"); + 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); + + var result = DateMath.Parse(expression, pacificTimeZone); + + _logger.LogDebug("Parse result: {Result}", result); + + Assert.Equal(2023, result.Year); + Assert.Equal(6, result.Month); + Assert.Equal(15, result.Day); + Assert.Equal(14, result.Hour); + Assert.Equal(30, result.Minute); + Assert.Equal(0, result.Second); + + // Should preserve the original +05:00 timezone, not use Pacific + Assert.Equal(TimeSpan.FromHours(5), result.Offset); + } + + [Theory] + [InlineData("now+1h", 1)] + [InlineData("now+6h", 6)] + [InlineData("now-2h", -2)] + [InlineData("now+24h", 24)] + public void Parse_WithTimeZoneInfo_HourOperations_ReturnsCorrectResult(string expression, int hours) + { + var utcTimeZone = TimeZoneInfo.Utc; + + _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}, Hours: {Hours}", + expression, utcTimeZone.Id, hours); + + var result = DateMath.Parse(expression, utcTimeZone); + var utcNow = DateTimeOffset.UtcNow; + var expected = utcNow.AddHours(hours); + + _logger.LogDebug("Parse result: {Result}, Expected: approximately {Expected}", result, expected); + + // Should be close to expected time (within 5 seconds to account for execution time) + Assert.True(Math.Abs((result - expected).TotalSeconds) < 5, + $"Result {result} should be within 5 seconds of expected {expected}"); + Assert.Equal(TimeSpan.Zero, result.Offset); // Should be UTC + } + + [Theory] + [InlineData("now/d", false)] + [InlineData("now/d", true)] + [InlineData("now/h", false)] + [InlineData("now/h", true)] + [InlineData("now/M", false)] + [InlineData("now/M", true)] + public void Parse_WithTimeZoneInfo_RoundingOperations_ReturnsCorrectResult(string expression, bool isUpperLimit) + { + var centralTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Central"); + + _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}, IsUpperLimit: {IsUpperLimit}", + expression, centralTimeZone.Id, isUpperLimit); + + var result = DateMath.Parse(expression, centralTimeZone, isUpperLimit); + + _logger.LogDebug("Parse result: {Result}", result); + + // Verify the result uses Central Time offset + var expectedOffset = centralTimeZone.GetUtcOffset(DateTime.UtcNow); + Assert.Equal(expectedOffset, result.Offset); + + // Verify rounding behavior + if (expression.EndsWith("/d")) + { + if (isUpperLimit) + { + Assert.Equal(23, result.Hour); + Assert.Equal(59, result.Minute); + Assert.Equal(59, result.Second); + } + else + { + Assert.Equal(0, result.Hour); + Assert.Equal(0, result.Minute); + Assert.Equal(0, result.Second); + } + } + else if (expression.EndsWith("/h")) + { + if (isUpperLimit) + { + Assert.Equal(59, result.Minute); + Assert.Equal(59, result.Second); + } + else + { + Assert.Equal(0, result.Minute); + Assert.Equal(0, result.Second); + } + } + } + + [Fact] + public void TryParse_WithTimeZoneInfo_ValidExpression_ReturnsTrue() + { + var mountainTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Mountain"); + const string expression = "now+2d"; + + _logger.LogDebug("Testing TryParse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}", + expression, mountainTimeZone.Id); + + bool success = DateMath.TryParse(expression, mountainTimeZone, false, out DateTimeOffset result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.True(success); + Assert.NotEqual(default(DateTimeOffset), result); + + // Should use Mountain Time offset + var expectedOffset = mountainTimeZone.GetUtcOffset(DateTime.UtcNow); + Assert.Equal(expectedOffset, result.Offset); + } + + [Fact] + public void TryParse_WithTimeZoneInfo_InvalidExpression_ReturnsFalse() + { + var utcTimeZone = TimeZoneInfo.Utc; + const string expression = "invalid_expression"; + + _logger.LogDebug("Testing TryParse with TimeZoneInfo for invalid expression: '{Expression}', TimeZone: {TimeZone}", + expression, utcTimeZone.Id); + + bool success = DateMath.TryParse(expression, utcTimeZone, false, out DateTimeOffset result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.False(success); + Assert.Equal(default(DateTimeOffset), result); + } + + [Fact] + public void Parse_WithTimeZoneInfo_ComplexExpression_WorksCorrectly() + { + var utcTimeZone = TimeZoneInfo.Utc; + const string expression = "now+1M-2d+3h/h"; + + _logger.LogDebug("Testing Parse with TimeZoneInfo for complex expression: '{Expression}', TimeZone: {TimeZone}", + expression, utcTimeZone.Id); + + var result = DateMath.Parse(expression, utcTimeZone, false); + + _logger.LogDebug("Parse result: {Result}", result); + + // Should be UTC + Assert.Equal(TimeSpan.Zero, result.Offset); + + // Should be rounded to start of hour + Assert.Equal(0, result.Minute); + Assert.Equal(0, result.Second); + Assert.Equal(0, result.Millisecond); + } + + [Fact] + public void Parse_WithTimeZoneInfo_NullTimeZone_ThrowsArgumentNullException() + { + const string expression = "now"; + + _logger.LogDebug("Testing Parse with null TimeZoneInfo for expression: '{Expression}'", expression); + + Assert.Throws(() => DateMath.Parse(expression, (TimeZoneInfo)null!)); + } + + [Fact] + public void TryParse_WithTimeZoneInfo_NullTimeZone_ThrowsArgumentNullException() + { + const string expression = "now"; + + _logger.LogDebug("Testing TryParse with null TimeZoneInfo for expression: '{Expression}'", expression); + + Assert.Throws(() => DateMath.TryParse(expression, (TimeZoneInfo)null!, false, out _)); + } } From b74c5ea921b0e4a16a53d1a8751a6cc09b5ad9cf Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 21 Sep 2025 23:04:59 -0500 Subject: [PATCH 13/13] Renames DateMath tests for timezone parsing Updates test method names to clearly indicate that they specifically test timezone parsing functionality. This improves clarity and maintainability of the test suite. --- .../DateMathTests.cs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs index c72cc24..866bcd2 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs @@ -513,7 +513,7 @@ public void Parse_ComplexExpression_MultipleOperationsWithRounding() } [Fact] - public void Parse_WithTimeZoneInfo_Now_ReturnsCurrentTimeInSpecifiedTimezone() + public void ParseTimeZone_Now_ReturnsCurrentTimeInSpecifiedTimezone() { var utcTimeZone = TimeZoneInfo.Utc; const string expression = "now"; @@ -536,7 +536,7 @@ public void Parse_WithTimeZoneInfo_Now_ReturnsCurrentTimeInSpecifiedTimezone() [InlineData("UTC", 0)] [InlineData("US/Eastern", -5)] // EST offset (not considering DST for this test) [InlineData("US/Pacific", -8)] // PST offset (not considering DST for this test) - public void Parse_WithTimeZoneInfo_Now_ReturnsCorrectTimezone(string timeZoneId, int expectedOffsetHours) + public void ParseTimeZone_Now_ReturnsCorrectTimezone(string timeZoneId, int expectedOffsetHours) { var timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); const string expression = "now"; @@ -553,7 +553,7 @@ public void Parse_WithTimeZoneInfo_Now_ReturnsCorrectTimezone(string timeZoneId, } [Fact] - public void Parse_WithTimeZoneInfo_ExplicitDateWithoutTimezone_UsesSpecifiedTimezone() + public void ParseTimeZone_ExplicitDateWithoutTimezone_UsesSpecifiedTimezone() { var easternTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Eastern"); const string expression = "2023-06-15T14:30:00||"; @@ -578,7 +578,7 @@ public void Parse_WithTimeZoneInfo_ExplicitDateWithoutTimezone_UsesSpecifiedTime } [Fact] - public void Parse_WithTimeZoneInfo_ExplicitDateWithTimezone_PreservesOriginalTimezone() + 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 @@ -606,7 +606,7 @@ public void Parse_WithTimeZoneInfo_ExplicitDateWithTimezone_PreservesOriginalTim [InlineData("now+6h", 6)] [InlineData("now-2h", -2)] [InlineData("now+24h", 24)] - public void Parse_WithTimeZoneInfo_HourOperations_ReturnsCorrectResult(string expression, int hours) + public void ParseTimeZone_HourOperations_ReturnsCorrectResult(string expression, int hours) { var utcTimeZone = TimeZoneInfo.Utc; @@ -632,7 +632,7 @@ public void Parse_WithTimeZoneInfo_HourOperations_ReturnsCorrectResult(string ex [InlineData("now/h", true)] [InlineData("now/M", false)] [InlineData("now/M", true)] - public void Parse_WithTimeZoneInfo_RoundingOperations_ReturnsCorrectResult(string expression, bool isUpperLimit) + public void ParseTimeZone_RoundingOperations_ReturnsCorrectResult(string expression, bool isUpperLimit) { var centralTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Central"); @@ -679,7 +679,7 @@ public void Parse_WithTimeZoneInfo_RoundingOperations_ReturnsCorrectResult(strin } [Fact] - public void TryParse_WithTimeZoneInfo_ValidExpression_ReturnsTrue() + public void TryParseTimeZone_ValidExpression_ReturnsTrue() { var mountainTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Mountain"); const string expression = "now+2d"; @@ -700,7 +700,7 @@ public void TryParse_WithTimeZoneInfo_ValidExpression_ReturnsTrue() } [Fact] - public void TryParse_WithTimeZoneInfo_InvalidExpression_ReturnsFalse() + public void TryParseTimeZone_InvalidExpression_ReturnsFalse() { var utcTimeZone = TimeZoneInfo.Utc; const string expression = "invalid_expression"; @@ -717,7 +717,7 @@ public void TryParse_WithTimeZoneInfo_InvalidExpression_ReturnsFalse() } [Fact] - public void Parse_WithTimeZoneInfo_ComplexExpression_WorksCorrectly() + public void ParseTimeZone_ComplexExpression_WorksCorrectly() { var utcTimeZone = TimeZoneInfo.Utc; const string expression = "now+1M-2d+3h/h"; @@ -739,7 +739,7 @@ public void Parse_WithTimeZoneInfo_ComplexExpression_WorksCorrectly() } [Fact] - public void Parse_WithTimeZoneInfo_NullTimeZone_ThrowsArgumentNullException() + public void ParseTimeZone_NullTimeZone_ThrowsArgumentNullException() { const string expression = "now"; @@ -749,7 +749,7 @@ public void Parse_WithTimeZoneInfo_NullTimeZone_ThrowsArgumentNullException() } [Fact] - public void TryParse_WithTimeZoneInfo_NullTimeZone_ThrowsArgumentNullException() + public void TryParseTimeZone_NullTimeZone_ThrowsArgumentNullException() { const string expression = "now";