Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,5 @@ _NCrunch_*
# Rider auto-generates .iml files, and contentModel.xml
**/.idea/**/*.iml
**/.idea/**/contentModel.xml
**/.idea/**/modules.xml
**/.idea/**/modules.xml
.idea/.idea.Exceptionless.DateTimeExtensions/.idea/
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,42 @@ bool isDay = day.IsBusinessDay(date);

### DateTime Ranges

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

```csharp
// Basic range parsing
var range = DateTimeRange.Parse("yesterday", DateTime.Now);
if (range.Contains(DateTime.Now.Subtract(TimeSpan.FromHours(6)))) {
//...
}

// Elasticsearch Date Math support with proper timezone handling
var elasticRange = DateTimeRange.Parse("2025-01-01T01:25:35Z||+3d/d", DateTime.Now);
// Supports timezone-aware operations: Z (UTC), +05:00, -08:00

// Bracket notation support [start TO end]
var bracketRange = DateTimeRange.Parse("[2023-01-01 TO 2023-12-31]", DateTime.Now);
Comment on lines +50 to +51
Copy link

Copilot AI Sep 22, 2025

Choose a reason for hiding this comment

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

Documentation shows only square bracket usage but the tests and parser also accept curly braces; the README does not clarify (a) whether {} are supported, or (b) inclusive vs exclusive semantics. Either document that {} are currently treated the same as [] (temporary limitation) or explain exclusivity once implemented to avoid developer confusion.

Suggested change
// Bracket notation support [start TO end]
var bracketRange = DateTimeRange.Parse("[2023-01-01 TO 2023-12-31]", DateTime.Now);
// Bracket notation support [start TO end] or {start TO end}
// Both square brackets [ ] and curly braces { } are accepted for range expressions.
// Currently, both are treated the same (inclusive bounds). Exclusive semantics for { } may be added in the future.
var bracketRange = DateTimeRange.Parse("[2023-01-01 TO 2023-12-31]", DateTime.Now);
var curlyBracketRange = DateTimeRange.Parse("{2023-01-01 TO 2023-12-31}", DateTime.Now); // Currently same as above

Copilot uses AI. Check for mistakes.


// Wildcard support for open-ended ranges
var wildcardRange = DateTimeRange.Parse("[2023-01-01 TO *]", DateTime.Now); // From date to infinity
```

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

- **Anchors**: `now`, explicit dates with `||` separator
- **Operations**: `+1d` (add), `-1h` (subtract), `/d` (round down)
- **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

Examples:

- `now+1h` - One hour from now
- `now-1d/d` - Start of yesterday
- `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

### TimeUnit

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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;

namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers;

/// <summary>
/// Parses Elasticsearch date math expressions with proper timezone support.
/// Supports: now, explicit dates with ||, operations (+, -, /), and time units (y, M, w, d, h, H, m, s).
/// Examples: now+1h, now-1d/d, 2001.02.01||+1M/d, 2025-01-01T01:25:35Z||+3d/d
///
/// Timezone Handling (following Elasticsearch standards):
/// - Explicit timezone (Z, +05:00, -08:00): Preserved from input
/// - No timezone: Uses current system timezone
///
/// References:
/// - https://www.elastic.co/guide/en/elasticsearch/reference/current/common-options.html#date-math
/// - https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-range-query.html#date-math-rounding
/// </summary>
[Priority(35)]
public class DateMathPartParser : IPartParser
{
// Match date math expressions with anchors and operations
private static readonly Regex _parser = new(
@"^(?<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)?)?)\|\|)" +
Copy link

Copilot AI Sep 22, 2025

Choose a reason for hiding this comment

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

The date portion allows a dotted format (e.g., 2001.02.01) via the [-.]? separators, but there are no tests covering dotted date inputs. Add tests like "2001.02.01||" (with and without operations) to validate this branch and prevent future regressions.

Copilot generated this review using guidance from repository custom instructions.

@"(?<operations>(?:[+\-/]\d*[yMwdhHms])*)$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
Copy link

Copilot AI Sep 22, 2025

Choose a reason for hiding this comment

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

The regex permits multiple rounding operations or additional arithmetic after a rounding (e.g., "now/d+1h"), but tests do not cover invalid sequencing; Elasticsearch requires rounding (/unit) be the final operation. Add negative tests (expecting null) for sequences like "now/d+1h" and "/d/d" to ensure spec-aligned enforcement (or clarify in docs if divergence is intentional).

Copilot generated this review using guidance from repository custom instructions.


public Regex Regex => _parser;

public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit)
{
if (!match.Success)
return null;

try
{
// Parse the anchor (now or explicit date)
DateTimeOffset baseTime;
string anchor = match.Groups["anchor"].Value;

if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase))
{
baseTime = relativeBaseTime;
}
else
{
// Parse explicit date from the date group
string dateStr = match.Groups["date"].Value;
if (!TryParseExplicitDate(dateStr, relativeBaseTime.Offset, out baseTime))
return null;
}

// Parse and apply operations
string operations = match.Groups["operations"].Value;
var result = ApplyOperations(baseTime, operations, isUpperLimit);

return result;
}
catch
{
// Return null for any parsing errors to maintain robustness
return null;
}
}

/// <summary>
/// Attempts to parse an explicit date string with proper timezone handling.
/// Supports various Elasticsearch-compatible date formats with optional timezone information.
///
/// Timezone Behavior:
/// - If timezone is specified (Z, +HH:MM, -HH:MM): Preserved from input
/// - If no timezone specified: Uses the provided fallback offset
///
/// This matches Elasticsearch's behavior where explicit timezone information takes precedence.
/// </summary>
/// <param name="dateStr">The date string to parse</param>
/// <param name="offset">Fallback timezone offset for dates without explicit timezone</param>
/// <param name="result">The parsed DateTimeOffset with correct timezone</param>
/// <returns>True if parsing succeeded, false otherwise</returns>
private static bool TryParseExplicitDate(string dateStr, TimeSpan offset, out DateTimeOffset result)
{
result = default;

if (string.IsNullOrEmpty(dateStr))
return false;

// Try various formats that Elasticsearch supports
string[] formats = {
"yyyy-MM-dd",
"yyyy-MM-ddTHH:mm:ss",
"yyyy-MM-ddTHH:mm",
"yyyy-MM-ddTHH",
"yyyy-MM-ddTHH:mm:ssZ",
"yyyy-MM-ddTHH:mm:ss.fff",
"yyyy-MM-ddTHH:mm:ss.fffZ",
"yyyy-MM-ddTHH:mm:sszzz",
"yyyy-MM-ddTHH:mm:ss.fffzzz",
"yyyy.MM.dd",
"yyyy.MM.ddTHH:mm:ss",
"yyyy.MM.ddTHH:mm",
"yyyy.MM.ddTHH",
"yyyy.MM.ddTHH:mm:ssZ",
"yyyy.MM.ddTHH:mm:ss.fff",
"yyyy.MM.ddTHH:mm:ss.fffZ",
"yyyy.MM.ddTHH:mm:sszzz",
"yyyy.MM.ddTHH:mm:ss.fffzzz",
"yyyyMMdd",
"yyyyMMddTHHmmss",
"yyyyMMddTHHmm",
"yyyyMMddTHH",
"yyyyMMddTHHmmssZ",
"yyyyMMddTHHmmss.fff",
"yyyyMMddTHHmmss.fffZ",
"yyyyMMddTHHmmsszzz",
"yyyyMMddTHHmmss.fffzzz"
};

foreach (string format in formats)
{
// Handle timezone-aware formats differently from timezone-naive formats
if (format.EndsWith("Z") || format.Contains("zzz"))
{
// Try parsing with timezone information preserved
if (DateTimeOffset.TryParseExact(dateStr, format, CultureInfo.InvariantCulture,
DateTimeStyles.None, out result))
{
return true;
}
}
else
{
// For formats without timezone, parse as DateTime and treat as if already in target timezone
if (DateTime.TryParseExact(dateStr, format, CultureInfo.InvariantCulture,
DateTimeStyles.None, out DateTime dateTime))
{
// Treat the parsed DateTime as if it's already in the target timezone
// This avoids any conversion issues
result = new DateTimeOffset(dateTime.Ticks, offset);
return true;
}
}
}

return false;
}

private static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string operations, bool isUpperLimit)
{
if (string.IsNullOrEmpty(operations))
return baseTime;

var result = baseTime;
var operationRegex = new Regex(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled);
var matches = operationRegex.Matches(operations);

// Validate that all operations were matched properly
var totalMatchLength = matches.Cast<Match>().Sum(m => m.Length);
if (totalMatchLength != operations.Length)
{
// If not all operations were matched, there are invalid operations
throw new ArgumentException("Invalid operations");
}

foreach (Match opMatch in matches)
{
string operation = opMatch.Groups[1].Value;
string amountStr = opMatch.Groups[2].Value;
string unit = opMatch.Groups[3].Value;

// Default amount is 1 if not specified
int amount = string.IsNullOrEmpty(amountStr) ? 1 : int.Parse(amountStr);

switch (operation)
{
case "+":
result = AddTimeUnit(result, amount, unit);
break;
case "-":
result = AddTimeUnit(result, -amount, unit);
break;
case "/":
result = RoundToUnit(result, unit, isUpperLimit);
break;
}
}

return result;
}

private static DateTimeOffset AddTimeUnit(DateTimeOffset dateTime, int amount, string unit)
{
try
{
return unit switch
{
"y" => dateTime.AddYears(amount),
"M" => dateTime.AddMonths(amount), // Capital M for months
"m" => dateTime.AddMinutes(amount), // Lowercase m for minutes
"w" => dateTime.AddDays(amount * 7),
"d" => dateTime.AddDays(amount),
"h" or "H" => dateTime.AddHours(amount),
"s" => dateTime.AddSeconds(amount),
_ => throw new ArgumentException($"Invalid time unit: {unit}")
};
}
catch (ArgumentOutOfRangeException)
{
// Return original date if operation would overflow
return dateTime;
}
}

private static DateTimeOffset RoundToUnit(DateTimeOffset dateTime, string unit, bool isUpperLimit)
{
return unit switch
{
"y" => isUpperLimit ? dateTime.EndOfYear() : dateTime.StartOfYear(),
"M" => isUpperLimit ? dateTime.EndOfMonth() : dateTime.StartOfMonth(),
"w" => isUpperLimit ? dateTime.EndOfWeek() : dateTime.StartOfWeek(),
"d" => isUpperLimit ? dateTime.EndOfDay() : dateTime.StartOfDay(),
"h" or "H" => isUpperLimit ? dateTime.EndOfHour() : dateTime.StartOfHour(),
"m" => isUpperLimit ? dateTime.EndOfMinute() : dateTime.StartOfMinute(),
"s" => isUpperLimit ? dateTime.EndOfSecond() : dateTime.StartOfSecond(),
_ => throw new ArgumentException($"Invalid time unit for rounding: {unit}")
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System;
using System.Text.RegularExpressions;

namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers;

[Priority(1)]
public class WildcardPartParser : IPartParser
{
private static readonly Regex _wildcardRegex = new(@"\G\s*\*(?=\s|\]|\}|$)", RegexOptions.Compiled | RegexOptions.IgnoreCase);

public Regex Regex => _wildcardRegex;

public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit)
{
if (!match.Success)
return null;

return isUpperLimit ? DateTimeOffset.MaxValue : DateTimeOffset.MinValue;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ namespace Exceptionless.DateTimeExtensions.FormatParsers;
[Priority(25)]
public class TwoPartFormatParser : IFormatParser
{
private static readonly Regex _beginRegex = new(@"^\s*", RegexOptions.Compiled);
private static readonly Regex _beginRegex = new(@"^\s*(?:[\[\{])?\s*", RegexOptions.Compiled);
Copy link

Copilot AI Sep 22, 2025

Choose a reason for hiding this comment

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

The updated regexes consume opening and closing brackets/braces but do not capture which type was used; without capturing, the parser cannot distinguish inclusive [] from exclusive {} semantics (Elasticsearch treats {} as exclusive). This results in silently treating exclusive bounds as inclusive. Either (a) remove brace support until exclusivity is implemented, or (b) capture bracket type and adjust start/end boundaries accordingly (e.g., shift by smallest representable unit). Example: modify begin/end patterns to capture the delimiter type and pass it through parsing logic.

Copilot uses AI. Check for mistakes.

Copy link

Copilot AI Sep 22, 2025

Choose a reason for hiding this comment

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

These patterns allow mismatches like an opening '{' with a closing ']' or a closing bracket without any opener, which can mask malformed input. Consider validating for a balanced, matching pair (and rejecting lone or mismatched delimiters) by deferring closing delimiter consumption until after successful two-part parsing and explicitly checking the original characters.

Copilot uses AI. Check for mistakes.

private static readonly Regex _delimiterRegex = new(@"\G(?:\s*-\s*|\s+TO\s+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex _endRegex = new(@"\G\s*$", RegexOptions.Compiled);
private static readonly Regex _endRegex = new(@"\G\s*(?:[\]\}])?\s*$", RegexOptions.Compiled);
Copy link

Copilot AI Sep 22, 2025

Choose a reason for hiding this comment

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

The updated regexes consume opening and closing brackets/braces but do not capture which type was used; without capturing, the parser cannot distinguish inclusive [] from exclusive {} semantics (Elasticsearch treats {} as exclusive). This results in silently treating exclusive bounds as inclusive. Either (a) remove brace support until exclusivity is implemented, or (b) capture bracket type and adjust start/end boundaries accordingly (e.g., shift by smallest representable unit). Example: modify begin/end patterns to capture the delimiter type and pass it through parsing logic.

Copilot uses AI. Check for mistakes.

Copy link

Copilot AI Sep 22, 2025

Choose a reason for hiding this comment

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

These patterns allow mismatches like an opening '{' with a closing ']' or a closing bracket without any opener, which can mask malformed input. Consider validating for a balanced, matching pair (and rejecting lone or mismatched delimiters) by deferring closing delimiter consumption until after successful two-part parsing and explicitly checking the original characters.

Copilot uses AI. Check for mistakes.


public TwoPartFormatParser()
{
Expand Down
Loading