Skip to content

Commit 9298e48

Browse files
committed
Refactors date parsing and validates date math.
- Improves date parsing by removing support for dotted date formats and enforcing hyphenated formats for consistency. - Adds validation to ensure rounding operations in date math expressions are only used as the final operation, aligning with specification requirements. - Enhances two-part format parser to validate matching brackets, preventing parsing errors due to unbalanced brackets.
1 parent 5eddd17 commit 9298e48

File tree

4 files changed

+104
-54
lines changed

4 files changed

+104
-54
lines changed

src/Exceptionless.DateTimeExtensions/DateMath.cs

Lines changed: 51 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public static class DateMath
2222
{
2323
// Match date math expressions with anchors and operations
2424
internal static readonly Regex Parser = new(
25-
@"^(?<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)?)?)\|\|)" +
25+
@"^(?<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)?)?)\|\|)" +
2626
@"(?<operations>(?:[+\-/]\d*[yMwdhHms])*)$",
2727
RegexOptions.Compiled | RegexOptions.IgnoreCase);
2828

@@ -178,67 +178,60 @@ private static bool TryParseExplicitDate(string dateStr, TimeSpan offset, out Da
178178
case 4: // Built-in: year (yyyy)
179179
return TryParseWithFormat(dateStr, "yyyy", offset, false, out result);
180180

181-
case 7: // Built-in: year_month (yyyy-MM or yyyy.MM)
182-
if (dateStr[4] is '-' or '.')
183-
return TryParseWithFormat(dateStr, dateStr[4] == '-' ? "yyyy-MM" : "yyyy.MM", offset, false, out result);
181+
case 7: // Built-in: year_month (yyyy-MM)
182+
if (dateStr[4] == '-')
183+
return TryParseWithFormat(dateStr, "yyyy-MM", offset, false, out result);
184184
break;
185185

186186
case 8: // Built-in: basic_date (yyyyMMdd)
187187
return TryParseWithFormat(dateStr, "yyyyMMdd", offset, false, out result);
188188

189-
case 10: // Built-in: date (yyyy-MM-dd or yyyy.MM.dd)
190-
if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.')
189+
case 10: // Built-in: date (yyyy-MM-dd)
190+
if (dateStr[4] == '-' && dateStr[7] == '-')
191191
{
192-
string format = dateStr[4] == '-' ? "yyyy-MM-dd" : "yyyy.MM.dd";
193-
return TryParseWithFormat(dateStr, format, offset, false, out result);
192+
return TryParseWithFormat(dateStr, "yyyy-MM-dd", offset, false, out result);
194193
}
195194
break;
196195

197-
case 13: // Built-in: date_hour (yyyy-MM-ddTHH or yyyy.MM.ddTHH)
198-
if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T')
196+
case 13: // Built-in: date_hour (yyyy-MM-ddTHH)
197+
if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T')
199198
{
200-
string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH" : "yyyy.MM.ddTHH";
201-
return TryParseWithFormat(dateStr, format, offset, false, out result);
199+
return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH", offset, false, out result);
202200
}
203201
break;
204202

205-
case 16: // Built-in: date_hour_minute (yyyy-MM-ddTHH:mm or yyyy.MM.ddTHH:mm)
206-
if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':')
203+
case 16: // Built-in: date_hour_minute (yyyy-MM-ddTHH:mm)
204+
if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':')
207205
{
208-
string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm" : "yyyy.MM.ddTHH:mm";
209-
return TryParseWithFormat(dateStr, format, offset, false, out result);
206+
return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm", offset, false, out result);
210207
}
211208
break;
212209

213-
case 19: // Built-in: date_hour_minute_second (yyyy-MM-ddTHH:mm:ss or yyyy.MM.ddTHH:mm:ss)
214-
if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':')
210+
case 19: // Built-in: date_hour_minute_second (yyyy-MM-ddTHH:mm:ss)
211+
if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':')
215212
{
216-
string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm:ss" : "yyyy.MM.ddTHH:mm:ss";
217-
return TryParseWithFormat(dateStr, format, offset, false, out result);
213+
return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss", offset, false, out result);
218214
}
219215
break;
220216

