diff --git a/.gitignore b/.gitignore index 897f2f0..6bc9163 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,5 @@ _NCrunch_* # Rider auto-generates .iml files, and contentModel.xml **/.idea/**/*.iml **/.idea/**/contentModel.xml -**/.idea/**/modules.xml \ No newline at end of file +**/.idea/**/modules.xml +.idea/.idea.Exceptionless.DateTimeExtensions/.idea/ diff --git a/.vscode/settings.json b/.vscode/settings.json index d5cdf52..ac7149a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "cSpell.words": [ + "millis", "timespan" ] } \ No newline at end of file diff --git a/README.md b/README.md index fc7d868..cc5ab43 100644 --- a/README.md +++ b/README.md @@ -34,15 +34,101 @@ 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); + +// 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 + +### DateMath Utility + +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. + +```csharp +using Exceptionless.DateTimeExtensions; + +// Parse date math expressions with standard .NET conventions +var baseTime = DateTimeOffset.Now; + +// Parse method - throws ArgumentException on invalid input +var result = DateMath.Parse("now+1h", baseTime); +var rounded = DateMath.Parse("now-1d/d", baseTime, isUpperLimit: false); // Start of yesterday + +// TryParse method - returns bool for success/failure +if (DateMath.TryParse("2023.06.15||+1M/d", baseTime, false, out var parsed)) { + // Successfully parsed: June 15, 2023 + 1 month, rounded to start of day + Console.WriteLine($"Parsed: {parsed:O}"); +} + +// Upper limit behavior affects rounding +var startOfDay = DateMath.Parse("now/d", baseTime, isUpperLimit: false); // 00:00:00 +var endOfDay = DateMath.Parse("now/d", baseTime, isUpperLimit: true); // 23:59:59.999 + +// Explicit dates with timezone preservation +var utcResult = DateMath.Parse("2025-01-01T01:25:35Z||+3d/d", baseTime); +var offsetResult = DateMath.Parse("2023-06-15T14:30:00+05:00||+1M", baseTime); +``` + +#### TimeZone-Aware DateMath + +The `DateMath` utility also provides overloads that work directly with `TimeZoneInfo` for better timezone handling: + +```csharp +using Exceptionless.DateTimeExtensions; + +// Parse expressions using a specific timezone +var utcTimeZone = TimeZoneInfo.Utc; +var easternTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Eastern"); + +// "now" will use current time in the specified timezone +var utcResult = DateMath.Parse("now+1h", utcTimeZone); +var easternResult = DateMath.Parse("now/d", easternTimeZone, isUpperLimit: false); + +// TryParse with timezone +if (DateMath.TryParse("now+2d-3h", easternTimeZone, false, out var result)) { + Console.WriteLine($"Eastern time result: {result:O}"); +} + +// Dates without explicit timezone use the provided TimeZoneInfo +var localDate = DateMath.Parse("2023-06-15T14:30:00||+1M", easternTimeZone); + +// Dates with explicit timezone are preserved regardless of TimeZoneInfo parameter +var preservedTz = DateMath.Parse("2023-06-15T14:30:00+05:00||+1M", easternTimeZone); +// Result will still have +05:00 offset, not Eastern time offset +``` + +The `DateMath` utility supports the same comprehensive syntax as `DateTimeRange` but provides a simpler API for direct parsing operations. + ### 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. diff --git a/src/Exceptionless.DateTimeExtensions/DateMath.cs b/src/Exceptionless.DateTimeExtensions/DateMath.cs new file mode 100644 index 0000000..5f7f7ce --- /dev/null +++ b/src/Exceptionless.DateTimeExtensions/DateMath.cs @@ -0,0 +1,503 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Exceptionless.DateTimeExtensions; + +/// +/// Provides Elasticsearch date math parsing functionality. +/// 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 +/// +public static class DateMath +{ + // Match date math expressions with anchors and operations + internal static readonly Regex Parser = new( + @"^(?now|(?\d{4}-?\d{2}-?\d{2}(?:[T\s](?:\d{1,2}(?::?\d{2}(?::?\d{2})?)?(?:\.\d{1,3})?)?(?:[+-]\d{2}:?\d{2}|Z)?)?)\|\|)" + + @"(?(?:[+\-/]\d*[yMwdhHms])*)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // Pre-compiled regex for operation parsing to avoid repeated compilation + private static readonly Regex _operationRegex = new(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled); + + /// + /// Parses a date math expression and returns the resulting DateTimeOffset. + /// + /// The date math expression to parse + /// The base time to use for relative calculations (e.g., 'now') + /// Whether this is for an upper limit (affects rounding behavior) + /// The parsed DateTimeOffset + /// Thrown when the expression is invalid or cannot be parsed + public static DateTimeOffset Parse(string expression, DateTimeOffset relativeBaseTime, bool isUpperLimit = false) + { + if (!TryParse(expression, relativeBaseTime, isUpperLimit, out DateTimeOffset result)) + throw new ArgumentException($"Invalid date math expression: {expression}", nameof(expression)); + + return result; + } + + /// + /// Tries to parse a date math expression and returns the resulting DateTimeOffset. + /// + /// The date math expression to parse + /// The base time to use for relative calculations (e.g., 'now') + /// Whether this is for an upper limit (affects rounding behavior) + /// The parsed DateTimeOffset if successful + /// True if parsing succeeded, false otherwise + public static bool TryParse(string expression, DateTimeOffset relativeBaseTime, bool isUpperLimit, out DateTimeOffset result) + { + result = default; + + if (String.IsNullOrEmpty(expression)) + return false; + + var match = Parser.Match(expression); + if (!match.Success) + return false; + + return TryParseFromMatch(match, relativeBaseTime, isUpperLimit, out result); + } + + /// + /// Parses a date math expression and returns the resulting DateTimeOffset using the specified timezone. + /// + /// The date math expression to parse + /// The timezone to use for 'now' calculations and dates without explicit timezone information + /// Whether this is for an upper limit (affects rounding behavior) + /// The parsed DateTimeOffset + /// Thrown when the expression is invalid or cannot be parsed + /// Thrown when timeZone is null + public static DateTimeOffset Parse(string expression, TimeZoneInfo timeZone, bool isUpperLimit = false) + { + if (timeZone == null) + throw new ArgumentNullException(nameof(timeZone)); + + if (!TryParse(expression, timeZone, isUpperLimit, out DateTimeOffset result)) + throw new ArgumentException($"Invalid date math expression: {expression}", nameof(expression)); + + return result; + } + + /// + /// Tries to parse a date math expression and returns the resulting DateTimeOffset using the specified timezone. + /// + /// The date math expression to parse + /// The timezone to use for 'now' calculations and dates without explicit timezone information + /// Whether this is for an upper limit (affects rounding behavior) + /// The parsed DateTimeOffset if successful + /// True if parsing succeeded, false otherwise + /// Thrown when timeZone is null + public static bool TryParse(string expression, TimeZoneInfo timeZone, bool isUpperLimit, out DateTimeOffset result) + { + if (timeZone == null) + throw new ArgumentNullException(nameof(timeZone)); + + result = default; + + if (String.IsNullOrEmpty(expression)) + return false; + + var match = Parser.Match(expression); + if (!match.Success) + return false; + + return TryParseFromMatch(match, timeZone, isUpperLimit, out result); + } + + /// + /// Tries to parse a date math expression from a regex match and returns the resulting DateTimeOffset. + /// This method bypasses the regex matching for cases where the match is already available. + /// + /// The regex match containing the parsed expression groups + /// The base time to use for relative calculations (e.g., 'now') + /// Whether this is for an upper limit (affects rounding behavior) + /// The parsed DateTimeOffset if successful + /// True if parsing succeeded, false otherwise + public static bool TryParseFromMatch(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit, out DateTimeOffset result) + { + result = default; + + 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 false; + } + + // Parse and apply operations + string operations = match.Groups["operations"].Value; + result = ApplyOperations(baseTime, operations, isUpperLimit); + return true; + } + catch + { + return false; + } + } + + /// + /// Tries to parse a date math expression from a regex match and returns the resulting DateTimeOffset using the specified timezone. + /// This method bypasses the regex matching for cases where the match is already available. + /// + /// The regex match containing the parsed expression groups + /// The timezone to use for 'now' calculations and dates without explicit timezone information + /// Whether this is for an upper limit (affects rounding behavior) + /// The parsed DateTimeOffset if successful + /// True if parsing succeeded, false otherwise + public static bool TryParseFromMatch(Match match, TimeZoneInfo timeZone, bool isUpperLimit, out DateTimeOffset result) + { + result = default; + + try + { + // Parse the anchor (now or explicit date) + DateTimeOffset baseTime; + string anchor = match.Groups["anchor"].Value; + + if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase)) + { + // Use current time in the specified timezone + baseTime = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZone); + } + else + { + // Parse explicit date from the date group + string dateStr = match.Groups["date"].Value; + TimeSpan offset = timeZone.GetUtcOffset(DateTime.UtcNow); + if (!TryParseExplicitDate(dateStr, offset, out baseTime)) + return false; + } + + // Parse and apply operations + string operations = match.Groups["operations"].Value; + result = ApplyOperations(baseTime, operations, isUpperLimit); + return true; + } + catch + { + return false; + } + } + + /// + /// Checks if the given expression is a valid date math expression. + /// + /// The expression to validate + /// True if the expression is valid date math, false otherwise + public static bool IsValidExpression(string expression) + { + if (String.IsNullOrEmpty(expression)) + return false; + + return Parser.IsMatch(expression); + } + + /// + /// Attempts to parse an explicit date string with proper timezone handling. + /// Supports various Elasticsearch-compatible date formats with optional timezone information. + /// + /// Performance-optimized with length checks and format ordering by likelihood. + /// + /// 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. + /// + /// The date string to parse + /// Fallback timezone offset for dates without explicit timezone + /// The parsed DateTimeOffset with correct timezone + /// True if parsing succeeded, false otherwise + private static bool TryParseExplicitDate(string dateStr, TimeSpan offset, out DateTimeOffset result) + { + result = default; + + if (String.IsNullOrEmpty(dateStr)) + return false; + + int len = dateStr.Length; + + // Early exit for obviously invalid lengths + if (len is < 4 or > 29) // Min: yyyy (4), Max: yyyy-MM-ddTHH:mm:ss.fffzzz (29) + return false; + + // Fast character validation for year digits + if (!Char.IsDigit(dateStr[0]) || !Char.IsDigit(dateStr[1]) || + !Char.IsDigit(dateStr[2]) || !Char.IsDigit(dateStr[3])) + return false; + + // Detect timezone presence for smart format selection + bool hasZ = dateStr[len - 1] == 'Z'; + bool hasTimezone = hasZ; + if (!hasTimezone && len > 10) // Check for +/-HH:mm timezone format + { + for (int index = Math.Max(10, len - 6); index < len - 1; index++) + { + if (dateStr[index] is '+' or '-' && index + 1 < len && Char.IsDigit(dateStr[index + 1])) + { + hasTimezone = true; + break; + } + } + } + + // Length-based format selection for maximum performance + // Only try formats that match the exact length to avoid unnecessary parsing attempts + switch (len) + { + case 4: // Built-in: year (yyyy) + return TryParseWithFormat(dateStr, "yyyy", offset, false, out result); + + case 7: // Built-in: year_month (yyyy-MM) + if (dateStr[4] == '-') + return TryParseWithFormat(dateStr, "yyyy-MM", offset, false, out result); + break; + + case 8: // Built-in: basic_date (yyyyMMdd) + return TryParseWithFormat(dateStr, "yyyyMMdd", offset, false, out result); + + case 10: // Built-in: date (yyyy-MM-dd) + if (dateStr[4] == '-' && dateStr[7] == '-') + { + return TryParseWithFormat(dateStr, "yyyy-MM-dd", offset, false, out result); + } + break; + + case 13: // Built-in: date_hour (yyyy-MM-ddTHH) + if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T') + { + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH", offset, false, out result); + } + break; + + case 16: // Built-in: date_hour_minute (yyyy-MM-ddTHH:mm) + if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':') + { + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm", offset, false, out result); + } + break; + + case 19: // Built-in: date_hour_minute_second (yyyy-MM-ddTHH:mm:ss) + if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':') + { + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss", offset, false, out result); + } + break; + + case 20: // Built-in: date_time_no_millis (yyyy-MM-ddTHH:mm:ssZ) + if (hasZ && dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':') + { + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ssZ", offset, true, out result); + } + break; + + case 23: // Built-in: date_hour_minute_second_millis (yyyy-MM-ddTHH:mm:ss.fff) + if (dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.') + { + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fff", offset, false, out result); + } + break; + + case 24: // Built-in: date_time (yyyy-MM-ddTHH:mm:ss.fffZ) + if (hasZ && dateStr[4] == '-' && dateStr[7] == '-' && dateStr[10] == 'T' && dateStr[13] == ':' && dateStr[16] == ':' && dateStr[19] == '.') + { + return TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fffZ", offset, true, out result); + } + break; + } + + // Handle RFC 822 timezone offset formats (variable lengths: +05:00, +0500, etc.) + // Note: .NET uses 'zzz' pattern for timezone offsets like +05:00 + if (hasTimezone && !hasZ) + { + // Only try timezone formats for lengths that make sense + if (len is >= 25 and <= 29) // +05:00 variants + { + if (dateStr.Contains(".")) // with milliseconds + { + // yyyy-MM-ddTHH:mm:ss.fff+05:00 + if (TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:ss.fffzzz", offset, true, out result)) + return true; + } + } + + if (len is >= 22 and <= 25) // without milliseconds + { + // yyyy-MM-ddTHH:mm:ss+05:00 + if (TryParseWithFormat(dateStr, "yyyy-MM-ddTHH:mm:sszzz", offset, true, out result)) + return true; + } + } + + return false; + } + + /// + /// Helper method to parse with a specific format, handling timezone appropriately. + /// + private static bool TryParseWithFormat(string dateStr, string format, TimeSpan offset, bool hasTimezone, out DateTimeOffset result) + { + result = default; + + if (hasTimezone) + { + // Try parsing with timezone information preserved + return DateTimeOffset.TryParseExact(dateStr, format, CultureInfo.InvariantCulture, + DateTimeStyles.None, out result); + } + + // 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 + result = new DateTimeOffset(dateTime.Ticks, offset); + return true; + } + + return false; + } + + /// + /// Applies date math operations to a base time. + /// + /// The base time to apply operations to + /// The operations string (e.g., "+1d-2h/d") + /// Whether this is for an upper limit (affects rounding) + /// The result after applying all operations + public static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string operations, bool isUpperLimit = false) + { + if (String.IsNullOrEmpty(operations)) + return baseTime; + + var result = baseTime; + var matches = _operationRegex.Matches(operations); + + // Validate that all operations were matched properly + int totalMatchLength = matches.Cast().Sum(m => m.Length); + if (totalMatchLength != operations.Length) + { + // If not all operations were matched, there are invalid operations + throw new ArgumentException("Invalid operations"); + } + + // Validate that rounding operations (/) are only at the end + // According to Elasticsearch spec, rounding must be the final operation + bool foundRounding = false; + for (int i = 0; i < matches.Count; i++) + { + string operation = matches[i].Groups[1].Value; + if (operation == "/") + { + if (foundRounding) + { + // Multiple rounding operations are not allowed + throw new ArgumentException("Multiple rounding operations are not allowed"); + } + if (i != matches.Count - 1) + { + // Rounding operation must be the last operation + throw new ArgumentException("Rounding operation must be the final operation"); + } + foundRounding = true; + } + } + + 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 : Int32.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; + } + + /// + /// Adds a time unit to a DateTimeOffset. + /// + /// The base date time + /// The amount to add + /// The time unit (y, M, w, d, h, H, m, s) + /// The result after adding the time unit + public 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), // Only lowercase d for days + "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; + } + } + + /// + /// Rounds a DateTimeOffset to a time unit. + /// + /// The date time to round + /// The time unit to round to (y, M, w, d, h, H, m, s) + /// Whether to round up (end of period) or down (start of period) + /// The rounded DateTimeOffset + public static DateTimeOffset RoundToUnit(DateTimeOffset dateTime, string unit, bool isUpperLimit = false) + { + 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(), // Only lowercase d for days + "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}") + }; + } +} diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs new file mode 100644 index 0000000..7840db4 --- /dev/null +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/DateMathPartParser.cs @@ -0,0 +1,28 @@ +using System; +using System.Text.RegularExpressions; + +namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers; + +/// +/// Parses Elasticsearch date math expressions using the DateMath utility class. +/// This is a thin wrapper that adapts the DateMath utility for use within the format parser framework. +/// +/// 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 +/// +/// For more details about date math functionality, see . +/// +[Priority(35)] +public class DateMathPartParser : IPartParser +{ + public Regex Regex => DateMath.Parser; + + public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit) + { + // Since we already have a successful regex match, use the efficient TryParseFromMatch method + // to avoid redundant regex matching and validation + return DateMath.TryParseFromMatch(match, relativeBaseTime, isUpperLimit, out DateTimeOffset result) + ? result + : null; + } +} diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs new file mode 100644 index 0000000..78a434d --- /dev/null +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/WildcardPartParser.cs @@ -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; + } +} diff --git a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs index 6445245..c20742a 100644 --- a/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs +++ b/src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs @@ -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); 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); public TwoPartFormatParser() { @@ -33,6 +33,9 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime) if (!begin.Success) return null; + // Capture the opening bracket if present + string openingBracket = begin.Groups[1].Value; + index += begin.Length; DateTimeOffset? start = null; foreach (var parser in Parsers) @@ -70,9 +73,36 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime) break; } - if (!_endRegex.IsMatch(content, index)) + var endMatch = _endRegex.Match(content, index); + if (!endMatch.Success) + return null; + + // Validate bracket matching + string closingBracket = endMatch.Groups[1].Value; + if (!IsValidBracketPair(openingBracket, closingBracket)) return null; return new DateTimeRange(start ?? DateTime.MinValue, end ?? DateTime.MaxValue); } + + /// + /// Validates that opening and closing brackets are properly matched. + /// + /// The opening bracket character + /// The closing bracket character + /// True if brackets are properly matched, false otherwise + private static bool IsValidBracketPair(string opening, string closing) + { + // Both empty - valid (no brackets) + if (String.IsNullOrEmpty(opening) && String.IsNullOrEmpty(closing)) + return true; + + // One empty, one not - invalid (unbalanced) + if (String.IsNullOrEmpty(opening) || String.IsNullOrEmpty(closing)) + return false; + + // Check for proper matching pairs + return (opening == "[" && closing == "]") || + (opening == "{" && closing == "}"); + } } diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs new file mode 100644 index 0000000..866bcd2 --- /dev/null +++ b/tests/Exceptionless.DateTimeExtensions.Tests/DateMathTests.cs @@ -0,0 +1,760 @@ +using System; +using Foundatio.Xunit; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Exceptionless.DateTimeExtensions.Tests; + +/// +/// Comprehensive tests for the DateMath utility class, covering all parsing scenarios, +/// edge cases, timezone handling, and error conditions. +/// +public class DateMathTests : TestWithLoggingBase +{ + private readonly DateTimeOffset _baseTime = new(2023, 6, 15, 14, 30, 45, 123, TimeSpan.FromHours(5)); + + public DateMathTests(ITestOutputHelper output) : base(output) + { + } + + [Theory] + [InlineData("now", false)] + [InlineData("now", true)] + public void Parse_Now_ReturnsBaseTime(string expression, bool isUpperLimit) + { + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, IsUpperLimit: {IsUpperLimit}", + expression, _baseTime, isUpperLimit); + + var result = DateMath.Parse(expression, _baseTime, isUpperLimit); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(_baseTime, result); + } + + [Theory] + [InlineData("now+1h", 1)] + [InlineData("now+2h", 2)] + [InlineData("now+24h", 24)] + [InlineData("now+1H", 1)] // Case insensitive + [InlineData("now-1h", -1)] + [InlineData("now-12h", -12)] + public void Parse_HourOperations_ReturnsCorrectResult(string expression, int hours) + { + var expected = _baseTime.AddHours(hours); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now+1d", 1)] + [InlineData("now+7d", 7)] + [InlineData("now-1d", -1)] + [InlineData("now-30d", -30)] + public void Parse_DayOperations_ReturnsCorrectResult(string expression, int days) + { + var expected = _baseTime.AddDays(days); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now+1M", 1)] + [InlineData("now+6M", 6)] + [InlineData("now-1M", -1)] + [InlineData("now-12M", -12)] + public void Parse_MonthOperations_ReturnsCorrectResult(string expression, int months) + { + var expected = _baseTime.AddMonths(months); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now+1y", 1)] + [InlineData("now+5y", 5)] + [InlineData("now-1y", -1)] + [InlineData("now-10y", -10)] + public void Parse_YearOperations_ReturnsCorrectResult(string expression, int years) + { + var expected = _baseTime.AddYears(years); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now+1w", 7)] + [InlineData("now+2w", 14)] + [InlineData("now-1w", -7)] + [InlineData("now-4w", -28)] + public void Parse_WeekOperations_ReturnsCorrectResult(string expression, int days) + { + var expected = _baseTime.AddDays(days); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now+1m", 1)] + [InlineData("now+30m", 30)] + [InlineData("now-1m", -1)] + [InlineData("now-60m", -60)] + public void Parse_MinuteOperations_ReturnsCorrectResult(string expression, int minutes) + { + var expected = _baseTime.AddMinutes(minutes); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now+1s", 1)] + [InlineData("now+30s", 30)] + [InlineData("now-1s", -1)] + [InlineData("now-3600s", -3600)] + public void Parse_SecondOperations_ReturnsCorrectResult(string expression, int seconds) + { + var expected = _baseTime.AddSeconds(seconds); + _logger.LogDebug("Testing Parse with expression: '{Expression}', BaseTime: {BaseTime}, Expected: {Expected}", + expression, _baseTime, expected); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("now/d", false)] // Start of day + [InlineData("now/d", true)] // End of day + [InlineData("now/h", false)] // Start of hour + [InlineData("now/h", true)] // End of hour + [InlineData("now/m", false)] // Start of minute + [InlineData("now/m", true)] // End of minute + public void Parse_RoundingOperations_ReturnsCorrectResult(string expression, bool isUpperLimit) + { + _logger.LogDebug("Testing Parse with rounding expression: '{Expression}', BaseTime: {BaseTime}, IsUpperLimit: {IsUpperLimit}", + expression, _baseTime, isUpperLimit); + + var result = DateMath.Parse(expression, _baseTime, isUpperLimit); + + _logger.LogDebug("Parse result: {Result}", result); + + if (expression.EndsWith("/d")) + { + if (isUpperLimit) + { + // End of day: 23:59:59.999 + var expectedEnd = new DateTimeOffset(_baseTime.Year, _baseTime.Month, _baseTime.Day, 23, 59, 59, 999, _baseTime.Offset); + Assert.Equal(expectedEnd, result); + } + else + { + // Start of day: 00:00:00.000 + var expectedStart = new DateTimeOffset(_baseTime.Year, _baseTime.Month, _baseTime.Day, 0, 0, 0, 0, _baseTime.Offset); + Assert.Equal(expectedStart, result); + } + } + else if (expression.EndsWith("/h")) + { + var hourStart = new DateTimeOffset(_baseTime.Year, _baseTime.Month, _baseTime.Day, + _baseTime.Hour, 0, 0, 0, _baseTime.Offset); + + Assert.Equal(isUpperLimit ? hourStart.AddHours(1).AddMilliseconds(-1) : hourStart, result); + } + else if (expression.EndsWith("/m")) + { + var minuteStart = new DateTimeOffset(_baseTime.Year, _baseTime.Month, _baseTime.Day, + _baseTime.Hour, _baseTime.Minute, 0, 0, _baseTime.Offset); + + Assert.Equal(isUpperLimit ? minuteStart.AddMinutes(1).AddMilliseconds(-1) : minuteStart, result); + } + } + + [Theory] + [InlineData("now+1d+2h")] + [InlineData("now-1d+12h")] + [InlineData("now+1M+1d")] + [InlineData("now+1y-1M")] + public void Parse_MultipleOperations_ReturnsCorrectResult(string expression) + { + _logger.LogDebug("Testing Parse with multiple operations: '{Expression}', BaseTime: {BaseTime}", + expression, _baseTime); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + + // For simple combinations, we can verify exact results + if (expression == "now+1d+2h") + { + var expected = _baseTime.AddDays(1).AddHours(2); + Assert.Equal(expected, result); + } + else if (expression == "now-1d+12h") + { + var expected = _baseTime.AddDays(-1).AddHours(12); + Assert.Equal(expected, result); + } + else + { + // For month/year operations, just verify the result is reasonable + Assert.NotEqual(_baseTime, result); + } + } + + [Theory] + [InlineData("2023-06-15||")] + [InlineData("2023-06-15T10:30:00||")] + [InlineData("2023-06-15T10:30:00.123||")] + public void Parse_ExplicitDateFormats_ReturnsCorrectResult(string expression) + { + _logger.LogDebug("Testing Parse with explicit date format: '{Expression}', BaseTime: {BaseTime}", + expression, _baseTime); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + + // All should resolve to June 15, 2023 in the base time's timezone + Assert.Equal(2023, result.Year); + Assert.Equal(6, result.Month); + Assert.Equal(15, result.Day); + Assert.Equal(_baseTime.Offset, result.Offset); + } + + [Theory] + [InlineData("2023-06-15T10:30:00Z||", 0)] + [InlineData("2023-06-15T10:30:00+02:00||", 2)] + [InlineData("2023-06-15T10:30:00-05:00||", -5)] + [InlineData("2023-06-15T10:30:00+09:30||", 9.5)] + public void Parse_ExplicitTimezones_PreservesTimezone(string expression, double offsetHours) + { + _logger.LogDebug("Testing Parse with explicit timezone: '{Expression}', BaseTime: {BaseTime}, Expected offset hours: {OffsetHours}", + expression, _baseTime, offsetHours); + + var result = DateMath.Parse(expression, _baseTime); + var expectedOffset = TimeSpan.FromHours(offsetHours); + + _logger.LogDebug("Parse result: {Result}, Expected offset: {ExpectedOffset}", result, expectedOffset); + + Assert.Equal(2023, result.Year); + Assert.Equal(6, result.Month); + Assert.Equal(15, result.Day); + Assert.Equal(10, result.Hour); + Assert.Equal(30, result.Minute); + Assert.Equal(expectedOffset, result.Offset); + } + + [Theory] + [InlineData("2023-06-15||+1M")] + [InlineData("2023-06-15T10:30:00||+2d")] + [InlineData("2023-06-15T10:30:00Z||+1h")] + [InlineData("2023-06-15T10:30:00+02:00||-1d/d")] + public void Parse_ExplicitDateWithOperations_ReturnsCorrectResult(string expression) + { + _logger.LogDebug("Testing Parse with explicit date and operations: '{Expression}', BaseTime: {BaseTime}", + expression, _baseTime); + + var result = DateMath.Parse(expression, _baseTime); + + _logger.LogDebug("Parse result: {Result}", result); + + // Verify it's not the base time and the operation was applied + Assert.NotEqual(_baseTime, result); + + if (expression.Contains("+1M")) + { + Assert.Equal(7, result.Month); // June + 1 month = July + } + else if (expression.Contains("+2d")) + { + Assert.Equal(17, result.Day); // 15 + 2 days = 17 + } + else if (expression.Contains("+1h")) + { + Assert.Equal(11, result.Hour); // 10 + 1 hour = 11 + } + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("invalid")] + [InlineData("now+1x")] // Invalid unit + [InlineData("2023-01-01")] // Missing || + [InlineData("||+1d")] // Missing anchor + [InlineData("now/x")] // Invalid rounding unit + [InlineData("2023-13-01||")] // Invalid month + [InlineData("2023-01-32||")] // Invalid day + [InlineData("2001.02.01||")] // Dotted format no longer supported + [InlineData("now/d+1h")] // Rounding must be final operation + [InlineData("now/d/d")] // Multiple rounding operations + [InlineData("now+1h/d+2m")] // Rounding in middle of operations + public void Parse_InvalidExpressions_ThrowsArgumentException(string expression) + { + _logger.LogDebug("Testing Parse with invalid expression: '{Expression}', expecting ArgumentException", expression); + + var exception = Assert.Throws(() => DateMath.Parse(expression, _baseTime)); + + _logger.LogDebug("Exception thrown as expected: {Message}", exception.Message); + Assert.Contains("Invalid date math expression", exception.Message); + } + + [Fact] + public void Parse_NullExpression_ThrowsArgumentException() + { + _logger.LogDebug("Testing Parse with null expression, expecting ArgumentException"); + + var exception = Assert.Throws(() => DateMath.Parse(null!, _baseTime)); + + _logger.LogDebug("Exception thrown as expected: {Message}", exception.Message); + } + + [Theory] + [InlineData("now")] + [InlineData("now+1h")] + [InlineData("now-1d/d")] + [InlineData("2023-06-15||")] + [InlineData("2023-06-15||+1M/d")] + [InlineData("2025-01-01T01:25:35Z||+3d/d")] + public void TryParse_ValidExpressions_ReturnsTrueAndCorrectResult(string expression) + { + _logger.LogDebug("Testing TryParse with valid expression: '{Expression}', BaseTime: {BaseTime}", + expression, _baseTime); + + bool success = DateMath.TryParse(expression, _baseTime, false, out var result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.True(success); + Assert.NotEqual(default, result); + + // Verify TryParse and Parse return the same result + var parseResult = DateMath.Parse(expression, _baseTime, false); + Assert.Equal(parseResult, result); + } + + [Theory] + [InlineData("")] + [InlineData("invalid")] + [InlineData("now+")] + [InlineData("2023-01-01")] // Missing || + [InlineData("||+1d")] // Missing anchor + [InlineData("2001.02.01||")] // Dotted format no longer supported + [InlineData("now/d+1h")] // Rounding must be final operation + [InlineData("now/d/d")] // Multiple rounding operations + public void TryParse_InvalidExpressions_ReturnsFalse(string expression) + { + _logger.LogDebug("Testing TryParse with invalid expression: '{Expression}', expecting false", expression); + + bool success = DateMath.TryParse(expression, _baseTime, false, out var result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.False(success); + Assert.Equal(default, result); + } + + [Fact] + public void TryParse_NullExpression_ReturnsFalse() + { + _logger.LogDebug("Testing TryParse with null expression, expecting false"); + + bool success = DateMath.TryParse(null!, _baseTime, false, out var result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.False(success); + Assert.Equal(default, result); + } + + [Theory] + [InlineData("now+1h", false)] + [InlineData("now-1d/d", true)] + [InlineData("2023-06-15||+1M", false)] + [InlineData("2025-01-01T01:25:35Z||+3d/d", true)] + public void Parse_And_TryParse_ReturnSameResults(string expression, bool isUpperLimit) + { + _logger.LogDebug("Testing Parse vs TryParse consistency for expression: '{Expression}', IsUpperLimit: {IsUpperLimit}", + expression, isUpperLimit); + + var parseResult = DateMath.Parse(expression, _baseTime, isUpperLimit); + bool tryParseSuccess = DateMath.TryParse(expression, _baseTime, isUpperLimit, out var tryParseResult); + + _logger.LogDebug("Parse result: {ParseResult}, TryParse success: {TryParseSuccess}, TryParse result: {TryParseResult}", + parseResult, tryParseSuccess, tryParseResult); + + Assert.True(tryParseSuccess); + Assert.Equal(parseResult, tryParseResult); + } + + [Theory] + [InlineData("now/d")] + [InlineData("now/h")] + [InlineData("now/m")] + [InlineData("now+1d/d")] + [InlineData("now-1M/d")] + public void Parse_UpperLimitVsLowerLimit_ProducesDifferentResults(string expression) + { + _logger.LogDebug("Testing upper vs lower limit behavior for expression: '{Expression}'", expression); + + var lowerResult = DateMath.Parse(expression, _baseTime, false); + var upperResult = DateMath.Parse(expression, _baseTime, true); + + _logger.LogDebug("Lower limit result: {LowerResult}, Upper limit result: {UpperResult}", + lowerResult, upperResult); + + // Upper limit should be later than lower limit for rounding operations + Assert.True(upperResult > lowerResult, + $"Upper limit ({upperResult}) should be greater than lower limit ({lowerResult})"); + } + + [Fact] + public void Parse_EdgeCase_LeapYear() + { + var leapYearDate = new DateTimeOffset(2024, 2, 28, 12, 0, 0, _baseTime.Offset); + const string expression = "now+1d"; + + _logger.LogDebug("Testing leap year edge case with date: {LeapYearDate}, expression: '{Expression}'", + leapYearDate, expression); + + var result = DateMath.Parse(expression, leapYearDate); + + _logger.LogDebug("Parse result: {Result}", result); + + Assert.Equal(29, result.Day); // Should go to Feb 29 in leap year + Assert.Equal(2, result.Month); + } + + [Fact] + public void Parse_EdgeCase_MonthOverflow() + { + var endOfMonth = new DateTimeOffset(2023, 1, 31, 12, 0, 0, _baseTime.Offset); + const string expression = "now+1M"; + + _logger.LogDebug("Testing month overflow edge case with date: {EndOfMonth}, expression: '{Expression}'", + endOfMonth, expression); + + var result = DateMath.Parse(expression, endOfMonth); + + _logger.LogDebug("Parse result: {Result}", result); + + // January 31 + 1 month should go to February 28 (or 29 in leap year) + Assert.Equal(2, result.Month); + Assert.True(result.Day <= 29); + } + + [Fact] + public void Parse_EdgeCase_YearOverflow() + { + var endOfYear = new DateTimeOffset(2023, 12, 31, 23, 59, 59, _baseTime.Offset); + const string expression = "now+1d"; + + _logger.LogDebug("Testing year overflow edge case with date: {EndOfYear}, expression: '{Expression}'", + endOfYear, expression); + + var result = DateMath.Parse(expression, endOfYear); + + _logger.LogDebug("Parse result: {Result}", result); + + Assert.Equal(2024, result.Year); + Assert.Equal(1, result.Month); + Assert.Equal(1, result.Day); + } + + [Fact] + public void Parse_ComplexExpression_MultipleOperationsWithRounding() + { + const string expression = "now+1M-2d+3h/h"; + + _logger.LogDebug("Testing complex expression: '{Expression}', BaseTime: {BaseTime}", expression, _baseTime); + + var result = DateMath.Parse(expression, _baseTime, false); + + _logger.LogDebug("Parse result: {Result}", result); + + // Should be rounded to start of hour + Assert.Equal(0, result.Minute); + Assert.Equal(0, result.Second); + Assert.Equal(0, result.Millisecond); + + // Should not equal base time + Assert.NotEqual(_baseTime, result); + } + + [Fact] + public void ParseTimeZone_Now_ReturnsCurrentTimeInSpecifiedTimezone() + { + var utcTimeZone = TimeZoneInfo.Utc; + const string expression = "now"; + + _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}", + expression, utcTimeZone.Id); + + var result = DateMath.Parse(expression, utcTimeZone); + + _logger.LogDebug("Parse result: {Result}", result); + + // Should be close to current UTC time + var utcNow = DateTimeOffset.UtcNow; + Assert.True(Math.Abs((result - utcNow).TotalSeconds) < 5, + $"Result {result} should be within 5 seconds of UTC now {utcNow}"); + Assert.Equal(TimeSpan.Zero, result.Offset); // Should be UTC + } + + [Theory] + [InlineData("UTC", 0)] + [InlineData("US/Eastern", -5)] // EST offset (not considering DST for this test) + [InlineData("US/Pacific", -8)] // PST offset (not considering DST for this test) + public void ParseTimeZone_Now_ReturnsCorrectTimezone(string timeZoneId, int expectedOffsetHours) + { + var timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + const string expression = "now"; + + _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}", + expression, timeZone.Id); + + var result = DateMath.Parse(expression, timeZone); + + _logger.LogDebug("Parse result: {Result}, Expected offset hours: {ExpectedOffsetHours}", result, expectedOffsetHours); + + // Note: This test might need adjustment for DST, but it demonstrates the concept + Assert.Equal(timeZone.GetUtcOffset(DateTime.UtcNow), result.Offset); + } + + [Fact] + public void ParseTimeZone_ExplicitDateWithoutTimezone_UsesSpecifiedTimezone() + { + var easternTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Eastern"); + const string expression = "2023-06-15T14:30:00||"; + + _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}", + expression, easternTimeZone.Id); + + var result = DateMath.Parse(expression, easternTimeZone); + + _logger.LogDebug("Parse result: {Result}", result); + + Assert.Equal(2023, result.Year); + Assert.Equal(6, result.Month); + Assert.Equal(15, result.Day); + Assert.Equal(14, result.Hour); + Assert.Equal(30, result.Minute); + Assert.Equal(0, result.Second); + + // Should use the timezone offset from Eastern Time + var expectedOffset = easternTimeZone.GetUtcOffset(new DateTime(2023, 6, 15, 14, 30, 0)); + Assert.Equal(expectedOffset, result.Offset); + } + + [Fact] + public void ParseTimeZone_ExplicitDateWithTimezone_PreservesOriginalTimezone() + { + var pacificTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Pacific"); + const string expression = "2023-06-15T14:30:00+05:00||"; // Explicit +05:00 timezone + + _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}", + expression, pacificTimeZone.Id); + + var result = DateMath.Parse(expression, pacificTimeZone); + + _logger.LogDebug("Parse result: {Result}", result); + + Assert.Equal(2023, result.Year); + Assert.Equal(6, result.Month); + Assert.Equal(15, result.Day); + Assert.Equal(14, result.Hour); + Assert.Equal(30, result.Minute); + Assert.Equal(0, result.Second); + + // Should preserve the original +05:00 timezone, not use Pacific + Assert.Equal(TimeSpan.FromHours(5), result.Offset); + } + + [Theory] + [InlineData("now+1h", 1)] + [InlineData("now+6h", 6)] + [InlineData("now-2h", -2)] + [InlineData("now+24h", 24)] + public void ParseTimeZone_HourOperations_ReturnsCorrectResult(string expression, int hours) + { + var utcTimeZone = TimeZoneInfo.Utc; + + _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}, Hours: {Hours}", + expression, utcTimeZone.Id, hours); + + var result = DateMath.Parse(expression, utcTimeZone); + var utcNow = DateTimeOffset.UtcNow; + var expected = utcNow.AddHours(hours); + + _logger.LogDebug("Parse result: {Result}, Expected: approximately {Expected}", result, expected); + + // Should be close to expected time (within 5 seconds to account for execution time) + Assert.True(Math.Abs((result - expected).TotalSeconds) < 5, + $"Result {result} should be within 5 seconds of expected {expected}"); + Assert.Equal(TimeSpan.Zero, result.Offset); // Should be UTC + } + + [Theory] + [InlineData("now/d", false)] + [InlineData("now/d", true)] + [InlineData("now/h", false)] + [InlineData("now/h", true)] + [InlineData("now/M", false)] + [InlineData("now/M", true)] + public void ParseTimeZone_RoundingOperations_ReturnsCorrectResult(string expression, bool isUpperLimit) + { + var centralTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Central"); + + _logger.LogDebug("Testing Parse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}, IsUpperLimit: {IsUpperLimit}", + expression, centralTimeZone.Id, isUpperLimit); + + var result = DateMath.Parse(expression, centralTimeZone, isUpperLimit); + + _logger.LogDebug("Parse result: {Result}", result); + + // Verify the result uses Central Time offset + var expectedOffset = centralTimeZone.GetUtcOffset(DateTime.UtcNow); + Assert.Equal(expectedOffset, result.Offset); + + // Verify rounding behavior + if (expression.EndsWith("/d")) + { + if (isUpperLimit) + { + Assert.Equal(23, result.Hour); + Assert.Equal(59, result.Minute); + Assert.Equal(59, result.Second); + } + else + { + Assert.Equal(0, result.Hour); + Assert.Equal(0, result.Minute); + Assert.Equal(0, result.Second); + } + } + else if (expression.EndsWith("/h")) + { + if (isUpperLimit) + { + Assert.Equal(59, result.Minute); + Assert.Equal(59, result.Second); + } + else + { + Assert.Equal(0, result.Minute); + Assert.Equal(0, result.Second); + } + } + } + + [Fact] + public void TryParseTimeZone_ValidExpression_ReturnsTrue() + { + var mountainTimeZone = TimeZoneInfo.FindSystemTimeZoneById("US/Mountain"); + const string expression = "now+2d"; + + _logger.LogDebug("Testing TryParse with TimeZoneInfo for expression: '{Expression}', TimeZone: {TimeZone}", + expression, mountainTimeZone.Id); + + bool success = DateMath.TryParse(expression, mountainTimeZone, false, out DateTimeOffset result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.True(success); + Assert.NotEqual(default(DateTimeOffset), result); + + // Should use Mountain Time offset + var expectedOffset = mountainTimeZone.GetUtcOffset(DateTime.UtcNow); + Assert.Equal(expectedOffset, result.Offset); + } + + [Fact] + public void TryParseTimeZone_InvalidExpression_ReturnsFalse() + { + var utcTimeZone = TimeZoneInfo.Utc; + const string expression = "invalid_expression"; + + _logger.LogDebug("Testing TryParse with TimeZoneInfo for invalid expression: '{Expression}', TimeZone: {TimeZone}", + expression, utcTimeZone.Id); + + bool success = DateMath.TryParse(expression, utcTimeZone, false, out DateTimeOffset result); + + _logger.LogDebug("TryParse success: {Success}, Result: {Result}", success, result); + + Assert.False(success); + Assert.Equal(default(DateTimeOffset), result); + } + + [Fact] + public void ParseTimeZone_ComplexExpression_WorksCorrectly() + { + var utcTimeZone = TimeZoneInfo.Utc; + const string expression = "now+1M-2d+3h/h"; + + _logger.LogDebug("Testing Parse with TimeZoneInfo for complex expression: '{Expression}', TimeZone: {TimeZone}", + expression, utcTimeZone.Id); + + var result = DateMath.Parse(expression, utcTimeZone, false); + + _logger.LogDebug("Parse result: {Result}", result); + + // Should be UTC + Assert.Equal(TimeSpan.Zero, result.Offset); + + // Should be rounded to start of hour + Assert.Equal(0, result.Minute); + Assert.Equal(0, result.Second); + Assert.Equal(0, result.Millisecond); + } + + [Fact] + public void ParseTimeZone_NullTimeZone_ThrowsArgumentNullException() + { + const string expression = "now"; + + _logger.LogDebug("Testing Parse with null TimeZoneInfo for expression: '{Expression}'", expression); + + Assert.Throws(() => DateMath.Parse(expression, (TimeZoneInfo)null!)); + } + + [Fact] + public void TryParseTimeZone_NullTimeZone_ThrowsArgumentNullException() + { + const string expression = "now"; + + _logger.LogDebug("Testing TryParse with null TimeZoneInfo for expression: '{Expression}'", expression); + + Assert.Throws(() => DateMath.TryParse(expression, (TimeZoneInfo)null!, false, out _)); + } +} diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs new file mode 100644 index 0000000..680d640 --- /dev/null +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/DateMathPartParserTests.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Generic; +using Exceptionless.DateTimeExtensions.FormatParsers.PartParsers; +using Xunit; +using Xunit.Abstractions; + +namespace Exceptionless.DateTimeExtensions.Tests.FormatParsers.PartParsers; + +public class DateMathPartParserTests : PartParserTestsBase +{ + public DateMathPartParserTests(ITestOutputHelper output) : base(output) { } + + [Theory] + [MemberData(nameof(NowInputs))] + public void ParseNowInput(string input, bool isUpperLimit, DateTimeOffset? expected) + { + ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected); + } + + [Theory] + [MemberData(nameof(ExplicitDateInputs))] + public void ParseExplicitDateInput(string input, bool isUpperLimit, DateTimeOffset? expected) + { + ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected); + } + + [Theory] + [MemberData(nameof(InvalidInputs))] + public void ParseInvalidInput(string input, bool isUpperLimit, DateTimeOffset? expected) + { + ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected); + } + + [Theory] + [MemberData(nameof(ComplexOperationInputs))] + public void ParseComplexOperations(string input, bool isUpperLimit, DateTimeOffset? expected) + { + ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected); + } + + [Theory] + [MemberData(nameof(RoundingInputs))] + public void ParseRoundingOperations(string input, bool isUpperLimit, DateTimeOffset? expected) + { + ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected); + } + + [Theory] + [MemberData(nameof(EdgeCaseInputs))] + public void ParseEdgeCases(string input, bool isUpperLimit, DateTimeOffset? expected) + { + ValidateInput(new DateMathPartParser(), input, isUpperLimit, expected); + } + + public static IEnumerable NowInputs + { + get + { + return new[] { + // Basic "now" anchor + new object[] { "now", false, _now }, + ["now", true, _now], + + // Now with single operations + ["now+1h", false, _now.AddHours(1)], + ["now+1h", true, _now.AddHours(1)], + ["now-1h", false, _now.AddHours(-1)], + ["now-1h", true, _now.AddHours(-1)], + ["now+1d", false, _now.AddDays(1)], + ["now+1d", true, _now.AddDays(1)], + ["now-1d", false, _now.AddDays(-1)], + ["now-1d", true, _now.AddDays(-1)], + ["now+1w", false, _now.AddDays(7)], + ["now+1w", true, _now.AddDays(7)], + ["now-1w", false, _now.AddDays(-7)], + ["now-1w", true, _now.AddDays(-7)], + ["now+1M", false, _now.AddMonths(1)], + ["now+1M", true, _now.AddMonths(1)], + ["now-1M", false, _now.AddMonths(-1)], + ["now-1M", true, _now.AddMonths(-1)], + ["now+1y", false, _now.AddYears(1)], + ["now+1y", true, _now.AddYears(1)], + ["now-1y", false, _now.AddYears(-1)], + ["now-1y", true, _now.AddYears(-1)], + ["now+1m", false, _now.AddMinutes(1)], + ["now+1m", true, _now.AddMinutes(1)], + ["now-1m", false, _now.AddMinutes(-1)], + ["now-1m", true, _now.AddMinutes(-1)], + ["now+1s", false, _now.AddSeconds(1)], + ["now+1s", true, _now.AddSeconds(1)], + ["now-1s", false, _now.AddSeconds(-1)], + ["now-1s", true, _now.AddSeconds(-1)], + + // Multi-digit amounts + ["now+10h", false, _now.AddHours(10)], + ["now+24h", true, _now.AddHours(24)], + ["now-365d", false, _now.AddDays(-365)], + ["now+100y", true, _now.AddYears(100)], + + // Capital H for hours + ["now+1H", false, _now.AddHours(1)], + ["now-2H", true, _now.AddHours(-2)], + + // Without amount (defaults to 1) + ["now+h", false, _now.AddHours(1)], + ["now-d", true, _now.AddDays(-1)], + ["now+M", false, _now.AddMonths(1)], + ["now-y", true, _now.AddYears(-1)] + }; + } + } + + public static IEnumerable ExplicitDateInputs + { + get + { + var baseDate = new DateTimeOffset(2001, 2, 1, 0, 0, 0, _now.Offset); + + return new[] { + // Basic explicit date formats (officially supported by Elasticsearch) + new object[] { "2001-02-01||", false, baseDate }, + ["2001-02-01||", true, baseDate], + ["20010201||", false, baseDate], + ["20010201||", true, baseDate], + + // With time components (ISO 8601 formats) + ["2001-02-01T12:30:45||", false, new DateTimeOffset(2001, 2, 1, 12, 30, 45, _now.Offset)], + ["2001-02-01T12:30:45||", true, new DateTimeOffset(2001, 2, 1, 12, 30, 45, _now.Offset)], + ["2001-02-01T12:30||", false, new DateTimeOffset(2001, 2, 1, 12, 30, 0, _now.Offset)], + ["2001-02-01T12:30||", true, new DateTimeOffset(2001, 2, 1, 12, 30, 0, _now.Offset)], + ["2001-02-01T12||", false, new DateTimeOffset(2001, 2, 1, 12, 0, 0, _now.Offset)], + ["2001-02-01T12||", true, new DateTimeOffset(2001, 2, 1, 12, 0, 0, _now.Offset)], + + // With operations + ["2001-02-01||+1M", false, baseDate.AddMonths(1)], + ["2001-02-01||+1M", true, baseDate.AddMonths(1)], + ["2001-02-01||+1y", false, baseDate.AddYears(1)], + ["2001-02-01||+1y", true, baseDate.AddYears(1)], + ["2001-02-01||-1d", false, baseDate.AddDays(-1)], + ["2001-02-01||-1d", true, baseDate.AddDays(-1)], + + // With operations and rounding (basic_date format + operations) + ["20010201||+1M/d", false, baseDate.AddMonths(1).StartOfDay()], + ["20010201||+1M/d", true, baseDate.AddMonths(1).EndOfDay()], + + // User's specific test case - UTC date with operations and rounding + ["2025-01-01T01:25:35Z||+3d/d", false, new DateTimeOffset(2025, 1, 4, 0, 0, 0, TimeSpan.Zero)], + ["2025-01-01T01:25:35Z||+3d/d", true, new DateTimeOffset(2025, 1, 4, 23, 59, 59, 999, TimeSpan.Zero)], + + // Timezone variations - should preserve the timezone + ["2023-06-15T14:30:00Z||", false, new DateTimeOffset(2023, 6, 15, 14, 30, 0, TimeSpan.Zero)], + ["2023-06-15T14:30:00Z||", true, new DateTimeOffset(2023, 6, 15, 14, 30, 0, TimeSpan.Zero)], + ["2023-06-15T14:30:00+05:00||", false, new DateTimeOffset(2023, 6, 15, 14, 30, 0, TimeSpan.FromHours(5))], + ["2023-06-15T14:30:00+05:00||", true, new DateTimeOffset(2023, 6, 15, 14, 30, 0, TimeSpan.FromHours(5))], + ["2023-06-15T14:30:00-08:00||", false, new DateTimeOffset(2023, 6, 15, 14, 30, 0, TimeSpan.FromHours(-8))], + ["2023-06-15T14:30:00-08:00||", true, new DateTimeOffset(2023, 6, 15, 14, 30, 0, TimeSpan.FromHours(-8))], + + // Dates without timezone - should use current timezone + ["2023-06-15T14:30:00||", false, new DateTimeOffset(2023, 6, 15, 14, 30, 0, _now.Offset)], + ["2023-06-15T14:30:00||", true, new DateTimeOffset(2023, 6, 15, 14, 30, 0, _now.Offset)], + + // Milliseconds support with and without timezone + ["2023-01-01T12:00:00.123||", false, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, _now.Offset)], + ["2023-01-01T12:00:00.123||", true, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, _now.Offset)], + ["2023-01-01T12:00:00.123Z||", false, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, TimeSpan.Zero)], + ["2023-01-01T12:00:00.123Z||", true, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, TimeSpan.Zero)], + ["2023-01-01T12:00:00.123+02:00||", false, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, TimeSpan.FromHours(2))], + ["2023-01-01T12:00:00.123+02:00||", true, new DateTimeOffset(2023, 1, 1, 12, 0, 0, 123, TimeSpan.FromHours(2))], + + // Basic format variations (yyyyMMdd is officially supported) + ["20230615||", false, new DateTimeOffset(2023, 6, 15, 0, 0, 0, _now.Offset)], + ["20230615||", true, new DateTimeOffset(2023, 6, 15, 0, 0, 0, _now.Offset)] + }; + } + } + + public static IEnumerable ComplexOperationInputs + { + get + { + return new[] { + // Multiple operations + new object[] { "now+1d+1h", false, _now.AddDays(1).AddHours(1) }, + ["now+1d+1h", true, _now.AddDays(1).AddHours(1)], + ["now-1d-1h", false, _now.AddDays(-1).AddHours(-1)], + ["now-1d-1h", true, _now.AddDays(-1).AddHours(-1)], + ["now+1M-1d", false, _now.AddMonths(1).AddDays(-1)], + ["now+1M-1d", true, _now.AddMonths(1).AddDays(-1)], + ["now+1y+6M+15d", false, _now.AddYears(1).AddMonths(6).AddDays(15)], + ["now+1y+6M+15d", true, _now.AddYears(1).AddMonths(6).AddDays(15)], + + // Mixed units and operations + ["now+2h+30m+45s", false, _now.AddHours(2).AddMinutes(30).AddSeconds(45)], + ["now+2h+30m+45s", true, _now.AddHours(2).AddMinutes(30).AddSeconds(45)], + ["now-1w+2d", false, _now.AddDays(-7).AddDays(2)], + ["now-1w+2d", true, _now.AddDays(-7).AddDays(2)], + + // Operations with explicit dates and timezones + ["2025-01-01T01:25:35||+3d/d", false, new DateTimeOffset(2025, 1, 4, 0, 0, 0, _now.Offset)], + ["2025-01-01T01:25:35||+3d/d", true, new DateTimeOffset(2025, 1, 4, 23, 59, 59, 999, _now.Offset)], + + // Complex operations with UTC timezone + ["2025-01-01T01:25:35Z||+3d/d", false, new DateTimeOffset(2025, 1, 4, 0, 0, 0, TimeSpan.Zero)], + ["2025-01-01T01:25:35Z||+3d/d", true, new DateTimeOffset(2025, 1, 4, 23, 59, 59, 999, TimeSpan.Zero)], + ["2023-12-31T23:59:59Z||+1d+1h", false, new DateTimeOffset(2024, 1, 2, 0, 59, 59, TimeSpan.Zero)], + ["2023-12-31T23:59:59Z||+1d+1h", true, new DateTimeOffset(2024, 1, 2, 0, 59, 59, TimeSpan.Zero)], + + // Complex operations with timezone offsets + ["2023-06-15T10:00:00+05:00||+1d-2h", false, new DateTimeOffset(2023, 6, 16, 8, 0, 0, TimeSpan.FromHours(5))], + ["2023-06-15T10:00:00+05:00||+1d-2h", true, new DateTimeOffset(2023, 6, 16, 8, 0, 0, TimeSpan.FromHours(5))] + }; + } + } + + public static IEnumerable RoundingInputs + { + get + { + return new[] { + // Rounding to different units + new object[] { "now/d", false, _now.StartOfDay() }, + ["now/d", true, _now.EndOfDay()], + ["now/h", false, _now.StartOfHour()], + ["now/h", true, _now.EndOfHour()], + ["now/H", false, _now.StartOfHour()], + ["now/H", true, _now.EndOfHour()], + ["now/m", false, _now.StartOfMinute()], + ["now/m", true, _now.EndOfMinute()], + ["now/s", false, _now.StartOfSecond()], + ["now/s", true, _now.EndOfSecond()], + ["now/w", false, _now.StartOfWeek()], + ["now/w", true, _now.EndOfWeek()], + ["now/M", false, _now.StartOfMonth()], + ["now/M", true, _now.EndOfMonth()], + ["now/y", false, _now.StartOfYear()], + ["now/y", true, _now.EndOfYear()], + + // Combined operations with rounding (Elasticsearch example) + ["now-1h/d", false, _now.AddHours(-1).StartOfDay()], + ["now-1h/d", true, _now.AddHours(-1).EndOfDay()], + ["now+1d/h", false, _now.AddDays(1).StartOfHour()], + ["now+1d/h", true, _now.AddDays(1).EndOfHour()], + + // Multiple operations ending with rounding + ["now+1M-1d/d", false, _now.AddMonths(1).AddDays(-1).StartOfDay()], + ["now+1M-1d/d", true, _now.AddMonths(1).AddDays(-1).EndOfDay()] + }; + } + } + + public static IEnumerable EdgeCaseInputs + { + get + { + return new[] { + // Case sensitivity + new object[] { "NOW", false, _now }, + ["NOW", true, _now], + ["Now", false, _now], + ["Now", true, _now], + + // Zero amounts (edge case, should work) + ["now+0d", false, _now], + ["now+0d", true, _now], + ["now-0h", false, _now], + ["now-0h", true, _now], + + // Large amounts (but not overflow) + ["now+100y", false, _now.AddYears(100)], + ["now+100y", true, _now.AddYears(100)], + + // Overflow case - should return original date + ["now+9999y", false, _now], // AddYears would overflow, should return original + ["now+9999y", true, _now], + + // Mixed case units + ["now+1D", false, null], // Should fail - lowercase d required + ["now+1D", true, null], + ["now+1HOUR", false, null], // Should fail - single char required + ["now+1HOUR", true, null] + }; + } + } + + public static IEnumerable InvalidInputs + { + get + { + return new[] { + // Invalid formats + new object[] { "invalid", false, null }, + ["invalid", true, null], + ["blah", false, null], + ["blah blah", true, null], + ["", false, null], + ["", true, null], + + // Invalid date formats + ["2001-13-01||", false, null], // Invalid month + ["2001-13-01||", true, null], + ["2001-02-30||", false, null], // Invalid day for February + ["2001-02-30||", true, null], + ["invalid-date||", false, null], + ["invalid-date||", true, null], + + // Invalid operations + ["now+", false, null], // Missing unit + ["now+", true, null], + ["now+1", false, null], // Missing unit + ["now+1", true, null], + ["now+1x", false, null], // Invalid unit + ["now+1x", true, null], + ["now++1d", false, null], // Double operator + ["now++1d", true, null], + ["now+1d+", false, null], // Trailing operator + ["now+1d+", true, null], + + // Invalid anchor + ["yesterday+1d", false, null], // Invalid anchor + ["yesterday+1d", true, null], + ["2001||+1d", false, null], // Incomplete date + ["2001||+1d", true, null], + + // Invalid timezone formats + // Invalid date components + ["2023-00-01||", false, null], // Invalid month + ["2023-00-01||", true, null], + ["2023-01-32||", false, null], // Invalid day + ["2023-01-32||", true, null], + ["2023-02-29||", false, null], // Invalid day for non-leap year + ["2023-02-29||", true, null] + }; + } + } +} diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs new file mode 100644 index 0000000..3a9d21e --- /dev/null +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/PartParsers/WildcardPartParserTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using Exceptionless.DateTimeExtensions.FormatParsers.PartParsers; +using Microsoft.Extensions.Logging; +using Xunit; +using Xunit.Abstractions; + +namespace Exceptionless.DateTimeExtensions.Tests.FormatParsers.PartParsers; + +public class WildcardPartParserTests : PartParserTestsBase +{ + public WildcardPartParserTests(ITestOutputHelper output) : base(output) { } + + [Theory] + [MemberData(nameof(ParseInputs))] + public void ParseInput(string input, bool isUpperLimit, DateTimeOffset? expected) + { + var parser = new WildcardPartParser(); + _logger.LogInformation("Testing input: '{Input}', IsUpperLimit: {IsUpperLimit}, Expected: {Expected}", input, isUpperLimit, expected); + + var match = parser.Regex.Match(input); + _logger.LogInformation("Regex match success: {Success}, Value: '{Value}', Index: {Index}, Length: {Length}", match.Success, match.Value, match.Index, match.Length); + + var result = parser.Parse(match, _now, isUpperLimit); + _logger.LogInformation("Parse result: {Result}", result); + + if (expected == null) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + Assert.Equal(expected.Value.DateTime, result.Value.DateTime); + } + } + + public static IEnumerable ParseInputs + { + get + { + return new[] + { + // Valid wildcard inputs + new object[] { "*", false, DateTimeOffset.MinValue }, + ["*", true, DateTimeOffset.MaxValue], + [" * ", false, DateTimeOffset.MinValue], + [" * ", true, DateTimeOffset.MaxValue], + [" * ", false, DateTimeOffset.MinValue], + [" * ", true, DateTimeOffset.MaxValue], + + // Invalid inputs (patterns that should not match a complete wildcard) + ["blah", false, null], + ["blah", true, null], + ["2012", false, null], + ["2012", true, null], + ["**", false, null], + + // This should match the first * in a two-part context like "* *" + ["* *", false, DateTimeOffset.MinValue], + }; + } + } + + [Fact] + public void RegexPatternTest() + { + var parser = new WildcardPartParser(); + var regex = parser.Regex; + + _logger.LogInformation("Regex pattern: {Pattern}", regex); + + // Test various inputs + var testInputs = new[] { "*", " * ", " * ", "blah", "2012", "**", "* *", "" }; + + foreach (var input in testInputs) + { + var match = regex.Match(input); + _logger.LogInformation("Input: '{Input}' -> Success: {Success}, Value: '{Value}', Index: {Index}, Length: {Length}", input, match.Success, match.Value, match.Index, match.Length); + } + } + + [Fact] + public void TestInTwoPartContext() + { + var parser = new WildcardPartParser(); + + // Test how it behaves in a two-part parsing context + var inputs = new[] { "* TO 2013", "2012 TO *", "[* TO 2013]", "{2012 TO *}" }; + + foreach (var input in inputs) + { + _logger.LogInformation("Testing two-part context for: '{Input}'", input); + + // Test parsing at the beginning + var match = parser.Regex.Match(input, 0); + _logger.LogInformation(" At position 0: Success: {Success}, Value: '{Value}', Index: {Index}, Length: {Length}", match.Success, match.Value, match.Index, match.Length); + + // Test parsing after bracket + if (input.StartsWith("[") || input.StartsWith("{")) + { + match = parser.Regex.Match(input, 1); + _logger.LogInformation(" At position 1: Success: {Success}, Value: '{Value}', Index: {Index}, Length: {Length}", match.Success, match.Value, match.Index, match.Length); + } + + // Find TO and test parsing after it + var toIndex = input.IndexOf(" TO ", StringComparison.OrdinalIgnoreCase); + if (toIndex >= 0) + { + var afterTo = toIndex + 4; + match = parser.Regex.Match(input, afterTo); + _logger.LogInformation(" After TO at position {Position}: Success: {Success}, Value: '{Value}', Index: {Index}, Length: {Length}", afterTo, match.Success, match.Value, match.Index, match.Length); + } + } + } +} diff --git a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs index 6317860..0da82d1 100644 --- a/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs +++ b/tests/Exceptionless.DateTimeExtensions.Tests/FormatParsers/TwoPartFormatParserTests.cs @@ -22,12 +22,40 @@ public static IEnumerable Inputs get { return new[] { + // Original dash delimiter syntax new object[] { "2012-2013", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear() }, - new object[] { "5 days ago - now", _now.SubtractDays(5).StartOfDay(), _now }, - new object[] { "jan-feb", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth() }, - new object[] { "now-this feb", _now, _now.AddYears(1).ChangeMonth(2).EndOfMonth() }, - new object[] { "blah", null, null }, - new object[] { "blah blah", null, null } + ["5 days ago - now", _now.SubtractDays(5).StartOfDay(), _now], + ["jan-feb", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth()], + ["now-this feb", _now, _now.AddYears(1).ChangeMonth(2).EndOfMonth()], + + // TO delimiter syntax (case-insensitive) + ["2012 TO 2013", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear()], + ["jan to feb", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth()], + ["5 days ago TO now", _now.SubtractDays(5).StartOfDay(), _now], + + // Elasticsearch bracket syntax + ["[2012 TO 2013]", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear()], + ["{jan TO feb}", _now.ChangeMonth(1).StartOfMonth(), _now.ChangeMonth(2).EndOfMonth()], + ["[2012-2013]", _now.ChangeYear(2012).StartOfYear(), _now.ChangeYear(2013).EndOfYear()], + + // Wildcard support + ["* TO 2013", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear()], + ["2012 TO *", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue], + ["[* TO 2013]", DateTime.MinValue, _now.ChangeYear(2013).EndOfYear()], + ["{2012 TO *}", _now.ChangeYear(2012).StartOfYear(), DateTime.MaxValue], + + // Invalid inputs + ["blah", null, null], + ["[invalid", null, null], + ["invalid}", null, null], + + // Mismatched bracket validation + ["{2012 TO 2013]", null, null], // Opening brace with closing bracket + ["[2012 TO 2013}", null, null], // Opening bracket with closing brace + ["}2012 TO 2013{", null, null], // Wrong orientation + ["]2012 TO 2013[", null, null], // Wrong orientation + ["[2012 TO 2013", null, null], // Missing closing bracket + ["2012 TO 2013]", null, null], // Missing opening bracket }; } }