diff --git a/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMath.cs b/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMath.cs new file mode 100644 index 00000000000..c30cefac3c5 --- /dev/null +++ b/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMath.cs @@ -0,0 +1,189 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace Elastic.Clients.Elasticsearch; + +[JsonConverter(typeof(DateMathConverter))] +public abstract class DateMath +{ + private static readonly Regex DateMathRegex = + new(@"^(?now|.+(?:\|\||$))(?(?:(?:\+|\-)[^\/]*))?(?\/(?:y|M|w|d|h|m|s))?$"); + + public static DateMathExpression Now => new("now"); + + internal DateMath(string anchor) => Anchor = anchor; + internal DateMath(DateTime anchor) => Anchor = anchor; + internal DateMath(Union anchor, DateMathTime range, DateMathOperation operation) + { + anchor.ThrowIfNull(nameof(anchor)); + range.ThrowIfNull(nameof(range)); + operation.ThrowIfNull(nameof(operation)); + Anchor = anchor; + Ranges.Add(Tuple.Create(operation, range)); + } + + public Union Anchor { get; } + public IList> Ranges { get; } = new List>(); + public DateMathTimeUnit? Round { get; protected set; } + + public static DateMathExpression Anchored(DateTime anchor) => new(anchor); + + public static DateMathExpression Anchored(string dateAnchor) => new(dateAnchor); + + public static implicit operator DateMath(DateTime dateTime) => Anchored(dateTime); + + public static implicit operator DateMath(string dateMath) => FromString(dateMath); + + public static DateMath FromString(string dateMath) + { + if (dateMath == null) return null; + + var match = DateMathRegex.Match(dateMath); + if (!match.Success) throw new ArgumentException($"Cannot create a {nameof(DateMathExpression)} out of '{dateMath}'"); + + var math = new DateMathExpression(match.Groups["anchor"].Value); + + if (match.Groups["ranges"].Success) + { + var rangeString = match.Groups["ranges"].Value; + do + { + var nextRangeStart = rangeString.Substring(1).IndexOfAny(new[] { '+', '-', '/' }); + if (nextRangeStart == -1) nextRangeStart = rangeString.Length - 1; + var unit = rangeString.Substring(1, nextRangeStart); + if (rangeString.StartsWith("+", StringComparison.Ordinal)) + { + math = math.Add(unit); + rangeString = rangeString.Substring(nextRangeStart + 1); + } + else if (rangeString.StartsWith("-", StringComparison.Ordinal)) + { + math = math.Subtract(unit); + rangeString = rangeString.Substring(nextRangeStart + 1); + } + else rangeString = null; + } while (!rangeString.IsNullOrEmpty()); + } + + if (match.Groups["rounding"].Success) + { + var rounding = match.Groups["rounding"].Value.Substring(1).ToEnum(StringComparison.Ordinal); + if (rounding.HasValue) + return math.RoundTo(rounding.Value); + } + return math; + } + + internal static bool IsValidDateMathString(string dateMath) => dateMath != null && DateMathRegex.IsMatch(dateMath); + + internal bool IsValid => Anchor.Match(_ => true, s => !s.IsNullOrEmpty()); + + public override string ToString() + { + if (!IsValid) return string.Empty; + + var separator = Round.HasValue || Ranges.HasAny() ? "||" : string.Empty; + + var sb = new StringBuilder(); + var anchor = Anchor.Match( + d => ToMinThreeDecimalPlaces(d) + separator, + s => s == "now" || s.EndsWith("||", StringComparison.Ordinal) ? s : s + separator + ); + sb.Append(anchor); + foreach (var r in Ranges) + { + sb.Append(r.Item1.GetStringValue()); + //date math does not support fractional time units so e.g TimeSpan.FromHours(25) should not yield 1.04d + sb.Append(r.Item2); + } + if (Round.HasValue) + sb.Append("/" + Round.Value.GetStringValue()); + + return sb.ToString(); + } + + /// + /// Formats a to have a minimum of 3 decimal places if there are sub second values + /// + private static string ToMinThreeDecimalPlaces(DateTime dateTime) + { + var builder = new StringBuilder(33); + var format = dateTime.ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFF", CultureInfo.InvariantCulture); + builder.Append(format); + + if (format.Length > 20 && format.Length < 23) + { + var diff = 23 - format.Length; + for (var i = 0; i < diff; i++) + builder.Append('0'); + } + + switch (dateTime.Kind) + { + case DateTimeKind.Local: + var offset = TimeZoneInfo.Local.GetUtcOffset(dateTime); + if (offset >= TimeSpan.Zero) + builder.Append('+'); + else + { + builder.Append('-'); + offset = offset.Negate(); + } + + AppendTwoDigitNumber(builder, offset.Hours); + builder.Append(':'); + AppendTwoDigitNumber(builder, offset.Minutes); + break; + case DateTimeKind.Utc: + builder.Append('Z'); + break; + } + + return builder.ToString(); + } + + private static void AppendTwoDigitNumber(StringBuilder result, int val) + { + result.Append((char)('0' + (val / 10))); + result.Append((char)('0' + (val % 10))); + } +} + +internal sealed class DateMathConverter : JsonConverter +{ + public override DateMath? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + return null; + + // TODO: Performance - Review potential to avoid allocation on DateTime path and use Span + + var value = reader.GetString(); + reader.Read(); + + if (!value.Contains("|") && DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dateTime)) + return DateMath.Anchored(dateTime); + + return DateMath.Anchored(value); + } + + public override void Write(Utf8JsonWriter writer, DateMath value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStringValue(value.ToString()); + } +} diff --git a/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMathExpression.cs b/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMathExpression.cs new file mode 100644 index 00000000000..bac070f3404 --- /dev/null +++ b/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMathExpression.cs @@ -0,0 +1,75 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elastic.Clients.Elasticsearch; + +[JsonConverter(typeof(DateMathExpressionConverter))] +public class DateMathExpression : DateMath +{ + public DateMathExpression(string anchor) : base(anchor) { } + + public DateMathExpression(DateTime anchor) : base(anchor) { } + + public DateMathExpression(Union anchor, DateMathTime range, DateMathOperation operation) + : base(anchor, range, operation) { } + + public DateMathExpression Add(DateMathTime expression) + { + Ranges.Add(Tuple.Create(DateMathOperation.Add, expression)); + return this; + } + + public DateMathExpression Subtract(DateMathTime expression) + { + Ranges.Add(Tuple.Create(DateMathOperation.Subtract, expression)); + return this; + } + + public DateMathExpression Operation(DateMathTime expression, DateMathOperation operation) + { + Ranges.Add(Tuple.Create(operation, expression)); + return this; + } + + public DateMath RoundTo(DateMathTimeUnit round) + { + Round = round; + return this; + } +} + +internal sealed class DateMathExpressionConverter : JsonConverter +{ + public override DateMathExpression? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + return null; + + // TODO: Performance - Review potential to avoid allocation on DateTime path and use Span + + var value = reader.GetString(); + reader.Read(); + + if (!value.Contains("|") && DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dateTime)) + return DateMath.Anchored(dateTime); + + return new DateMathExpression(value); + } + + public override void Write(Utf8JsonWriter writer, DateMathExpression value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStringValue(value.ToString()); + } +} diff --git a/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMathOperation.cs b/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMathOperation.cs new file mode 100644 index 00000000000..eb542b0115c --- /dev/null +++ b/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMathOperation.cs @@ -0,0 +1,70 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elastic.Clients.Elasticsearch; + +[StringEnum] +[JsonConverter(typeof(DateMathOperationConverter))] +public enum DateMathOperation +{ + [EnumMember(Value = "+")] + Add, + + [EnumMember(Value = "-")] + Subtract +} + +internal sealed class DateMathOperationConverter : JsonConverter +{ + public override DateMathOperation Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var enumString = reader.GetString(); + switch (enumString) + { + case "+": + return DateMathOperation.Add; + case "-": + return DateMathOperation.Subtract; + } + + ThrowHelper.ThrowJsonException(); + return default; + } + + public override void Write(Utf8JsonWriter writer, DateMathOperation value, JsonSerializerOptions options) + { + switch (value) + { + case DateMathOperation.Add: + writer.WriteStringValue("+"); + return; + case DateMathOperation.Subtract: + writer.WriteStringValue("-"); + return; + } + + writer.WriteNullValue(); + } +} + +public static class DateMathOperationExtensions +{ + public static string GetStringValue(this DateMathOperation value) + { + switch (value) + { + case DateMathOperation.Add: + return "+"; + case DateMathOperation.Subtract: + return "-"; + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } +} diff --git a/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMathTime.cs b/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMathTime.cs new file mode 100644 index 00000000000..ba472b8fbe3 --- /dev/null +++ b/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMathTime.cs @@ -0,0 +1,339 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace Elastic.Clients.Elasticsearch; + +/// +/// A time representation for use within expressions. +/// +[JsonConverter(typeof(DateMathTimeConverter))] +public class DateMathTime : IComparable, IEquatable +{ + private const double MillisecondsInADay = MillisecondsInAnHour * 24; + private const double MillisecondsInAMinute = MillisecondsInASecond * 60; + private const double MillisecondsInAMonthApproximate = MillisecondsInAYearApproximate / MonthsInAYear; + private const double MillisecondsInAnHour = MillisecondsInAMinute * 60; + private const double MillisecondsInASecond = 1000; + private const double MillisecondsInAWeek = MillisecondsInADay * 7; + private const double MillisecondsInAYearApproximate = MillisecondsInADay * 365; + private const int MonthsInAYear = 12; + + private static readonly Regex ExpressionRegex = + new(@"^ + (?[+\-]? # open factor capture, allowing optional +- signs + (?:(?#numeric)(?:\d+(?:\.\d*)?)|(?:\.\d+)) #a numeric in the forms: (N, N., .N, N.N) + (?:(?#exponent)e[+\-]?\d+)? #an optional exponential scientific component, E also matches here (IgnoreCase) + ) # numeric and exponent fall under the factor capture + \s{0,10} #optional spaces (sanity checked for max 10 repetitions) + (?(?:y|w|d|h|m|s)) #interval indicator + $", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); + + private double _approximateSeconds; + + /// + /// Instantiates a new instance of from a TimeSpan. + /// Rounding can be specified to determine how fractional second values should be rounded. + /// + public DateMathTime(TimeSpan timeSpan, MidpointRounding rounding = MidpointRounding.AwayFromZero) + : this(timeSpan.TotalMilliseconds, rounding) { } + + /// + /// Instantiates a new instance of from a milliseconds value. + /// Rounding can be specified to determine how fractional second values should be rounded. + /// + public DateMathTime(double milliseconds, MidpointRounding rounding = MidpointRounding.AwayFromZero) => + SetWholeFactorIntervalAndSeconds(milliseconds, rounding); + + /// + /// Instantiates a new instance of from a factor and interval. + /// + public DateMathTime(int factor, DateMathTimeUnit interval) => + SetWholeFactorIntervalAndSeconds(factor, interval, MidpointRounding.AwayFromZero); + + /// + /// Instantiates a new instance of from the timeUnit string expression. + /// Rounding can be specified to determine how fractional second values should be rounded. + /// + public DateMathTime(string timeUnit, MidpointRounding rounding = MidpointRounding.AwayFromZero) + { + if (timeUnit == null) throw new ArgumentNullException(nameof(timeUnit)); + if (timeUnit.Length == 0) throw new ArgumentException("Expression string is empty", nameof(timeUnit)); + + var match = ExpressionRegex.Match(timeUnit); + if (!match.Success) throw new ArgumentException($"Expression '{timeUnit}' string is invalid", nameof(timeUnit)); + + var factor = match.Groups["factor"].Value; + if (!double.TryParse(factor, NumberStyles.Any, CultureInfo.InvariantCulture, out var fraction)) + throw new ArgumentException($"Expression '{timeUnit}' contains invalid factor: {factor}", nameof(timeUnit)); + + var intervalValue = match.Groups["interval"].Value; + var interval = intervalValue switch + { + "M" => DateMathTimeUnit.Month, + "m" => DateMathTimeUnit.Minute, + _ => intervalValue.ToEnum().GetValueOrDefault(), + }; + SetWholeFactorIntervalAndSeconds(fraction, interval, rounding); + } + + /// + /// The numeric time factor + /// + public int Factor { get; private set; } + + /// + /// The time units + /// + public DateMathTimeUnit Interval { get; private set; } + + public int CompareTo(DateMathTime other) + { + if (other == null) return 1; + if (Math.Abs(_approximateSeconds - other._approximateSeconds) < double.Epsilon) return 0; + if (_approximateSeconds < other._approximateSeconds) return -1; + + return 1; + } + + public bool Equals(DateMathTime other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + + return Math.Abs(_approximateSeconds - other._approximateSeconds) < double.Epsilon; + } + + public static implicit operator DateMathTime(TimeSpan span) => new(span); + + public static implicit operator DateMathTime(double milliseconds) => new(milliseconds); + + public static implicit operator DateMathTime(string expression) => new(expression); + + private void SetWholeFactorIntervalAndSeconds(double factor, DateMathTimeUnit interval, MidpointRounding rounding) + { + var fraction = factor; + double milliseconds; + + // if the factor is already a whole number then use it + if (TryGetIntegerGreaterThanZero(fraction, out var whole)) + { + Factor = whole; + Interval = interval; + switch (interval) + { + case DateMathTimeUnit.Second: + _approximateSeconds = whole; + break; + case DateMathTimeUnit.Minute: + _approximateSeconds = whole * (MillisecondsInAMinute / MillisecondsInASecond); + break; + case DateMathTimeUnit.Hour: + _approximateSeconds = whole * (MillisecondsInAnHour / MillisecondsInASecond); + break; + case DateMathTimeUnit.Day: + _approximateSeconds = whole * (MillisecondsInADay / MillisecondsInASecond); + break; + case DateMathTimeUnit.Week: + _approximateSeconds = whole * (MillisecondsInAWeek / MillisecondsInASecond); + break; + case DateMathTimeUnit.Month: + _approximateSeconds = whole * (MillisecondsInAMonthApproximate / MillisecondsInASecond); + break; + case DateMathTimeUnit.Year: + _approximateSeconds = whole * (MillisecondsInAYearApproximate / MillisecondsInASecond); + break; + default: + throw new ArgumentOutOfRangeException(nameof(interval), interval, null); + } + return; + } + + switch (interval) + { + case DateMathTimeUnit.Second: + milliseconds = factor * MillisecondsInASecond; + break; + case DateMathTimeUnit.Minute: + milliseconds = factor * MillisecondsInAMinute; + break; + case DateMathTimeUnit.Hour: + milliseconds = factor * MillisecondsInAnHour; + break; + case DateMathTimeUnit.Day: + milliseconds = factor * MillisecondsInADay; + break; + case DateMathTimeUnit.Week: + milliseconds = factor * MillisecondsInAWeek; + break; + case DateMathTimeUnit.Month: + if (TryGetIntegerGreaterThanZero(fraction, out whole)) + { + Factor = whole; + Interval = interval; + _approximateSeconds = whole * (MillisecondsInAMonthApproximate / MillisecondsInASecond); + return; + } + + milliseconds = factor * MillisecondsInAMonthApproximate; + break; + case DateMathTimeUnit.Year: + if (TryGetIntegerGreaterThanZero(fraction, out whole)) + { + Factor = whole; + Interval = interval; + _approximateSeconds = whole * (MillisecondsInAYearApproximate / MillisecondsInASecond); + return; + } + + fraction = fraction * MonthsInAYear; + if (TryGetIntegerGreaterThanZero(fraction, out whole)) + { + Factor = whole; + Interval = DateMathTimeUnit.Month; + _approximateSeconds = whole * (MillisecondsInAMonthApproximate / MillisecondsInASecond); + return; + } + milliseconds = factor * MillisecondsInAYearApproximate; + break; + default: + throw new ArgumentOutOfRangeException(nameof(interval), interval, null); + } + + SetWholeFactorIntervalAndSeconds(milliseconds, rounding); + } + + private void SetWholeFactorIntervalAndSeconds(double milliseconds, MidpointRounding rounding) + { + double fraction; + int whole; + + if (milliseconds >= MillisecondsInAWeek) + { + fraction = milliseconds / MillisecondsInAWeek; + if (TryGetIntegerGreaterThanZero(fraction, out whole)) + { + Factor = whole; + Interval = DateMathTimeUnit.Week; + _approximateSeconds = Factor * (MillisecondsInAWeek / MillisecondsInASecond); + return; + } + } + if (milliseconds >= MillisecondsInADay) + { + fraction = milliseconds / MillisecondsInADay; + if (TryGetIntegerGreaterThanZero(fraction, out whole)) + { + Factor = whole; + Interval = DateMathTimeUnit.Day; + _approximateSeconds = Factor * (MillisecondsInADay / MillisecondsInASecond); + return; + } + } + if (milliseconds >= MillisecondsInAnHour) + { + fraction = milliseconds / MillisecondsInAnHour; + if (TryGetIntegerGreaterThanZero(fraction, out whole)) + { + Factor = whole; + Interval = DateMathTimeUnit.Hour; + _approximateSeconds = Factor * (MillisecondsInAnHour / MillisecondsInASecond); + return; + } + } + if (milliseconds >= MillisecondsInAMinute) + { + fraction = milliseconds / MillisecondsInAMinute; + if (TryGetIntegerGreaterThanZero(fraction, out whole)) + { + Factor = whole; + Interval = DateMathTimeUnit.Minute; + _approximateSeconds = Factor * (MillisecondsInAMinute / MillisecondsInASecond); + return; + } + } + if (milliseconds >= MillisecondsInASecond) + { + fraction = milliseconds / MillisecondsInASecond; + if (TryGetIntegerGreaterThanZero(fraction, out whole)) + { + Factor = whole; + Interval = DateMathTimeUnit.Second; + _approximateSeconds = Factor; + return; + } + } + + // round to nearest second, using specified rounding + Factor = Convert.ToInt32(Math.Round(milliseconds / MillisecondsInASecond, rounding)); + Interval = DateMathTimeUnit.Second; + _approximateSeconds = Factor; + } + + private static bool TryGetIntegerGreaterThanZero(double d, out int value) + { + if (Math.Abs(d % 1) < double.Epsilon) + { + value = Convert.ToInt32(d); + return true; + } + + value = 0; + return false; + } + + public static bool operator <(DateMathTime left, DateMathTime right) => left.CompareTo(right) < 0; + + public static bool operator <=(DateMathTime left, DateMathTime right) => left.CompareTo(right) < 0 || left.Equals(right); + + public static bool operator >(DateMathTime left, DateMathTime right) => left.CompareTo(right) > 0; + + public static bool operator >=(DateMathTime left, DateMathTime right) => left.CompareTo(right) > 0 || left.Equals(right); + + public static bool operator ==(DateMathTime left, DateMathTime right) => + left?.Equals(right) ?? ReferenceEquals(right, null); + + public static bool operator !=(DateMathTime left, DateMathTime right) => !(left == right); + + public override string ToString() => Factor + Interval.GetStringValue(); + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + + return Equals((DateMathTime)obj); + } + + // ReSharper disable once NonReadonlyMemberInGetHashCode + public override int GetHashCode() => _approximateSeconds.GetHashCode(); +} + +internal sealed class DateMathTimeConverter : JsonConverter +{ + public override DateMathTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + reader.Read(); + return value; + } + + public override void Write(Utf8JsonWriter writer, DateMathTime value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStringValue(value.ToString()); + } +} diff --git a/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMathTimeUnit.cs b/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMathTimeUnit.cs new file mode 100644 index 00000000000..1e0b13040e5 --- /dev/null +++ b/src/Elastic.Clients.Elasticsearch/Common/DateTime/DateMath/DateMathTimeUnit.cs @@ -0,0 +1,110 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Elastic.Clients.Elasticsearch; + +[StringEnum] +[JsonConverter(typeof(DateMathTimeUnitConverter))] +public enum DateMathTimeUnit +{ + [EnumMember(Value = "s")] + Second, + + [EnumMember(Value = "m")] + Minute, + + [EnumMember(Value = "h")] + Hour, + + [EnumMember(Value = "d")] + Day, + + [EnumMember(Value = "w")] + Week, + + [EnumMember(Value = "M")] + Month, + + [EnumMember(Value = "y")] + Year +} + +internal sealed class DateMathTimeUnitConverter : JsonConverter +{ + public override DateMathTimeUnit Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var enumString = reader.GetString(); + switch (enumString) + { + case "h": + return DateMathTimeUnit.Hour; + case "m": + return DateMathTimeUnit.Minute; + case "s": + return DateMathTimeUnit.Second; + case "d": + return DateMathTimeUnit.Day; + case "w": + return DateMathTimeUnit.Week; + case "M": + return DateMathTimeUnit.Month; + case "y": + return DateMathTimeUnit.Year; + } + + ThrowHelper.ThrowJsonException(); + return default; + } + + public override void Write(Utf8JsonWriter writer, DateMathTimeUnit value, JsonSerializerOptions options) + { + switch (value) + { + case DateMathTimeUnit.Hour: + writer.WriteStringValue("h"); + return; + case DateMathTimeUnit.Minute: + writer.WriteStringValue("m"); + return; + case DateMathTimeUnit.Second: + writer.WriteStringValue("s"); + return; + case DateMathTimeUnit.Day: + writer.WriteStringValue("d"); + return; + case DateMathTimeUnit.Week: + writer.WriteStringValue("w"); + return; + case DateMathTimeUnit.Month: + writer.WriteStringValue("M"); + return; + case DateMathTimeUnit.Year: + writer.WriteStringValue("y"); + return; + } + + writer.WriteNullValue(); + } +} + +public static class DateMathTimeUnitExtensions +{ + public static string GetStringValue(this DateMathTimeUnit value) => + value switch + { + DateMathTimeUnit.Second => "s", + DateMathTimeUnit.Minute => "m", + DateMathTimeUnit.Hour => "h", + DateMathTimeUnit.Day => "d", + DateMathTimeUnit.Week => "w", + DateMathTimeUnit.Month => "M", + DateMathTimeUnit.Year => "y", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, null), + }; +} diff --git a/src/Elastic.Clients.Elasticsearch/_Generated/Types/Aggregations/FieldDateMath.g.cs b/src/Elastic.Clients.Elasticsearch/_Generated/Types/Aggregations/FieldDateMath.g.cs index 98952251fdc..a259376452f 100644 --- a/src/Elastic.Clients.Elasticsearch/_Generated/Types/Aggregations/FieldDateMath.g.cs +++ b/src/Elastic.Clients.Elasticsearch/_Generated/Types/Aggregations/FieldDateMath.g.cs @@ -25,9 +25,9 @@ #nullable restore namespace Elastic.Clients.Elasticsearch.Aggregations { - public partial class FieldDateMath : Union + public partial class FieldDateMath : Union { - public FieldDateMath(string dateMath) : base(dateMath) + public FieldDateMath(Elastic.Clients.Elasticsearch.DateMath dateMath) : base(dateMath) { } diff --git a/src/Elastic.Clients.Elasticsearch/_Generated/Types/Analysis/MappingCharFilter.g.cs b/src/Elastic.Clients.Elasticsearch/_Generated/Types/Analysis/MappingCharFilter.g.cs index 24c12589ad1..839ba9395b3 100644 --- a/src/Elastic.Clients.Elasticsearch/_Generated/Types/Analysis/MappingCharFilter.g.cs +++ b/src/Elastic.Clients.Elasticsearch/_Generated/Types/Analysis/MappingCharFilter.g.cs @@ -28,7 +28,7 @@ public partial class MappingCharFilter : CharFilterBase, ICharFilterDefinition { [JsonInclude] [JsonPropertyName("mappings")] - public IEnumerable Mappings { get; set; } + public IEnumerable? Mappings { get; set; } [JsonInclude] [JsonPropertyName("mappings_path")] @@ -46,13 +46,13 @@ public MappingCharFilterDescriptor() : base() { } - private IEnumerable MappingsValue { get; set; } + private IEnumerable? MappingsValue { get; set; } private string? MappingsPathValue { get; set; } private string? VersionValue { get; set; } - public MappingCharFilterDescriptor Mappings(IEnumerable mappings) + public MappingCharFilterDescriptor Mappings(IEnumerable? mappings) { MappingsValue = mappings; return Self; @@ -73,8 +73,12 @@ public MappingCharFilterDescriptor Version(string? version) protected override void Serialize(Utf8JsonWriter writer, JsonSerializerOptions options, IElasticsearchClientSettings settings) { writer.WriteStartObject(); - writer.WritePropertyName("mappings"); - JsonSerializer.Serialize(writer, MappingsValue, options); + if (MappingsValue is not null) + { + writer.WritePropertyName("mappings"); + JsonSerializer.Serialize(writer, MappingsValue, options); + } + if (!string.IsNullOrEmpty(MappingsPathValue)) { writer.WritePropertyName("mappings_path"); diff --git a/src/Elastic.Clients.Elasticsearch/_Generated/Types/QueryDsl/DateRangeQuery.g.cs b/src/Elastic.Clients.Elasticsearch/_Generated/Types/QueryDsl/DateRangeQuery.g.cs index 2ab4879aecc..ec4cc81ae68 100644 --- a/src/Elastic.Clients.Elasticsearch/_Generated/Types/QueryDsl/DateRangeQuery.g.cs +++ b/src/Elastic.Clients.Elasticsearch/_Generated/Types/QueryDsl/DateRangeQuery.g.cs @@ -32,23 +32,23 @@ public partial class DateRangeQuery : RangeQueryBase [JsonInclude] [JsonPropertyName("from")] - public string? From { get; set; } + public Elastic.Clients.Elasticsearch.DateMath? From { get; set; } [JsonInclude] [JsonPropertyName("gt")] - public string? Gt { get; set; } + public Elastic.Clients.Elasticsearch.DateMath? Gt { get; set; } [JsonInclude] [JsonPropertyName("gte")] - public string? Gte { get; set; } + public Elastic.Clients.Elasticsearch.DateMath? Gte { get; set; } [JsonInclude] [JsonPropertyName("lt")] - public string? Lt { get; set; } + public Elastic.Clients.Elasticsearch.DateMath? Lt { get; set; } [JsonInclude] [JsonPropertyName("lte")] - public string? Lte { get; set; } + public Elastic.Clients.Elasticsearch.DateMath? Lte { get; set; } [JsonInclude] [JsonPropertyName("time_zone")] @@ -56,7 +56,7 @@ public partial class DateRangeQuery : RangeQueryBase [JsonInclude] [JsonPropertyName("to")] - public string? To { get; set; } + public Elastic.Clients.Elasticsearch.DateMath? To { get; set; } } public sealed partial class DateRangeQueryDescriptor : SerializableDescriptorBase @@ -72,21 +72,21 @@ public DateRangeQueryDescriptor() : base() private string? FormatValue { get; set; } - private string? FromValue { get; set; } + private Elastic.Clients.Elasticsearch.DateMath? FromValue { get; set; } - private string? GtValue { get; set; } + private Elastic.Clients.Elasticsearch.DateMath? GtValue { get; set; } - private string? GteValue { get; set; } + private Elastic.Clients.Elasticsearch.DateMath? GteValue { get; set; } - private string? LtValue { get; set; } + private Elastic.Clients.Elasticsearch.DateMath? LtValue { get; set; } - private string? LteValue { get; set; } + private Elastic.Clients.Elasticsearch.DateMath? LteValue { get; set; } private Elastic.Clients.Elasticsearch.QueryDsl.RangeRelation? RelationValue { get; set; } private string? TimeZoneValue { get; set; } - private string? ToValue { get; set; } + private Elastic.Clients.Elasticsearch.DateMath? ToValue { get; set; } public DateRangeQueryDescriptor QueryName(string? queryName) { @@ -106,31 +106,31 @@ public DateRangeQueryDescriptor Format(string? format) return Self; } - public DateRangeQueryDescriptor From(string? from) + public DateRangeQueryDescriptor From(Elastic.Clients.Elasticsearch.DateMath? from) { FromValue = from; return Self; } - public DateRangeQueryDescriptor Gt(string? gt) + public DateRangeQueryDescriptor Gt(Elastic.Clients.Elasticsearch.DateMath? gt) { GtValue = gt; return Self; } - public DateRangeQueryDescriptor Gte(string? gte) + public DateRangeQueryDescriptor Gte(Elastic.Clients.Elasticsearch.DateMath? gte) { GteValue = gte; return Self; } - public DateRangeQueryDescriptor Lt(string? lt) + public DateRangeQueryDescriptor Lt(Elastic.Clients.Elasticsearch.DateMath? lt) { LtValue = lt; return Self; } - public DateRangeQueryDescriptor Lte(string? lte) + public DateRangeQueryDescriptor Lte(Elastic.Clients.Elasticsearch.DateMath? lte) { LteValue = lte; return Self; @@ -148,7 +148,7 @@ public DateRangeQueryDescriptor TimeZone(string? timeZone) return Self; } - public DateRangeQueryDescriptor To(string? to) + public DateRangeQueryDescriptor To(Elastic.Clients.Elasticsearch.DateMath? to) { ToValue = to; return Self; diff --git a/tests/Tests.Configuration/EnvironmentConfiguration.cs b/tests/Tests.Configuration/EnvironmentConfiguration.cs index 80ada781e38..4be46edaddc 100644 --- a/tests/Tests.Configuration/EnvironmentConfiguration.cs +++ b/tests/Tests.Configuration/EnvironmentConfiguration.cs @@ -30,7 +30,7 @@ public EnvironmentConfiguration(YamlConfiguration yamlConfiguration) TestOnlyOne = RandomBoolConfig("TEST_ONLY_ONE", randomizer, false); Random = new RandomConfiguration { - SourceSerializer = RandomBoolConfig("SOURCESERIALIZER", randomizer), + //SourceSerializer = RandomBoolConfig("SOURCESERIALIZER", randomizer), // Disabling this until we have properly reviewed the Newtonsoft.JSON serializer. HttpCompression = RandomBoolConfig("HTTPCOMPRESSION", randomizer) }; } diff --git a/tests/Tests.Configuration/TestConfigurationBase.cs b/tests/Tests.Configuration/TestConfigurationBase.cs index 6c18c00ac61..329fdc5200e 100644 --- a/tests/Tests.Configuration/TestConfigurationBase.cs +++ b/tests/Tests.Configuration/TestConfigurationBase.cs @@ -69,7 +69,8 @@ protected void SetExternalSeed(int? seed, out Random randomizer) public class RandomConfiguration { /// Run tests with a custom source serializer rather than the built in one - public bool SourceSerializer { get; set; } + public bool SourceSerializer => false; // Disabling this until we have properly reviewed the Newtonsoft.JSON serializer. + // When reenabling, ensure setters in YamlConfiguration and EnvironmentConfiguration are also enabled. /// Randomly enable compression on the http requests public bool HttpCompression { get; set; } diff --git a/tests/Tests.Configuration/YamlConfiguration.cs b/tests/Tests.Configuration/YamlConfiguration.cs index e33869ffee7..43976f90bb3 100644 --- a/tests/Tests.Configuration/YamlConfiguration.cs +++ b/tests/Tests.Configuration/YamlConfiguration.cs @@ -36,7 +36,7 @@ public YamlConfiguration(string configurationFile) SetExternalSeed(externalSeed, out var randomizer); Random = new RandomConfiguration { - SourceSerializer = RandomBool("source_serializer", randomizer), + //SourceSerializer = RandomBool("source_serializer", randomizer), // Disabling this until we have properly reviewed the Newtonsoft.JSON serializer. HttpCompression = RandomBool("http_compression", randomizer) }; } diff --git a/tests/Tests/Common/DateTime/DateMath/DateMathExpressions.doc.cs b/tests/Tests/Common/DateTime/DateMath/DateMathExpressions.doc.cs new file mode 100644 index 00000000000..7212c91b436 --- /dev/null +++ b/tests/Tests/Common/DateTime/DateMath/DateMathExpressions.doc.cs @@ -0,0 +1,232 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System; +using static Tests.Core.Serialization.SerializationTestHelper; + +namespace Tests.CommonOptions; + +public class DateMathExpressions +{ + /**[[date-math-expressions]] + * === Date math expressions + * The date type supports using date math expression when using it in a query/filter + * Whenever durations need to be specified, eg for a timeout parameter, the duration can be specified + * + * The expression starts with an "anchor" date, which can be either now or a date string (in the applicable format) ending with `||`. + * It can be followed by a math expression, supporting `+`, `-` and `/` (rounding). + * The units supported are + * + * - `y` (year) + * - `M` (month) + * - `w` (week) + * - `d` (day) + * - `h` (hour) + * - `m` (minute) + * - `s` (second) + * + * :datemath: {ref_current}/common-options.html#date-math + * Be sure to read the Elasticsearch documentation on {datemath}[Date Math]. + */ + [U] public void SimpleExpressions() + { + /** + * ==== Simple expressions + * You can create simple expressions using any of the static methods on `DateMath` + */ + //Expect("now").WhenSerializing(DateMath.Now); + Expect("2015-05-05T00:00:00").WhenSerializing(DateMath.Anchored(new DateTime(2015,05, 05))); + + /** strings implicitly convert to `DateMath` */ + Expect("now").WhenSerializing("now"); + + /** but are lenient to bad math expressions */ + var nonsense = "now||*asdaqwe"; + + /** the resulting date math will assume the whole string is the anchor */ + Expect(nonsense) + .WhenSerializing(nonsense) + .AssertSubject(dateMath => dateMath + .Anchor.Match( + d => d.Should().NotBe(default), + s => s.Should().Be(nonsense) + ) + ); + + /**`DateTime` also implicitly convert to simple date math expressions; the resulting + * anchor will be an actual `DateTime`, even after a serialization/deserialization round trip + */ + var date = new DateTime(2015, 05, 05); + + /** + * will serialize to + */ + //json + var expected = "2015-05-05T00:00:00"; + + // hide + Expect(expected) + .WhenSerializing(date) + .AssertSubject(dateMath => dateMath + .Anchor.Match( + d => d.Should().Be(date), + s => s.Should().BeNull() + ) + ); + + /** + * When the `DateTime` is local or UTC, the time zone information is included. + * For example, for a UTC `DateTime` + */ + var utcDate = new DateTime(2015, 05, 05, 0, 0, 0, DateTimeKind.Utc); + + /** + * will serialize to + */ + //json + expected = "2015-05-05T00:00:00Z"; + + // hide + Expect(expected) + .WhenSerializing(utcDate) + .AssertSubject(dateMath => dateMath + .Anchor.Match( + d => d.Should().Be(utcDate), + s => s.Should().BeNull() + ) + ); + } + + [U] public void ComplexExpressions() + { + /** + * ==== Complex expressions + * Ranges can be chained on to simple expressions + */ + Expect("now+1d").WhenSerializing( + DateMath.Now.Add("1d")); + + /** Including multiple operations */ + Expect("now+1d-1m").WhenSerializing( + DateMath.Now.Add("1d").Subtract(TimeSpan.FromMinutes(1))); + + /** A rounding value can be chained to the end of the expression, after which no more ranges can be appended */ + Expect("now+1d-1m/d").WhenSerializing( + DateMath.Now.Add("1d") + .Subtract(TimeSpan.FromMinutes(1)) + .RoundTo(DateMathTimeUnit.Day)); + + /** When anchoring dates, a `||` needs to be appended as clear separator between the anchor and ranges. + * Again, multiple ranges can be chained + */ + Expect("2015-05-05T00:00:00||+1d-1m").WhenSerializing( + DateMath.Anchored(new DateTime(2015,05,05)) + .Add("1d") + .Subtract(TimeSpan.FromMinutes(1))); + } + + [U] public void FractionalsUnitsAreDroppedToNearestInteger() + { + /** + * ==== Fractional times + * Date math expressions within Elasticsearch do not support fractional numbers. To make working with Date math + * easier within NEST, conversions from `string`, `TimeSpan` and `double` will convert a fractional value to the + * largest whole number value and unit, rounded to the nearest second. + * + */ + Expect("now+1w").WhenSerializing(DateMath.Now.Add(TimeSpan.FromDays(7))); + + Expect("now+1w").WhenSerializing(DateMath.Now.Add("1w")); + + Expect("now+1w").WhenSerializing(DateMath.Now.Add(604800000)); + + Expect("now+7d").WhenSerializing(DateMath.Now.Add("7d")); + + Expect("now+30h").WhenSerializing(DateMath.Now.Add(TimeSpan.FromHours(30))); + + Expect("now+30h").WhenSerializing(DateMath.Now.Add("1.25d")); + + Expect("now+90001s").WhenSerializing( + DateMath.Now.Add(TimeSpan.FromHours(25).Add(TimeSpan.FromSeconds(1)))); + + Expect("now+90000s").WhenSerializing( + DateMath.Now.Add(TimeSpan.FromHours(25).Add(TimeSpan.FromMilliseconds(1)))); + + Expect("now+1y").WhenSerializing(DateMath.Now.Add("1y")); + + Expect("now+12M").WhenSerializing(DateMath.Now.Add("12M")); + + Expect("now+18M").WhenSerializing(DateMath.Now.Add("1.5y")); + + Expect("now+52w").WhenSerializing(DateMath.Now.Add(TimeSpan.FromDays(7 * 52))); + } + + [U] public void Rounding() + { + /** + * ==== Rounding + * Rounding can be controlled using the constructor, and passing a value for rounding + */ + Expect("now+2s").WhenSerializing( + DateMath.Now.Add(new DateMathTime("2.5s", MidpointRounding.ToEven))); + + Expect("now+3s").WhenSerializing( + DateMath.Now.Add(new DateMathTime("2.5s", MidpointRounding.AwayFromZero))); + + Expect("now+0s").WhenSerializing( + DateMath.Now.Add(new DateMathTime(500, MidpointRounding.ToEven))); + + Expect("now+1s").WhenSerializing( + DateMath.Now.Add(new DateMathTime(500, MidpointRounding.AwayFromZero))); + } + + [U] public void EqualityAndComparison() + { + /** + * ==== Equality and Comparisons + * + * `DateMathTime` supports implements equality and comparison + */ + + DateMathTime twoSeconds = new DateMathTime(2, DateMathTimeUnit.Second); + DateMathTime twoSecondsFromString = "2s"; + DateMathTime twoSecondsFromTimeSpan = TimeSpan.FromSeconds(2); + DateMathTime twoSecondsFromDouble = 2000; + + twoSeconds.Should().Be(twoSecondsFromString); + twoSeconds.Should().Be(twoSecondsFromTimeSpan); + twoSeconds.Should().Be(twoSecondsFromDouble); + + DateMathTime threeSecondsFromString = "3s"; + DateMathTime oneMinuteFromTimeSpan = TimeSpan.FromMinutes(1); + + (threeSecondsFromString > twoSecondsFromString).Should().BeTrue(); + (oneMinuteFromTimeSpan > threeSecondsFromString).Should().BeTrue(); + + /** + * Since years and months do not + * contain exact values + * + * - A year is approximated to 365 days + * - A month is approximated to (365 / 12) days + */ + DateMathTime oneYear = new DateMathTime(1, DateMathTimeUnit.Year); + DateMathTime oneYearFromString = "1y"; + DateMathTime twelveMonths = new DateMathTime(12, DateMathTimeUnit.Month); + DateMathTime twelveMonthsFromString = "12M"; + + oneYear.Should().Be(oneYearFromString); + oneYear.Should().Be(twelveMonths); + twelveMonths.Should().Be(twelveMonthsFromString); + + DateMathTime thirteenMonths = new DateMathTime(13, DateMathTimeUnit.Month); + DateMathTime thirteenMonthsFromString = "13M"; + DateMathTime fiftyTwoWeeks = "52w"; + + (oneYear < thirteenMonths).Should().BeTrue(); + (oneYear < thirteenMonthsFromString).Should().BeTrue(); + (twelveMonths > fiftyTwoWeeks).Should().BeTrue(); + (oneYear > fiftyTwoWeeks).Should().BeTrue(); + } +} diff --git a/tests/Tests/Common/DateTime/DateMath/DateMathTests.cs b/tests/Tests/Common/DateTime/DateMath/DateMathTests.cs new file mode 100644 index 00000000000..e784a867f28 --- /dev/null +++ b/tests/Tests/Common/DateTime/DateMath/DateMathTests.cs @@ -0,0 +1,51 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Tests.Comomon; + +public class DateMathTests +{ + [U] + public void ImplicitConversionFromNullString() + { + string nullString = null; + DateMath dateMath = nullString; + dateMath.Should().BeNull(); + } + + [U] + public void ImplicitConversionFromNullNullableDateTime() + { + DateTime? nullableDateTime = null; + DateMath dateMath = nullableDateTime; + dateMath.Should().BeNull(); + } + + [U] + public void ImplicitConversionFromDefaultDateTimeIsMinValue() + { + DateTime dateTime = default; + DateMath dateMath = dateTime; + dateMath.Should().NotBeNull(); + dateMath.ToString().Should().Be("0001-01-01T00:00:00"); + } + + [U] + public void ImplicitConversionFromDateMathString() + { + var nullString = "now+3d"; + DateMath dateMath = nullString; + dateMath.Should().NotBeNull(); + } + + [U] + public void ImplicitConversionFromNullableDateTimeWithValue() + { + DateTime? nullableDateTime = DateTime.Now; + DateMath dateMath = nullableDateTime; + dateMath.Should().NotBeNull(); + } +} diff --git a/tests/Tests/Tests.csproj b/tests/Tests/Tests.csproj index e3f43e89c94..efe26c0e2e3 100644 --- a/tests/Tests/Tests.csproj +++ b/tests/Tests/Tests.csproj @@ -32,5 +32,6 @@ + \ No newline at end of file