Skip to content

Commit dc9b7c0

Browse files
Fix date math rounding to respect inclusive/exclusive bracket boundaries (#129)
* 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> * Address PR review feedback - Simplify boolean ternary expressions per CodeQL: replace `A ? false : B` with `A is not X && B` and `A ? true : B` with `A is X || B` for the wildcard type checks - Collapse inverted ranges from exclusive brackets instead of letting DateTimeRange reorder bounds and unintentionally expand the range - Update test comment to reflect the explicit collapse behavior Co-authored-by: Cursor <cursoragent@cursor.com> * Fixes bracket parsing logic for inclusivity Corrects the logic for determining inclusive and exclusive date ranges based on brackets. Improves the bracket parsing logic to handle null or empty content and bracket characters, ensuring accurate date range calculations. Also fixes the IsValidBracketPair function to improve bracket validation. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ff789a9 commit dc9b7c0

4 files changed

Lines changed: 214 additions & 29 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: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,55 @@ public TwoPartFormatParser(IEnumerable<IPartParser> parsers, bool includeDefault
2828

2929
public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
3030
{
31-
int index = 0;
32-
var begin = _beginRegex.Match(content, index);
31+
if (String.IsNullOrEmpty(content))
32+
return null;
33+
34+
var begin = _beginRegex.Match(content);
3335
if (!begin.Success)
3436
return null;
3537

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

39-
index += begin.Length;
41+
// Scan backwards from end of string to find closing bracket character.
42+
// This is cheaper than a regex and lets us determine max inclusivity upfront.
43+
char? closingBracket = null;
44+
for (int pos = content.Length - 1; pos >= 0; pos--)
45+
{
46+
char ch = content[pos];
47+
if (ch is ']' or '}')
48+
{
49+
closingBracket = ch;
50+
break;
51+
}
52+
53+
if (!Char.IsWhiteSpace(ch))
54+
break;
55+
}
56+
57+
if (!IsValidBracketPair(openingBracket, closingBracket))
58+
return null;
59+
60+
// Inclusive min ([): round down (start of period) — ">= start"
61+
// Exclusive min ({): round up (end of period) — "> end"
62+
bool minInclusive = openingBracket != '{';
63+
64+
// Inclusive max (]): round up (end of period) — "<= end"
65+
// Exclusive max (}): round down (start of period) — "< start"
66+
bool maxInclusive = closingBracket != '}';
67+
68+
int index = begin.Length;
4069
DateTimeOffset? start = null;
4170
foreach (var parser in Parsers)
4271
{
4372
var match = parser.Regex.Match(content, index);
4473
if (!match.Success)
4574
continue;
4675

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

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

68-
end = parser.Parse(match, relativeBaseTime, true);
100+
bool isUpperLimit = parser is WildcardPartParser || maxInclusive;
101+
end = parser.Parse(match, relativeBaseTime, isUpperLimit);
69102
if (end == null)
70103
continue;
71104

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

80-
// Validate bracket matching
81-
string closingBracket = endMatch.Groups[1].Value;
82-
if (!IsValidBracketPair(openingBracket, closingBracket))
83-
return null;
113+
var rangeStart = start ?? DateTimeOffset.MinValue;
114+
var rangeEnd = end ?? DateTimeOffset.MaxValue;
115+
116+
// Bracket-aware rounding can produce start > end (e.g., "{now/d TO now/d}" yields
117+
// end-of-day then start-of-day). Collapse to a single instant rather than letting
118+
// DateTimeRange reorder the bounds and unintentionally expand the range.
119+
if (rangeStart > rangeEnd)
120+
return new DateTimeRange(rangeEnd, rangeEnd);
84121

85-
return new DateTimeRange(start ?? DateTime.MinValue, end ?? DateTime.MaxValue);
122+
return new DateTimeRange(rangeStart, rangeEnd);
86123
}
87124

88125
/// <summary>
89-
/// Validates that opening and closing brackets are properly matched.
126+
/// Validates that opening and closing brackets form a valid pair.
127+
/// Both Elasticsearch bracket types can be mixed: [ with ], [ with }, { with ], { with }.
90128
/// </summary>
91-
/// <param name="opening">The opening bracket character</param>
92-
/// <param name="closing">The closing bracket character</param>
93-
/// <returns>True if brackets are properly matched, false otherwise</returns>
94-
private static bool IsValidBracketPair(string opening, string closing)
129+
private static bool IsValidBracketPair(char? opening, char? closing)
95130
{
96-
// Both empty - valid (no brackets)
97-
if (String.IsNullOrEmpty(opening) && String.IsNullOrEmpty(closing))
131+
if (opening == null && closing == null)
98132
return true;
99133

100-
// One empty, one not - invalid (unbalanced)
101-
if (String.IsNullOrEmpty(opening) || String.IsNullOrEmpty(closing))
134+
if (opening == null || closing == null)
102135
return false;
103136

104-
// Check for proper matching pairs
105-
return (String.Equals(opening, "[") && String.Equals(closing, "]")) ||
106-
(String.Equals(opening, "{") && String.Equals(closing, "}"));
137+
bool validOpening = opening is '[' or '{';
138+
bool validClosing = closing is ']' or '}';
139+
return validOpening && validClosing;
107140
}
108141
}

tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,4 +352,90 @@ 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+
// This produces an inverted pair (end-of-day, start-of-day), which is explicitly collapsed
373+
// to a single instant at the end value (start-of-day) to avoid expanding the range.
374+
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
375+
var range = DateTimeRange.Parse("{now/d TO now/d}", baseTime);
376+
377+
Assert.NotEqual(DateTimeRange.Empty, range);
378+
Assert.Equal(baseTime.StartOfDay(), range.Start);
379+
Assert.Equal(baseTime.StartOfDay(), range.End);
380+
}
381+
382+
[Fact]
383+
public void Parse_InclusiveExclusiveMixedWithDayRounding_StartOfDayToStartOfDay()
384+
{
385+
// [now/d TO now/d} — inclusive min rounds down (start of day), exclusive max rounds down (start of day)
386+
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
387+
var range = DateTimeRange.Parse("[now/d TO now/d}", baseTime);
388+
389+
Assert.NotEqual(DateTimeRange.Empty, range);
390+
Assert.Equal(baseTime.StartOfDay(), range.Start);
391+
Assert.Equal(baseTime.StartOfDay(), range.End);
392+
}
393+
394+
[Fact]
395+
public void Parse_ExclusiveInclusiveMixedWithDayRounding_EndOfDayToEndOfDay()
396+
{
397+
// {now/d TO now/d] — exclusive min rounds up (end of day), inclusive max rounds up (end of day)
398+
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
399+
var range = DateTimeRange.Parse("{now/d TO now/d]", baseTime);
400+
401+
Assert.NotEqual(DateTimeRange.Empty, range);
402+
Assert.Equal(baseTime.EndOfDay(), range.Start);
403+
Assert.Equal(baseTime.EndOfDay(), range.End);
404+
}
405+
406+
[Fact]
407+
public void Parse_InclusiveBracketsWithMonthRounding_ReturnsFullMonth()
408+
{
409+
// [now/M TO now/M] — inclusive min rounds to start of month, inclusive max rounds to end of month
410+
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
411+
var range = DateTimeRange.Parse("[now/M TO now/M]", baseTime);
412+
413+
Assert.NotEqual(DateTimeRange.Empty, range);
414+
Assert.Equal(baseTime.StartOfMonth(), range.Start);
415+
Assert.Equal(baseTime.EndOfMonth(), range.End);
416+
}
417+
418+
[Fact]
419+
public void Parse_InclusiveBracketsWithHourRounding_ReturnsFullHour()
420+
{
421+
// [now/h TO now/h] — inclusive min rounds to start of hour, inclusive max rounds to end of hour
422+
var baseTime = new DateTime(2023, 12, 25, 12, 30, 0);
423+
var range = DateTimeRange.Parse("[now/h TO now/h]", baseTime);
424+
425+
Assert.NotEqual(DateTimeRange.Empty, range);
426+
Assert.Equal(baseTime.StartOfHour(), range.Start);
427+
Assert.Equal(baseTime.EndOfHour(), range.End);
428+
}
429+
430+
[Fact]
431+
public void Parse_MixedBracketsWithDateMathOperations_ParsesCorrectly()
432+
{
433+
// [now-1d/d TO now/d} — inclusive start rounds down, exclusive end rounds down
434+
var baseTime = new DateTime(2023, 12, 25, 12, 0, 0);
435+
var range = DateTimeRange.Parse("[now-1d/d TO now/d}", baseTime);
436+
437+
Assert.NotEqual(DateTimeRange.Empty, range);
438+
Assert.Equal(baseTime.AddDays(-1).StartOfDay(), range.Start);
439+
Assert.Equal(baseTime.StartOfDay(), range.End);
440+
}
355441
}

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)