diff --git a/src/Core/Azure.DataApiBuilder.Core.csproj b/src/Core/Azure.DataApiBuilder.Core.csproj index 3850c5f587..a4ac48aeb5 100644 --- a/src/Core/Azure.DataApiBuilder.Core.csproj +++ b/src/Core/Azure.DataApiBuilder.Core.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Core/Parsers/EdmModelBuilder.cs b/src/Core/Parsers/EdmModelBuilder.cs index 9babe11280..d895c8391b 100644 --- a/src/Core/Parsers/EdmModelBuilder.cs +++ b/src/Core/Parsers/EdmModelBuilder.cs @@ -67,30 +67,9 @@ SourceDefinition sourceDefinition // each column represents a property of the current entity we are adding foreach (string column in sourceDefinition.Columns.Keys) { - // need to convert our column system type to an Edm type Type columnSystemType = sourceDefinition.Columns[column].SystemType; - EdmPrimitiveTypeKind type = EdmPrimitiveTypeKind.None; - if (columnSystemType.IsArray) - { - columnSystemType = columnSystemType.GetElementType()!; - } - - type = columnSystemType.Name switch - { - "String" => EdmPrimitiveTypeKind.String, - "Guid" => EdmPrimitiveTypeKind.Guid, - "Byte" => EdmPrimitiveTypeKind.Byte, - "Int16" => EdmPrimitiveTypeKind.Int16, - "Int32" => EdmPrimitiveTypeKind.Int32, - "Int64" => EdmPrimitiveTypeKind.Int64, - "Single" => EdmPrimitiveTypeKind.Single, - "Double" => EdmPrimitiveTypeKind.Double, - "Decimal" => EdmPrimitiveTypeKind.Decimal, - "Boolean" => EdmPrimitiveTypeKind.Boolean, - "DateTime" or "DateTimeOffset" => EdmPrimitiveTypeKind.DateTimeOffset, - "Date" => EdmPrimitiveTypeKind.Date, - _ => throw new ArgumentException($"Column type {columnSystemType.Name} not yet supported."), - }; + // need to convert our column system type to an Edm type + EdmPrimitiveTypeKind type = TypeHelper.GetEdmPrimitiveTypeFromSystemType(columnSystemType); // The mapped (aliased) field name defined in the runtime config is used to create a representative // OData StructuralProperty. The created property is then added to the EdmEntityType. diff --git a/src/Core/Parsers/ODataASTVisitor.cs b/src/Core/Parsers/ODataASTVisitor.cs index 829602f677..901de39935 100644 --- a/src/Core/Parsers/ODataASTVisitor.cs +++ b/src/Core/Parsers/ODataASTVisitor.cs @@ -143,6 +143,8 @@ private static object GetParamWithSystemType(string param, IEdmTypeReference edm return DateTimeOffset.Parse(param); case EdmPrimitiveTypeKind.String: return param; + case EdmPrimitiveTypeKind.TimeOfDay: + return TimeOnly.Parse(param); default: // should never happen due to the config being validated for correct types throw new NotSupportedException($"{edmType} is not supported"); diff --git a/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs index 30bcd60710..b3c89b9004 100644 --- a/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Data; +using System.Globalization; using System.Net; using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config.DatabasePrimitives; @@ -360,10 +361,12 @@ protected static object ParseParamAsSystemType(string param, Type systemType) "Double" => double.Parse(param), "Decimal" => decimal.Parse(param), "Boolean" => bool.Parse(param), - "DateTime" => DateTimeOffset.Parse(param), - "DateTimeOffset" => DateTimeOffset.Parse(param), + "DateTime" => DateTimeOffset.Parse(param, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal), + "DateTimeOffset" => DateTimeOffset.Parse(param, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal), "Date" => DateOnly.Parse(param), "Guid" => Guid.Parse(param), + "TimeOnly" => TimeOnly.Parse(param), + "TimeSpan" => TimeOnly.Parse(param), _ => throw new NotSupportedException($"{systemType.Name} is not supported") }; } diff --git a/src/Core/Services/ResolverMiddleware.cs b/src/Core/Services/ResolverMiddleware.cs index aa3c857807..d3941e7805 100644 --- a/src/Core/Services/ResolverMiddleware.cs +++ b/src/Core/Services/ResolverMiddleware.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Globalization; using System.Text.Json; using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Models; @@ -9,8 +10,10 @@ using HotChocolate.Execution; using HotChocolate.Language; using HotChocolate.Resolvers; +using HotChocolate.Types.NodaTime; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; +using NodaTime.Text; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedTypes; namespace Azure.DataApiBuilder.Core.Services @@ -196,8 +199,9 @@ private static object PreParseLeaf(IMiddlewareContext context, string leafJson) { ByteType => byte.Parse(leafJson), SingleType => Single.Parse(leafJson), - DateTimeType => DateTimeOffset.Parse(leafJson), + DateTimeType => DateTimeOffset.Parse(leafJson, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal), ByteArrayType => Convert.FromBase64String(leafJson), + LocalTimeType => LocalTimePattern.ExtendedIso.Parse(leafJson).Value, _ => leafJson }; } diff --git a/src/Core/Services/TypeHelper.cs b/src/Core/Services/TypeHelper.cs index 7c41909748..2bd6d448af 100644 --- a/src/Core/Services/TypeHelper.cs +++ b/src/Core/Services/TypeHelper.cs @@ -5,6 +5,7 @@ using System.Net; using Azure.DataApiBuilder.Core.Services.OpenAPI; using Azure.DataApiBuilder.Service.Exceptions; +using Microsoft.OData.Edm; namespace Azure.DataApiBuilder.Core.Services { @@ -16,6 +17,10 @@ public static class TypeHelper { /// /// Maps .NET Framework types to DbType enum + /// Not Adding a hard mapping for System.DateTime to DbType.DateTime as + /// Hotchocolate only has Hotchocolate.Types.DateTime for DbType.DateTime/DateTime2/DateTimeOffset, + /// which throws error when inserting/updating dateTime values due to type mismatch. + /// Therefore, seperate logic exists for proper mapping conversion in BaseSqlQueryStructure. /// private static Dictionary _systemTypeToDbTypeMap = new() { @@ -34,21 +39,10 @@ public static class TypeHelper [typeof(string)] = DbType.String, [typeof(char)] = DbType.StringFixedLength, [typeof(Guid)] = DbType.Guid, + [typeof(DateTimeOffset)] = DbType.DateTimeOffset, [typeof(byte[])] = DbType.Binary, - [typeof(byte?)] = DbType.Byte, - [typeof(sbyte?)] = DbType.SByte, - [typeof(short?)] = DbType.Int16, - [typeof(ushort?)] = DbType.UInt16, - [typeof(int?)] = DbType.Int32, - [typeof(uint?)] = DbType.UInt32, - [typeof(long?)] = DbType.Int64, - [typeof(ulong?)] = DbType.UInt64, - [typeof(float?)] = DbType.Single, - [typeof(double?)] = DbType.Double, - [typeof(decimal?)] = DbType.Decimal, - [typeof(bool?)] = DbType.Boolean, - [typeof(char?)] = DbType.StringFixedLength, - [typeof(Guid?)] = DbType.Guid, + [typeof(TimeOnly)] = DbType.Time, + [typeof(TimeSpan)] = DbType.Time, [typeof(object)] = DbType.Object }; @@ -77,6 +71,7 @@ public static class TypeHelper [typeof(Guid)] = JsonDataType.String, [typeof(byte[])] = JsonDataType.String, [typeof(TimeSpan)] = JsonDataType.String, + [typeof(TimeOnly)] = JsonDataType.String, [typeof(object)] = JsonDataType.Object, [typeof(DateTime)] = JsonDataType.String, [typeof(DateTimeOffset)] = JsonDataType.String @@ -108,7 +103,7 @@ public static class TypeHelper [SqlDbType.SmallInt] = typeof(short), [SqlDbType.SmallMoney] = typeof(decimal), [SqlDbType.Text] = typeof(string), - [SqlDbType.Time] = typeof(TimeSpan), + [SqlDbType.Time] = typeof(TimeOnly), [SqlDbType.Timestamp] = typeof(byte[]), [SqlDbType.TinyInt] = typeof(byte), [SqlDbType.UniqueIdentifier] = typeof(Guid), @@ -116,6 +111,43 @@ public static class TypeHelper [SqlDbType.VarChar] = typeof(string) }; + /// + /// Given the system type, returns the corresponding primitive type kind. + /// + /// Type of the column. + /// EdmPrimitiveTypeKind + /// Throws when the column + public static EdmPrimitiveTypeKind GetEdmPrimitiveTypeFromSystemType(Type columnSystemType) + { + if (columnSystemType.IsArray) + { + columnSystemType = columnSystemType.GetElementType()!; + } + + EdmPrimitiveTypeKind type = columnSystemType.Name switch + { + "String" => EdmPrimitiveTypeKind.String, + "Guid" => EdmPrimitiveTypeKind.Guid, + "Byte" => EdmPrimitiveTypeKind.Byte, + "Int16" => EdmPrimitiveTypeKind.Int16, + "Int32" => EdmPrimitiveTypeKind.Int32, + "Int64" => EdmPrimitiveTypeKind.Int64, + "Single" => EdmPrimitiveTypeKind.Single, + "Double" => EdmPrimitiveTypeKind.Double, + "Decimal" => EdmPrimitiveTypeKind.Decimal, + "Boolean" => EdmPrimitiveTypeKind.Boolean, + "DateTime" => EdmPrimitiveTypeKind.DateTimeOffset, + "DateTimeOffset" => EdmPrimitiveTypeKind.DateTimeOffset, + "Date" => EdmPrimitiveTypeKind.Date, + "TimeOnly" => EdmPrimitiveTypeKind.TimeOfDay, + "TimeSpan" => EdmPrimitiveTypeKind.TimeOfDay, + _ => throw new ArgumentException($"Column type" + + $" {columnSystemType.Name} not yet supported.") + }; + + return type; + } + /// /// Converts the .NET Framework (System/CLR) type to JsonDataType. /// Primitive data types in the OpenAPI standard (OAS) are based on the types supported @@ -151,6 +183,15 @@ public static JsonDataType GetJsonDataTypeFromSystemType(Type type) /// DbType for the given system type. Null when no mapping exists. public static DbType? GetDbTypeFromSystemType(Type systemType) { + // Get the underlying type argument if the 'systemType' argument is a nullable type. + Type? nullableUnderlyingType = Nullable.GetUnderlyingType(systemType); + + // Will not be null when the input argument 'systemType' is a closed generic nullable type. + if (nullableUnderlyingType is not null) + { + systemType = nullableUnderlyingType; + } + if (!_systemTypeToDbTypeMap.TryGetValue(systemType, out DbType dbType)) { return null; diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index dde5860283..ec66e68506 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -9,6 +9,7 @@ + @@ -19,7 +20,7 @@ - + diff --git a/src/Service.GraphQLBuilder/Azure.DataApiBuilder.Service.GraphQLBuilder.csproj b/src/Service.GraphQLBuilder/Azure.DataApiBuilder.Service.GraphQLBuilder.csproj index 71530f1eba..80fc4b94eb 100644 --- a/src/Service.GraphQLBuilder/Azure.DataApiBuilder.Service.GraphQLBuilder.csproj +++ b/src/Service.GraphQLBuilder/Azure.DataApiBuilder.Service.GraphQLBuilder.csproj @@ -20,6 +20,7 @@ + diff --git a/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs b/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs index 2ce9d6b95e..be27c13555 100644 --- a/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs +++ b/src/Service.GraphQLBuilder/GraphQLTypes/DefaultValueType.cs @@ -3,6 +3,7 @@ using Azure.DataApiBuilder.Service.GraphQLBuilder.CustomScalars; using HotChocolate.Types; +using HotChocolate.Types.NodaTime; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedTypes; namespace Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes @@ -24,6 +25,7 @@ protected override void Configure(IInputObjectTypeDescriptor descriptor) descriptor.Field(DECIMAL_TYPE).Type(); descriptor.Field(DATETIME_TYPE).Type(); descriptor.Field(BYTEARRAY_TYPE).Type(); + descriptor.Field(LOCALTIME_TYPE).Type(); } } } diff --git a/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs b/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs index 52c1b25ad4..4470f8e2d1 100644 --- a/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs +++ b/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs @@ -18,8 +18,10 @@ public static class SupportedTypes public const string STRING_TYPE = "String"; public const string BOOLEAN_TYPE = "Boolean"; public const string DATETIME_TYPE = "DateTime"; - public const string DATETIME_NONUTC_TYPE = "DateTimeNonUTC"; + public const string DATETIMEOFFSET_TYPE = "DateTimeOffset"; public const string BYTEARRAY_TYPE = "ByteArray"; public const string GUID_TYPE = "Guid"; + public const string LOCALTIME_TYPE = "LocalTime"; + public const string TIME_TYPE = "Time"; } } diff --git a/src/Service.GraphQLBuilder/GraphQLUtils.cs b/src/Service.GraphQLBuilder/GraphQLUtils.cs index 3b7d10bb1b..972ec5f5b4 100644 --- a/src/Service.GraphQLBuilder/GraphQLUtils.cs +++ b/src/Service.GraphQLBuilder/GraphQLUtils.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Net; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; @@ -11,6 +12,8 @@ using Azure.DataApiBuilder.Service.GraphQLBuilder.Sql; using HotChocolate.Language; using HotChocolate.Types; +using HotChocolate.Types.NodaTime; +using NodaTime.Text; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedTypes; namespace Azure.DataApiBuilder.Service.GraphQLBuilder @@ -52,7 +55,8 @@ public static bool IsBuiltInType(ITypeNode typeNode) STRING_TYPE, BOOLEAN_TYPE, DATETIME_TYPE, - BYTEARRAY_TYPE + BYTEARRAY_TYPE, + LOCALTIME_TYPE }; string name = typeNode.NamedType().Name.Value; return inBuiltTypes.Contains(name); @@ -239,8 +243,10 @@ public static Tuple ConvertValueToGraphQLType(string default SINGLE_TYPE => new(SINGLE_TYPE, new SingleType().ParseValue(float.Parse(defaultValueFromConfig))), FLOAT_TYPE => new(FLOAT_TYPE, new FloatValueNode(double.Parse(defaultValueFromConfig))), DECIMAL_TYPE => new(DECIMAL_TYPE, new FloatValueNode(decimal.Parse(defaultValueFromConfig))), - DATETIME_TYPE => new(DATETIME_TYPE, new DateTimeType().ParseResult(DateTime.Parse(defaultValueFromConfig))), + DATETIME_TYPE => new(DATETIME_TYPE, new DateTimeType().ParseResult( + DateTime.Parse(defaultValueFromConfig, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal))), BYTEARRAY_TYPE => new(BYTEARRAY_TYPE, new ByteArrayType().ParseValue(Convert.FromBase64String(defaultValueFromConfig))), + LOCALTIME_TYPE => new(LOCALTIME_TYPE, new LocalTimeType().ParseResult(LocalTimePattern.ExtendedIso.Parse(defaultValueFromConfig).Value)), _ => throw new NotSupportedException(message: $"The {defaultValueFromConfig} parameter's value type [{paramValueType}] is not supported.") }; diff --git a/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs b/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs index 69ead89827..365aca7f74 100644 --- a/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs +++ b/src/Service.GraphQLBuilder/Queries/StandardQueryInputs.cs @@ -4,6 +4,7 @@ using Azure.DataApiBuilder.Service.GraphQLBuilder.CustomScalars; using HotChocolate.Language; using HotChocolate.Types; +using HotChocolate.Types.NodaTime; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedTypes; namespace Azure.DataApiBuilder.Service.GraphQLBuilder.Queries @@ -201,6 +202,23 @@ public static InputObjectTypeDefinitionNode ByteArrayInputType() => } ); + public static InputObjectTypeDefinitionNode LocalTimeInputType() => + new( + location: null, + new NameNode("LocalTimeFilterInput"), + new StringValueNode("Input type for adding LocalTime filters"), + new List(), + new List { + new InputValueDefinitionNode(null, new NameNode("eq"), new StringValueNode("Equals"), new LocalTimeType().ToTypeNode(), null, new List()), + new InputValueDefinitionNode(null, new NameNode("gt"), new StringValueNode("Greater Than"), new LocalTimeType().ToTypeNode(), null, new List()), + new InputValueDefinitionNode(null, new NameNode("gte"), new StringValueNode("Greater Than or Equal To"), new LocalTimeType().ToTypeNode(), null, new List()), + new InputValueDefinitionNode(null, new NameNode("lt"), new StringValueNode("Less Than"), new LocalTimeType().ToTypeNode(), null, new List()), + new InputValueDefinitionNode(null, new NameNode("lte"), new StringValueNode("Less Than or Equal To"), new LocalTimeType().ToTypeNode(), null, new List()), + new InputValueDefinitionNode(null, new NameNode("neq"), new StringValueNode("Not Equals"), new LocalTimeType().ToTypeNode(), null, new List()), + new InputValueDefinitionNode(null, new NameNode("isNull"), new StringValueNode("is null test"), new BooleanType().ToTypeNode(), null, new List()) + } + ); + public static Dictionary InputTypes = new() { { "ID", IdInputType() }, @@ -214,7 +232,8 @@ public static InputObjectTypeDefinitionNode ByteArrayInputType() => { BOOLEAN_TYPE, BooleanInputType() }, { STRING_TYPE, StringInputType() }, { DATETIME_TYPE, DateTimeInputType() }, - { BYTEARRAY_TYPE, ByteArrayInputType() } + { BYTEARRAY_TYPE, ByteArrayInputType() }, + { LOCALTIME_TYPE, LocalTimeInputType() }, }; /// diff --git a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs index 703dc2c975..d51d397a71 100644 --- a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs +++ b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs @@ -12,6 +12,7 @@ using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries; using HotChocolate.Language; using HotChocolate.Types; +using HotChocolate.Types.NodaTime; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLStoredProcedureBuilder; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedTypes; @@ -231,6 +232,8 @@ public static string GetGraphQLTypeFromSystemType(Type type) "DateTime" => DATETIME_TYPE, "DateTimeOffset" => DATETIME_TYPE, "Byte[]" => BYTEARRAY_TYPE, + "TimeOnly" => LOCALTIME_TYPE, + "TimeSpan" => LOCALTIME_TYPE, _ => throw new DataApiBuilderException( message: $"Column type {type} not handled by case. Please add a case resolving {type} to the appropriate GraphQL type", statusCode: HttpStatusCode.InternalServerError, @@ -264,6 +267,7 @@ public static IValueNode CreateValueNodeFromDbObjectMetadata(object metadataValu DateTimeOffset value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ParseValue(value))), DateTime value => new ObjectValueNode(new ObjectFieldNode(DATETIME_TYPE, new DateTimeType().ParseResult(value))), byte[] value => new ObjectValueNode(new ObjectFieldNode(BYTEARRAY_TYPE, new ByteArrayType().ParseValue(value))), + TimeOnly value => new ObjectValueNode(new ObjectFieldNode(LOCALTIME_TYPE, new LocalTimeType().ParseResult(value))), _ => throw new DataApiBuilderException( message: $"The type {metadataValue.GetType()} is not supported as a GraphQL default value", statusCode: HttpStatusCode.InternalServerError, diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql index 38a4f9c329..4768723a12 100644 --- a/src/Service.Tests/DatabaseSchema-MsSql.sql +++ b/src/Service.Tests/DatabaseSchema-MsSql.sql @@ -66,7 +66,7 @@ CREATE TABLE books( CREATE TABLE players( id int IDENTITY(5001, 1) PRIMARY KEY, - name varchar(max) NOT NULL, + [name] varchar(max) NOT NULL, current_club_id int NOT NULL, new_club_id int NOT NULL ); @@ -162,6 +162,7 @@ CREATE TABLE type_table( datetime2_types datetime2, datetimeoffset_types datetimeoffset, smalldatetime_types smalldatetime, + time_types time, bytearray_types varbinary(max), guid_types uniqueidentifier DEFAULT newid() ); @@ -377,7 +378,7 @@ VALUES (1, 'Awesome book', 1234), SET IDENTITY_INSERT books OFF SET IDENTITY_INSERT players ON -INSERT INTO players(id, name, current_club_id, new_club_id) +INSERT INTO players(id, [name], current_club_id, new_club_id) VALUES (1, 'Cristiano Ronaldo', 1113, 1111), (2, 'Leonel Messi', 1112, 1113); SET IDENTITY_INSERT players OFF @@ -398,22 +399,22 @@ byte_types, short_types, int_types, long_types, string_types, single_types, float_types, decimal_types, boolean_types, -date_types, datetime_types, datetime2_types, datetimeoffset_types, smalldatetime_types, +date_types, datetime_types, datetime2_types, datetimeoffset_types, smalldatetime_types, time_types, bytearray_types) VALUES (1, 1, 1, 1, 1, '', 0.33, 0.33, 0.333333, 1, - '1999-01-08', '1999-01-08 10:23:54', '1999-01-08 10:23:54.9999999', '1999-01-08 10:23:54.9999999-14:00', '1999-01-08 10:23:54', + '1999-01-08', '1999-01-08 10:23:54', '1999-01-08 10:23:54.9999999', '1999-01-08 10:23:54.9999999-14:00', '1999-01-08 10:23:54', '10:23:54.9999999', 0xABCDEF0123), (2, 0, -1, -1, -1, 'lksa;jdflasdf;alsdflksdfkldj', -9.2, -9.2, -9.292929, 0, - '1999-01-08', '1999-01-08 10:23:00', '1999-01-08 10:23:00.9999999', '1999-01-08 10:23:00.9999999+13:00', '1999-01-08 10:23:00', + '1999-01-08', '1999-01-08 10:23:00', '1999-01-08 10:23:00.9999999', '1999-01-08 10:23:00.9999999+13:00', '1999-01-08 10:23:00', '10:23:00.9999999', 0x98AB7511AABB1234), (3, 0, -32768, -2147483648, -9223372036854775808, 'null', -3.4E38, -1.7E308, 2.929292E-19, 1, - '0001-01-01', '1753-01-01 00:00:00.000', '0001-01-01 00:00:00.0000000', '0001-01-01 00:00:00.0000000+0:00', '1900-01-01 00:00:00', + '0001-01-01', '1753-01-01 00:00:00.000', '0001-01-01 00:00:00.0000000', '0001-01-01 00:00:00.0000000+0:00', '1900-01-01 00:00:00', '00:00:00.0000000', 0x00000000), (4, 255, 32767, 2147483647, 9223372036854775807, 'null', 3.4E38, 1.7E308, 2.929292E-14, 1, - '9998-12-31', '9998-12-31 23:59:59', '9998-12-31 23:59:59.9999999', '9998-12-31 23:59:59.9999999+00:00', '2079-06-06', + '9999-12-31', '9999-12-31 23:59:59', '9999-12-31 23:59:59.9999999', '9999-12-31 23:59:59.9999999+14:00', '2079-06-06', '23:59:59.9999999', 0xFFFFFFFF), - (5, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); + (5, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); SET IDENTITY_INSERT type_table OFF SET IDENTITY_INSERT sales ON diff --git a/src/Service.Tests/DatabaseSchema-MySql.sql b/src/Service.Tests/DatabaseSchema-MySql.sql index 034041d70a..ecc27255c1 100644 --- a/src/Service.Tests/DatabaseSchema-MySql.sql +++ b/src/Service.Tests/DatabaseSchema-MySql.sql @@ -334,7 +334,7 @@ INSERT INTO type_table(id, byte_types, short_types, int_types, long_types, strin (1, 1, 1, 1, 1, '', 0.33, 0.33, 0.333333, true, '1999-01-08 10:23:54', 0xABCDEF0123), (2, 0, -1, -1, -1, 'lksa;jdflasdf;alsdflksdfkldj', -9.2, -9.2, -9.292929, false, '1999-01-08 10:23:00', 0x98AB7511AABB1234), (3, 0, -32768, -2147483648, -9223372036854775808, '', -3.4E38, -1.7E308, 2.929292E-19, true, '1753-01-01 00:00:00.000', 0x00000000), - (4, 255, 32767, 2147483647, 9223372036854775807, 'null', 3.4E38, 1.7E308, 2.929292E-14, true, '9998-12-31 23:59:59', 0xFFFFFFFF), + (4, 255, 32767, 2147483647, 9223372036854775807, 'null', 3.4E38, 1.7E308, 2.929292E-14, true, '9999-12-31 23:59:59', 0xFFFFFFFF), (5, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); INSERT INTO trees(treeId, species, region, height) VALUES (1, 'Tsuga terophylla', 'Pacific Northwest', '30m'), (2, 'Pseudotsuga menziesii', 'Pacific Northwest', '40m'); INSERT INTO fungi(speciesid, region) VALUES (1, 'northeast'), (2, 'southwest'); diff --git a/src/Service.Tests/DatabaseSchema-PostgreSql.sql b/src/Service.Tests/DatabaseSchema-PostgreSql.sql index 091c5958b6..2a01af8c79 100644 --- a/src/Service.Tests/DatabaseSchema-PostgreSql.sql +++ b/src/Service.Tests/DatabaseSchema-PostgreSql.sql @@ -326,7 +326,7 @@ INSERT INTO type_table(id, short_types, int_types, long_types, string_types, sin (1, 1, 1, 1, '', 0.33, 0.33, 0.333333, true, '1999-01-08 10:23:54', '\xABCDEF0123'), (2, -1, -1, -1, 'lksa;jdflasdf;alsdflksdfkldj', -9.2, -9.2, -9.292929, false, '19990108 10:23:00', '\x98AB7511AABB1234'), (3, -32768, -2147483648, -9223372036854775808, '', -3.4E38, -1.7E308, 2.929292E-19, true, '1753-01-01 00:00:00.000', '\x00000000'), - (4, 32767, 2147483647, 9223372036854775807, 'null', 3.4E38, 1.7E308, 2.929292E-14, true, '9998-12-31 23:59:59.997', '\xFFFFFFFF'), + (4, 32767, 2147483647, 9223372036854775807, 'null', 3.4E38, 1.7E308, 2.929292E-14, true, '9999-12-31 23:59:59.997', '\xFFFFFFFF'), (5, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL); INSERT INTO trees("treeId", species, region, height) VALUES (1, 'Tsuga terophylla', 'Pacific Northwest', '30m'), (2, 'Pseudotsuga menziesii', 'Pacific Northwest', '40m'); INSERT INTO fungi(speciesid, region) VALUES (1, 'northeast'), (2, 'southwest'); diff --git a/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs index fb51f0fd5e..fb0f8180ad 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/SchemaConverterTests.cs @@ -230,6 +230,7 @@ public void MultipleColumnsAllMapped() [DataRow(typeof(DateTimeOffset), DATETIME_TYPE)] [DataRow(typeof(byte[]), BYTEARRAY_TYPE)] [DataRow(typeof(Guid), STRING_TYPE)] + [DataRow(typeof(TimeOnly), LOCALTIME_TYPE)] public void SystemTypeMapsToCorrectGraphQLType(Type systemType, string graphQLType) { SourceDefinition table = new(); diff --git a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs index b758fdf829..be33d35d58 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs @@ -54,6 +54,7 @@ public class StoredProcedureBuilderTests [DataRow(typeof(DateTime), DATETIME_TYPE, "12/31/2030 12:00:00 AM", false, DisplayName = "DateTime")] [DataRow(typeof(DateTime), DATETIME_TYPE, "12/31/2030 12000 AM", true, DisplayName = "DateTime")] [DataRow(typeof(DateTimeOffset), DATETIME_TYPE, "11/19/2012 10:57:11 AM -08:00", false, DisplayName = "DateTimeOffset")] + [DataRow(typeof(TimeOnly), LOCALTIME_TYPE, "10:57:11.0000", false, DisplayName = "LocalTime")] [DataRow(typeof(byte[]), BYTEARRAY_TYPE, "AgQGCAoMDhASFA==", false, DisplayName = "Byte[]")] public void StoredProcedure_ParameterValueTypeResolution( Type systemType, diff --git a/src/Service.Tests/OpenApiDocumentor/CLRtoJsonValueTypeUnitTests.cs b/src/Service.Tests/OpenApiDocumentor/CLRtoJsonValueTypeUnitTests.cs index 7d497ed8ca..b5964a04d2 100644 --- a/src/Service.Tests/OpenApiDocumentor/CLRtoJsonValueTypeUnitTests.cs +++ b/src/Service.Tests/OpenApiDocumentor/CLRtoJsonValueTypeUnitTests.cs @@ -75,8 +75,8 @@ private static IEnumerable GetTestData_SupportedSystemTypesMapToJsonVa /// /// Validates the behavior of TypeHelper.GetJsonDataTypeFromSystemType(Type type) by /// ensuring that a nullable value type like int? is resolved to its underlying type int. - /// Consequently, the lookup in the _systemTypeToJsonDataTypeMap dictionary succeeds without - /// requiring nullable value type be defined as keys. + /// Consequently, the lookup in the _systemTypeToJsonDataTypeMap and _systemTypeToDbTypeMap + /// dictionary succeeds without requiring nullable value types be defined as keys. /// Nullable value types are represented in runtime as Nullable. Whereas /// nullable reference types do no have a standalone runtime representation. /// See csharplang discussion on why typeof(string?) (nullable reference type) is not valid, @@ -99,12 +99,12 @@ private static IEnumerable GetTestData_SupportedSystemTypesMapToJsonVa [DataRow(typeof(bool?))] [DataRow(typeof(char?))] [DataRow(typeof(Guid?))] + [DataRow(typeof(TimeOnly?))] [DataRow(typeof(TimeSpan?))] - [DataRow(typeof(DateTime?))] - [DataRow(typeof(DateTimeOffset?))] [DataTestMethod] public void ResolveUnderlyingTypeForNullableValueType(Type nullableType) { Assert.AreNotEqual(notExpected: JsonDataType.Undefined, actual: TypeHelper.GetJsonDataTypeFromSystemType(nullableType)); + Assert.IsNotNull(TypeHelper.GetDbTypeFromSystemType(nullableType)); } } diff --git a/src/Service.Tests/SqlTests/GraphQLPaginationTests/MsSqlGraphQLPaginationTests.cs b/src/Service.Tests/SqlTests/GraphQLPaginationTests/MsSqlGraphQLPaginationTests.cs index 6e1353d640..1a958ce783 100644 --- a/src/Service.Tests/SqlTests/GraphQLPaginationTests/MsSqlGraphQLPaginationTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLPaginationTests/MsSqlGraphQLPaginationTests.cs @@ -44,16 +44,16 @@ public static async Task SetupAsync(TestContext context) [DataRow("boolean_types", "false", "true", 2, 4, DisplayName = "Test after token for boolean values.")] [DataRow("date_types", "\"0001-01-01\"", - "\"9998-12-31\"", 3, 4, + "\"9999-12-31\"", 3, 4, DisplayName = "Test after token for date values.")] [DataRow("datetime_types", "\"1753-01-01T00:00:00.000\"", - "\"9998-12-31T23:59:59\"", 3, 4, + "\"9999-12-31T23:59:59\"", 3, 4, DisplayName = "Test after token for datetime values.")] [DataRow("datetime2_types", "\"0001-01-01 00:00:00.0000000\"", - "\"9998-12-31T23:59:59.9999999\"", 3, 4, + "\"9999-12-31T23:59:59.9999999\"", 3, 4, DisplayName = "Test after token for datetime2 values.")] [DataRow("datetimeoffset_types", "\"0001-01-01 00:00:00.0000000+0:00\"", - "\"9998-12-31T23:59:59.9999999+00:00\"", 3, 4, + "\"9999-12-31T23:59:59.9999999+14:00\"", 3, 4, DisplayName = "Test after token for datetimeoffset values.")] [DataRow("smalldatetime_types", "\"1900-01-01 00:00:00\"", "\"2079-06-06T00:00:00\"", 3, 4, diff --git a/src/Service.Tests/SqlTests/GraphQLPaginationTests/MySqlGraphQLPaginationTests.cs b/src/Service.Tests/SqlTests/GraphQLPaginationTests/MySqlGraphQLPaginationTests.cs index d6aa33b291..3095265b60 100644 --- a/src/Service.Tests/SqlTests/GraphQLPaginationTests/MySqlGraphQLPaginationTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLPaginationTests/MySqlGraphQLPaginationTests.cs @@ -44,7 +44,7 @@ public static async Task SetupAsync(TestContext context) [DataRow("boolean_types", "false", "true", 2, 4, DisplayName = "Test after token for boolean values.")] [DataRow("datetime_types", "\"1753-01-01T00:00:00.000\"", - "\"9998-12-31 23:59:59.000000\"", 3, 4, + "\"9999-12-31 23:59:59.000000\"", 3, 4, DisplayName = "Test after token for datetime values.")] [DataRow("bytearray_types", "\"AAAAAA==\"", "\"/////w==\"", 3, 4, DisplayName = "Test after token for bytearray values.")] diff --git a/src/Service.Tests/SqlTests/GraphQLPaginationTests/PostgreSqlGraphQLPaginationTests.cs b/src/Service.Tests/SqlTests/GraphQLPaginationTests/PostgreSqlGraphQLPaginationTests.cs index 60bbece479..ae58ed8f5c 100644 --- a/src/Service.Tests/SqlTests/GraphQLPaginationTests/PostgreSqlGraphQLPaginationTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLPaginationTests/PostgreSqlGraphQLPaginationTests.cs @@ -43,7 +43,7 @@ public static async Task SetupAsync(TestContext context) [DataRow("boolean_types", "false", "true", 2, 4, DisplayName = "Test after token for boolean values.")] [DataRow("datetime_types", "\"1753-01-01T00:00:00.000\"", - "\"9998-12-31T23:59:59.997\"", 3, 4, + "\"9999-12-31T23:59:59.997\"", 3, 4, DisplayName = "Test after token for datetime values.")] [DataRow("bytearray_types", "\"AAAAAA==\"", "\"/////w==\"", 3, 4, DisplayName = "Test after token for bytearray values.")] diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs index 7936566f72..04e2306f3e 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Globalization; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedTypes; @@ -60,6 +61,11 @@ public abstract class GraphQLSupportedTypesTestBase : SqlTestBase [DataRow(DATETIME_TYPE, 2)] [DataRow(DATETIME_TYPE, 3)] [DataRow(DATETIME_TYPE, 4)] + [DataRow(TIME_TYPE, 1)] + [DataRow(TIME_TYPE, 2)] + [DataRow(TIME_TYPE, 3)] + [DataRow(TIME_TYPE, 4)] + [DataRow(TIME_TYPE, 5)] [DataRow(BYTEARRAY_TYPE, 1)] [DataRow(BYTEARRAY_TYPE, 2)] [DataRow(BYTEARRAY_TYPE, 3)] @@ -161,30 +167,32 @@ public async Task QueryTypeColumnFilterAndOrderBy(string type, string filterOper /// /// Separate test case for DateTime to allow overwrite for postgreSql. - /// Year 9998 used in test and data within test tables to avoid out of - /// date range error within GQL. + /// The method constructs a GraphQL query to filter and order the datetime column based on the given parameters. + /// The test checks various datetime data types such as datetime, datetimeoffset, and time. /// [DataTestMethod] [DataRow(DATETIME_TYPE, "gt", "\'1999-01-08\'", "\"1999-01-08\"", " > ")] [DataRow(DATETIME_TYPE, "gte", "\'1999-01-08\'", "\"1999-01-08\"", " >= ")] - [DataRow(DATETIME_TYPE, "lt", "\'0001-01-01\'", "\"0001-01-01\"", " < ")] - [DataRow(DATETIME_TYPE, "lte", "\'0001-01-01\'", "\"0001-01-01\"", " <= ")] - [DataRow(DATETIME_TYPE, "neq", "\'0001-01-01\'", "\"0001-01-01\"", "!=")] - [DataRow(DATETIME_TYPE, "eq", "\'0001-01-01\'", "\"0001-01-01T01:01:01\"", "=")] + [DataRow(DATETIME_TYPE, "lt", "\'1999-01-08\'", "\"1999-01-08\"", " < ")] + [DataRow(DATETIME_TYPE, "lte", "\'1999-01-08\'", "\"1999-01-08\"", " <= ")] + [DataRow(DATETIME_TYPE, "neq", "\'1999-01-08\'", "\"1999-01-08\"", "!=")] + [DataRow(DATETIME_TYPE, "eq", "\'1999-01-08\'", "\"1999-01-08T01:01:01\"", "=")] [DataRow(DATETIME_TYPE, "gt", "\'1999-01-08 10:23:00\'", "\"1999-01-08 10:23:00\"", " > ")] [DataRow(DATETIME_TYPE, "gte", "\'1999-01-08 10:23:00\'", "\"1999-01-08 10:23:00\"", " >= ")] - [DataRow(DATETIME_TYPE, "lt", "\'9998-12-31 23:59:59\'", "\"9998-12-31 23:59:59\"", " < ")] - [DataRow(DATETIME_TYPE, "lte", "\'9998-12-31 23:59:59\'", "\"9998-12-31 23:59:59\"", " <= ")] + [DataRow(DATETIME_TYPE, "lt", "\'9999-12-31 23:59:59\'", "\"9999-12-31 23:59:59\"", " < ")] + [DataRow(DATETIME_TYPE, "lte", "\'9999-12-31 23:59:59\'", "\"9999-12-31 23:59:59\"", " <= ")] [DataRow(DATETIME_TYPE, "neq", "\'1999-01-08 10:23:00\'", "\"1999-01-08 10:23:00\"", "!=")] [DataRow(DATETIME_TYPE, "eq", "\'1999-01-08 10:23:00\'", "\"1999-01-08 10:23:00\"", "=")] [DataRow(DATETIME_TYPE, "gt", "\'1999-01-08 10:23:00.9999999\'", "\"1999-01-08 10:23:00.9999999\"", " > ")] [DataRow(DATETIME_TYPE, "gte", "\'1999-01-08 10:23:00.9999999\'", "\"1999-01-08 10:23:00.9999999\"", " >= ")] - [DataRow(DATETIME_TYPE, "lt", "\'9998-12-31 23:59:59.9999999\'", "\"9998-12-31 23:59:59.9999999\"", " < ")] - [DataRow(DATETIME_TYPE, "lte", "\'9998-12-31 23:59:59.9999999\'", "\"9998-12-31 23:59:59.9999999\"", " <= ")] + [DataRow(DATETIME_TYPE, "lt", "\'9999-12-31 23:59:59.9999999\'", "\"9999-12-31 23:59:59.9999999\"", " < ")] + [DataRow(DATETIME_TYPE, "lte", "\'9999-12-31 23:59:59.9999999\'", "\"9999-12-31 23:59:59.9999999\"", " <= ")] [DataRow(DATETIME_TYPE, "neq", "\'1999-01-08 10:23:00.9999999\'", "\"1999-01-08 10:23:00.9999999\"", "!=")] [DataRow(DATETIME_TYPE, "eq", "\'1999-01-08 10:23:00.9999999\'", "\"1999-01-08 10:23:00.9999999\"", "=")] [DataRow(DATETIME_TYPE, "neq", "\'1999-01-08 10:23:54.9999999-14:00\'", "\"1999-01-08 10:23:54.9999999-14:00\"", "!=")] [DataRow(DATETIME_TYPE, "eq", "\'1999-01-08 10:23:54.9999999-14:00\'", "\"1999-01-08 10:23:54.9999999-14:00\"", "=")] + [DataRow(DATETIMEOFFSET_TYPE, "neq", "\'1999-01-08 10:23:54.9999999-14:00\'", "\"1999-01-08 10:23:54.9999999-14:00\"", "!=")] + [DataRow(DATETIMEOFFSET_TYPE, "eq", "\'1999-01-08 10:23:54.9999999-14:00\'", "\"1999-01-08 10:23:54.9999999-14:00\"", "=")] [DataRow(DATETIME_TYPE, "gt", "\'1999-01-08 10:22:00\'", "\"1999-01-08 10:22:00\"", " > ")] [DataRow(DATETIME_TYPE, "gte", "\'1999-01-08 10:23:54\'", "\"1999-01-08 10:23:54\"", " >= ")] [DataRow(DATETIME_TYPE, "lt", "\'2079-06-06\'", "\"2079-06-06\"", " < ")] @@ -193,9 +201,63 @@ public async Task QueryTypeColumnFilterAndOrderBy(string type, string filterOper [DataRow(DATETIME_TYPE, "eq", "\'1999-01-08 10:23:54\'", "\"1999-01-08 10:23:54\"", "=")] public async Task QueryTypeColumnFilterAndOrderByDateTime(string type, string filterOperator, string sqlValue, string gqlValue, string queryOperator) { + // In MySQL, the DATETIME data type supports a range from '1000-01-01 00:00:00.0000000' to '9999-12-31 23:59:59.0000000' + if (DatabaseEngine is TestCategory.MYSQL && sqlValue is "\'9999-12-31 23:59:59.9999999\'") + { + sqlValue = "\'9999-12-31 23:59:59.0000000\'"; + gqlValue = "\"9999-12-31 23:59:59.0000000\""; + } + await QueryTypeColumnFilterAndOrderBy(type, filterOperator, sqlValue, gqlValue, queryOperator); } + /// + /// Validates that usage of LocalTime values with comparison operators in GraphQL filters results in the expected filtered result set. + /// + [DataTestMethod] + [DataRow(TIME_TYPE, "gt", "\'00:00:00.000\'", "\"00:00:00.000\"", " > ")] + [DataRow(TIME_TYPE, "gte", "\'10:13:14.123\'", "\"10:13:14.123\"", " >= ")] + [DataRow(TIME_TYPE, "lt", "\'23:59:59.999\'", "\"23:59:59.999\"", " < ")] + [DataRow(TIME_TYPE, "lte", "\'23:59:59.999\'", "\"23:59:59.999\"", " <= ")] + [DataRow(TIME_TYPE, "neq", "\'10:23:54.9999999\'", "\"10:23:54.9999999\"", "!=")] + [DataRow(TIME_TYPE, "eq", "\'10:23:54.9999999\'", "\"10:23:54.9999999\"", "=")] + public async Task QueryTypeColumnFilterAndOrderByLocalTime(string type, string filterOperator, string sqlValue, string gqlValue, string queryOperator) + { + await QueryTypeColumnFilterAndOrderBy(type, filterOperator, sqlValue, gqlValue, queryOperator); + } + + /// + /// Validates that LocalTime values with X precision are handled correctly: precision of 7 decimal places used with eq (=) will + /// not return result with only 3 decimal places i.e. 10:23:54.999 != 10:23:54.9999999 + /// In the Database only one row exist with value 23:59:59.9999999 + /// + [DataTestMethod] + [DataRow("\"23:59:59.9999999\"", 1, DisplayName = "TimeType Precision Check with 7 decimal places")] + [DataRow("\"23:59:59.999\"", 0, DisplayName = "TimeType Precision Check with 3 decimal places")] + public async Task TestTimeTypePrecisionCheck(string gqlValue, int count) + { + if (!IsSupportedType(TIME_TYPE)) + { + Assert.Inconclusive("Type not supported"); + } + + string graphQLQueryName = "supportedTypes"; + string gqlQuery = @"{ + supportedTypes(first: 100 orderBy: { " + "time_types" + ": ASC } filter: { " + "time_types" + ": {" + "eq" + ": " + gqlValue + @"} }) { + items { + " + "time_types" + @" + } + } + }"; + + JsonElement gqlResponse = await ExecuteGraphQLRequestAsync(gqlQuery, graphQLQueryName, isAuthenticated: false); + Assert.AreEqual(count, gqlResponse.GetProperty("items").GetArrayLength()); + } + + /// + /// the method constructs a GraphQL query to insert the value into the database table + /// and then executes the query and compares the expected result with the actual result to verify if different types are supported. + /// [DataTestMethod] [DataRow(BYTE_TYPE, "255")] [DataRow(BYTE_TYPE, "0")] @@ -227,11 +289,17 @@ public async Task QueryTypeColumnFilterAndOrderByDateTime(string type, string fi [DataRow(BOOLEAN_TYPE, "true")] [DataRow(BOOLEAN_TYPE, "false")] [DataRow(BOOLEAN_TYPE, "null")] - [DataRow(DATETIME_NONUTC_TYPE, "\"1999-01-08 10:23:54+8:00\"")] + [DataRow(DATETIMEOFFSET_TYPE, "\"1999-01-08 10:23:54+8:00\"")] + [DataRow(DATETIMEOFFSET_TYPE, "\"1999-01-08 10:23:54.671287+8:00\"")] [DataRow(DATETIME_TYPE, "\"1999-01-08 09:20:00\"")] [DataRow(DATETIME_TYPE, "\"1999-01-08\"")] [DataRow(DATETIME_TYPE, "null")] [DataRow(BYTEARRAY_TYPE, "\"U3RyaW5neQ==\"")] + [DataRow(TIME_TYPE, "\"23:59:59.9999999\"")] + [DataRow(TIME_TYPE, "\"23:59:59\"")] + [DataRow(TIME_TYPE, "\"23:59:59.9\"")] + [DataRow(TIME_TYPE, "\"23:59\"")] + [DataRow(TIME_TYPE, "null")] [DataRow(BYTEARRAY_TYPE, "\"V2hhdGNodSBkb2luZyBkZWNvZGluZyBvdXIgdGVzdCBiYXNlNjQgc3RyaW5ncz8=\"")] [DataRow(BYTEARRAY_TYPE, "null")] public async Task InsertIntoTypeColumn(string type, string value) @@ -241,13 +309,6 @@ public async Task InsertIntoTypeColumn(string type, string value) Assert.Inconclusive("Type not supported"); } - // Datetime non utc type is a characterization of the value added to the datetime type, - // so before executing the query reset it to mean the actually underlying type. - if (DATETIME_NONUTC_TYPE.Equals(type)) - { - type = DATETIME_TYPE; - } - string field = $"{type.ToLowerInvariant()}_types"; string graphQLQueryName = "createSupportedType"; string gqlQuery = "mutation{ createSupportedType (item: {" + field + ": " + value + " }){ " + field + " } }"; @@ -262,6 +323,37 @@ public async Task InsertIntoTypeColumn(string type, string value) await ResetDbStateAsync(); } + /// + /// Test case for invalid time, such as negative values or hours>24 or minutes/seconds>60. + /// + [DataTestMethod] + [DataRow(TIME_TYPE, "\"32:59:59.9999999\"")] + [DataRow(TIME_TYPE, "\"22:67:59.9999999\"")] + [DataRow(TIME_TYPE, "\"14:12:99.9999999\"")] + [DataRow(TIME_TYPE, "\"-22:67:59.9999999\"")] + [DataRow(TIME_TYPE, "\"22:-67:59.9999999\"")] + [DataRow(TIME_TYPE, "\"22:67:59.-9999999\"")] + public async Task InsertInvalidTimeIntoTimeTypeColumn(string type, string value) + { + if (!IsSupportedType(type)) + { + Assert.Inconclusive("Type not supported"); + } + + string field = $"{type.ToLowerInvariant()}_types"; + string graphQLQueryName = "createSupportedType"; + string gqlQuery = "mutation{ createSupportedType (item: {" + field + ": " + value + " }){ " + field + " } }"; + + JsonElement response = await ExecuteGraphQLRequestAsync(gqlQuery, graphQLQueryName, isAuthenticated: true); + string responseMessage = Regex.Unescape(JsonSerializer.Serialize(response)); + Assert.IsTrue(responseMessage.Contains($"{value} cannot be resolved as column \"{field}\" with type \"TimeSpan\".")); + } + + /// + /// The code contains test rows that are used to test the insertion of various data types into a database table using GraphQL, + /// where the parameter values are passed as GraphQL request Variables. The test supports various data types such as byte, short, + /// int, long, string, float, decimal, boolean, datetimeoffset, datetime, time, and bytearray. + /// [DataTestMethod] [DataRow(BYTE_TYPE, 255)] [DataRow(SHORT_TYPE, 30000)] @@ -271,8 +363,10 @@ public async Task InsertIntoTypeColumn(string type, string value) [DataRow(FLOAT_TYPE, -3.33)] [DataRow(DECIMAL_TYPE, 1222222.00000929292)] [DataRow(BOOLEAN_TYPE, true)] - [DataRow(DATETIME_NONUTC_TYPE, "1999-01-08 10:23:54+8:00")] + [DataRow(DATETIMEOFFSET_TYPE, "1999-01-08 10:23:54+8:00")] [DataRow(DATETIME_TYPE, "1999-01-08 10:23:54")] + [DataRow(TIME_TYPE, "\"23:59:59.9999999\"")] + [DataRow(TIME_TYPE, "null")] [DataRow(BYTEARRAY_TYPE, "V2hhdGNodSBkb2luZyBkZWNvZGluZyBvdXIgdGVzdCBiYXNlNjQgc3RyaW5ncz8=")] public async Task InsertIntoTypeColumnWithArgument(string type, object value) { @@ -281,16 +375,9 @@ public async Task InsertIntoTypeColumnWithArgument(string type, object value) Assert.Inconclusive("Type not supported"); } - // Datetime non utc type is a characterization of the value added to the datetime type, - // so before executing the query reset it to mean the actually underlying type. - if (DATETIME_NONUTC_TYPE.Equals(type)) - { - type = DATETIME_TYPE; - } - string field = $"{type.ToLowerInvariant()}_types"; string graphQLQueryName = "createSupportedType"; - string gqlQuery = "mutation($param: " + type + "){ createSupportedType (item: {" + field + ": $param }){ " + field + " } }"; + string gqlQuery = "mutation($param: " + TypeNameToGraphQLType(type) + "){ createSupportedType (item: {" + field + ": $param }){ " + field + " } }"; string dbQuery = MakeQueryOnTypeTable(new List { field }, id: 5001); @@ -333,10 +420,12 @@ public async Task InsertIntoTypeColumnWithArgument(string type, object value) [DataRow(BOOLEAN_TYPE, "true")] [DataRow(BOOLEAN_TYPE, "false")] [DataRow(BOOLEAN_TYPE, "null")] - [DataRow(DATETIME_NONUTC_TYPE, "\"1999-01-08 10:23:54+8:00\"")] + [DataRow(DATETIMEOFFSET_TYPE, "\"1999-01-08 10:23:54+8:00\"")] [DataRow(DATETIME_TYPE, "\"1999-01-08 09:20:00\"")] [DataRow(DATETIME_TYPE, "\"1999-01-08\"")] [DataRow(DATETIME_TYPE, "null")] + [DataRow(TIME_TYPE, "\"23:59:59.9999999\"")] + [DataRow(TIME_TYPE, "null")] [DataRow(BYTEARRAY_TYPE, "\"U3RyaW5neQ==\"")] [DataRow(BYTEARRAY_TYPE, "\"V2hhdGNodSBkb2luZyBkZWNvZGluZyBvdXIgdGVzdCBiYXNlNjQgc3RyaW5ncz8=\"")] [DataRow(BYTEARRAY_TYPE, "null")] @@ -349,13 +438,6 @@ public async Task UpdateTypeColumn(string type, string value) Assert.Inconclusive("Type not supported"); } - // Datetime non utc type is a characterization of the value added to the datetime type, - // so before executing the query reset it to mean the actually underlying type. - if (DATETIME_NONUTC_TYPE.Equals(type)) - { - type = DATETIME_TYPE; - } - string field = $"{type.ToLowerInvariant()}_types"; string graphQLQueryName = "updateSupportedType"; string gqlQuery = "mutation{ updateSupportedType (typeid: 1, item: {" + field + ": " + value + " }){ " + field + " } }"; @@ -380,7 +462,7 @@ public async Task UpdateTypeColumn(string type, string value) [DataRow(DECIMAL_TYPE, 1222222.00000929292)] [DataRow(BOOLEAN_TYPE, true)] [DataRow(DATETIME_TYPE, "1999-01-08 10:23:54")] - [DataRow(DATETIME_NONUTC_TYPE, "1999-01-08 10:23:54+8:00")] + [DataRow(DATETIMEOFFSET_TYPE, "1999-01-08 10:23:54+8:00")] [DataRow(BYTEARRAY_TYPE, "V2hhdGNodSBkb2luZyBkZWNvZGluZyBvdXIgdGVzdCBiYXNlNjQgc3RyaW5ncz8=")] [DataRow(GUID_TYPE, "3a1483a5-9ac2-4998-bcf3-78a28078c6ac")] [DataRow(GUID_TYPE, null)] @@ -391,13 +473,6 @@ public async Task UpdateTypeColumnWithArgument(string type, object value) Assert.Inconclusive("Type not supported"); } - // Datetime non utc type is a characterization of the value added to the datetime type, - // so before executing the query reset it to mean the actually underlying type. - if (DATETIME_NONUTC_TYPE.Equals(type)) - { - type = DATETIME_TYPE; - } - string field = $"{type.ToLowerInvariant()}_types"; string graphQLQueryName = "updateSupportedType"; string gqlQuery = "mutation($param: " + TypeNameToGraphQLType(type) + "){ updateSupportedType (typeid: 1, item: {" + field + ": $param }){ " + field + " } }"; @@ -428,6 +503,14 @@ private static void PerformTestEqualsForExtendedTypes(string type, string expect { CompareDateTimeResults(actual.ToString(), expected); } + else if (type == DATETIMEOFFSET_TYPE) + { + CompareDateTimeOffsetResults(actual.ToString(), expected); + } + else if (type == TIME_TYPE) + { + CompareTimeResults(actual.ToString(), expected); + } else { SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); @@ -509,7 +592,68 @@ private static void CompareDateTimeResults(string actual, string expected) } else { - Assert.AreEqual(DateTimeOffset.Parse(expectedDateTime), DateTimeOffset.Parse(actualDateTime)); + AssertOnFields(fieldName, actualDateTime, expectedDateTime); + } + } + + /// + /// Required due to different format between sql datetimeoffset and HotChocolate datetime + /// result + /// + private static void CompareDateTimeOffsetResults(string actual, string expected) + { + string fieldName = "datetimeoffset_types"; + + using JsonDocument actualJsonDoc = JsonDocument.Parse(actual); + using JsonDocument expectedJsonDoc = JsonDocument.Parse(expected); + + if (actualJsonDoc.RootElement.ValueKind is JsonValueKind.Array) + { + ValidateArrayResults(actualJsonDoc, expectedJsonDoc, fieldName); + return; + } + + string actualDateTimeOffsetString = actualJsonDoc.RootElement.GetProperty(fieldName).ToString(); + string expectedDateTimeOffsetString = expectedJsonDoc.RootElement.GetProperty(fieldName).ToString(); + + // handles cases when one of the values is null + if (string.IsNullOrEmpty(actualDateTimeOffsetString) || string.IsNullOrEmpty(expectedDateTimeOffsetString)) + { + Assert.AreEqual(expectedDateTimeOffsetString, actualDateTimeOffsetString); + } + else + { + AssertOnFields(fieldName, actualDateTimeOffsetString, expectedDateTimeOffsetString); + } + } + + /// + /// Compares the value from SQL time and HotChocolate LocalTime. + /// + private static void CompareTimeResults(string actual, string expected) + { + string fieldName = "time_types"; + + using JsonDocument actualJsonDoc = JsonDocument.Parse(actual); + using JsonDocument expectedJsonDoc = JsonDocument.Parse(expected); + + if (actualJsonDoc.RootElement.ValueKind is JsonValueKind.Array) + { + ValidateArrayResults(actualJsonDoc, expectedJsonDoc, fieldName); + return; + } + + string actualTimeString = actualJsonDoc.RootElement.GetProperty(fieldName).ToString(); + string expectedTimeString = expectedJsonDoc.RootElement.GetProperty(fieldName).ToString(); + + // handles cases when one of the values is null + if (string.IsNullOrEmpty(actualTimeString) || string.IsNullOrEmpty(expectedTimeString)) + { + Assert.AreEqual(expectedTimeString, actualTimeString); + } + else + { + AssertOnFields(fieldName, actualTimeString, expectedTimeString); } } @@ -523,21 +667,43 @@ private static void ValidateArrayResults(JsonDocument actualJsonDoc, JsonDocumen actualElement.TryGetProperty(fieldName, out JsonElement actualValue); expectedElement.TryGetProperty(fieldName, out JsonElement expectedValue); - if (fieldName.StartsWith(DATETIME_TYPE.ToLower())) - { - // MySql returns a format that will not directly parse into DateTime type so we use string here for parsing - DateTime actualDateTime = DateTime.Parse(actualValue.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.None); - DateTime expectedDateTime = DateTime.Parse(expectedValue.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.None); - Assert.AreEqual(expectedDateTime, actualDateTime); - } - else if (fieldName.StartsWith(SINGLE_TYPE.ToLower())) - { - Assert.AreEqual(expectedValue.GetSingle(), actualValue.GetSingle()); - } - else - { - Assert.AreEqual(expectedValue.GetDouble(), actualValue.GetDouble()); - } + AssertOnFields(fieldName, actualValue.ToString(), expectedValue.ToString()); + } + } + + /// + /// Compare given fields from actual and expected json. + /// + private static void AssertOnFields(string field, string actualElement, string expectedElement) + { + if (field.StartsWith(DATETIMEOFFSET_TYPE.ToLower())) + { + DateTimeOffset actualDateTimeOffset = DateTimeOffset.Parse(actualElement.ToString(), DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal); + DateTimeOffset expectedDateTimeOffset = DateTimeOffset.Parse(expectedElement.ToString(), DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal); + Assert.AreEqual(actualDateTimeOffset.ToString(), expectedDateTimeOffset.ToString()); + // Comparing for milliseconds separately since HotChocolate time type is resolved only to 3 decimal places. + Assert.AreEqual(actualDateTimeOffset.Millisecond, expectedDateTimeOffset.Millisecond); + } + else if (field.StartsWith(DATETIME_TYPE.ToLower())) + { + // Adjusting to universal, since DateTime doesn't account for TimeZone + DateTime actualDateTime = DateTime.Parse(actualElement.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal); + DateTime expectedDateTime = DateTime.Parse(expectedElement.ToString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal); + Assert.AreEqual(expectedDateTime, actualDateTime); + } + else if (field.StartsWith(SINGLE_TYPE.ToLower())) + { + Assert.AreEqual(float.Parse(expectedElement), float.Parse(actualElement)); + } + else if (field.StartsWith(TIME_TYPE.ToLower())) + { + TimeOnly actualTime = TimeOnly.Parse(actualElement.ToString()); + TimeOnly expectedTime = TimeOnly.Parse(expectedElement.ToString()); + Assert.AreEqual(expectedTime.ToLongTimeString(), actualTime.ToLongTimeString()); + } + else + { + Assert.AreEqual(double.Parse(expectedElement), double.Parse(actualElement)); } } @@ -551,6 +717,10 @@ private static string TypeNameToGraphQLType(string typeName) { return STRING_TYPE; } + else if (typeName is DATETIMEOFFSET_TYPE) + { + return DATETIME_TYPE; + } return typeName; } diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs index bc45b12e28..a0a6863fa6 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs @@ -57,6 +57,8 @@ protected override bool IsSupportedType(string type) return type switch { GUID_TYPE => false, + DATETIMEOFFSET_TYPE => false, + TIME_TYPE => false, _ => true }; } diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs index ecb493c2ef..e409510b92 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs @@ -54,7 +54,8 @@ protected override bool IsSupportedType(string type) return type switch { BYTE_TYPE => false, - DATETIME_NONUTC_TYPE => false, + DATETIMEOFFSET_TYPE => false, + TIME_TYPE => false, _ => true }; } diff --git a/src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs index 2d7b8e2d70..ec15851c6e 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Insert/MsSqlInsertApiTests.cs @@ -32,7 +32,7 @@ public class MsSqlInsertApiTests : InsertApiTestBase "InsertOneInSupportedTypes", $"SELECT [id] as [typeid], [byte_types], [short_types], [int_types], [long_types],string_types, [single_types], [float_types], " + $"[decimal_types], [boolean_types], [date_types], [datetime_types], [datetime2_types], [datetimeoffset_types], [smalldatetime_types], " + - $"[bytearray_types], LOWER([guid_types]) as [guid_types] FROM { _integrationTypeTable } " + + $"[time_types], [bytearray_types], LOWER([guid_types]) as [guid_types] FROM { _integrationTypeTable } " + $"WHERE [id] = { STARTING_ID_FOR_TEST_INSERTS } AND [bytearray_types] is NULL " + $"FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" }, diff --git a/src/Service.Tests/Unittests/ODataASTVisitorUnitTests.cs b/src/Service.Tests/Unittests/ODataASTVisitorUnitTests.cs index 69722a3825..c440b5b572 100644 --- a/src/Service.Tests/Unittests/ODataASTVisitorUnitTests.cs +++ b/src/Service.Tests/Unittests/ODataASTVisitorUnitTests.cs @@ -67,6 +67,8 @@ public static async Task SetupAsync(TestContext context) DisplayName = "Equate smalldatetime types.")] [DataRow("bytearray_types eq 1000", "([bytearray_types] = @param1)", DisplayName = "Equate bytearray types.")] [DataRow("guid_types eq 9A19103F-16F7-4668-BE54-9A1E7A4F7556", "([guid_types] = @param1)", DisplayName = "Equate guid types.")] + [DataRow("time_types eq 10:23:54.9999999", "([time_types] = @param1)", DisplayName = "Equate time types.")] + [DataRow("time_types eq null", "([time_types] IS NULL)", DisplayName = "Equate time types for null.")] [TestMethod] public void VisitorLeftFieldRightConstantFilterTest(string filterExp, string expectedPredicate) { @@ -200,6 +202,24 @@ public void InvalidEdmTypeReferenceTest() Assert.ThrowsException(() => visitor.Visit(nodeIn)); } + /// + /// Tests that we throw an exception when trying to use an invalid + /// Time with negative value or time > 24 hours. + /// + [DataTestMethod] + [DataRow("time_types eq 25:23:54.9999999", DisplayName = "Exception thrown with invalid time>24 hrs.")] + [DataRow("time_types eq -13:23:54.9999999", DisplayName = "Exception thrown with invalid time>24 hrs.")] + public void InvalidTimeTypeODataFilterTest(string filterExp) + { + Assert.ThrowsException(() => PerformVisitorTest( + entityName: DEFAULT_ENTITY, + schemaName: DEFAULT_SCHEMA_NAME, + tableName: DEFAULT_TABLE_NAME, + filterString: $"?$filter={filterExp}", + expected: string.Empty + )); + } + /// /// Verifies that we throw an exception for values that can /// not be parsed into a valid Edm Type Kind. Create a constant