Skip to content

Commit c06ead3

Browse files
Fix date math rounding to respect inclusive/exclusive bracket boundaries
Previously, TwoPartFormatParser always passed isUpperLimit=false for the start side and isUpperLimit=true for the end side of a range, regardless of bracket type. This is only correct for inclusive [...] brackets. Per Elasticsearch conventions, rounding behavior must change based on whether each boundary is inclusive or exclusive: - Inclusive min ([): rounds down (start of period) — gte semantics - Exclusive min ({): rounds up (end of period) — gt semantics - Inclusive max (]): rounds up (end of period) — lte semantics - Exclusive max (}): rounds down (start of period) — lt semantics This means [now/d TO now/d] correctly produces the entire day (start to end), while {now/d TO now/d} collapses, and mixed brackets like [now/d TO now/d} produce start-of-day to start-of-day. Changes: - Pre-scan closing bracket before parsing parts so isUpperLimit is known upfront (uses a simple backwards char scan, no extra regex) - Pass bracket-aware isUpperLimit to part parsers; wildcard parsers still receive positional isUpperLimit (false=min, true=max) since they use it for position semantics rather than rounding - Allow mixed bracket pairs ([..}, {..]) per Elasticsearch Lucene syntax - Add comprehensive tests for all four bracket combinations with /d, /M, /h rounding and mixed date math operations - Document rounding behavior in README with reference table and common date range patterns Ref: FoundatioFx/Foundatio.Lucene@a8426ab Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ff789a9 commit c06ead3

4 files changed

Lines changed: 196 additions & 18 deletions

File tree

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,61 @@ Examples:
7070
- `2025-01-01T01:25:35Z||+3d/d` - January 4th, 2025 (start of day) in UTC
7171
- `2023-06-15T14:30:00+05:00||+1M-2d` - One month minus 2 days from the specified date/time in +05:00 timezone
7272

73+
#### Rounding with Inclusive/Exclusive Ranges
74+
75+
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):
76+
77+
**Inclusive boundaries** round to maximize the matched range:
78+
79+
- Inclusive min (`[`): rounds **down** (start of period) -- e.g., `[now/d` rounds to start of today
80+
- Inclusive max (`]`): rounds **up** (end of period) -- e.g., `now/d]` rounds to end of today
81+
82+
**Exclusive boundaries** round to minimize the matched range:
83+
84+
- Exclusive min (`{`): rounds **up** (end of period) -- e.g., `{now/d` rounds to end of today
85+
- Exclusive max (`}`): rounds **down** (start of period) -- e.g., `now/d}` rounds to start of today
86+
87+
All four bracket combinations are supported (including mixed):
88+
89+
| Query | Rounding | Effective |
90+
| ----- | -------- | --------- |
91+
| `[now/d TO now/d]` | min: start, max: end | Entire current day |
92+
| `[now/d TO now/d}` | min: start, max: start | Empty (start = start) |
93+
| `{now/d TO now/d]` | min: end, max: end | Empty (end = end) |
94+
| `[now/M TO now/M]` | min: start of month, max: end of month | Entire current month |
95+
| `[now/h TO now/h]` | min: start of hour, max: end of hour | Entire current hour |
96+
97+
Common date range patterns:
98+
99+
```text
100+
// Today (start of day through end of day)
101+
[now/d TO now/d]
102+
103+
// Yesterday
104+
[now-1d/d TO now-1d/d]
105+
106+
// This month
107+
[now/M TO now/M]
108+
109+
// Last month
110+
[now-1M/M TO now-1M/M]
111+
112+
// Last 7 full days (not including today)
113+
[now-7d/d TO now-1d/d]
114+
115+
// Last 30 days (rolling, including partial today)
116+
[now-30d TO now]
117+
118+
// This week
119+
[now/w TO now/w]
120+
121+
// Last hour
122+
[now-1h/h TO now-1h/h]
123+
124+
// Last 4 full hours (rounded to hour boundaries)
125+
[now-4h/h TO now/h]
126+
```
127+
73128
### DateMath Utility
74129

75130
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.

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

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,35 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
3333
if (!begin.Success)
3434
return null;
3535

36-
// Capture the opening bracket if present
3736
string openingBracket = begin.Groups[1].Value;
3837

38+
// Scan backwards from end of string to find closing bracket character.
39+
// This is cheaper than a regex and lets us determine max inclusivity upfront.
40+
string closingBracket = "";
41+
for (int i = content.Length - 1; i >= 0; i--)
42+
{
43+
char c = content[i];
44+
if (c == ']' || c == '}')
45+
{
46+
closingBracket = c.ToString();
47+
break;
48+
}
49+
50+
if (!Char.IsWhiteSpace(c))
51+
break;
52+
}
53+
54+
if (!IsValidBracketPair(openingBracket, closingBracket))
55+
return null;
56+
57+
// Inclusive min ([): round down (start of period) — ">= start"
58+
// Exclusive min ({): round up (end of period) — "> end"
59+
bool minInclusive = !String.Equals(openingBracket, "{");
60+
61+
// Inclusive max (]): round up (end of period) — "<= end"
62+
// Exclusive max (}): round down (start of period) — "< start"
63+
bool maxInclusive = !String.Equals(closingBracket, "}");
64+
3965
index += begin.Length;
4066
DateTimeOffset? start = null;
4167
foreach (var parser in Parsers)
@@ -44,7 +70,10 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
4470
if (!match.Success)
4571
continue;
4672