221-
case 20: // Built-in: date_time_no_millis (yyyy-MM-ddTHH:mm:ssZ or yyyy.MM.ddTHH:mm:ssZ)
222-
if (hasZ && dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':')
217+
case 20: // Built-in: date_time_no_millis (yyyy-MM-ddTHH:mm:ssZ)
218+
if (hasZ && dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':')
223219
{
224-
string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm:ssZ" : "yyyy.MM.ddTHH:mm:ssZ";
225-
return TryParseWithFormat(dateStr, format, offset, true, out result);
220+
return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ssZ", offset, true, out result);
226221
}
227222
break;
228223

229-
case 23: // Built-in: date_hour_minute_second_millis (yyyy-MM-ddTHH:mm:ss.fff or yyyy.MM.ddTHH:mm:ss.fff)
230-
if (dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.')
224+
case 23: // Built-in: date_hour_minute_second_millis (yyyy-MM-ddTHH:mm:ss.fff)
225+
if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.')
231226
{
232-
string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm:ss.fff" : "yyyy.MM.ddTHH:mm:ss.fff";
233-
return TryParseWithFormat(dateStr, format, offset, false, out result);
227+
return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fff", offset, false, out result);
234228
}
235229
break;
236230

237-
case 24: // Built-in: date_time (yyyy-MM-ddTHH:mm:ss.fffZ or yyyy.MM.ddTHH:mm:ss.fffZ)
238-
if (hasZ && dateStr[4] is '-' or '.' && dateStr[7] is '-' or '.' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.')
231+
case 24: // Built-in: date_time (yyyy-MM-ddTHH:mm:ss.fffZ)
232+
if (hasZ && dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.')
239233
{
240-
string format = dateStr[4] == '-' ? "yyyy-MM-ddTHH:mm:ss.fffZ" : "yyyy.MM.ddTHH:mm:ss.fffZ";
241-
return TryParseWithFormat(dateStr, format, offset, true, out result);
234+
return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fffZ", offset, true, out result);
242235
}
243236
break;
244237
}
@@ -247,30 +240,21 @@ private static bool TryParseExplicitDate(string dateStr, TimeSpan offset, out Da
247240
// Note: .NET uses 'zzz' pattern for timezone offsets like +05:00
248241
if (hasTimezone && !hasZ)
249242
{
250-
// Determine the date separator for format construction
251-
char dateSeparator = (len > 4 && dateStr[4] == '.') ? '.' : '-';
252-
253243
// Only try timezone formats for lengths that make sense
254244
if (len is >= 25 and <= 29) // +05:00 variants
255245
{
256246
if (dateStr.Contains(".")) // with milliseconds
257247
{
258-
// Try both separators: yyyy-MM-ddTHH:mm:ss.fff+05:00 or yyyy.MM.ddTHH:mm:ss.fff+05:00
259-
string format = dateSeparator == '.'
260-
? "yyyy.MM.ddTHH:mm:ss.fffzzz"
261-
: "yyyy-MM-ddTHH:mm:ss.fffzzz";
262-
if (TryParseWithFormat(dateStr, format, offset, true, out result))
248+
// yyyy-MM-ddTHH:mm:ss.fff+05:00
249+
if (TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fffzzz", offset, true, out result))
263250
return true;
264251
}
265252
}
266253

267254
if (len is >= 22 and <= 25) // without milliseconds
268255
{
269-
// Try both separators: yyyy-MM-ddTHH:mm:ss+05:00 or yyyy.MM.ddTHH:mm:ss+05:00
270-
string format = dateSeparator == '.'
271-
? "yyyy.MM.ddTHH:mm:sszzz"
272-
: "yyyy-MM-ddTHH:mm:sszzz";
273-
if (TryParseWithFormat(dateStr, format, offset, true, out result))
256+
// yyyy-MM-ddTHH:mm:ss+05:00
257+
if (TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:sszzz", offset, true, out result))
274258
return true;
275259
}
276260
}
@@ -327,6 +311,28 @@ public static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string ope
327311
throw new ArgumentException("Invalid operations");
328312
}
329313

314+
// Validate that rounding operations (/) are only at the end
315+
// According to Elasticsearch spec, rounding must be the final operation
316+
bool foundRounding = false;
317+
for (int i = 0; i < matches.Count; i++)
318+
{
319+
string operation = matches[i].Groups[1].Value;
320+
if (operation == "/")
321+
{
322+
if (foundRounding)
323+
{
324+
// Multiple rounding operations are not allowed
325+
throw new ArgumentException("Multiple rounding operations are not allowed");
326+
}
327+
if (i != matches.Count - 1)
328+
{
329+
// Rounding operation must be the last operation
330+
throw new ArgumentException("Rounding operation must be the final operation");
331+
}
332+
foundRounding = true;
333+
}
334+
}
335+
330336
foreach (Match opMatch in matches)
331337
{
332338
string operation = opMatch.Groups[1].Value;

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

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ namespace Exceptionless.DateTimeExtensions.FormatParsers;
88
[Priority(25)]
99
public class TwoPartFormatParser : IFormatParser
1010
{
11-
private static readonly Regex _beginRegex = new(@"^\s*(?:[\[\{])?\s*", RegexOptions.Compiled);
11+
private static readonly Regex _beginRegex = new(@"^\s*([\[\{])?\s*", RegexOptions.Compiled);
1212
private static readonly Regex _delimiterRegex = new(@"\G(?:\s*-\s*|\s+TO\s+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
13-
private static readonly Regex _endRegex = new(@"\G\s*(?:[\]\}])?\s*$", RegexOptions.Compiled);
13+
private static readonly Regex _endRegex = new(@"\G\s*([\]\}])?\s*$", RegexOptions.Compiled);
1414

1515
public TwoPartFormatParser()
1616
{
@@ -33,6 +33,9 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
3333
if (!begin.Success)
3434
return null;
3535

36+
// Capture the opening bracket if present
37+
string openingBracket = begin.Groups[1].Value;
38+
3639
index += begin.Length;
3740
DateTimeOffset? start = null;
3841
foreach (var parser in Parsers)
@@ -70,9 +73,36 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
7073
break;
7174
}
7275

73-
if (!_endRegex.IsMatch(content, index))
76+
var endMatch = _endRegex.Match(content, index);
77+
if (!endMatch.Success)
78+
return null;
79+
80+
// Validate bracket matching
81+
string closingBracket = endMatch.Groups[1].Value;
82+
if (!IsValidBracketPair(openingBracket, closingBracket))
7483
return null;
7584

7685
return new DateTimeRange(start ?? DateTime.MinValue, end ?? DateTime.MaxValue);
7786
}
87+
88+
/// <summary>
89+
/// Validates that opening and closing brackets are properly matched.
90+
/// </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)
95+
{
96+
// Both empty - valid (no brackets)
97+
if (String.IsNullOrEmpty(opening) && String.IsNullOrEmpty(closing))
98+
return true;
99+
100+
// One empty, one not - invalid (unbalanced)
101+
if (String.IsNullOrEmpty(opening) || String.IsNullOrEmpty(closing))
102+
return false;
103+
104+
// Check for proper matching pairs
105+
return (opening == "[" && closing == "]") ||
106+
(opening == "{" && closing == "}");
107+
}
78108
}

tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,6 @@ public void Parse_MultipleOperations_ReturnsCorrectResult(string expression)
233233
}
234234

235235
[Theory]
236-
[InlineData("2023.06.15||")]
237236
[InlineData("2023-06-15||")]
238237
[InlineData("2023-06-15T10:30:00||")]
239238
[InlineData("2023-06-15T10:30:00.123||")]
@@ -277,7 +276,7 @@ public void Parse_ExplicitTimezones_PreservesTimezone(string expression, double
277276
}
278277

279278
[Theory]
280-
[InlineData("2023.06.15||+1M")]
279+
[InlineData("2023-06-15||+1M")]
281280
[InlineData("2023-06-15T10:30:00||+2d")]
282281
[InlineData("2023-06-15T10:30:00Z||+1h")]
283282
[InlineData("2023-06-15T10:30:00+02:00||-1d/d")]
@@ -317,6 +316,10 @@ public void Parse_ExplicitDateWithOperations_ReturnsCorrectResult(string express
317316
[InlineData("now/x")] // Invalid rounding unit
318317
[InlineData("2023-13-01||")] // Invalid month
319318
[InlineData("2023-01-32||")] // Invalid day
319+
[InlineData("2001.02.01||")] // Dotted format no longer supported
320+
[InlineData("now/d+1h")] // Rounding must be final operation
321+
[InlineData("now/d/d")] // Multiple rounding operations
322+
[InlineData("now+1h/d+2m")] // Rounding in middle of operations
320323
public void Parse_InvalidExpressions_ThrowsArgumentException(string expression)
321324
{
322325
_logger.LogDebug("Testing Parse with invalid expression: '{Expression}', expecting ArgumentException", expression);
@@ -341,8 +344,8 @@ public void Parse_NullExpression_ThrowsArgumentException()
341344
[InlineData("now")]
342345
[InlineData("now+1h")]
343346
[InlineData("now-1d/d")]
344-
[InlineData("2023.06.15||")]
345-
[InlineData("2023.06.15||+1M/d")]
347+
[InlineData("2023-06-15||")]
348+
[InlineData("2023-06-15||+1M/d")]
346349
[InlineData("2025-01-01T01:25:35Z||+3d/d")]
347350
public void TryParse_ValidExpressions_ReturnsTrueAndCorrectResult(string expression)
348351
{
@@ -367,6 +370,9 @@ public void TryParse_ValidExpressions_ReturnsTrueAndCorrectResult(string express
367370
[InlineData("now+")]
368371
[InlineData("2023-01-01")] // Missing ||
369372
[InlineData("||+1d")] // Missing anchor
373+
[InlineData("2001.02.01||")] // Dotted format no longer supported
374+
[InlineData("now/d+1h")] // Rounding must be final operation
375+
[InlineData("now/d/d")] // Multiple rounding operations
370376
public void TryParse_InvalidExpressions_ReturnsFalse(string expression)
371377
{
372378
_logger.LogDebug("Testing TryParse with invalid expression: '{Expression}', expecting false", expression);
@@ -395,7 +401,7 @@ public void TryParse_NullExpression_ReturnsFalse()
395401
[Theory]
396402
[InlineData("now+1h", false)]
397403
[InlineData("now-1d/d", true)]
398-
[InlineData("2023.06.15||+1M", false)]
404+
[InlineData("2023-06-15||+1M", false)]
399405
[InlineData("2025-01-01T01:25:35Z||+3d/d", true)]
400406
public void Parse_And_TryParse_ReturnSameResults(string expression, bool isUpperLimit)
401407
{

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,15 @@ public static IEnumerable<object[]> Inputs
4747
// Invalid inputs
4848
["blah", null, null],
4949
["[invalid", null, null],
50-
["invalid}", null, null]
50+
["invalid}", null, null],
51+
52+
// 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
55+
["}2012 TO 2013{", null, null], // Wrong orientation
56+
["]2012 TO 2013[", null, null], // Wrong orientation
57+
["[2012 TO 2013", null, null], // Missing closing bracket
58+
["2012 TO 2013]", null, null], // Missing opening bracket
5159
};
5260
}
5361
}

0 commit comments

Comments
 (0)