diff --git a/README.md b/README.md index 4268cc9..93b8bd1 100644 --- a/README.md +++ b/README.md @@ -56,12 +56,52 @@ var wildcardRange = DateTimeRange.Parse("[2023-01-01 TO *]", DateTime.Now); // F #### 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): +Supports full [Elasticsearch date math syntax](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) +- **Operations**: `+1d` (add), `-1h` (subtract), `/d` (round) - **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 +- **Escaped slashes**: `\/d` is treated identically to `/d` for contexts where `/` must be escaped + +##### Real-World Patterns + +```csharp +// Common date range queries +var thisMonth = DateTimeRange.Parse("[now/M TO now/M]", now); // Start of month through end of month +var lastMonth = DateTimeRange.Parse("[now-1M/M TO now/M}", now); // Start of last month through start of this month +var yearToDate = DateTimeRange.Parse("[now/y TO now]", now); // Start of year through now +var last7Days = DateTimeRange.Parse("[now-7d/d TO now]", now); // Start of 7 days ago through now +var last15Minutes = DateTimeRange.Parse("[now-15m TO now]", now); // 15 minutes ago through now +var last24Hours = DateTimeRange.Parse("[now-24h TO now]", now); // 24 hours ago through now +var tomorrow = DateTimeRange.Parse("[now+1d/d TO now+1d/d]", now); // Start through end of tomorrow + +// Short-form comparison operators +var recentItems = DateTimeRange.Parse(">=now-1h", now); // From 1 hour ago to max +var futureOnly = DateTimeRange.Parse(">now", now); // After now to max +var beforeToday = DateTimeRange.Parse("=` | Floor (start of period) | `2025-02-16T00:00:00` | +| Exclusive lower | `{` | `>` | Ceiling (end of period) | `2025-02-16T23:59:59.999` | +| Inclusive upper | `]` | `<=` | Ceiling (end of period) | `2025-02-16T23:59:59.999` | +| Exclusive upper | `}` | `<` | Floor (start of period) | `2025-02-16T00:00:00` | + +All four bracket combinations are supported: `[]`, `[}`, `{]`, `{}`. + +```csharp +// [now/d TO now/d] → start of today through end of today (full day, inclusive both ends) +// [now/d TO now/d} → start of today through start of today (exclusive upper) +// {now/d TO now/d] → end of today through end of today (exclusive lower) +// {now/d TO now/d} → collapsed to start of today (exclusive both ends inverts the range, which is then normalized) +``` Examples: diff --git a/src/Exceptionless.DateTimeExtensions/DateMath.cs b/src/Exceptionless.DateTimeExtensions/DateMath.cs index f910d08..3ffc62e 100644 --- a/src/Exceptionless.DateTimeExtensions/DateMath.cs +++ b/src/Exceptionless.DateTimeExtensions/DateMath.cs @@ -29,11 +29,12 @@ public static class DateMath // https://www.elastic.co/docs/reference/elasticsearch/rest-apis/common-options internal static readonly Regex Parser = new( @"\G(?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])*)(?=\s|$|[\]\}])", + @"(?(?:(?:[+\-]|\\?/)\d*[yMwdhHms])*)(?=\s|$|[\]\}])", RegexOptions.Compiled); // Pre-compiled regex for operation parsing to avoid repeated compilation - private static readonly Regex _operationRegex = new(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled); + // Supports both / and \/ (escaped forward slash) for rounding operations + private static readonly Regex _operationRegex = new(@"([+\-]|\\?/)(\d*)([yMwdhHms])", RegexOptions.Compiled); // Pre-compiled regex for offset parsing to avoid repeated compilation private static readonly Regex _offsetRegex = new(@"(Z|[+-]\d{2}:\d{2})$", RegexOptions.Compiled); @@ -461,12 +462,12 @@ public static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string ope throw new ArgumentException("Invalid operations"); } - // Validate that rounding operations (/) are only at the end + // Validate that rounding operations (/ or \/) 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; + string operation = matches[i].Groups[1].Value.TrimStart('\\'); if (String.Equals(operation, "/")) { if (foundRounding) @@ -485,7 +486,8 @@ public static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string ope foreach (Match opMatch in matches) { - string operation = opMatch.Groups[1].Value; + // Normalize escaped forward slash (\/) to unescaped (/) for rounding operations + string operation = opMatch.Groups[1].Value.TrimStart('\\'); string amountStr = opMatch.Groups[2].Value; string unit = opMatch.Groups[3].Value; diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/ComparisonFormatParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/ComparisonFormatParser.cs new file mode 100644 index 0000000..bdbcbe0 --- /dev/null +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/ComparisonFormatParser.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Exceptionless.DateTimeExtensions.FormatParsers.PartParsers; + +namespace Exceptionless.DateTimeExtensions.FormatParsers; + +/// +/// Parses short-form comparison operators (>, >=, <, <=) as syntactic sugar for +/// ranges with one open end. The operator determines boundary inclusivity, which +/// controls rounding direction for date math expressions. +/// +/// Operator mapping (per Elasticsearch range query semantics): +/// >value → exclusive lower bound → isUpperLimit = true → rounds to end of period +/// >=value → inclusive lower bound → isUpperLimit = false → rounds to start of period +/// <value → exclusive upper bound → isUpperLimit = false → rounds to start of period +/// <=value → inclusive upper bound → isUpperLimit = true → rounds to end of period +/// +/// Examples: +/// >now/d → range from end of today to MaxValue +/// >=now/d → range from start of today to MaxValue +/// <now/d → range from MinValue to start of today +/// <=now/d → range from MinValue to end of today +/// +[Priority(24)] +public class ComparisonFormatParser : IFormatParser +{ + private static readonly Regex _operatorRegex = new(@"^\s*(>=?|<=?)\s*", RegexOptions.Compiled); + + public ComparisonFormatParser() + { + Parsers = new List(DateTimeRange.PartParsers); + } + + public List Parsers { get; private set; } + + public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime) + { + var opMatch = _operatorRegex.Match(content); + if (!opMatch.Success) + return null; + + string op = opMatch.Groups[1].Value; + int index = opMatch.Length; + + // Must have an expression after the operator + if (index >= content.Length || content.Substring(index).Trim().Length == 0) + return null; + + // Determine inclusivity from operator + bool isLowerBound = op[0] == '>'; + bool isInclusive = op.Length == 2; // >= or <= + + // isUpperLimit follows the boundary inclusivity rule: + // For lower bounds: isUpperLimit = !inclusive (gt rounds up, gte rounds down) + // For upper bounds: isUpperLimit = inclusive (lte rounds up, lt rounds down) + bool isUpperLimit = isLowerBound ? !isInclusive : isInclusive; + + DateTimeOffset? value = null; + foreach (var parser in Parsers.Where(p => p is not WildcardPartParser)) + { + var match = parser.Regex.Match(content, index); + if (!match.Success) + continue; + + value = parser.Parse(match, relativeBaseTime, isUpperLimit); + if (value == null) + continue; + + index += match.Length; + break; + } + + if (value == null) + return null; + + // Verify entire input was consumed (only trailing whitespace allowed) + if (index < content.Length && content.Substring(index).Trim().Length > 0) + return null; + + return isLowerBound + ? new DateTimeRange(value.Value, DateTimeOffset.MaxValue) + : new DateTimeRange(DateTimeOffset.MinValue, value.Value); + } +} diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs index 6567951..dfa8813 100644 --- a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs @@ -57,6 +57,7 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime) if (!IsValidBracketPair(openingBracket, closingBracket)) return null; + // Determine isUpperLimit from bracket inclusivity (per Elasticsearch date math rounding spec): // Inclusive min ([): round down (start of period) — ">= start" // Exclusive min ({): round up (end of period) — "> end" bool minInclusive = openingBracket != '{'; @@ -124,7 +125,7 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime) /// /// Validates that opening and closing brackets form a valid pair. - /// Both Elasticsearch bracket types can be mixed: [ with ], [ with }, { with ], { with }. + /// All four Elasticsearch bracket combinations are valid: [/], [/}, {/], {/}. /// private static bool IsValidBracketPair(char? opening, char? closing) { diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs index 040edda..235f7ea 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs @@ -905,4 +905,40 @@ public void IsValidExpression_CaseSensitiveInputs_ValidatesCorrectly() Assert.False(DateMath.IsValidExpression("Now-7d")); Assert.False(DateMath.IsValidExpression("NOW-7d")); } + + /// + /// Tests that escaped forward slash (\/) is handled identically to unescaped forward slash (/) + /// in date math rounding operations. This is important for contexts where / is escaped + /// (e.g., Lucene query syntax). + /// + [Theory] + [InlineData("now/d", @"now\/d", false)] + [InlineData("now/d", @"now\/d", true)] + [InlineData("now+1d/d", @"now+1d\/d", false)] + [InlineData("now+1d/d", @"now+1d\/d", true)] + [InlineData("now-1M/M", @"now-1M\/M", false)] + [InlineData("now-1M/M", @"now-1M\/M", true)] + [InlineData("now/h", @"now\/h", false)] + [InlineData("now/h", @"now\/h", true)] + public void Parse_EscapedAndUnescapedSlash_ProduceIdenticalResults(string unescaped, string escaped, bool isUpperLimit) + { + _logger.LogDebug("Testing escaped vs unescaped: '{Unescaped}' vs '{Escaped}', IsUpperLimit: {IsUpperLimit}", + unescaped, escaped, isUpperLimit); + + var unescapedResult = DateMath.Parse(unescaped, _baseTime, isUpperLimit); + var escapedResult = DateMath.Parse(escaped, _baseTime, isUpperLimit); + + _logger.LogDebug("Unescaped result: {Unescaped}, Escaped result: {Escaped}", unescapedResult, escapedResult); + + Assert.Equal(unescapedResult, escapedResult); + } + + [Theory] + [InlineData(@"now\/d")] + [InlineData(@"now+1h\/h")] + [InlineData(@"now-1d\/d")] + public void IsValidExpression_EscapedSlash_ReturnsTrue(string expression) + { + Assert.True(DateMath.IsValidExpression(expression)); + } } diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs index bf28ec5..6a16cf4 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs @@ -438,4 +438,102 @@ public void Parse_MixedBracketsWithDateMathOperations_ParsesCorrectly() Assert.Equal(baseTime.AddDays(-1).StartOfDay(), range.Start); Assert.Equal(baseTime.StartOfDay(), range.End); } + + /// + /// Short-form operators are syntactic sugar for ranges with one open end. + /// The parser must interpret the operator's inclusive/exclusive semantics + /// correctly so that downstream rounding (driven by the isUpperLimit flag) + /// produces the expected boundaries. + /// + /// >value → exclusive lower bound → treated as an upper-limit rounding case (isUpperLimit = true) + /// >=value → inclusive lower bound → treated as a lower-limit rounding case (isUpperLimit = false) + /// <value → exclusive upper bound → treated as a lower-limit rounding case (isUpperLimit = false) + /// <=value → inclusive upper bound → treated as an upper-limit rounding case (isUpperLimit = true) + /// + [Fact] + public void Parse_GreaterThanWithRounding_RoundsToEndOfPeriod() + { + // >now/d → exclusive lower → isUpperLimit=true → rounds to end of day + // Range: (end_of_today, MaxValue) + var range = DateTimeRange.Parse(">now/d", _now); + Assert.NotEqual(DateTimeRange.Empty, range); + Assert.Equal(_now.EndOfDay(), range.Start); + Assert.Equal(DateTime.MaxValue, range.End); + } + + [Fact] + public void Parse_GreaterThanOrEqualWithRounding_RoundsToStartOfPeriod() + { + // >=now/d → inclusive lower → isUpperLimit=false → rounds to start of day + // Range: [start_of_today, MaxValue) + var range = DateTimeRange.Parse(">=now/d", _now); + Assert.NotEqual(DateTimeRange.Empty, range); + Assert.Equal(_now.StartOfDay(), range.Start); + Assert.Equal(DateTime.MaxValue, range.End); + } + + [Fact] + public void Parse_LessThanWithRounding_RoundsToStartOfPeriod() + { + // now-1h")] + [InlineData(">=now-1h")] + [InlineData("now/M → exclusive lower → isUpperLimit=true → rounds to end of month + var range = DateTimeRange.Parse(">now/M", _now); + Assert.NotEqual(DateTimeRange.Empty, range); + Assert.Equal(_now.EndOfMonth(), range.Start); + Assert.Equal(DateTime.MaxValue, range.End); + } + + [Fact] + public void Parse_LessThanOrEqualWithMonthRounding_RoundsCorrectly() + { + // <=now/M → inclusive upper → isUpperLimit=true → rounds to end of month + var range = DateTimeRange.Parse("<=now/M", _now); + Assert.NotEqual(DateTimeRange.Empty, range); + Assert.Equal(DateTime.MinValue, range.Start); + Assert.Equal(_now.EndOfMonth(), range.End); + } + + [Theory] + [InlineData(">")] + [InlineData(">=")] + [InlineData("<")] + [InlineData("<=")] + [InlineData("> ")] + public void Parse_ComparisonOperatorWithoutExpression_ReturnsEmpty(string input) + { + var range = DateTimeRange.Parse(input, _now); + Assert.Equal(DateTimeRange.Empty, range); + } } diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs index 8787a77..a451bbc 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs @@ -51,18 +51,46 @@ public static IEnumerable Inputs ["{2012 TO 2013]", _now.ChangeYear(2012).EndOfYear(), _now.ChangeYear(2013).EndOfYear()], ["{jan TO feb]", _now.ChangeMonth(1).EndOfMonth(), _now.ChangeMonth(2).EndOfMonth()], - // Wildcard support + // Wildcard support — wildcards always return min/max regardless of bracket type ["* TO 2013", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear()], ["2012 TO *", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue], ["[* TO 2013]", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear()], + ["{* TO 2013}", DateTime.MinValue, _now.ChangeYear(2013).StartOfYear()], ["{2012 TO *}", _now.ChangeYear(2012).EndOfYear(), DateTime.MaxValue], + ["[2012 TO *}", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue], + + // Bracket-aware rounding with /d — all four bracket combinations + // [ = inclusive (gte) rounds to start of day, ] = inclusive (lte) rounds to end of day + // { = exclusive (gt) rounds to end of day, } = exclusive (lt) rounds to start of day + ["[now-7d/d TO now/d]", _now.SubtractDays(7).StartOfDay(), _now.EndOfDay()], + ["{now-7d/d TO now/d}", _now.SubtractDays(7).EndOfDay(), _now.StartOfDay()], + ["[now-7d/d TO now/d}", _now.SubtractDays(7).StartOfDay(), _now.StartOfDay()], + ["{now-7d/d TO now/d]", _now.SubtractDays(7).EndOfDay(), _now.EndOfDay()], + + // Bracket-aware rounding with /M — all four bracket combinations + ["[now/M TO now+2M/M]", _now.StartOfMonth(), _now.AddMonths(2).EndOfMonth()], + ["{now/M TO now+2M/M}", _now.EndOfMonth(), _now.AddMonths(2).StartOfMonth()], + ["[now/M TO now+2M/M}", _now.StartOfMonth(), _now.AddMonths(2).StartOfMonth()], + ["{now/M TO now+2M/M]", _now.EndOfMonth(), _now.AddMonths(2).EndOfMonth()], + + // Bracket-aware rounding with /h — all four bracket combinations + ["[now-3h/h TO now/h]", _now.AddHours(-3).StartOfHour(), _now.EndOfHour()], + ["{now-3h/h TO now/h}", _now.AddHours(-3).EndOfHour(), _now.StartOfHour()], + ["[now-3h/h TO now/h}", _now.AddHours(-3).StartOfHour(), _now.StartOfHour()], + ["{now-3h/h TO now/h]", _now.AddHours(-3).EndOfHour(), _now.EndOfHour()], + + // Bracket-aware rounding with /y — all four bracket combinations + ["[now-2y/y TO now/y]", _now.AddYears(-2).StartOfYear(), _now.EndOfYear()], + ["{now-2y/y TO now/y}", _now.AddYears(-2).EndOfYear(), _now.StartOfYear()], + ["[now-2y/y TO now/y}", _now.AddYears(-2).StartOfYear(), _now.StartOfYear()], + ["{now-2y/y TO now/y]", _now.AddYears(-2).EndOfYear(), _now.EndOfYear()], // Invalid inputs ["blah", null, null], ["[invalid", null, null], ["invalid}", null, null], - // Mismatched bracket validation + // Invalid bracket validation ["}2012 TO 2013{", null, null], // Wrong orientation ["]2012 TO 2013[", null, null], // Wrong orientation ["[2012 TO 2013", null, null], // Missing closing bracket