47-
start = parser.Parse(match, relativeBaseTime, false);
73+
// Wildcard parsers use isUpperLimit for position (min/max), not rounding.
74+
// For non-wildcard parsers, bracket inclusivity determines rounding direction.
75+
bool isUpperLimit = parser is WildcardPartParser ? false : !minInclusive;
76+
start = parser.Parse(match, relativeBaseTime, isUpperLimit);
4877
if (start == null)
4978
continue;
5079

@@ -65,7 +94,8 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
6594
if (!match.Success)
6695
continue;
6796

68-
end = parser.Parse(match, relativeBaseTime, true);
97+
bool isUpperLimit = parser is WildcardPartParser ? true : maxInclusive;
98+
end = parser.Parse(match, relativeBaseTime, isUpperLimit);
6999
if (end == null)
70100
continue;
71101

@@ -77,20 +107,16 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
77107
if (!endMatch.Success)
78108
return null;
79109

80-
// Validate bracket matching
81-
string closingBracket = endMatch.Groups[1].Value;
82-
if (!IsValidBracketPair(openingBracket, closingBracket))
83-
return null;
84-
85110
return new DateTimeRange(start ?? DateTime.MinValue, end ?? DateTime.MaxValue);
86111
}
87112

88113
/// <summary>
89-
/// Validates that opening and closing brackets are properly matched.
114+
/// Validates that opening and closing brackets form a valid pair.
115+
/// Both Elasticsearch bracket types can be mixed: [ with ], [ with }, { with ], { with }.
90116
/// </summary>
91117
/// <param name="opening">The opening bracket character</param>
92118
/// <param name="closing">The closing bracket character</param>
93-
/// <returns>True if brackets are properly matched, false otherwise</returns>
119+
/// <returns>True if brackets are properly paired, false otherwise</returns>
94120
private static bool IsValidBracketPair(string opening, string closing)
95121
{
96122
// Both empty - valid (no brackets)
@@ -101,8 +127,9 @@ private static bool IsValidBracketPair(string opening, string closing)
101127
if (String.IsNullOrEmpty(opening) || String.IsNullOrEmpty(closing))
102128
return false;
103129

104-
// Check for proper matching pairs
105-
return (String.Equals(opening, "[") && String.Equals(closing, "]")) ||
106-
(String.Equals(opening, "{") && String.Equals(closing, "}"));
130+
// Any valid opening bracket ([, {) can pair with any valid closing bracket (], })
131+
bool validOpening = String.Equals(opening, "[") || String.Equals(opening, "{");
132+
bool validClosing = String.Equals(closing, "]") || String.Equals(closing, "}");
133+
return validOpening && validClosing;
107134
}
108135
}

tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,4 +352,89 @@ public void Parse_InvalidDateMathExpressions_ReturnsEmptyRange(string input, str
352352
Assert.True(range == DateTimeRange.Empty || range.Start != DateTime.MinValue,
353353
$"{reason}. Input '{input}' should either return empty range or valid fallback parsing");
354354
}
355+
356+
[Fact]
357+
public void Parse_InclusiveBracketsWithDayRounding_ReturnsFullDay()
358+
{
359+
// [now/d TO now/d] — inclusive min rounds down, inclusive max rounds up
360+
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
361+
var range = DateTimeRange.Parse("[now/d TO now/d]", baseTime);
362+
363+
Assert.NotEqual(DateTimeRange.Empty, range);
364+
Assert.Equal(baseTime.StartOfDay(), range.Start);
365+
Assert.Equal(baseTime.EndOfDay(), range.End);
366+
}
367+
368+
[Fact]
369+
public void Parse_ExclusiveBracketsWithDayRounding_InvertsRounding()
370+
{
371+
// {now/d TO now/d} — exclusive min rounds up (end of day), exclusive max rounds down (start of day)
372+
// Since end-of-day > start-of-day, DateTimeRange normalizes to a collapsed range
373+
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
374+
var range = DateTimeRange.Parse("{now/d TO now/d}", baseTime);
375+
376+
Assert.NotEqual(DateTimeRange.Empty, range);
377+
Assert.Equal(baseTime.StartOfDay(), range.Start);
378+
Assert.Equal(baseTime.StartOfDay(), range.End);
379+
}
380+
381+
[Fact]
382+
public void Parse_InclusiveExclusiveMixedWithDayRounding_StartOfDayToStartOfDay()
383+
{
384+
// [now/d TO now/d} — inclusive min rounds down (start of day), exclusive max rounds down (start of day)
385+
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
386+
var range = DateTimeRange.Parse("[now/d TO now/d}", baseTime);
387+
388+
Assert.NotEqual(DateTimeRange.Empty, range);
389+
Assert.Equal(baseTime.StartOfDay(), range.Start);
390+
Assert.Equal(baseTime.StartOfDay(), range.End);
391+
}
392+
393+
[Fact]
394+
public void Parse_ExclusiveInclusiveMixedWithDayRounding_EndOfDayToEndOfDay()
395+
{
396+
// {now/d TO now/d] — exclusive min rounds up (end of day), inclusive max rounds up (end of day)
397+
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
398+
var range = DateTimeRange.Parse("{now/d TO now/d]", baseTime);
399+
400+
Assert.NotEqual(DateTimeRange.Empty, range);
401+
Assert.Equal(baseTime.EndOfDay(), range.Start);
402+
Assert.Equal(baseTime.EndOfDay(), range.End);
403+
}
404+
405+
[Fact]
406+
public void Parse_InclusiveBracketsWithMonthRounding_ReturnsFullMonth()
407+
{
408+
// [now/M TO now/M] — inclusive min rounds to start of month, inclusive max rounds to end of month
409+
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
410+
var range = DateTimeRange.Parse("[now/M TO now/M]", baseTime);
411+
412+
Assert.NotEqual(DateTimeRange.Empty, range);
413+
Assert.Equal(baseTime.StartOfMonth(), range.Start);
414+
Assert.Equal(baseTime.EndOfMonth(), range.End);
415+
}
416+
417+
[Fact]
418+
public void Parse_InclusiveBracketsWithHourRounding_ReturnsFullHour()
419+
{
420+
// [now/h TO now/h] — inclusive min rounds to start of hour, inclusive max rounds to end of hour
421+
var baseTime = new DateTime(2023, 12, 25, 12, 30, 0);
422+
var range = DateTimeRange.Parse("[now/h TO now/h]", baseTime);
423+
424+
Assert.NotEqual(DateTimeRange.Empty, range);
425+
Assert.Equal(baseTime.StartOfHour(), range.Start);
426+
Assert.Equal(baseTime.EndOfHour(), range.End);
427+
}
428+
429+
[Fact]
430+
public void Parse_MixedBracketsWithDateMathOperations_ParsesCorrectly()
431+
{
432+
// [now-1d/d TO now/d} — inclusive start rounds down, exclusive end rounds down
433+
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
434+
var range = DateTimeRange.Parse("[now-1d/d TO now/d}", baseTime);
435+
436+
Assert.NotEqual(DateTimeRange.Empty, range);
437+
Assert.Equal(baseTime.AddDays(-1).StartOfDay(), range.Start);
438+
Assert.Equal(baseTime.StartOfDay(), range.End);
439+
}
355440
}

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

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,25 +33,36 @@ public static IEnumerable<object[]> Inputs
3333
["jan to feb", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth()],
3434
["5 days ago TO now", _now.SubtractDays(5).StartOfDay(), _now],
3535

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

41+
// Elasticsearch exclusive bracket syntax {exclusive TO exclusive}
42+
// Exclusive min rounds up (end of period), exclusive max rounds down (start of period)
43+
["{jan TO feb}", _now.ChangeMonth(1).EndOfMonth(), _now.ChangeMonth(2).StartOfMonth()],
44+
["{2012 TO 2013}", _now.ChangeYear(2012).EndOfYear(), _now.ChangeYear(2013).StartOfYear()],
45+
46+
// Mixed bracket syntax [inclusive TO exclusive}
47+
["[2012 TO 2013}", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).StartOfYear()],
48+
["[jan TO feb}", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).StartOfMonth()],
49+
50+
// Mixed bracket syntax {exclusive TO inclusive]
51+
["{2012 TO 2013]", _now.ChangeYear(2012).EndOfYear(), _now.ChangeYear(2013).EndOfYear()],
52+
["{jan TO feb]", _now.ChangeMonth(1).EndOfMonth(), _now.ChangeMonth(2).EndOfMonth()],
53+
4154
// Wildcard support
4255
["* TO 2013", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear()],
4356
["2012 TO *", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue],
4457
["[* TO 2013]", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear()],
45-
["{2012 TO *}", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue],
58+
["{2012 TO *}", _now.ChangeYear(2012).EndOfYear(), DateTime.MaxValue],
4659

4760
// Invalid inputs
4861
["blah", null, null],
4962
["[invalid", null, null],
5063
["invalid}", null, null],
5164

5265
// Mismatched bracket validation
53-
["{2012 TO 2013]", null, null], // Opening brace with closing bracket
54-
["[2012 TO 2013}", null, null], // Opening bracket with closing brace
5566
["}2012 TO 2013{", null, null], // Wrong orientation
5667
["]2012 TO 2013[", null, null], // Wrong orientation
5768
["[2012 TO 2013", null, null], // Missing closing bracket

0 commit comments

Comments
 (0)