Skip to content

Commit b5ebbfe

Browse files
committed
Adds Elasticsearch date math expression support
Implements parsing of Elasticsearch-style date math expressions within date ranges, offering flexible and precise date calculations. This includes support for: - Anchors (now, explicit dates) - Operations (+, -, /) - Units (y, M, w, d, h, m, s) - Timezone handling Updates the DateTimeRange parsing to support bracket notation and wildcards.
1 parent 1b7ef73 commit b5ebbfe

File tree

3 files changed

+602
-1
lines changed

3 files changed

+602
-1
lines changed

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,42 @@ bool isDay = day.IsBusinessDay(date);
3434

3535
### DateTime Ranges
3636

37-
Quickly work with date ranges. . Check out our [unit tests](https://github.com/exceptionless/Exceptionless.DateTimeExtensions/blob/main/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs) for more usage samples.
37+
Quickly work with date ranges with support for Elasticsearch-style date math expressions and bracket notation. Check out our [unit tests](https://github.com/exceptionless/Exceptionless.DateTimeExtensions/blob/main/tests/Exceptionless.DateTimeExtensions.Tests/DateTimeRangeTests.cs) for more usage samples.
3838

3939
```csharp
40+
// Basic range parsing
4041
var range = DateTimeRange.Parse("yesterday", DateTime.Now);
4142
if (range.Contains(DateTime.Now.Subtract(TimeSpan.FromHours(6)))) {
4243
//...
4344
}
45+
46+
// Elasticsearch Date Math support with proper timezone handling
47+
var elasticRange = DateTimeRange.Parse("2025-01-01T01:25:35Z||+3d/d", DateTime.Now);
48+
// Supports timezone-aware operations: Z (UTC), +05:00, -08:00
49+
50+
// Bracket notation support [start TO end]
51+
var bracketRange = DateTimeRange.Parse("[2023-01-01 TO 2023-12-31]", DateTime.Now);
52+
53+
// Wildcard support for open-ended ranges
54+
var wildcardRange = DateTimeRange.Parse("[2023-01-01 TO *]", DateTime.Now); // From date to infinity
4455
```
4556

57+
#### Date Math Features
58+
59+
Supports full Elasticsearch date math syntax following [official specifications](https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math):
60+
61+
- **Anchors**: `now`, explicit dates with `||` separator
62+
- **Operations**: `+1d` (add), `-1h` (subtract), `/d` (round down)
63+
- **Units**: `y` (years), `M` (months), `w` (weeks), `d` (days), `h`/`H` (hours), `m` (minutes), `s` (seconds)
64+
- **Timezone Support**: Preserves explicit timezones (`Z`, `+05:00`, `-08:00`) or uses system timezone as fallback
65+
66+
Examples:
67+
68+
- `now+1h` - One hour from now
69+
- `now-1d/d` - Start of yesterday
70+
- `2025-01-01T01:25:35Z||+3d/d` - January 4th, 2025 (start of day) in UTC
71+
- `2023-06-15T14:30:00+05:00||+1M-2d` - One month minus 2 days from the specified date/time in +05:00 timezone
72+
4673
### TimeUnit
4774

4875
Quickly work with time units. . Check out our [unit tests](https://github.com/exceptionless/Exceptionless.DateTimeExtensions/blob/main/tests/Exceptionless.DateTimeExtensions.Tests/TimeUnitTests.cs) for more usage samples.
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
using System;
2+
using System.Globalization;
3+
using System.Linq;
4+
using System.Text.RegularExpressions;
5+
6+
namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers;
7+
8+
/// <summary>
9+
/// Parses Elasticsearch date math expressions with proper timezone support.
10+
/// Supports: now, explicit dates with ||, operations (+, -, /), and time units (y, M, w, d, h, H, m, s).
11+
/// Examples: now+1h, now-1d/d, 2001.02.01||+1M/d, 2025-01-01T01:25:35Z||+3d/d
12+
///
13+
/// Timezone Handling (following Elasticsearch standards):
14+
/// - Explicit timezone (Z, +05:00, -08:00): Preserved from input
15+
/// - No timezone: Uses current system timezone
16+
///
17+
/// References:
18+
/// - https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math
19+
/// - https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html#date-math-rounding
20+
/// </summary>
21+
[Priority(35)]
22+
public class DateMathPartParser : IPartParser
23+
{
24+
// Match date math expressions with anchors and operations
25+
private static readonly Regex _parser = new(
26+
@"^(?<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)?)?)\|\|)" +
27+
@"(?<operations>(?:[+\-/]\d*[yMwdhHms])*)$",
28+
RegexOptions.Compiled | RegexOptions.IgnoreCase);
29+
30+
public Regex Regex => _parser;
31+
32+
public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit)
33+
{
34+
if (!match.Success)
35+
return null;
36+
37+
try
38+
{
39+
// Parse the anchor (now or explicit date)
40+
DateTimeOffset baseTime;
41+
string anchor = match.Groups["anchor"].Value;
42+
43+
if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase))
44+
{
45+
baseTime = relativeBaseTime;
46+
}
47+
else
48+
{
49+
// Parse explicit date from the date group
50+
string dateStr = match.Groups["date"].Value;
51+
if (!TryParseExplicitDate(dateStr, relativeBaseTime.Offset, out baseTime))
52+
return null;
53+
}
54+
55+
// Parse and apply operations
56+
string operations = match.Groups["operations"].Value;
57+
var result = ApplyOperations(baseTime, operations, isUpperLimit);
58+
59+
return result;
60+
}
61+
catch
62+
{
63+
// Return null for any parsing errors to maintain robustness
64+
return null;
65+
}
66+
}
67+
68+
/// <summary>
69+
/// Attempts to parse an explicit date string with proper timezone handling.
70+
/// Supports various Elasticsearch-compatible date formats with optional timezone information.
71+
///
72+
/// Timezone Behavior:
73+
/// - If timezone is specified (Z, +HH:MM, -HH:MM): Preserved from input
74+
/// - If no timezone specified: Uses the provided fallback offset
75+
///
76+
/// This matches Elasticsearch's behavior where explicit timezone information takes precedence.
77+
/// </summary>
78+
/// <param name="dateStr">The date string to parse</param>
79+
/// <param name="offset">Fallback timezone offset for dates without explicit timezone</param>
80+
/// <param name="result">The parsed DateTimeOffset with correct timezone</param>
81+
/// <returns>True if parsing succeeded, false otherwise</returns>
82+
private static bool TryParseExplicitDate(string dateStr, TimeSpan offset, out DateTimeOffset result)
83+
{
84+
result = default;
85+
86+
if (string.IsNullOrEmpty(dateStr))
87+
return false;
88+
89+
// Try various formats that Elasticsearch supports
90+
string[] formats = {
91+
"yyyy-MM-dd",
92+
"yyyy-MM-ddTHH:mm:ss",
93+
"yyyy-MM-ddTHH:mm",
94+
"yyyy-MM-ddTHH",
95+
"yyyy-MM-ddTHH:mm:ssZ",
96+
"yyyy-MM-ddTHH:mm:ss.fff",
97+
"yyyy-MM-ddTHH:mm:ss.fffZ",
98+
"yyyy-MM-ddTHH:mm:sszzz",
99+
"yyyy-MM-ddTHH:mm:ss.fffzzz",
100+
"yyyy.MM.dd",
101+
"yyyy.MM.ddTHH:mm:ss",
102+
"yyyy.MM.ddTHH:mm",
103+
"yyyy.MM.ddTHH",
104+
"yyyy.MM.ddTHH:mm:ssZ",
105+
"yyyy.MM.ddTHH:mm:ss.fff",
106+
"yyyy.MM.ddTHH:mm:ss.fffZ",
107+
"yyyy.MM.ddTHH:mm:sszzz",
108+
"yyyy.MM.ddTHH:mm:ss.fffzzz",
109+
"yyyyMMdd",
110+
"yyyyMMddTHHmmss",
111+
"yyyyMMddTHHmm",
112+
"yyyyMMddTHH",
113+
"yyyyMMddTHHmmssZ",
114+
"yyyyMMddTHHmmss.fff",
115+
"yyyyMMddTHHmmss.fffZ",
116+
"yyyyMMddTHHmmsszzz",
117+
"yyyyMMddTHHmmss.fffzzz"
118+
};
119+
120+
foreach (string format in formats)
121+
{
122+
// Handle timezone-aware formats differently from timezone-naive formats
123+
if (format.EndsWith("Z") || format.Contains("zzz"))
124+
{
125+
// Try parsing with timezone information preserved
126+
if (DateTimeOffset.TryParseExact(dateStr, format, CultureInfo.InvariantCulture,
127+
DateTimeStyles.None, out result))
128+
{
129+
return true;
130+
}
131+
}
132+
else
133+
{
134+
// For formats without timezone, parse as DateTime and treat as if already in target timezone
135+
if (DateTime.TryParseExact(dateStr, format, CultureInfo.InvariantCulture,
136+
DateTimeStyles.None, out DateTime dateTime))
137+
{
138+
// Treat the parsed DateTime as if it's already in the target timezone
139+
// This avoids any conversion issues
140+
result = new DateTimeOffset(dateTime.Ticks, offset);
141+
return true;
142+
}
143+
}
144+
}
145+
146+
return false;
147+
}
148+
149+
private static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string operations, bool isUpperLimit)
150+
{
151+
if (string.IsNullOrEmpty(operations))
152+
return baseTime;
153+
154+
var result = baseTime;
155+
var operationRegex = new Regex(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled);
156+
var matches = operationRegex.Matches(operations);
157+
158+
// Validate that all operations were matched properly
159+
var totalMatchLength = matches.Cast<Match>().Sum(m => m.Length);
160+
if (totalMatchLength != operations.Length)
161+
{
162+
// If not all operations were matched, there are invalid operations
163+
throw new ArgumentException("Invalid operations");
164+
}
165+
166+
foreach (Match opMatch in matches)
167+
{
168+
string operation = opMatch.Groups[1].Value;
169+
string amountStr = opMatch.Groups[2].Value;
170+
string unit = opMatch.Groups[3].Value;
171+
172+
// Default amount is 1 if not specified
173+
int amount = string.IsNullOrEmpty(amountStr) ? 1 : int.Parse(amountStr);
174+
175+
switch (operation)
176+
{
177+
case "+":
178+
result = AddTimeUnit(result, amount, unit);
179+
break;
180+
case "-":
181+
result = AddTimeUnit(result, -amount, unit);
182+
break;
183+
case "/":
184+
result = RoundToUnit(result, unit, isUpperLimit);
185+
break;
186+
}
187+
}
188+
189+
return result;
190+
}
191+
192+
private static DateTimeOffset AddTimeUnit(DateTimeOffset dateTime, int amount, string unit)
193+
{
194+
try
195+
{
196+
return unit switch
197+
{
198+
"y" => dateTime.AddYears(amount),
199+
"M" => dateTime.AddMonths(amount), // Capital M for months
200+
"m" => dateTime.AddMinutes(amount), // Lowercase m for minutes
201+
"w" => dateTime.AddDays(amount * 7),
202+
"d" => dateTime.AddDays(amount),
203+
"h" or "H" => dateTime.AddHours(amount),
204+
"s" => dateTime.AddSeconds(amount),
205+
_ => throw new ArgumentException($"Invalid time unit: {unit}")
206+
};
207+
}
208+
catch (ArgumentOutOfRangeException)
209+
{
210+
// Return original date if operation would overflow
211+
return dateTime;
212+
}
213+
}
214+
215+
private static DateTimeOffset RoundToUnit(DateTimeOffset dateTime, string unit, bool isUpperLimit)
216+
{
217+
return unit switch
218+
{
219+
"y" => isUpperLimit ? dateTime.EndOfYear() : dateTime.StartOfYear(),
220+
"M" => isUpperLimit ? dateTime.EndOfMonth() : dateTime.StartOfMonth(),
221+
"w" => isUpperLimit ? dateTime.EndOfWeek() : dateTime.StartOfWeek(),
222+
"d" => isUpperLimit ? dateTime.EndOfDay() : dateTime.StartOfDay(),
223+
"h" or "H" => isUpperLimit ? dateTime.EndOfHour() : dateTime.StartOfHour(),
224+
"m" => isUpperLimit ? dateTime.EndOfMinute() : dateTime.StartOfMinute(),
225+
"s" => isUpperLimit ? dateTime.EndOfSecond() : dateTime.StartOfSecond(),
226+
_ => throw new ArgumentException($"Invalid time unit for rounding: {unit}")
227+
};
228+
}
229+
}

0 commit comments

Comments
 (0)