diff --git a/dictionary.txt b/dictionary.txt index eb32caed457..0ec1c23941f 100644 --- a/dictionary.txt +++ b/dictionary.txt @@ -171,6 +171,7 @@ sortings Specwise sqft srid +sszzz Staib Starships starwars diff --git a/src/HotChocolate/Core/src/Types/Properties/TypeResources.Designer.cs b/src/HotChocolate/Core/src/Types/Properties/TypeResources.Designer.cs index bb50e74cddd..620fac42dba 100644 --- a/src/HotChocolate/Core/src/Types/Properties/TypeResources.Designer.cs +++ b/src/HotChocolate/Core/src/Types/Properties/TypeResources.Designer.cs @@ -293,6 +293,24 @@ internal static string DataLoaderResolverContextExtensions_UnableToRegister { } } + /// + /// Looks up a localized string similar to InputPrecision must be less than or equal to 7.. + /// + internal static string DateTimeOptions_InputPrecision_InvalidValue { + get { + return ResourceManager.GetString("DateTimeOptions_InputPrecision_InvalidValue", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to OutputPrecision must be less than or equal to 7.. + /// + internal static string DateTimeOptions_OutputPrecision_InvalidValue { + get { + return ResourceManager.GetString("DateTimeOptions_OutputPrecision_InvalidValue", resourceCulture); + } + } + /// /// Looks up a localized string similar to The `DateTime` scalar type represents a date and time with time zone offset information.. /// diff --git a/src/HotChocolate/Core/src/Types/Properties/TypeResources.resx b/src/HotChocolate/Core/src/Types/Properties/TypeResources.resx index 643feeecf10..a09a2d08b04 100644 --- a/src/HotChocolate/Core/src/Types/Properties/TypeResources.resx +++ b/src/HotChocolate/Core/src/Types/Properties/TypeResources.resx @@ -1032,4 +1032,10 @@ Type: `{0}` {0}Type cannot parse the provided value. The value does not match the required regular expression pattern. + + InputPrecision must be less than or equal to 7. + + + OutputPrecision must be less than or equal to 7. + diff --git a/src/HotChocolate/Core/src/Types/Types/Scalars/DateTimeOptions.cs b/src/HotChocolate/Core/src/Types/Types/Scalars/DateTimeOptions.cs new file mode 100644 index 00000000000..32bd19d18b6 --- /dev/null +++ b/src/HotChocolate/Core/src/Types/Types/Scalars/DateTimeOptions.cs @@ -0,0 +1,71 @@ +using HotChocolate.Properties; + +namespace HotChocolate.Types; + +/// +/// Defines options for configuring the behavior of date and time scalar types, such as +/// DateTime, LocalDateTime, and LocalTime. +/// +public struct DateTimeOptions +{ + public const byte DefaultInputPrecision = 7; + public const byte DefaultOutputPrecision = 7; + + public DateTimeOptions() + { + } + + /// + /// Gets the maximum number of fractional second digits to expect when parsing date and time + /// input values. + /// + /// + /// Thrown when the value is greater than 7. + /// + public byte InputPrecision + { + get; + init + { + if (value > 7) + { + throw new ArgumentOutOfRangeException( + nameof(InputPrecision), + value, + TypeResources.DateTimeOptions_InputPrecision_InvalidValue); + } + + field = value; + } + } = DefaultInputPrecision; + + /// + /// Gets the maximum number of fractional second digits to include when serializing date and + /// time output values. + /// + /// + /// Thrown when the value is greater than 7. + /// + public byte OutputPrecision + { + get; + init + { + if (value > 7) + { + throw new ArgumentOutOfRangeException( + nameof(OutputPrecision), + value, + TypeResources.DateTimeOptions_OutputPrecision_InvalidValue); + } + + field = value; + } + } = DefaultOutputPrecision; + + /// + /// Gets a value indicating whether the input format of date and time values should be validated + /// against the expected format. Defaults to true. + /// + public bool ValidateInputFormat { get; init; } = true; +} diff --git a/src/HotChocolate/Core/src/Types/Types/Scalars/DateTimeType.cs b/src/HotChocolate/Core/src/Types/Types/Scalars/DateTimeType.cs index b92aeda462c..7451fdf4375 100644 --- a/src/HotChocolate/Core/src/Types/Types/Scalars/DateTimeType.cs +++ b/src/HotChocolate/Core/src/Types/Types/Scalars/DateTimeType.cs @@ -22,7 +22,10 @@ public partial class DateTimeType : ScalarType private const string LocalFormat = "yyyy-MM-ddTHH\\:mm\\:ss.FFFFFFFzzz"; private const string SpecifiedByUri = "https://scalars.graphql.org/chillicream/date-time.html"; - private readonly bool _enforceSpecFormat; + private readonly DateTimeOptions _options; + private readonly string _utcFormat; + private readonly string _localFormat; + private readonly Regex _dateTimeRegex; /// /// Initializes a new instance of the class. @@ -31,24 +34,27 @@ public DateTimeType( string name, string? description = null, BindingBehavior bind = BindingBehavior.Explicit, - bool disableFormatCheck = false) + DateTimeOptions? options = null) : base(name, bind) { + _options = options ?? new DateTimeOptions(); Description = description; - Pattern = @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:[Zz]|[+-]\d{2}:\d{2})$"; + Pattern = GetPattern(); SpecifiedBy = new Uri(SpecifiedByUri); - _enforceSpecFormat = !disableFormatCheck; + _utcFormat = GetUtcFormat(); + _localFormat = GetLocalFormat(); + _dateTimeRegex = GetDateTimeRegex(); } /// /// Initializes a new instance of the class. /// - public DateTimeType(bool disableFormatCheck) + public DateTimeType(DateTimeOptions options) : this( ScalarNames.DateTime, TypeResources.DateTimeType_Description, BindingBehavior.Implicit, - disableFormatCheck: disableFormatCheck) + options: options) { } @@ -61,7 +67,40 @@ public DateTimeType() ScalarNames.DateTime, TypeResources.DateTimeType_Description, BindingBehavior.Implicit, - disableFormatCheck: false) + options: null) + { + } + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Use the constructor that accepts DateTimeOptions instead.")] + public DateTimeType( + string name, + string? description = null, + BindingBehavior bind = BindingBehavior.Explicit, + bool disableFormatCheck = false) + : base(name, bind) + { + _options = new DateTimeOptions { ValidateInputFormat = !disableFormatCheck }; + Description = description; + Pattern = GetPattern(); + SpecifiedBy = new Uri(SpecifiedByUri); + _utcFormat = GetUtcFormat(); + _localFormat = GetLocalFormat(); + _dateTimeRegex = GetDateTimeRegex(); + } + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Use the constructor that accepts DateTimeOptions instead.")] + public DateTimeType(bool disableFormatCheck) + : this( + ScalarNames.DateTime, + TypeResources.DateTimeType_Description, + BindingBehavior.Implicit, + disableFormatCheck: disableFormatCheck) { } @@ -87,32 +126,24 @@ protected override DateTimeOffset OnCoerceInputValue(JsonElement inputValue, IFe protected override void OnCoerceOutputValue(DateTimeOffset runtimeValue, ResultElement resultValue) { - if (runtimeValue.Offset == TimeSpan.Zero) - { - resultValue.SetStringValue(runtimeValue.ToString(UtcFormat, CultureInfo.InvariantCulture)); - } - else - { - resultValue.SetStringValue(runtimeValue.ToString(LocalFormat, CultureInfo.InvariantCulture)); - } + resultValue.SetStringValue( + runtimeValue.ToString( + runtimeValue.Offset == TimeSpan.Zero ? _utcFormat : _localFormat, + CultureInfo.InvariantCulture)); } protected override StringValueNode OnValueToLiteral(DateTimeOffset runtimeValue) { - if (runtimeValue.Offset == TimeSpan.Zero) - { - return new StringValueNode(runtimeValue.ToString(UtcFormat, CultureInfo.InvariantCulture)); - } - else - { - return new StringValueNode(runtimeValue.ToString(LocalFormat, CultureInfo.InvariantCulture)); - } + return new StringValueNode( + runtimeValue.ToString( + runtimeValue.Offset == TimeSpan.Zero ? _utcFormat : _localFormat, + CultureInfo.InvariantCulture)); } private bool TryParseStringValue(string serialized, out DateTimeOffset value) { // Check format. - if (_enforceSpecFormat && !DateTimeRegex().IsMatch(serialized)) + if (_options.ValidateInputFormat && !_dateTimeRegex.IsMatch(serialized)) { value = default; return false; @@ -132,8 +163,79 @@ private bool TryParseStringValue(string serialized, out DateTimeOffset value) return false; } + private string GetPattern() + => _options.InputPrecision == 0 + ? @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:[Zz]|[+-]\d{2}:\d{2})$" + : @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1," + + _options.InputPrecision + + @"})?(?:[Zz]|[+-]\d{2}:\d{2})$"; + + private string GetUtcFormat() + => _options.OutputPrecision switch + { + DateTimeOptions.DefaultOutputPrecision => UtcFormat, + 0 => @"yyyy-MM-ddTHH\:mm\:ssZ", + _ => @$"yyyy-MM-ddTHH\:mm\:ss.{new string('F', _options.OutputPrecision)}Z" + }; + + private string GetLocalFormat() + => _options.OutputPrecision switch + { + DateTimeOptions.DefaultOutputPrecision => LocalFormat, + 0 => @"yyyy-MM-ddTHH\:mm\:sszzz", + _ => @$"yyyy-MM-ddTHH\:mm\:ss.{new string('F', _options.OutputPrecision)}zzz" + }; + + private Regex GetDateTimeRegex() + => _options.InputPrecision switch + { + 0 => DateTimeRegex0(), + 1 => DateTimeRegex1(), + 2 => DateTimeRegex2(), + 3 => DateTimeRegex3(), + 4 => DateTimeRegex4(), + 5 => DateTimeRegex5(), + 6 => DateTimeRegex6(), + _ => DateTimeRegex7() + }; + + [GeneratedRegex( + @"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(Z|[+-][0-9]{2}:[0-9]{2})\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex DateTimeRegex0(); + + [GeneratedRegex( + @"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9])?(Z|[+-][0-9]{2}:[0-9]{2})\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex DateTimeRegex1(); + + [GeneratedRegex( + @"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,2})?(Z|[+-][0-9]{2}:[0-9]{2})\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex DateTimeRegex2(); + + [GeneratedRegex( + @"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,3})?(Z|[+-][0-9]{2}:[0-9]{2})\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex DateTimeRegex3(); + + [GeneratedRegex( + @"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,4})?(Z|[+-][0-9]{2}:[0-9]{2})\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex DateTimeRegex4(); + + [GeneratedRegex( + @"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,5})?(Z|[+-][0-9]{2}:[0-9]{2})\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex DateTimeRegex5(); + + [GeneratedRegex( + @"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,6})?(Z|[+-][0-9]{2}:[0-9]{2})\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex DateTimeRegex6(); + [GeneratedRegex( - @"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?(Z|[+-][0-9]{2}:[0-9]{2})\z", + @"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,7})?(Z|[+-][0-9]{2}:[0-9]{2})\z", RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] - private static partial Regex DateTimeRegex(); + private static partial Regex DateTimeRegex7(); } diff --git a/src/HotChocolate/Core/src/Types/Types/Scalars/LocalDateTimeType.cs b/src/HotChocolate/Core/src/Types/Types/Scalars/LocalDateTimeType.cs index 49d0dfaa1b8..cdd54992016 100644 --- a/src/HotChocolate/Core/src/Types/Types/Scalars/LocalDateTimeType.cs +++ b/src/HotChocolate/Core/src/Types/Types/Scalars/LocalDateTimeType.cs @@ -22,7 +22,9 @@ public partial class LocalDateTimeType : ScalarType private const string LocalFormat = "yyyy-MM-ddTHH\\:mm\\:ss.FFFFFFF"; private const string SpecifiedByUri = "https://scalars.graphql.org/chillicream/local-date-time.html"; - private readonly bool _enforceSpecFormat; + private readonly DateTimeOptions _options; + private readonly string _localFormat; + private readonly Regex _localDateTimeRegex; /// /// Initializes a new instance of the class. @@ -31,24 +33,26 @@ public LocalDateTimeType( string name, string? description = null, BindingBehavior bind = BindingBehavior.Explicit, - bool disableFormatCheck = false) + DateTimeOptions? options = null) : base(name, bind) { + _options = options ?? new DateTimeOptions(); Description = description; - Pattern = @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?$"; + Pattern = GetPattern(); SpecifiedBy = new Uri(SpecifiedByUri); - _enforceSpecFormat = !disableFormatCheck; + _localFormat = GetLocalFormat(); + _localDateTimeRegex = GetLocalDateTimeRegex(); } /// /// Initializes a new instance of the class. /// - public LocalDateTimeType(bool disableFormatCheck) + public LocalDateTimeType(DateTimeOptions options) : this( ScalarNames.LocalDateTime, TypeResources.LocalDateTimeType_Description, BindingBehavior.Implicit, - disableFormatCheck: disableFormatCheck) + options: options) { } @@ -59,7 +63,40 @@ public LocalDateTimeType(bool disableFormatCheck) public LocalDateTimeType() : this( ScalarNames.LocalDateTime, - TypeResources.LocalDateTimeType_Description) + TypeResources.LocalDateTimeType_Description, + options: null) + { + } + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Use the constructor that accepts DateTimeOptions instead.")] + public LocalDateTimeType( + string name, + string? description = null, + BindingBehavior bind = BindingBehavior.Explicit, + bool disableFormatCheck = false) + : base(name, bind) + { + _options = new DateTimeOptions { ValidateInputFormat = !disableFormatCheck }; + Description = description; + Pattern = GetPattern(); + SpecifiedBy = new Uri(SpecifiedByUri); + _localFormat = GetLocalFormat(); + _localDateTimeRegex = GetLocalDateTimeRegex(); + } + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Use the constructor that accepts DateTimeOptions instead.")] + public LocalDateTimeType(bool disableFormatCheck) + : this( + ScalarNames.LocalDateTime, + TypeResources.LocalDateTimeType_Description, + BindingBehavior.Implicit, + disableFormatCheck: disableFormatCheck) { } @@ -87,16 +124,16 @@ protected override DateTime OnCoerceInputValue(JsonElement inputValue, IFeatureP /// protected override void OnCoerceOutputValue(DateTime runtimeValue, ResultElement resultValue) - => resultValue.SetStringValue(runtimeValue.ToString(LocalFormat, CultureInfo.InvariantCulture)); + => resultValue.SetStringValue(runtimeValue.ToString(_localFormat, CultureInfo.InvariantCulture)); /// protected override StringValueNode OnValueToLiteral(DateTime runtimeValue) - => new StringValueNode(runtimeValue.ToString(LocalFormat, CultureInfo.InvariantCulture)); + => new StringValueNode(runtimeValue.ToString(_localFormat, CultureInfo.InvariantCulture)); private bool TryParseStringValue(string serialized, out DateTime value) { // Check format. - if (_enforceSpecFormat && !LocalDateTimeRegex().IsMatch(serialized)) + if (_options.ValidateInputFormat && !_localDateTimeRegex.IsMatch(serialized)) { value = default; return false; @@ -116,7 +153,61 @@ private bool TryParseStringValue(string serialized, out DateTime value) return false; } - [GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?\z", + private string GetPattern() + => _options.InputPrecision == 0 + ? @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}$" + : @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1," + _options.InputPrecision + "})?$"; + + private string GetLocalFormat() + => _options.OutputPrecision switch + { + DateTimeOptions.DefaultOutputPrecision => LocalFormat, + 0 => @"yyyy-MM-ddTHH\:mm\:ss", + _ => @$"yyyy-MM-ddTHH\:mm\:ss.{new string('F', _options.OutputPrecision)}" + }; + + private Regex GetLocalDateTimeRegex() + => _options.InputPrecision switch + { + 0 => LocalDateTimeRegex0(), + 1 => LocalDateTimeRegex1(), + 2 => LocalDateTimeRegex2(), + 3 => LocalDateTimeRegex3(), + 4 => LocalDateTimeRegex4(), + 5 => LocalDateTimeRegex5(), + 6 => LocalDateTimeRegex6(), + _ => LocalDateTimeRegex7() + }; + + [GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex LocalDateTimeRegex0(); + + [GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9])?\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex LocalDateTimeRegex1(); + + [GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,2})?\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex LocalDateTimeRegex2(); + + [GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,3})?\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex LocalDateTimeRegex3(); + + [GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,4})?\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex LocalDateTimeRegex4(); + + [GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,5})?\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex LocalDateTimeRegex5(); + + [GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,6})?\z", + RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] + private static partial Regex LocalDateTimeRegex6(); + + [GeneratedRegex(@"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,7})?\z", RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase)] - private static partial Regex LocalDateTimeRegex(); + private static partial Regex LocalDateTimeRegex7(); } diff --git a/src/HotChocolate/Core/src/Types/Types/Scalars/LocalTimeType.cs b/src/HotChocolate/Core/src/Types/Types/Scalars/LocalTimeType.cs index b7e709dbc34..42ed4e0b2d6 100644 --- a/src/HotChocolate/Core/src/Types/Types/Scalars/LocalTimeType.cs +++ b/src/HotChocolate/Core/src/Types/Types/Scalars/LocalTimeType.cs @@ -21,7 +21,9 @@ public partial class LocalTimeType : ScalarType private const string LocalFormat = "HH:mm:ss.FFFFFFF"; private const string SpecifiedByUri = "https://scalars.graphql.org/chillicream/local-time.html"; - private readonly bool _enforceSpecFormat; + private readonly DateTimeOptions _options; + private readonly string _localFormat; + private readonly Regex _localTimeRegex; /// /// Initializes a new instance of the class. @@ -30,24 +32,26 @@ public LocalTimeType( string name, string? description = null, BindingBehavior bind = BindingBehavior.Explicit, - bool disableFormatCheck = false) + DateTimeOptions? options = null) : base(name, bind) { + _options = options ?? new DateTimeOptions(); Description = description; - Pattern = @"^\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?$"; + Pattern = GetPattern(); SpecifiedBy = new Uri(SpecifiedByUri); - _enforceSpecFormat = !disableFormatCheck; + _localFormat = GetLocalFormat(); + _localTimeRegex = GetLocalTimeRegex(); } /// /// Initializes a new instance of the class. /// - public LocalTimeType(bool disableFormatCheck) + public LocalTimeType(DateTimeOptions options) : this( ScalarNames.LocalTime, TypeResources.LocalTimeType_Description, BindingBehavior.Implicit, - disableFormatCheck: disableFormatCheck) + options: options) { } @@ -58,7 +62,40 @@ public LocalTimeType(bool disableFormatCheck) public LocalTimeType() : this( ScalarNames.LocalTime, - TypeResources.LocalTimeType_Description) + TypeResources.LocalTimeType_Description, + options: null) + { + } + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Use the constructor that accepts DateTimeOptions instead.")] + public LocalTimeType( + string name, + string? description = null, + BindingBehavior bind = BindingBehavior.Explicit, + bool disableFormatCheck = false) + : base(name, bind) + { + _options = new DateTimeOptions { ValidateInputFormat = !disableFormatCheck }; + Description = description; + Pattern = GetPattern(); + SpecifiedBy = new Uri(SpecifiedByUri); + _localFormat = GetLocalFormat(); + _localTimeRegex = GetLocalTimeRegex(); + } + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Use the constructor that accepts DateTimeOptions instead.")] + public LocalTimeType(bool disableFormatCheck) + : this( + ScalarNames.LocalTime, + TypeResources.LocalTimeType_Description, + BindingBehavior.Implicit, + disableFormatCheck: disableFormatCheck) { } @@ -86,16 +123,16 @@ protected override TimeOnly OnCoerceInputValue(JsonElement inputValue, IFeatureP /// protected override void OnCoerceOutputValue(TimeOnly runtimeValue, ResultElement resultValue) - => resultValue.SetStringValue(runtimeValue.ToString(LocalFormat, CultureInfo.InvariantCulture)); + => resultValue.SetStringValue(runtimeValue.ToString(_localFormat, CultureInfo.InvariantCulture)); /// protected override StringValueNode OnValueToLiteral(TimeOnly runtimeValue) - => new StringValueNode(runtimeValue.ToString(LocalFormat, CultureInfo.InvariantCulture)); + => new StringValueNode(runtimeValue.ToString(_localFormat, CultureInfo.InvariantCulture)); private bool TryParseStringValue(string serialized, out TimeOnly value) { // Check format. - if (_enforceSpecFormat && !LocalTimeRegex().IsMatch(serialized)) + if (_options.ValidateInputFormat && !_localTimeRegex.IsMatch(serialized)) { value = default; return false; @@ -114,6 +151,53 @@ private bool TryParseStringValue(string serialized, out TimeOnly value) return false; } - [GeneratedRegex(@"^[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,9})?\z", RegexOptions.ExplicitCapture)] - private static partial Regex LocalTimeRegex(); + private string GetPattern() + => _options.InputPrecision == 0 + ? @"^\d{2}:\d{2}:\d{2}$" + : @"^\d{2}:\d{2}:\d{2}(?:\.\d{1," + _options.InputPrecision + "})?$"; + + private string GetLocalFormat() + => _options.OutputPrecision switch + { + DateTimeOptions.DefaultOutputPrecision => LocalFormat, + 0 => "HH:mm:ss", + _ => $"HH:mm:ss.{new string('F', _options.OutputPrecision)}" + }; + + private Regex GetLocalTimeRegex() + => _options.InputPrecision switch + { + 0 => LocalTimeRegex0(), + 1 => LocalTimeRegex1(), + 2 => LocalTimeRegex2(), + 3 => LocalTimeRegex3(), + 4 => LocalTimeRegex4(), + 5 => LocalTimeRegex5(), + 6 => LocalTimeRegex6(), + _ => LocalTimeRegex7() + }; + + [GeneratedRegex(@"^[0-9]{2}:[0-9]{2}:[0-9]{2}\z", RegexOptions.ExplicitCapture)] + private static partial Regex LocalTimeRegex0(); + + [GeneratedRegex(@"^[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9])?\z", RegexOptions.ExplicitCapture)] + private static partial Regex LocalTimeRegex1(); + + [GeneratedRegex(@"^[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,2})?\z", RegexOptions.ExplicitCapture)] + private static partial Regex LocalTimeRegex2(); + + [GeneratedRegex(@"^[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,3})?\z", RegexOptions.ExplicitCapture)] + private static partial Regex LocalTimeRegex3(); + + [GeneratedRegex(@"^[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,4})?\z", RegexOptions.ExplicitCapture)] + private static partial Regex LocalTimeRegex4(); + + [GeneratedRegex(@"^[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,5})?\z", RegexOptions.ExplicitCapture)] + private static partial Regex LocalTimeRegex5(); + + [GeneratedRegex(@"^[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,6})?\z", RegexOptions.ExplicitCapture)] + private static partial Regex LocalTimeRegex6(); + + [GeneratedRegex(@"^[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,7})?\z", RegexOptions.ExplicitCapture)] + private static partial Regex LocalTimeRegex7(); } diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/DateTimeOptionsTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/DateTimeOptionsTests.cs new file mode 100644 index 00000000000..3c35caed5ed --- /dev/null +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/DateTimeOptionsTests.cs @@ -0,0 +1,91 @@ +namespace HotChocolate.Types; + +public class DateTimeOptionsTests +{ + [Fact] + public void DefaultConstructor_ShouldSetDefaultPrecisions() + { + // arrange & act + var options = new DateTimeOptions(); + + // assert + Assert.Equal(DateTimeOptions.DefaultInputPrecision, options.InputPrecision); + Assert.Equal(DateTimeOptions.DefaultOutputPrecision, options.OutputPrecision); + } + + [Fact] + public void DefaultConstants_ShouldBeCorrect() + { + // assert + Assert.Equal(7, DateTimeOptions.DefaultInputPrecision); + Assert.Equal(7, DateTimeOptions.DefaultOutputPrecision); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + public void InputPrecision_ValidValues_ShouldSet(byte precision) + { + // arrange & act + var options = new DateTimeOptions { InputPrecision = precision }; + + // assert + Assert.Equal(precision, options.InputPrecision); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + public void OutputPrecision_ValidValues_ShouldSet(byte precision) + { + // arrange & act + var options = new DateTimeOptions { OutputPrecision = precision }; + + // assert + Assert.Equal(precision, options.OutputPrecision); + } + + [Theory] + [InlineData(8)] + [InlineData(9)] + [InlineData(10)] + [InlineData(255)] + public void InputPrecision_InvalidValues_ShouldThrow(byte precision) + { + // arrange & act + var exception = Assert.Throws(() + => new DateTimeOptions { InputPrecision = precision }); + + // assert + Assert.Equal("InputPrecision", exception.ParamName); + Assert.Equal(precision, exception.ActualValue); + } + + [Theory] + [InlineData(8)] + [InlineData(9)] + [InlineData(10)] + [InlineData(255)] + public void OutputPrecision_InvalidValues_ShouldThrow(byte precision) + { + // arrange & act + var exception = Assert.Throws(() + => new DateTimeOptions { OutputPrecision = precision }); + + // assert + Assert.Equal("OutputPrecision", exception.ParamName); + Assert.Equal(precision, exception.ActualValue); + } +} diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/DateTimeTypeTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/DateTimeTypeTests.cs index 7083d427bfc..2ba79dc582b 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/DateTimeTypeTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/DateTimeTypeTests.cs @@ -2,7 +2,6 @@ using System.Text.Json; using HotChocolate.Execution; using HotChocolate.Language; -using HotChocolate.Tests; using HotChocolate.Text.Json; using Microsoft.Extensions.DependencyInjection; @@ -39,11 +38,11 @@ public void CoerceInputLiteral() } [Theory] - [MemberData(nameof(ValidDateTimeScalarStrings))] - public void CoerceInputLiteral_Valid(string dateTime, DateTimeOffset result) + [MemberData(nameof(ValidInput))] + public void CoerceInputLiteral_Valid(byte precision, string dateTime, DateTimeOffset result) { // arrange - var type = new DateTimeType(); + var type = new DateTimeType(new DateTimeOptions { InputPrecision = precision }); var literal = new StringValueNode(dateTime); // act @@ -54,11 +53,11 @@ public void CoerceInputLiteral_Valid(string dateTime, DateTimeOffset result) } [Theory] - [MemberData(nameof(InvalidDateTimeScalarStrings))] - public void CoerceInputLiteral_Invalid(string dateTime) + [MemberData(nameof(InvalidInput))] + public void CoerceInputLiteral_Invalid(byte precision, string dateTime) { // arrange - var type = new DateTimeType(); + var type = new DateTimeType(new DateTimeOptions { InputPrecision = precision }); var literal = new StringValueNode(dateTime); // act @@ -143,6 +142,23 @@ public void CoerceInputValue_Invalid_Format() Assert.Throws(Action); } + [Theory] + [MemberData(nameof(ValidOutput))] + public void CoerceOutputValue_Valid(byte precision, DateTimeOffset dateTime, string result) + { + // arrange + var type = new DateTimeType(new DateTimeOptions { OutputPrecision = precision }); + + // act + var operation = CommonTestExtensions.CreateOperation(); + var resultDocument = new ResultDocument(operation, 0); + var resultValue = resultDocument.Data.GetProperty("first"); + type.CoerceOutputValue(dateTime, resultValue); + + // assert + resultValue.MatchInlineSnapshot($"\"{result}\""); + } + [Fact] public void CoerceOutputValue_Utc_DateTimeOffset() { @@ -295,7 +311,7 @@ public void DateTime_Relaxed_Format_Check() const string s = "2011-08-30"; // act - var type = new DateTimeType(disableFormatCheck: true); + var type = new DateTimeType(new DateTimeOptions { ValidateInputFormat = false }); var inputValue = JsonDocument.Parse($"\"{s}\"").RootElement; var result = type.CoerceInputValue(inputValue, null!); @@ -303,49 +319,139 @@ public void DateTime_Relaxed_Format_Check() Assert.IsType(result); } + [Theory] + [InlineData(0, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:[Zz]|[+-]\d{2}:\d{2})$")] + [InlineData(1, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,1})?(?:[Zz]|[+-]\d{2}:\d{2})$")] + [InlineData(2, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,2})?(?:[Zz]|[+-]\d{2}:\d{2})$")] + [InlineData(3, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:[Zz]|[+-]\d{2}:\d{2})$")] + [InlineData(4, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,4})?(?:[Zz]|[+-]\d{2}:\d{2})$")] + [InlineData(5, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,5})?(?:[Zz]|[+-]\d{2}:\d{2})$")] + [InlineData(6, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:[Zz]|[+-]\d{2}:\d{2})$")] + [InlineData(7, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?(?:[Zz]|[+-]\d{2}:\d{2})$")] + public void Pattern_Should_Match_InputPrecision(byte precision, string expectedPattern) + { + // arrange & act + var type = new DateTimeType(new DateTimeOptions { InputPrecision = precision }); + + // assert + Assert.Equal(expectedPattern, type.Pattern); + } + public class DefaultDateTime { public DateTime Test => default; } - public static TheoryData ValidDateTimeScalarStrings() + public static TheoryData ValidInput() { - return new TheoryData + return new TheoryData { // https://scalars.graphql.org/chillicream/date-time.html#sec-Input-spec.Examples (Valid input values) { + DateTimeOptions.DefaultInputPrecision, "2023-12-24T15:30:00Z", new DateTimeOffset(2023, 12, 24, 15, 30, 0, 0, TimeSpan.Zero) }, { - "2023-12-24T15:30:00.123456789+01:00", // Rounded to ".1234568". - new DateTimeOffset(2023, 12, 24, 15, 30, 0, 123, 456, TimeSpan.FromHours(1)).AddTicks(8) + DateTimeOptions.DefaultInputPrecision, + "2023-12-24T15:30:00.1234567+01:00", + new DateTimeOffset(2023, 12, 24, 15, 30, 0, 123, 456, TimeSpan.FromHours(1)).AddTicks(7) } }; } - public static TheoryData InvalidDateTimeScalarStrings() + public static TheoryData InvalidInput() { - return - [ + return new TheoryData + { // https://scalars.graphql.org/chillicream/date-time.html#sec-Input-spec.Examples (Invalid input values) // Missing time zone offset. - "2023-12-24T15:30:00", + { DateTimeOptions.DefaultInputPrecision, "2023-12-24T15:30:00" }, // Space instead of T or t separator. - "2023-12-24 15:30:00Z", + { DateTimeOptions.DefaultInputPrecision, "2023-12-24 15:30:00Z" }, // Invalid hour (25). - "2023-12-24T25:00:00Z", + { DateTimeOptions.DefaultInputPrecision, "2023-12-24T25:00:00Z" }, // Invalid minute (60). - "2023-12-24T15:60:00Z", + { DateTimeOptions.DefaultInputPrecision, "2023-12-24T15:60:00Z" }, // ReSharper disable once GrammarMistakeInComment // Invalid date (February 30th). - "2023-02-30T15:30:00Z", - // More than 9 fractional second digits. - "2023-12-24T15:30:00.1234567890Z", + { DateTimeOptions.DefaultInputPrecision, "2023-02-30T15:30:00Z" }, + // More than 7 fractional second digits. + { DateTimeOptions.DefaultInputPrecision, "2023-12-24T15:30:00.12345678Z" }, // Invalid offset (exceeds maximum). - "2023-12-24T15:30:00+25:00", + { DateTimeOptions.DefaultInputPrecision, "2023-12-24T15:30:00+25:00" }, // Invalid offset format. - "2023-12-24T15:30:00 UTC" - ]; + { DateTimeOptions.DefaultInputPrecision, "2023-12-24T15:30:00 UTC" }, + // Additional cases. + // More than 6 fractional second digits with precision set to 6. + { 6, "2023-12-24T15:30:00.1234567Z" }, + // More than 5 fractional second digits with precision set to 5. + { 5, "2023-12-24T15:30:00.123456Z" }, + // More than 4 fractional second digits with precision set to 4. + { 4, "2023-12-24T15:30:00.12345Z" }, + // More than 3 fractional second digits with precision set to 3. + { 3, "2023-12-24T15:30:00.1234Z" }, + // More than 2 fractional second digits with precision set to 2. + { 2, "2023-12-24T15:30:00.123Z" }, + // More than 1 fractional second digit with precision set to 1. + { 1, "2023-12-24T15:30:00.12Z" }, + // Fractional second digits with precision set to 0. + { 0, "2023-12-24T15:30:00.1Z" } + }; + } + + public static TheoryData ValidOutput() + { + return new TheoryData + { + // Up to 7 fractional second digits with default precision. + { + DateTimeOptions.DefaultOutputPrecision, + new DateTimeOffset(2023, 12, 24, 15, 30, 0, 123, 456, TimeSpan.Zero).AddTicks(7), + "2023-12-24T15:30:00.1234567Z" + }, + // Up to 6 fractional second digits with precision set to 6. + { + 6, + new DateTimeOffset(2023, 12, 24, 15, 30, 0, 123, 456, TimeSpan.Zero).AddTicks(7), + "2023-12-24T15:30:00.123456Z" + }, + // Up to 5 fractional second digits with precision set to 5. + { + 5, + new DateTimeOffset(2023, 12, 24, 15, 30, 0, 123, 456, TimeSpan.Zero).AddTicks(7), + "2023-12-24T15:30:00.12345Z" + }, + // Up to 4 fractional second digits with precision set to 4. + { + 4, + new DateTimeOffset(2023, 12, 24, 15, 30, 0, 123, 456, TimeSpan.Zero).AddTicks(7), + "2023-12-24T15:30:00.1234Z" + }, + // Up to 3 fractional second digits with precision set to 3. + { + 3, + new DateTimeOffset(2023, 12, 24, 15, 30, 0, 123, 456, TimeSpan.Zero).AddTicks(7), + "2023-12-24T15:30:00.123Z" + }, + // Up to 2 fractional second digits with precision set to 2. + { + 2, + new DateTimeOffset(2023, 12, 24, 15, 30, 0, 123, 456, TimeSpan.Zero).AddTicks(7), + "2023-12-24T15:30:00.12Z" + }, + // Up to 1 fractional second digit with precision set to 1. + { + 1, + new DateTimeOffset(2023, 12, 24, 15, 30, 0, 123, 456, TimeSpan.Zero).AddTicks(7), + "2023-12-24T15:30:00.1Z" + }, + // No fractional second digits with precision set to 0. + { + 0, + new DateTimeOffset(2023, 12, 24, 15, 30, 0, 123, 456, TimeSpan.Zero).AddTicks(7), + "2023-12-24T15:30:00Z" + } + }; } } diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/LocalDateTimeTypeTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/LocalDateTimeTypeTests.cs index 1ef00a51716..10b3016814d 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/LocalDateTimeTypeTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/LocalDateTimeTypeTests.cs @@ -37,11 +37,11 @@ public void CoerceInputLiteral() } [Theory] - [MemberData(nameof(ValidLocalDateTimeScalarStrings))] - public void CoerceInputLiteral_Valid(string dateTimeString, DateTime result) + [MemberData(nameof(ValidInput))] + public void CoerceInputLiteral_Valid(byte precision, string dateTimeString, DateTime result) { // arrange - var type = new LocalDateTimeType(); + var type = new LocalDateTimeType(new DateTimeOptions { InputPrecision = precision }); var literal = new StringValueNode(dateTimeString); // act @@ -52,11 +52,11 @@ public void CoerceInputLiteral_Valid(string dateTimeString, DateTime result) } [Theory] - [MemberData(nameof(InvalidLocalDateTimeScalarStrings))] - public void CoerceInputLiteral_Invalid(string dateTime) + [MemberData(nameof(InvalidInput))] + public void CoerceInputLiteral_Invalid(byte precision, string dateTime) { // arrange - var type = new LocalDateTimeType(); + var type = new LocalDateTimeType(new DateTimeOptions { InputPrecision = precision }); var literal = new StringValueNode(dateTime); // act @@ -134,6 +134,23 @@ public void CoerceInputValue_Invalid_Format() Assert.Throws(Action); } + [Theory] + [MemberData(nameof(ValidOutput))] + public void CoerceOutputValue_Valid(byte precision, DateTime dateTime, string result) + { + // arrange + var type = new LocalDateTimeType(new DateTimeOptions { OutputPrecision = precision }); + + // act + var operation = CommonTestExtensions.CreateOperation(); + var resultDocument = new ResultDocument(operation, 0); + var resultValue = resultDocument.Data.GetProperty("first"); + type.CoerceOutputValue(dateTime, resultValue); + + // assert + resultValue.MatchInlineSnapshot($"\"{result}\""); + } + [Fact] public void CoerceOutputValue() { @@ -289,7 +306,7 @@ public void LocalDateTime_Relaxed_Format_Check() const string s = "2011-08-30"; // act - var type = new LocalDateTimeType(disableFormatCheck: true); + var type = new LocalDateTimeType(new DateTimeOptions { ValidateInputFormat = false }); var inputValue = JsonDocument.Parse($"\"{s}\"").RootElement; var result = type.CoerceInputValue(inputValue, null!); @@ -297,6 +314,24 @@ public void LocalDateTime_Relaxed_Format_Check() Assert.IsType(result); } + [Theory] + [InlineData(0, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}$")] + [InlineData(1, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,1})?$")] + [InlineData(2, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,2})?$")] + [InlineData(3, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?$")] + [InlineData(4, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,4})?$")] + [InlineData(5, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,5})?$")] + [InlineData(6, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?$")] + [InlineData(7, @"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?$")] + public void Pattern_Should_Match_InputPrecision(byte precision, string expectedPattern) + { + // arrange & act + var type = new LocalDateTimeType(new DateTimeOptions { InputPrecision = precision }); + + // assert + Assert.Equal(expectedPattern, type.Pattern); + } + public class Query { [GraphQLType] @@ -328,42 +363,114 @@ public class Bar public DateTime GetLocalDateTime() => DateTime.MaxValue; } - public static TheoryData ValidLocalDateTimeScalarStrings() + public static TheoryData ValidInput() { - return new TheoryData + return new TheoryData { // https://scalars.graphql.org/chillicream/local-date-time.html#sec-Input-spec.Examples (Valid input values) { + DateTimeOptions.DefaultInputPrecision, "2023-12-24T15:30:00", new DateTime(2023, 12, 24, 15, 30, 0, 0) }, { - "2023-12-24t15:30:00.123456789", // Rounded to ".1234568". - new DateTime(2023, 12, 24, 15, 30, 0, 123, 456).AddTicks(8) + DateTimeOptions.DefaultInputPrecision, + "2023-12-24t15:30:00.1234567", + new DateTime(2023, 12, 24, 15, 30, 0, 123, 456).AddTicks(7) } }; } - public static TheoryData InvalidLocalDateTimeScalarStrings() + public static TheoryData InvalidInput() { - return - [ + return new TheoryData + { // https://scalars.graphql.org/chillicream/local-date-time.html#sec-Input-spec.Examples (Invalid input values) // Contains time zone indicator Z. - "2023-12-24T15:30:00Z", + { DateTimeOptions.DefaultInputPrecision, "2023-12-24T15:30:00Z" }, // Contains time zone offset. - "2023-12-24T15:30:00+05:30", + { DateTimeOptions.DefaultInputPrecision, "2023-12-24T15:30:00+05:30" }, // Invalid separator (space instead of T or t). - "2023-12-24 15:30:00", + { DateTimeOptions.DefaultInputPrecision, "2023-12-24 15:30:00" }, // Invalid hour (25). - "2023-12-24T25:00:00", + { DateTimeOptions.DefaultInputPrecision, "2023-12-24T25:00:00" }, // Invalid minute (60). - "2023-12-24T15:60:00", + { DateTimeOptions.DefaultInputPrecision, "2023-12-24T15:60:00" }, // ReSharper disable once GrammarMistakeInComment // Invalid date (February 30th). - "2023-02-30T15:30:00", - // More than 9 fractional second digits. - "2023-12-24T15:30:00.1234567890" - ]; + { DateTimeOptions.DefaultInputPrecision, "2023-02-30T15:30:00" }, + // More than 7 fractional second digits. + { DateTimeOptions.DefaultInputPrecision, "2023-12-24T15:30:00.12345678" }, + // Additional cases. + // More than 6 fractional second digits with precision set to 6. + { 6, "2023-12-24T15:30:00.1234567" }, + // More than 5 fractional second digits with precision set to 5. + { 5, "2023-12-24T15:30:00.123456" }, + // More than 4 fractional second digits with precision set to 4. + { 4, "2023-12-24T15:30:00.12345" }, + // More than 3 fractional second digits with precision set to 3. + { 3, "2023-12-24T15:30:00.1234" }, + // More than 2 fractional second digits with precision set to 2. + { 2, "2023-12-24T15:30:00.123" }, + // More than 1 fractional second digit with precision set to 1. + { 1, "2023-12-24T15:30:00.12" }, + // Fractional second digits with precision set to 0. + { 0, "2023-12-24T15:30:00.1" } + }; + } + + public static TheoryData ValidOutput() + { + return new TheoryData + { + // Up to 7 fractional second digits with default precision. + { + DateTimeOptions.DefaultOutputPrecision, + new DateTime(2023, 12, 24, 15, 30, 0, 123, 456).AddTicks(7), + "2023-12-24T15:30:00.1234567" + }, + // Up to 6 fractional second digits with precision set to 6. + { + 6, + new DateTime(2023, 12, 24, 15, 30, 0, 123, 456).AddTicks(7), + "2023-12-24T15:30:00.123456" + }, + // Up to 5 fractional second digits with precision set to 5. + { + 5, + new DateTime(2023, 12, 24, 15, 30, 0, 123, 456).AddTicks(7), + "2023-12-24T15:30:00.12345" + }, + // Up to 4 fractional second digits with precision set to 4. + { + 4, + new DateTime(2023, 12, 24, 15, 30, 0, 123, 456).AddTicks(7), + "2023-12-24T15:30:00.1234" + }, + // Up to 3 fractional second digits with precision set to 3. + { + 3, + new DateTime(2023, 12, 24, 15, 30, 0, 123, 456).AddTicks(7), + "2023-12-24T15:30:00.123" + }, + // Up to 2 fractional second digits with precision set to 2. + { + 2, + new DateTime(2023, 12, 24, 15, 30, 0, 123, 456).AddTicks(7), + "2023-12-24T15:30:00.12" + }, + // Up to 1 fractional second digit with precision set to 1. + { + 1, + new DateTime(2023, 12, 24, 15, 30, 0, 123, 456).AddTicks(7), + "2023-12-24T15:30:00.1" + }, + // No fractional second digits with precision set to 0. + { + 0, + new DateTime(2023, 12, 24, 15, 30, 0, 123, 456).AddTicks(7), + "2023-12-24T15:30:00" + } + }; } } diff --git a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/LocalTimeTypeTests.cs b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/LocalTimeTypeTests.cs index 0b7cb3b3ab9..1b41874f8cb 100644 --- a/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/LocalTimeTypeTests.cs +++ b/src/HotChocolate/Core/test/Types.Tests/Types/Scalars/LocalTimeTypeTests.cs @@ -37,11 +37,11 @@ public void CoerceInputLiteral() } [Theory] - [MemberData(nameof(ValidLocalTimeScalarStrings))] - public void CoerceInputLiteral_Valid(string time, TimeOnly result) + [MemberData(nameof(ValidInput))] + public void CoerceInputLiteral_Valid(byte precision, string time, TimeOnly result) { // arrange - var type = new LocalTimeType(); + var type = new LocalTimeType(new DateTimeOptions { InputPrecision = precision }); var literal = new StringValueNode(time); // act @@ -52,11 +52,11 @@ public void CoerceInputLiteral_Valid(string time, TimeOnly result) } [Theory] - [MemberData(nameof(InvalidLocalTimeScalarStrings))] - public void CoerceInputLiteral_Invalid(string time) + [MemberData(nameof(InvalidInput))] + public void CoerceInputLiteral_Invalid(byte precision, string time) { // arrange - var type = new LocalTimeType(); + var type = new LocalTimeType(new DateTimeOptions { InputPrecision = precision }); var literal = new StringValueNode(time); // act @@ -134,6 +134,23 @@ public void CoerceInputValue_Invalid_Format() Assert.Throws(Action); } + [Theory] + [MemberData(nameof(ValidOutput))] + public void CoerceOutputValue_Valid(byte precision, TimeOnly time, string result) + { + // arrange + var type = new LocalTimeType(new DateTimeOptions { OutputPrecision = precision }); + + // act + var operation = CommonTestExtensions.CreateOperation(); + var resultDocument = new ResultDocument(operation, 0); + var resultValue = resultDocument.Data.GetProperty("first"); + type.CoerceOutputValue(time, resultValue); + + // assert + resultValue.MatchInlineSnapshot($"\"{result}\""); + } + [Fact] public void CoerceOutputValue() { @@ -289,7 +306,7 @@ public void LocalTime_Relaxed_Format_Check() const string s = "15:30"; // act - var type = new LocalTimeType(disableFormatCheck: true); + var type = new LocalTimeType(new DateTimeOptions { ValidateInputFormat = false }); var inputValue = JsonDocument.Parse($"\"{s}\"").RootElement; var result = type.CoerceInputValue(inputValue, null!); @@ -297,6 +314,24 @@ public void LocalTime_Relaxed_Format_Check() Assert.IsType(result); } + [Theory] + [InlineData(0, @"^\d{2}:\d{2}:\d{2}$")] + [InlineData(1, @"^\d{2}:\d{2}:\d{2}(?:\.\d{1,1})?$")] + [InlineData(2, @"^\d{2}:\d{2}:\d{2}(?:\.\d{1,2})?$")] + [InlineData(3, @"^\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?$")] + [InlineData(4, @"^\d{2}:\d{2}:\d{2}(?:\.\d{1,4})?$")] + [InlineData(5, @"^\d{2}:\d{2}:\d{2}(?:\.\d{1,5})?$")] + [InlineData(6, @"^\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?$")] + [InlineData(7, @"^\d{2}:\d{2}:\d{2}(?:\.\d{1,7})?$")] + public void Pattern_Should_Match_InputPrecision(byte precision, string expectedPattern) + { + // arrange & act + var type = new LocalTimeType(new DateTimeOptions { InputPrecision = precision }); + + // assert + Assert.Equal(expectedPattern, type.Pattern); + } + public class Query { [GraphQLType(typeof(LocalTimeType))] @@ -325,41 +360,113 @@ public class Bar public TimeOnly GetTime() => TimeOnly.MaxValue; } - public static TheoryData ValidLocalTimeScalarStrings() + public static TheoryData ValidInput() { - return new TheoryData + return new TheoryData { // https://scalars.graphql.org/chillicream/local-time.html#sec-Input-spec.Examples (Valid input values) { + DateTimeOptions.DefaultInputPrecision, "09:00:00", new TimeOnly(9, 0, 0) }, { - "07:30:00.500", - new TimeOnly(7, 30, 0, 500) + DateTimeOptions.DefaultInputPrecision, + "07:30:00.1234567", + new TimeOnly(7, 30, 0, 123, 456).Add(TimeSpan.FromTicks(7)) } }; } - public static TheoryData InvalidLocalTimeScalarStrings() + public static TheoryData InvalidInput() { - return - [ + return new TheoryData + { // https://scalars.graphql.org/chillicream/local-time.html#sec-Input-spec.Examples (Invalid input values) // Contains time zone indicator Z. - "15:30:00Z", + { DateTimeOptions.DefaultInputPrecision, "15:30:00Z" }, // Contains time zone offset. - "15:30:00+05:30", + { DateTimeOptions.DefaultInputPrecision, "15:30:00+05:30" }, // Contains date component. - "2023-12-24T15:30:00", + { DateTimeOptions.DefaultInputPrecision, "2023-12-24T15:30:00" }, // Missing seconds component. - "15:30", + { DateTimeOptions.DefaultInputPrecision, "15:30" }, // Invalid hour (24). - "24:00:00", + { DateTimeOptions.DefaultInputPrecision, "24:00:00" }, // Invalid minute (60). - "15:60:00", - // More than 9 fractional second digits. - "15:30:00.1234567890" - ]; + { DateTimeOptions.DefaultInputPrecision, "15:60:00" }, + // More than 7 fractional second digits. + { DateTimeOptions.DefaultInputPrecision, "15:30:00.12345678" }, + // Additional cases. + // More than 6 fractional second digits with precision set to 6. + { 6, "15:30:00.1234567" }, + // More than 5 fractional second digits with precision set to 5. + { 5, "15:30:00.123456" }, + // More than 4 fractional second digits with precision set to 4. + { 4, "15:30:00.12345" }, + // More than 3 fractional second digits with precision set to 3. + { 3, "15:30:00.1234" }, + // More than 2 fractional second digits with precision set to 2. + { 2, "15:30:00.123" }, + // More than 1 fractional second digit with precision set to 1. + { 1, "15:30:00.12" }, + // Fractional second digits with precision set to 0. + { 0, "15:30:00.1" } + }; + } + + public static TheoryData ValidOutput() + { + return new TheoryData + { + // Up to 7 fractional second digits with default precision. + { + DateTimeOptions.DefaultOutputPrecision, + new TimeOnly(15, 30, 0, 123, 456).Add(TimeSpan.FromTicks(7)), + "15:30:00.1234567" + }, + // Up to 6 fractional second digits with precision set to 6. + { + 6, + new TimeOnly(15, 30, 0, 123, 456).Add(TimeSpan.FromTicks(7)), + "15:30:00.123456" + }, + // Up to 5 fractional second digits with precision set to 5. + { + 5, + new TimeOnly(15, 30, 0, 123, 456).Add(TimeSpan.FromTicks(7)), + "15:30:00.12345" + }, + // Up to 4 fractional second digits with precision set to 4. + { + 4, + new TimeOnly(15, 30, 0, 123, 456).Add(TimeSpan.FromTicks(7)), + "15:30:00.1234" + }, + // Up to 3 fractional second digits with precision set to 3. + { + 3, + new TimeOnly(15, 30, 0, 123, 456).Add(TimeSpan.FromTicks(7)), + "15:30:00.123" + }, + // Up to 2 fractional second digits with precision set to 2. + { + 2, + new TimeOnly(15, 30, 0, 123, 456).Add(TimeSpan.FromTicks(7)), + "15:30:00.12" + }, + // Up to 1 fractional second digit with precision set to 1. + { + 1, + new TimeOnly(15, 30, 0, 123, 456).Add(TimeSpan.FromTicks(7)), + "15:30:00.1" + }, + // No fractional second digits with precision set to 0. + { + 0, + new TimeOnly(15, 30, 0, 123, 456).Add(TimeSpan.FromTicks(7)), + "15:30:00" + } + }; } }