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
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,61 @@ 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

#### Rounding with Inclusive/Exclusive Ranges

Rounding behavior changes depending on whether a range boundary is inclusive or exclusive, following [Elasticsearch's conventions](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html#range-query-date-math-rounding):

**Inclusive boundaries** round to maximize the matched range:

- Inclusive min (`[`): rounds **down** (start of period) -- e.g., `[now/d` rounds to start of today
- Inclusive max (`]`): rounds **up** (end of period) -- e.g., `now/d]` rounds to end of today

**Exclusive boundaries** round to minimize the matched range:

- Exclusive min (`{`): rounds **up** (end of period) -- e.g., `{now/d` rounds to end of today
- Exclusive max (`}`): rounds **down** (start of period) -- e.g., `now/d}` rounds to start of today

All four bracket combinations are supported (including mixed):

| Query | Rounding | Effective |
| ----- | -------- | --------- |
| `[now/d TO now/d]` | min: start, max: end | Entire current day |
| `[now/d TO now/d}` | min: start, max: start | Empty (start = start) |
| `{now/d TO now/d]` | min: end, max: end | Empty (end = end) |
| `[now/M TO now/M]` | min: start of month, max: end of month | Entire current month |
| `[now/h TO now/h]` | min: start of hour, max: end of hour | Entire current hour |

Common date range patterns:

```text
// Today (start of day through end of day)
[now/d TO now/d]

// Yesterday
[now-1d/d TO now-1d/d]

// This month
[now/M TO now/M]

// Last month
[now-1M/M TO now-1M/M]

// Last 7 full days (not including today)
[now-7d/d TO now-1d/d]

// Last 30 days (rolling, including partial today)
[now-30d TO now]

// This week
[now/w TO now/w]

// Last hour
[now-1h/h TO now-1h/h]

// Last 4 full hours (rounded to hour boundaries)
[now-4h/h TO now/h]
```

### 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,55 @@ public TwoPartFormatParser(IEnumerable<IPartParser> parsers, bool includeDefault

public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
{
int index = 0;
var begin = _beginRegex.Match(content, index);
if (String.IsNullOrEmpty(content))
return null;

var begin = _beginRegex.Match(content);
if (!begin.Success)
return null;

// Capture the opening bracket if present
string openingBracket = begin.Groups[1].Value;
string openingValue = begin.Groups[1].Value;
char? openingBracket = openingValue.Length > 0 ? openingValue[0] : (char?)null;

index += begin.Length;
// Scan backwards from end of string to find closing bracket character.
// This is cheaper than a regex and lets us determine max inclusivity upfront.
char? closingBracket = null;
for (int pos = content.Length - 1; pos >= 0; pos--)
{
char ch = content[pos];
if (ch is ']' or '}')
{
closingBracket = ch;
break;
}

if (!Char.IsWhiteSpace(ch))
break;
}

if (!IsValidBracketPair(openingBracket, closingBracket))
return null;

// Inclusive min ([): round down (start of period) — ">= start"
// Exclusive min ({): round up (end of period) — "> end"
bool minInclusive = openingBracket != '{';

// Inclusive max (]): round up (end of period) — "<= end"
// Exclusive max (}): round down (start of period) — "< start"
bool maxInclusive = closingBracket != '}';

int index = begin.Length;
DateTimeOffset? start = null;
foreach (var parser in Parsers)
{
var match = parser.Regex.Match(content, index);
if (!match.Success)
continue;

start = parser.Parse(match, relativeBaseTime, false);
// Wildcard parsers use isUpperLimit for position (min/max), not rounding.
// For non-wildcard parsers, bracket inclusivity determines rounding direction.
bool isUpperLimit = parser is not WildcardPartParser && !minInclusive;
start = parser.Parse(match, relativeBaseTime, isUpperLimit);
if (start == null)
continue;

Expand All @@ -65,7 +97,8 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
if (!match.Success)
continue;

end = parser.Parse(match, relativeBaseTime, true);
bool isUpperLimit = parser is WildcardPartParser || maxInclusive;
end = parser.Parse(match, relativeBaseTime, isUpperLimit);
if (end == null)
continue;

Expand All @@ -77,32 +110,32 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
if (!endMatch.Success)
return null;

// Validate bracket matching
string closingBracket = endMatch.Groups[1].Value;
if (!IsValidBracketPair(openingBracket, closingBracket))
return null;
var rangeStart = start ?? DateTimeOffset.MinValue;
var rangeEnd = end ?? DateTimeOffset.MaxValue;

// Bracket-aware rounding can produce start > end (e.g., "{now/d TO now/d}" yields
// end-of-day then start-of-day). Collapse to a single instant rather than letting
// DateTimeRange reorder the bounds and unintentionally expand the range.
if (rangeStart > rangeEnd)
return new DateTimeRange(rangeEnd, rangeEnd);

return new DateTimeRange(start ?? DateTime.MinValue, end ?? DateTime.MaxValue);
return new DateTimeRange(rangeStart, rangeEnd);
}

/// <summary>
/// Validates that opening and closing brackets are properly matched.
/// Validates that opening and closing brackets form a valid pair.
/// Both Elasticsearch bracket types can be mixed: [ with ], [ with }, { with ], { with }.
/// </summary>
/// <param name="opening">The opening bracket character</param>
/// <param name="closing">The closing bracket character</param>
/// <returns>True if brackets are properly matched, false otherwise</returns>
private static bool IsValidBracketPair(string opening, string closing)
private static bool IsValidBracketPair(char? opening, char? closing)
{
// Both empty - valid (no brackets)
if (String.IsNullOrEmpty(opening) && String.IsNullOrEmpty(closing))
if (opening == null && closing == null)
return true;

// One empty, one not - invalid (unbalanced)
if (String.IsNullOrEmpty(opening) || String.IsNullOrEmpty(closing))
if (opening == null || closing == null)
return false;

// Check for proper matching pairs
return (String.Equals(opening, "[") && String.Equals(closing, "]")) ||
(String.Equals(opening, "{") && String.Equals(closing, "}"));
bool validOpening = opening is '[' or '{';
bool validClosing = closing is ']' or '}';
return validOpening && validClosing;
}
}
86 changes: 86 additions & 0 deletions tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -352,4 +352,90 @@ public void Parse_InvalidDateMathExpressions_ReturnsEmptyRange(string input, str
Assert.True(range == DateTimeRange.Empty || range.Start != DateTime.MinValue,
$"{reason}. Input '{input}' should either return empty range or valid fallback parsing");
}

[Fact]
public void Parse_InclusiveBracketsWithDayRounding_ReturnsFullDay()
{
// [now/d TO now/d] — inclusive min rounds down, inclusive max rounds up
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
var range = DateTimeRange.Parse("[now/d TO now/d]", baseTime);

Assert.NotEqual(DateTimeRange.Empty, range);
Assert.Equal(baseTime.StartOfDay(), range.Start);
Assert.Equal(baseTime.EndOfDay(), range.End);
}

[Fact]
public void Parse_ExclusiveBracketsWithDayRounding_InvertsRounding()
{
// {now/d TO now/d} — exclusive min rounds up (end of day), exclusive max rounds down (start of day)
// This produces an inverted pair (end-of-day, start-of-day), which is explicitly collapsed
// to a single instant at the end value (start-of-day) to avoid expanding the range.
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
var range = DateTimeRange.Parse("{now/d TO now/d}", baseTime);

Assert.NotEqual(DateTimeRange.Empty, range);
Assert.Equal(baseTime.StartOfDay(), range.Start);
Assert.Equal(baseTime.StartOfDay(), range.End);
}

[Fact]
public void Parse_InclusiveExclusiveMixedWithDayRounding_StartOfDayToStartOfDay()
{
// [now/d TO now/d} — inclusive min rounds down (start of day), exclusive max rounds down (start of day)
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
var range = DateTimeRange.Parse("[now/d TO now/d}", baseTime);

Assert.NotEqual(DateTimeRange.Empty, range);
Assert.Equal(baseTime.StartOfDay(), range.Start);
Assert.Equal(baseTime.StartOfDay(), range.End);
}

[Fact]
public void Parse_ExclusiveInclusiveMixedWithDayRounding_EndOfDayToEndOfDay()
{
// {now/d TO now/d] — exclusive min rounds up (end of day), inclusive max rounds up (end of day)
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
var range = DateTimeRange.Parse("{now/d TO now/d]", baseTime);

Assert.NotEqual(DateTimeRange.Empty, range);
Assert.Equal(baseTime.EndOfDay(), range.Start);
Assert.Equal(baseTime.EndOfDay(), range.End);
}

[Fact]
public void Parse_InclusiveBracketsWithMonthRounding_ReturnsFullMonth()
{
// [now/M TO now/M] — inclusive min rounds to start of month, inclusive max rounds to end of month
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
var range = DateTimeRange.Parse("[now/M TO now/M]", baseTime);

Assert.NotEqual(DateTimeRange.Empty, range);
Assert.Equal(baseTime.StartOfMonth(), range.Start);
Assert.Equal(baseTime.EndOfMonth(), range.End);
}

[Fact]
public void Parse_InclusiveBracketsWithHourRounding_ReturnsFullHour()
{
// [now/h TO now/h] — inclusive min rounds to start of hour, inclusive max rounds to end of hour
var baseTime = new DateTime(2023, 12, 25, 12, 30, 0);
var range = DateTimeRange.Parse("[now/h TO now/h]", baseTime);

Assert.NotEqual(DateTimeRange.Empty, range);
Assert.Equal(baseTime.StartOfHour(), range.Start);
Assert.Equal(baseTime.EndOfHour(), range.End);
}

[Fact]
public void Parse_MixedBracketsWithDateMathOperations_ParsesCorrectly()
{
// [now-1d/d TO now/d} — inclusive start rounds down, exclusive end rounds down
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
var range = DateTimeRange.Parse("[now-1d/d TO now/d}", baseTime);

Assert.NotEqual(DateTimeRange.Empty, range);
Assert.Equal(baseTime.AddDays(-1).StartOfDay(), range.Start);
Assert.Equal(baseTime.StartOfDay(), range.End);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,25 +33,36 @@ public static IEnumerable<object[]> Inputs
["jan to feb", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth()],
["5 days ago TO now", _now.SubtractDays(5).StartOfDay(), _now],

// Elasticsearch bracket syntax
// Elasticsearch inclusive bracket syntax [inclusive TO inclusive]
["[2012 TO 2013]", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear()],
["{jan TO feb}", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth()],
["[jan TO feb]", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth()],
["[2012-2013]", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear()],

// Elasticsearch exclusive bracket syntax {exclusive TO exclusive}
// Exclusive min rounds up (end of period), exclusive max rounds down (start of period)
["{jan TO feb}", _now.ChangeMonth(1).EndOfMonth(), _now.ChangeMonth(2).StartOfMonth()],
["{2012 TO 2013}", _now.ChangeYear(2012).EndOfYear(), _now.ChangeYear(2013).StartOfYear()],

// Mixed bracket syntax [inclusive TO exclusive}
["[2012 TO 2013}", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).StartOfYear()],
["[jan TO feb}", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).StartOfMonth()],

// Mixed bracket syntax {exclusive TO inclusive]
["{2012 TO 2013]", _now.ChangeYear(2012).EndOfYear(), _now.ChangeYear(2013).EndOfYear()],
["{jan TO feb]", _now.ChangeMonth(1).EndOfMonth(), _now.ChangeMonth(2).EndOfMonth()],

// Wildcard support
["* 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],
["{2012 TO *}", _now.ChangeYear(2012).EndOfYear(), DateTime.MaxValue],

// Invalid 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
["}2012 TO 2013{", null, null], // Wrong orientation
["]2012 TO 2013[", null, null], // Wrong orientation
["[2012 TO 2013", null, null], // Missing closing bracket
Expand Down