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