Skip to content

Commit 3552051

Browse files
authored
Handle range inclusivity, escaping and short forms (#130)
1 parent dc9b7c0 commit 3552051

File tree

7 files changed

+301
-10
lines changed

7 files changed

+301
-10
lines changed

README.md

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,52 @@ var wildcardRange = DateTimeRange.Parse("[2023-01-01 TO *]", DateTime.Now); // F
5656

5757
#### Date Math Features
5858

59-
Supports full Elasticsearch date math syntax following [official specifications](https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math):
59+
Supports full [Elasticsearch date math syntax](https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math):
6060

6161
- **Anchors**: `now`, explicit dates with `||` separator
62-
- **Operations**: `+1d` (add), `-1h` (subtract), `/d` (round down)
62+
- **Operations**: `+1d` (add), `-1h` (subtract), `/d` (round)
6363
- **Units**: `y` (years), `M` (months), `w` (weeks), `d` (days), `h`/`H` (hours), `m` (minutes), `s` (seconds)
6464
- **Timezone Support**: Preserves explicit timezones (`Z`, `+05:00`, `-08:00`) or uses system timezone as fallback
65+
- **Escaped slashes**: `\/d` is treated identically to `/d` for contexts where `/` must be escaped
66+
67+
##### Real-World Patterns
68+
69+
```csharp
70+
// Common date range queries
71+
var thisMonth = DateTimeRange.Parse("[now/M TO now/M]", now); // Start of month through end of month
72+
var lastMonth = DateTimeRange.Parse("[now-1M/M TO now/M}", now); // Start of last month through start of this month
73+
var yearToDate = DateTimeRange.Parse("[now/y TO now]", now); // Start of year through now
74+
var last7Days = DateTimeRange.Parse("[now-7d/d TO now]", now); // Start of 7 days ago through now
75+
var last15Minutes = DateTimeRange.Parse("[now-15m TO now]", now); // 15 minutes ago through now
76+
var last24Hours = DateTimeRange.Parse("[now-24h TO now]", now); // 24 hours ago through now
77+
var tomorrow = DateTimeRange.Parse("[now+1d/d TO now+1d/d]", now); // Start through end of tomorrow
78+
79+
// Short-form comparison operators
80+
var recentItems = DateTimeRange.Parse(">=now-1h", now); // From 1 hour ago to max
81+
var futureOnly = DateTimeRange.Parse(">now", now); // After now to max
82+
var beforeToday = DateTimeRange.Parse("<now/d", now); // Min to start of today
83+
var throughToday = DateTimeRange.Parse("<=now/d", now); // Min to end of today
84+
```
85+
86+
##### Rounding Behavior with Boundaries
87+
88+
Rounding direction (`/d`, `/M`, etc.) is controlled by boundary inclusivity, following [Elasticsearch range query rounding rules](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html#date-math-rounding):
89+
90+
| Boundary | Bracket | Operator | Rounding direction | `now/d` resolves to |
91+
| ---------- | --------- | ---------- | -------------------- | --------------------- |
92+
| Inclusive lower | `[` | `>=` | Floor (start of period) | `2025-02-16T00:00:00` |
93+
| Exclusive lower | `{` | `>` | Ceiling (end of period) | `2025-02-16T23:59:59.999` |
94+
| Inclusive upper | `]` | `<=` | Ceiling (end of period) | `2025-02-16T23:59:59.999` |
95+
| Exclusive upper | `}` | `<` | Floor (start of period) | `2025-02-16T00:00:00` |
96+
97+
All four bracket combinations are supported: `[]`, `[}`, `{]`, `{}`.
98+
99+
```csharp
100+
// [now/d TO now/d] → start of today through end of today (full day, inclusive both ends)
101+
// [now/d TO now/d} → start of today through start of today (exclusive upper)
102+
// {now/d TO now/d] → end of today through end of today (exclusive lower)
103+
// {now/d TO now/d} → collapsed to start of today (exclusive both ends inverts the range, which is then normalized)
104+
```
65105

66106
Examples:
67107

src/Exceptionless.DateTimeExtensions/DateMath.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,12 @@ public static class DateMath
2929
// https://www.elastic.co/docs/reference/elasticsearch/rest-apis/common-options
3030
internal static readonly Regex Parser = new(
3131
@"\G(?<anchor>now|(?<date>\d{4}-?\d{2}-?\d{2}(?:[T\s](?:\d{1,2}(?::?\d{2}(?::?\d{2})?)?(?:\.\d{1,3})?)?(?:[+-]\d{2}:?\d{2}|Z)?)?)\|\|)" +
32-
@"(?<operations>(?:[+\-/]\d*[yMwdhHms])*)(?=\s|$|[\]\}])",
32+
@"(?<operations>(?:(?:[+\-]|\\?/)\d*[yMwdhHms])*)(?=\s|$|[\]\}])",
3333
RegexOptions.Compiled);
3434

3535
// Pre-compiled regex for operation parsing to avoid repeated compilation
36-
private static readonly Regex _operationRegex = new(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled);
36+
// Supports both / and \/ (escaped forward slash) for rounding operations
37+
private static readonly Regex _operationRegex = new(@"([+\-]|\\?/)(\d*)([yMwdhHms])", RegexOptions.Compiled);
3738

3839
// Pre-compiled regex for offset parsing to avoid repeated compilation
3940
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
461462
throw new ArgumentException("Invalid operations");
462463
}
463464

464-
// Validate that rounding operations (/) are only at the end
465+
// Validate that rounding operations (/ or \/) are only at the end
465466
// According to Elasticsearch spec, rounding must be the final operation
466467
bool foundRounding = false;
467468
for (int i = 0; i < matches.Count; i++)
468469
{
469-
string operation = matches[i].Groups[1].Value;
470+
string operation = matches[i].Groups[1].Value.TrimStart('\\');
470471
if (String.Equals(operation, "/"))
471472
{
472473
if (foundRounding)
@@ -485,7 +486,8 @@ public static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string ope
485486

486487
foreach (Match opMatch in matches)
487488
{
488-
string operation = opMatch.Groups[1].Value;
489+
// Normalize escaped forward slash (\/) to unescaped (/) for rounding operations
490+
string operation = opMatch.Groups[1].Value.TrimStart('\\');
489491
string amountStr = opMatch.Groups[2].Value;
490492
string unit = opMatch.Groups[3].Value;
491493

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using System.Text.RegularExpressions;
5+
using Exceptionless.DateTimeExtensions.FormatParsers.PartParsers;
6+
7+
namespace Exceptionless.DateTimeExtensions.FormatParsers;
8+
9+
/// <summary>
10+
/// Parses short-form comparison operators (>, >=, &lt;, &lt;=) as syntactic sugar for
11+
/// ranges with one open end. The operator determines boundary inclusivity, which
12+
/// controls rounding direction for date math expressions.
13+
///
14+
/// Operator mapping (per Elasticsearch range query semantics):
15+
/// >value → exclusive lower bound → isUpperLimit = true → rounds to end of period
16+
/// >=value → inclusive lower bound → isUpperLimit = false → rounds to start of period
17+
/// &lt;value → exclusive upper bound → isUpperLimit = false → rounds to start of period
18+
/// &lt;=value → inclusive upper bound → isUpperLimit = true → rounds to end of period
19+
///
20+
/// Examples:
21+
/// >now/d → range from end of today to MaxValue
22+
/// >=now/d → range from start of today to MaxValue
23+
/// &lt;now/d → range from MinValue to start of today
24+
/// &lt;=now/d → range from MinValue to end of today
25+
/// </summary>
26+
[Priority(24)]
27+
public class ComparisonFormatParser : IFormatParser
28+
{
29+
private static readonly Regex _operatorRegex = new(@"^\s*(>=?|<=?)\s*", RegexOptions.Compiled);
30+
31+
public ComparisonFormatParser()
32+
{
33+
Parsers = new List<IPartParser>(DateTimeRange.PartParsers);
34+
}
35+
36+
public List<IPartParser> Parsers { get; private set; }
37+
38+
public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
39+
{
40+
var opMatch = _operatorRegex.Match(content);
41+
if (!opMatch.Success)
42+
return null;
43+
44+
string op = opMatch.Groups[1].Value;
45+
int index = opMatch.Length;
46+
47+
// Must have an expression after the operator
48+
if (index >= content.Length || content.Substring(index).Trim().Length == 0)
49+
return null;
50+
51+
// Determine inclusivity from operator
52+
bool isLowerBound = op[0] == '>';
53+
bool isInclusive = op.Length == 2; // >= or <=
54+
55+
// isUpperLimit follows the boundary inclusivity rule:
56+
// For lower bounds: isUpperLimit = !inclusive (gt rounds up, gte rounds down)
57+
// For upper bounds: isUpperLimit = inclusive (lte rounds up, lt rounds down)
58+
bool isUpperLimit = isLowerBound ? !isInclusive : isInclusive;
59+
60+
DateTimeOffset? value = null;
61+
foreach (var parser in Parsers.Where(p => p is not WildcardPartParser))
62+
{
63+
var match = parser.Regex.Match(content, index);
64+
if (!match.Success)
65+
continue;
66+
67+
value = parser.Parse(match, relativeBaseTime, isUpperLimit);
68+
if (value == null)
69+
continue;
70+
71+
index += match.Length;
72+
break;
73+
}
74+
75+
if (value == null)
76+
return null;
77+
78+
// Verify entire input was consumed (only trailing whitespace allowed)
79+
if (index < content.Length && content.Substring(index).Trim().Length > 0)
80+
return null;
81+
82+
return isLowerBound
83+
? new DateTimeRange(value.Value, DateTimeOffset.MaxValue)
84+
: new DateTimeRange(DateTimeOffset.MinValue, value.Value);
85+
}
86+
}

src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
5757
if (!IsValidBracketPair(openingBracket, closingBracket))
5858
return null;
5959

60+
// Determine isUpperLimit from bracket inclusivity (per Elasticsearch date math rounding spec):
6061
// Inclusive min ([): round down (start of period) — ">= start"
6162
// Exclusive min ({): round up (end of period) — "> end"
6263
bool minInclusive = openingBracket != '{';
@@ -124,7 +125,7 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
124125

125126
/// <summary>
126127
/// Validates that opening and closing brackets form a valid pair.
127-
/// Both Elasticsearch bracket types can be mixed: [ with ], [ with }, { with ], { with }.
128+
/// All four Elasticsearch bracket combinations are valid: [/], [/}, {/], {/}.
128129
/// </summary>
129130
private static bool IsValidBracketPair(char? opening, char? closing)
130131
{

tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,4 +905,40 @@ public void IsValidExpression_CaseSensitiveInputs_ValidatesCorrectly()
905905
Assert.False(DateMath.IsValidExpression("Now-7d"));
906906
Assert.False(DateMath.IsValidExpression("NOW-7d"));
907907
}
908+
909+
/// <summary>
910+
/// Tests that escaped forward slash (\/) is handled identically to unescaped forward slash (/)
911+
/// in date math rounding operations. This is important for contexts where / is escaped
912+
/// (e.g., Lucene query syntax).
913+
/// </summary>
914+
[Theory]
915+
[InlineData("now/d", @"now\/d", false)]
916+
[InlineData("now/d", @"now\/d", true)]
917+
[InlineData("now+1d/d", @"now+1d\/d", false)]
918+
[InlineData("now+1d/d", @"now+1d\/d", true)]
919+
[InlineData("now-1M/M", @"now-1M\/M", false)]
920+
[InlineData("now-1M/M", @"now-1M\/M", true)]
921+
[InlineData("now/h", @"now\/h", false)]
922+
[InlineData("now/h", @"now\/h", true)]
923+
public void Parse_EscapedAndUnescapedSlash_ProduceIdenticalResults(string unescaped, string escaped, bool isUpperLimit)
924+
{
925+
_logger.LogDebug("Testing escaped vs unescaped: '{Unescaped}' vs '{Escaped}', IsUpperLimit: {IsUpperLimit}",
926+
unescaped, escaped, isUpperLimit);
927+
928+
var unescapedResult = DateMath.Parse(unescaped, _baseTime, isUpperLimit);
929+
var escapedResult = DateMath.Parse(escaped, _baseTime, isUpperLimit);
930+
931+
_logger.LogDebug("Unescaped result: {Unescaped}, Escaped result: {Escaped}", unescapedResult, escapedResult);
932+
933+
Assert.Equal(unescapedResult, escapedResult);
934+
}
935+
936+
[Theory]
937+
[InlineData(@"now\/d")]
938+
[InlineData(@"now+1h\/h")]
939+
[InlineData(@"now-1d\/d")]
940+
public void IsValidExpression_EscapedSlash_ReturnsTrue(string expression)
941+
{
942+
Assert.True(DateMath.IsValidExpression(expression));
943+
}
908944
}

tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,4 +438,102 @@ public void Parse_MixedBracketsWithDateMathOperations_ParsesCorrectly()
438438
Assert.Equal(baseTime.AddDays(-1).StartOfDay(), range.Start);
439439
Assert.Equal(baseTime.StartOfDay(), range.End);
440440
}
441+
442+
/// <summary>
443+
/// Short-form operators are syntactic sugar for ranges with one open end.
444+
/// The parser must interpret the operator's inclusive/exclusive semantics
445+
/// correctly so that downstream rounding (driven by the isUpperLimit flag)
446+
/// produces the expected boundaries.
447+
///
448+
/// >value → exclusive lower bound → treated as an upper-limit rounding case (isUpperLimit = true)
449+
/// >=value → inclusive lower bound → treated as a lower-limit rounding case (isUpperLimit = false)
450+
/// &lt;value → exclusive upper bound → treated as a lower-limit rounding case (isUpperLimit = false)
451+
/// &lt;=value → inclusive upper bound → treated as an upper-limit rounding case (isUpperLimit = true)
452+
/// </summary>
453+
[Fact]
454+
public void Parse_GreaterThanWithRounding_RoundsToEndOfPeriod()
455+
{
456+
// >now/d → exclusive lower → isUpperLimit=true → rounds to end of day
457+
// Range: (end_of_today, MaxValue)
458+
var range = DateTimeRange.Parse(">now/d", _now);
459+
Assert.NotEqual(DateTimeRange.Empty, range);
460+
Assert.Equal(_now.EndOfDay(), range.Start);
461+
Assert.Equal(DateTime.MaxValue, range.End);
462+
}
463+
464+
[Fact]
465+
public void Parse_GreaterThanOrEqualWithRounding_RoundsToStartOfPeriod()
466+
{
467+
// >=now/d → inclusive lower → isUpperLimit=false → rounds to start of day
468+
// Range: [start_of_today, MaxValue)
469+
var range = DateTimeRange.Parse(">=now/d", _now);
470+
Assert.NotEqual(DateTimeRange.Empty, range);
471+
Assert.Equal(_now.StartOfDay(), range.Start);
472+
Assert.Equal(DateTime.MaxValue, range.End);
473+
}
474+
475+
[Fact]
476+
public void Parse_LessThanWithRounding_RoundsToStartOfPeriod()
477+
{
478+
// <now/d → exclusive upper → isUpperLimit=false → rounds to start of day
479+
// Range: (MinValue, start_of_today)
480+
var range = DateTimeRange.Parse("<now/d", _now);
481+
Assert.NotEqual(DateTimeRange.Empty, range);
482+
Assert.Equal(DateTime.MinValue, range.Start);
483+
Assert.Equal(_now.StartOfDay(), range.End);
484+
}
485+
486+
[Fact]
487+
public void Parse_LessThanOrEqualWithRounding_RoundsToEndOfPeriod()
488+
{
489+
// <=now/d → inclusive upper → isUpperLimit=true → rounds to end of day
490+
// Range: (MinValue, end_of_today]
491+
var range = DateTimeRange.Parse("<=now/d", _now);
492+
Assert.NotEqual(DateTimeRange.Empty, range);
493+
Assert.Equal(DateTime.MinValue, range.Start);
494+
Assert.Equal(_now.EndOfDay(), range.End);
495+
}
496+
497+
[Theory]
498+
[InlineData(">now-1h")]
499+
[InlineData(">=now-1h")]
500+
[InlineData("<now+1h")]
501+
[InlineData("<=now+1h")]
502+
public void Parse_ComparisonOperatorsWithoutRounding_ParseSuccessfully(string input)
503+
{
504+
var range = DateTimeRange.Parse(input, _now);
505+
Assert.NotEqual(DateTimeRange.Empty, range);
506+
}
507+
508+
[Fact]
509+
public void Parse_GreaterThanWithMonthRounding_RoundsCorrectly()
510+
{
511+
// >now/M → exclusive lower → isUpperLimit=true → rounds to end of month
512+
var range = DateTimeRange.Parse(">now/M", _now);
513+
Assert.NotEqual(DateTimeRange.Empty, range);
514+
Assert.Equal(_now.EndOfMonth(), range.Start);
515+
Assert.Equal(DateTime.MaxValue, range.End);
516+
}
517+
518+
[Fact]
519+
public void Parse_LessThanOrEqualWithMonthRounding_RoundsCorrectly()
520+
{
521+
// <=now/M → inclusive upper → isUpperLimit=true → rounds to end of month
522+
var range = DateTimeRange.Parse("<=now/M", _now);
523+
Assert.NotEqual(DateTimeRange.Empty, range);
524+
Assert.Equal(DateTime.MinValue, range.Start);
525+
Assert.Equal(_now.EndOfMonth(), range.End);
526+
}
527+
528+
[Theory]
529+
[InlineData(">")]
530+
[InlineData(">=")]
531+
[InlineData("<")]
532+
[InlineData("<=")]
533+
[InlineData("> ")]
534+
public void Parse_ComparisonOperatorWithoutExpression_ReturnsEmpty(string input)
535+
{
536+
var range = DateTimeRange.Parse(input, _now);
537+
Assert.Equal(DateTimeRange.Empty, range);
538+
}
441539
}

tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,46 @@ public static IEnumerable<object[]> Inputs
5151
["{2012 TO 2013]", _now.ChangeYear(2012).EndOfYear(), _now.ChangeYear(2013).EndOfYear()],
5252
["{jan TO feb]", _now.ChangeMonth(1).EndOfMonth(), _now.ChangeMonth(2).EndOfMonth()],
5353

54-
// Wildcard support
54+
// Wildcard support — wildcards always return min/max regardless of bracket type
5555
["* TO 2013", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear()],
5656
["2012 TO *", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue],
5757
["[* TO 2013]", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear()],
58+
["{* TO 2013}", DateTime.MinValue, _now.ChangeYear(2013).StartOfYear()],
5859
["{2012 TO *}", _now.ChangeYear(2012).EndOfYear(), DateTime.MaxValue],
60+
["[2012 TO *}", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue],
61+
62+
// Bracket-aware rounding with /d — all four bracket combinations
63+
// [ = inclusive (gte) rounds to start of day, ] = inclusive (lte) rounds to end of day
64+
// { = exclusive (gt) rounds to end of day, } = exclusive (lt) rounds to start of day
65+
["[now-7d/d TO now/d]", _now.SubtractDays(7).StartOfDay(), _now.EndOfDay()],
66+
["{now-7d/d TO now/d}", _now.SubtractDays(7).EndOfDay(), _now.StartOfDay()],
67+
["[now-7d/d TO now/d}", _now.SubtractDays(7).StartOfDay(), _now.StartOfDay()],
68+
["{now-7d/d TO now/d]", _now.SubtractDays(7).EndOfDay(), _now.EndOfDay()],
69+
70+
// Bracket-aware rounding with /M — all four bracket combinations
71+
["[now/M TO now+2M/M]", _now.StartOfMonth(), _now.AddMonths(2).EndOfMonth()],
72+
["{now/M TO now+2M/M}", _now.EndOfMonth(), _now.AddMonths(2).StartOfMonth()],
73+
["[now/M TO now+2M/M}", _now.StartOfMonth(), _now.AddMonths(2).StartOfMonth()],
74+
["{now/M TO now+2M/M]", _now.EndOfMonth(), _now.AddMonths(2).EndOfMonth()],
75+
76+
// Bracket-aware rounding with /h — all four bracket combinations
77+
["[now-3h/h TO now/h]", _now.AddHours(-3).StartOfHour(), _now.EndOfHour()],
78+
["{now-3h/h TO now/h}", _now.AddHours(-3).EndOfHour(), _now.StartOfHour()],
79+
["[now-3h/h TO now/h}", _now.AddHours(-3).StartOfHour(), _now.StartOfHour()],
80+
["{now-3h/h TO now/h]", _now.AddHours(-3).EndOfHour(), _now.EndOfHour()],
81+
82+
// Bracket-aware rounding with /y — all four bracket combinations
83+
["[now-2y/y TO now/y]", _now.AddYears(-2).StartOfYear(), _now.EndOfYear()],
84+
["{now-2y/y TO now/y}", _now.AddYears(-2).EndOfYear(), _now.StartOfYear()],
85+
["[now-2y/y TO now/y}", _now.AddYears(-2).StartOfYear(), _now.StartOfYear()],
86+
["{now-2y/y TO now/y]", _now.AddYears(-2).EndOfYear(), _now.EndOfYear()],
5987

6088
// Invalid inputs
6189
["blah", null, null],
6290
["[invalid", null, null],
6391
["invalid}", null, null],
6492

65-
// Mismatched bracket validation
93+
// Invalid bracket validation
6694
["}2012 TO 2013{", null, null], // Wrong orientation
6795
["]2012 TO 2013[", null, null], // Wrong orientation
6896
["[2012 TO 2013", null, null], // Missing closing bracket

0 commit comments

Comments
 (0)