Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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("<now/d", now); // Min to start of today
var throughToday = DateTimeRange.Parse("<=now/d", now); // Min to end of today
```

##### Rounding Behavior with Boundaries

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):

| Boundary | Bracket | Operator | Rounding direction | `now/d` resolves to |
| ---------- | --------- | ---------- | -------------------- | --------------------- |
| Inclusive lower | `[` | `>=` | 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:

Expand Down
12 changes: 7 additions & 5 deletions src/Exceptionless.DateTimeExtensions/DateMath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(?<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)?)?)\|\|)" +
@"(?<operations>(?:[+\-/]\d*[yMwdhHms])*)(?=\s|$|[\]\}])",
@"(?<operations>(?:(?:[+\-]|\\?/)\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);
Expand Down Expand Up @@ -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)
Expand All @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Parses short-form comparison operators (>, >=, &lt;, &lt;=) 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
/// &lt;value → exclusive upper bound → isUpperLimit = false → rounds to start of period
/// &lt;=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
/// &lt;now/d → range from MinValue to start of today
/// &lt;=now/d → range from MinValue to end of today
/// </summary>
[Priority(24)]
public class ComparisonFormatParser : IFormatParser
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this used?

{
private static readonly Regex _operatorRegex = new(@"^\s*(>=?|<=?)\s*", RegexOptions.Compiled);

public ComparisonFormatParser()
{
Parsers = new List<IPartParser>(DateTimeRange.PartParsers);
}

public List<IPartParser> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 != '{';
Expand Down Expand Up @@ -124,7 +125,7 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)

/// <summary>
/// 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: [/], [/}, {/], {/}.
/// </summary>
private static bool IsValidBracketPair(char? opening, char? closing)
{
Expand Down
36 changes: 36 additions & 0 deletions tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -905,4 +905,40 @@ public void IsValidExpression_CaseSensitiveInputs_ValidatesCorrectly()
Assert.False(DateMath.IsValidExpression("Now-7d"));
Assert.False(DateMath.IsValidExpression("NOW-7d"));
}

/// <summary>
/// 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).
/// </summary>
[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));
}
}
98 changes: 98 additions & 0 deletions tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -438,4 +438,102 @@ public void Parse_MixedBracketsWithDateMathOperations_ParsesCorrectly()
Assert.Equal(baseTime.AddDays(-1).StartOfDay(), range.Start);
Assert.Equal(baseTime.StartOfDay(), range.End);
}

/// <summary>
/// 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)
/// &lt;value → exclusive upper bound → treated as a lower-limit rounding case (isUpperLimit = false)
/// &lt;=value → inclusive upper bound → treated as an upper-limit rounding case (isUpperLimit = true)
/// </summary>
[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/d → exclusive upper → isUpperLimit=false → rounds to start of day
// Range: (MinValue, start_of_today)
var range = DateTimeRange.Parse("<now/d", _now);
Assert.NotEqual(DateTimeRange.Empty, range);
Assert.Equal(DateTime.MinValue, range.Start);
Assert.Equal(_now.StartOfDay(), range.End);
}

[Fact]
public void Parse_LessThanOrEqualWithRounding_RoundsToEndOfPeriod()
{
// <=now/d → inclusive upper → isUpperLimit=true → rounds to end of day
// Range: (MinValue, end_of_today]
var range = DateTimeRange.Parse("<=now/d", _now);
Assert.NotEqual(DateTimeRange.Empty, range);
Assert.Equal(DateTime.MinValue, range.Start);
Assert.Equal(_now.EndOfDay(), range.End);
}

[Theory]
[InlineData(">now-1h")]
[InlineData(">=now-1h")]
[InlineData("<now+1h")]
[InlineData("<=now+1h")]
public void Parse_ComparisonOperatorsWithoutRounding_ParseSuccessfully(string input)
{
var range = DateTimeRange.Parse(input, _now);
Assert.NotEqual(DateTimeRange.Empty, range);
}

[Fact]
public void Parse_GreaterThanWithMonthRounding_RoundsCorrectly()
{
// >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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,46 @@ public static IEnumerable<object[]> 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
Expand Down