diff --git a/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs index 4bd1aca768..d7d900d4f9 100644 --- a/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs @@ -439,7 +439,14 @@ protected static object ParseParamAsSystemType(string param, Type systemType) "Double" => double.Parse(param), "Decimal" => decimal.Parse(param), "Boolean" => bool.Parse(param), - "DateTime" => DateTimeOffset.Parse(param, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal).DateTime, + // When GraphQL input specifies a TZ offset "12-31-2024T12:00:00+03:00" + // and DAB has resolved the ColumnDefinition.SystemType to DateTime, + // DAB converts the input to UTC because: + // - DAB assumes that values without timezone offset are UTC. + // - DAB shouldn't store values in the backend database which the user did not intend + // - e.g. Storing this value for the original example would be incorrect: "12-31-2024T12:00:00" + // - The correct value to store would be "12-31-2024T09:00:00" (UTC) + "DateTime" => DateTimeOffset.Parse(param, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal).UtcDateTime, "DateTimeOffset" => DateTimeOffset.Parse(param, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AssumeUniversal), "Date" => DateOnly.Parse(param), "Guid" => Guid.Parse(param), diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/DabField.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/DabField.cs new file mode 100644 index 0000000000..344b659041 --- /dev/null +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/DabField.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Service.Tests.SqlTests.GraphQLSupportedTypesTests; + +/// +/// Encapsulates field name metadata which is used when +/// creating database queries to validate test results +/// in the GraphQLSupportedTypesTestBase. +/// +public class DabField +{ + /// + /// Mapped (aliased) column name defined in DAB runtime config. + /// + public string Alias { get; set; } + + /// + /// Database column name. + /// + public string BackingColumnName { get; set; } + + /// + /// Creates a new DabField instance with both alias and backing column name. + /// + /// Mapped (aliased) column name defined in DAB runtime config. + /// Database column name. + public DabField(string alias, string backingColumnName) + { + Alias = alias; + BackingColumnName = backingColumnName; + } + + /// + /// Creates a new DabField instance with only the backing column name + /// where the alias is the same as the backing column name. + /// + /// Database column name. + public DabField(string backingColumnName) + { + Alias = backingColumnName; + BackingColumnName = backingColumnName; + } +} diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs index d2626484fa..1ad5bbc93e 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/GraphQLSupportedTypesTestsBase.cs @@ -97,9 +97,11 @@ public async Task QueryTypeColumn(string type, int id) string field = $"{type.ToLowerInvariant()}_types"; string graphQLQueryName = "supportedType_by_pk"; - string gqlQuery = "{ supportedType_by_pk(typeid: " + id + ") { " + field + " } }"; + string gqlQuery = "{ supportedType_by_pk(typeid: " + id + ") { typeid, " + field + " } }"; - string dbQuery = MakeQueryOnTypeTable(new List { field }, id); + string dbQuery = MakeQueryOnTypeTable( + queryFields: new List { new(alias: "typeid", backingColumnName: "id"), new(backingColumnName: field) }, + id: id); JsonElement actual = await ExecuteGraphQLRequestAsync(gqlQuery, graphQLQueryName, isAuthenticated: false); string expected = await GetDatabaseResultAsync(dbQuery); @@ -134,12 +136,6 @@ public async Task QueryTypeColumn(string type, int id) [DataRow(LONG_TYPE, "eq", "-1", "-1", "=")] [DataRow(STRING_TYPE, "neq", "'foo'", "\"foo\"", "!=")] [DataRow(STRING_TYPE, "eq", "'lksa;jdflasdf;alsdflksdfkldj'", "\"lksa;jdflasdf;alsdflksdfkldj\"", "=")] - [DataRow(SINGLE_TYPE, "gt", "-9.3", "-9.3", ">")] - [DataRow(SINGLE_TYPE, "gte", "-9.2", "-9.2", ">=")] - [DataRow(SINGLE_TYPE, "lt", ".33", "0.33", "<")] - [DataRow(SINGLE_TYPE, "lte", ".33", "0.33", "<=")] - [DataRow(SINGLE_TYPE, "neq", "9.2", "9.2", "!=")] - [DataRow(SINGLE_TYPE, "eq", "'0.33'", "0.33", "=")] [DataRow(FLOAT_TYPE, "gt", "-9.2", "-9.2", ">")] [DataRow(FLOAT_TYPE, "gte", "-9.2", "-9.2", ">=")] [DataRow(FLOAT_TYPE, "lt", ".33", "0.33", "<")] @@ -166,14 +162,20 @@ public async Task QueryTypeColumnFilterAndOrderBy(string type, string filterOper string field = $"{type.ToLowerInvariant()}_types"; string graphQLQueryName = "supportedTypes"; string gqlQuery = @"{ - supportedTypes(first: 100 orderBy: { " + field + ": ASC } filter: { " + field + ": {" + filterOperator + ": " + gqlValue + @"} }) { + supportedTypes(first: 100 orderBy: { typeid: ASC } filter: { " + field + ": {" + filterOperator + ": " + gqlValue + @"} }) { items { - " + field + @" + typeid, " + field + @" } } }"; - string dbQuery = MakeQueryOnTypeTable(new List { field }, filterValue: sqlValue, filterOperator: queryOperator, filterField: field, orderBy: field, limit: "100"); + string dbQuery = MakeQueryOnTypeTable( + queryFields: new List { new(alias: "typeid", backingColumnName: "id"), new(backingColumnName: field) }, + filterValue: sqlValue, + filterOperator: queryOperator, + filterField: field, + orderBy: "id", + limit: "100"); JsonElement actual = await ExecuteGraphQLRequestAsync(gqlQuery, graphQLQueryName, isAuthenticated: false); string expected = await GetDatabaseResultAsync(dbQuery); @@ -215,8 +217,8 @@ public async Task QueryTypeColumnFilterAndOrderBy(string type, string filterOper DisplayName = "datetime type filter and orderby test with neq operator and specific value '1999-01-08 10:23:54'.")] [DataRow(DATETIMEOFFSET_TYPE, "neq", "'1999-01-08 10:23:54.9999999-14:00'", "\"1999-01-08 10:23:54.9999999-14:00\"", "!=", DisplayName = "datetimeoffset type filter and orderby test with neq operator")] - [DataRow(DATETIMEOFFSET_TYPE, "lt", "'9999-12-31 23:59:59.9999999'", "\"9999-12-31 23:59:59.9999999\"", "<", - DisplayName = "datetimeoffset type filter and orderby test with lt operator and max value for datetimeoffset.")] + [DataRow(DATETIMEOFFSET_TYPE, "lt", "'9999-12-31 23:59:59.9999999'", "\"9999-12-31 23:59:59.9999999Z\"", "<", + DisplayName = "datetimeoffset type filter and orderby test with lt operator and max value (with UTC offset specified) for datetimeoffset.")] [DataRow(DATETIMEOFFSET_TYPE, "eq", "'1999-01-08 10:23:54.9999999-14:00'", "\"1999-01-08 10:23:54.9999999-14:00\"", "=", DisplayName = "datetimeoffset type filter and orderby test with eq operator")] [DataRow(DATE_TYPE, "eq", "'1999-01-08'", "\"1999-01-08\"", "=", @@ -245,10 +247,11 @@ public async Task QueryTypeColumnFilterAndOrderBy(string type, string filterOper DisplayName = "datetime2 type filter and orderby test with neq operator")] 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' + // In MySQL, the DATETIME data type supports a range from '1000-01-01 00:00:00.0000000' to '9999-12-31 23:59:59.499999' + // https://dev.mysql.com/doc/refman/8.4/en/datetime.html if (DatabaseEngine is TestCategory.MYSQL && sqlValue is "'9999-12-31 23:59:59.9999999'") { - sqlValue = "'9999-12-31 23:59:59.0000000'"; + sqlValue = "'9999-12-31 23:59:59.499999"; gqlValue = "\"9999-12-31 23:59:59.0000000\""; } @@ -271,36 +274,68 @@ public async Task QueryTypeColumnFilterAndOrderByLocalTime(string type, string f } /// + /// (MSSQL Test, which supports time type with 7 decimal precision) /// 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 + /// In the Database only one row exists 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) + [DataRow("23:59:59.9999999", DisplayName = "TimeType Precision Check with 7 decimal places")] + [DataRow("23:59:59.999", DisplayName = "TimeType Precision Check with 3 decimal places")] + public async Task TestTimeTypePrecisionCheck(string gqlInput) { + // Arrange if (!IsSupportedType(TIME_TYPE)) { Assert.Inconclusive("Type not supported"); } + string graphQLMutationName = "createSupportedType"; + string gqlMutation = "mutation{ createSupportedType (item: { time_types: \"" + gqlInput + "\"}){ typeid, time_types } }"; + JsonElement testScopedInsertedRecord = await ExecuteGraphQLRequestAsync(gqlMutation, graphQLMutationName, isAuthenticated: true); + + if (!testScopedInsertedRecord.TryGetProperty("typeid", out JsonElement typeid)) + { + Assert.Fail(message: "Failed to insert test record."); + } + + int insertedRecordId = typeid.GetInt32(); + string graphQLQueryName = "supportedTypes"; + string idFilter = @"typeid: { eq: " + typeid.GetInt32() + " }"; string gqlQuery = @"{ - supportedTypes(first: 100 orderBy: { " + "time_types" + ": ASC } filter: { " + "time_types" + ": {" + "eq" + ": " + gqlValue + @"} }) { + supportedTypes( + orderBy: { " + "time_types" + @": ASC } + filter: { " + "time_types" + ": {" + "eq" + ": \"" + gqlInput + "\"}," + idFilter + @"}) { items { - " + "time_types" + @" + typeid, " + "time_types" + @" } } }"; + // Act - Execute query with filter on time_types, expect to get back time_types with 7 decimal places. JsonElement gqlResponse = await ExecuteGraphQLRequestAsync(gqlQuery, graphQLQueryName, isAuthenticated: false); - Assert.AreEqual(count, gqlResponse.GetProperty("items").GetArrayLength()); + + // Assert + // Validate that the filter returned a result. + bool receivedResultWithFilter = gqlResponse.TryGetProperty("items", out JsonElement items) && items.GetArrayLength() > 0; + Assert.AreEqual(expected: true, actual: receivedResultWithFilter, message: "Unexpected results and result count."); + + // Validate that the filter returned the value inserted during setup. + JsonElement firstItem = items[0]; + JsonElement typeId = firstItem.GetProperty("typeid"); + int actualTypeIdInResponse = typeId.GetInt32(); + Assert.AreEqual(expected: insertedRecordId, actual: actualTypeIdInResponse, message: "typeid mismatch, filter didn't work"); + + // Validate that the filter returned the value with expected time_types precision. + JsonElement timeValue = firstItem.GetProperty("time_types"); + string actualTimeValue = timeValue.GetString(); + Assert.AreEqual(expected: gqlInput, actual: actualTimeValue, message: "returned time value unexpected."); } /// - /// 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. + /// This test executes a GraphQL insert mutation for each data row's {value} of GraphQL data type {type} + /// to validate that the DAB engine supports inserting different types into the database. /// [DataTestMethod] [DataRow(BYTE_TYPE, "255")] @@ -314,7 +349,7 @@ public async Task TestTimeTypePrecisionCheck(string gqlValue, int count) [DataRow(INT_TYPE, "0")] [DataRow(INT_TYPE, "-9999")] [DataRow(INT_TYPE, "null")] - [DataRow(UUID_TYPE, "3a1483a5-9ac2-4998-bcf3-78a28078c6ac")] + [DataRow(UUID_TYPE, "\"3a1483a5-9ac2-4998-bcf3-78a28078c6ac\"")] [DataRow(UUID_TYPE, "null")] [DataRow(LONG_TYPE, "0")] [DataRow(LONG_TYPE, "9000000000000000000")] @@ -345,9 +380,9 @@ public async Task TestTimeTypePrecisionCheck(string gqlValue, int count) [DataRow(TIME_TYPE, "\"23:59\"")] [DataRow(TIME_TYPE, "null")] [DataRow(DATETIME_TYPE, "\"1753-01-01 00:00:00.000\"")] - [DataRow(DATETIME_TYPE, "\"9999-12-31 23:59:59.997\"")] - [DataRow(DATETIME_TYPE, "\"9999-12-31T23:59:59.997\"")] - [DataRow(DATETIME_TYPE, "\"9999-12-31 23:59:59.997Z\"")] + [DataRow(DATETIME_TYPE, "\"9999-12-31 23:59:59.499\"")] + [DataRow(DATETIME_TYPE, "\"9999-12-31T23:59:59.499\"")] + [DataRow(DATETIME_TYPE, "\"9999-12-31 23:59:59.499Z\"")] [DataRow(DATETIME_TYPE, "null")] [DataRow(SMALLDATETIME_TYPE, "\"1900-01-01\"")] [DataRow(SMALLDATETIME_TYPE, "\"2079-06-06\"")] @@ -369,17 +404,17 @@ public async Task InsertIntoTypeColumn(string type, string value) } string field = $"{type.ToLowerInvariant()}_types"; - string graphQLQueryName = "createSupportedType"; - string gqlQuery = "mutation{ createSupportedType (item: {" + field + ": " + value + " }){ " + field + " } }"; - - string dbQuery = MakeQueryOnTypeTable(new List { field }, id: 5001); + string graphQLMutationName = "createSupportedType"; + string gqlMutation = "mutation{ createSupportedType (item: {" + field + ": " + value + " }){ typeid, " + field + " } }"; - JsonElement actual = await ExecuteGraphQLRequestAsync(gqlQuery, graphQLQueryName, isAuthenticated: true); - string expected = await GetDatabaseResultAsync(dbQuery); + JsonElement actual = await ExecuteGraphQLRequestAsync(gqlMutation, graphQLMutationName, isAuthenticated: true); + Assert.IsTrue( + condition: actual.GetProperty("typeid").TryGetInt32(out int insertedRecordId), + message: "Error: GraphQL mutation result indicates issue during record creation."); + string expectedResultDbQuery = MakeQueryOnTypeTable(new List { new(alias: "typeid", backingColumnName: "id"), new(backingColumnName: field) }, id: insertedRecordId); + string expectedResult = await GetDatabaseResultAsync(expectedResultDbQuery); - PerformTestEqualsForExtendedTypes(type, expected, actual.ToString()); - - await ResetDbStateAsync(); + PerformTestEqualsForExtendedTypes(type, expectedResult, actual.ToString()); } /// @@ -401,7 +436,7 @@ public async Task InsertInvalidTimeIntoTimeTypeColumn(string type, string value) string field = $"{type.ToLowerInvariant()}_types"; string graphQLQueryName = "createSupportedType"; - string gqlQuery = "mutation{ createSupportedType (item: {" + field + ": " + value + " }){ " + field + " } }"; + string gqlQuery = "mutation{ createSupportedType (item: {" + field + ": " + value + " }){ typeid, " + field + " } }"; JsonElement response = await ExecuteGraphQLRequestAsync(gqlQuery, graphQLQueryName, isAuthenticated: true); string responseMessage = Regex.Unescape(JsonSerializer.Serialize(response)); @@ -426,8 +461,7 @@ public async Task InsertInvalidTimeIntoTimeTypeColumn(string type, string value) [DataRow(BOOLEAN_TYPE, true)] [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(TIME_TYPE, "23:59:59")] [DataRow(BYTEARRAY_TYPE, "V2hhdGNodSBkb2luZyBkZWNvZGluZyBvdXIgdGVzdCBiYXNlNjQgc3RyaW5ncz8=")] [DataRow(UUID_TYPE, "3a1483a5-9ac2-4998-bcf3-78a28078c6ac")] public async Task InsertIntoTypeColumnWithArgument(string type, object value) @@ -439,16 +473,16 @@ public async Task InsertIntoTypeColumnWithArgument(string type, object value) string field = $"{type.ToLowerInvariant()}_types"; string graphQLQueryName = "createSupportedType"; - string gqlQuery = "mutation($param: " + TypeNameToGraphQLType(type) + "){ createSupportedType (item: {" + field + ": $param }){ " + field + " } }"; - - string dbQuery = MakeQueryOnTypeTable(new List { field }, id: 5001); + string gqlQuery = "mutation($param: " + TypeNameToGraphQLType(type) + "){ createSupportedType (item: {" + field + ": $param }){ typeid, " + field + " } }"; JsonElement actual = await ExecuteGraphQLRequestAsync(gqlQuery, graphQLQueryName, isAuthenticated: true, new() { { "param", value } }); - string expected = await GetDatabaseResultAsync(dbQuery); - - PerformTestEqualsForExtendedTypes(type, expected, actual.ToString()); + Assert.IsTrue( + condition: actual.GetProperty("typeid").TryGetInt32(out int insertedRecordId), + message: "Error: GraphQL mutation result indicates issue during record creation."); + string expectedResultDbQuery = MakeQueryOnTypeTable(new List { new(alias: "typeid", backingColumnName: "id"), new(backingColumnName: field) }, id: insertedRecordId); + string expectedResult = await GetDatabaseResultAsync(expectedResultDbQuery); - await ResetDbStateAsync(); + PerformTestEqualsForExtendedTypes(type, expectedResult, actual.ToString()); } [DataTestMethod] @@ -505,16 +539,14 @@ public async Task UpdateTypeColumn(string type, string value) string field = $"{type.ToLowerInvariant()}_types"; string graphQLQueryName = "updateSupportedType"; - string gqlQuery = "mutation{ updateSupportedType (typeid: 1, item: {" + field + ": " + value + " }){ " + field + " } }"; + string gqlQuery = "mutation{ updateSupportedType (typeid: 1, item: {" + field + ": " + value + " }){ typeid " + field + " } }"; - string dbQuery = MakeQueryOnTypeTable(new List { field }, id: 1); + string dbQuery = MakeQueryOnTypeTable(new List { new(alias: "typeid", backingColumnName: "id"), new(backingColumnName: field) }, id: 1); JsonElement actual = await ExecuteGraphQLRequestAsync(gqlQuery, graphQLQueryName, isAuthenticated: true); string expected = await GetDatabaseResultAsync(dbQuery); PerformTestEqualsForExtendedTypes(type, expected, actual.ToString()); - - await ResetDbStateAsync(); } [DataTestMethod] @@ -547,16 +579,14 @@ public async Task UpdateTypeColumnWithArgument(string type, object value) string field = $"{type.ToLowerInvariant()}_types"; string graphQLQueryName = "updateSupportedType"; - string gqlQuery = "mutation($param: " + TypeNameToGraphQLType(type) + "){ updateSupportedType (typeid: 1, item: {" + field + ": $param }){ " + field + " } }"; + string gqlQuery = "mutation($param: " + TypeNameToGraphQLType(type) + "){ updateSupportedType (typeid: 1, item: {" + field + ": $param }){ typeid, " + field + " } }"; - string dbQuery = MakeQueryOnTypeTable(new List { field }, id: 1); + string dbQuery = MakeQueryOnTypeTable(new List { new(alias: "typeid", backingColumnName: "id"), new(backingColumnName: field) }, id: 1); JsonElement actual = await ExecuteGraphQLRequestAsync(gqlQuery, graphQLQueryName, isAuthenticated: true, new() { { "param", value } }); string expected = await GetDatabaseResultAsync(dbQuery); PerformTestEqualsForExtendedTypes(type, expected, actual.ToString()); - - await ResetDbStateAsync(); } #endregion @@ -565,7 +595,7 @@ public async Task UpdateTypeColumnWithArgument(string type, object value) /// Utility function to do special comparisons for some of the extended types /// if json compare doesn't suffice /// - private static void PerformTestEqualsForExtendedTypes(string type, string expected, string actual) + protected static void PerformTestEqualsForExtendedTypes(string type, string expected, string actual) { switch (type) { @@ -833,23 +863,24 @@ private static void AssertOnFields(string field, string actualElement, string ex /// private static string TypeNameToGraphQLType(string typeName) { - if (typeName is DATETIMEOFFSET_TYPE) + return typeName switch { - return DATETIME_TYPE; - } - - return typeName; + DATETIMEOFFSET_TYPE => DATETIME_TYPE, + TIME_TYPE => LOCALTIME_TYPE, + _ => typeName + }; } protected abstract string MakeQueryOnTypeTable( - List queriedColumns, + List queryFields, string filterValue = "1", string filterOperator = "=", string filterField = "1", string orderBy = "id", string limit = "1"); - protected abstract string MakeQueryOnTypeTable(List columnsToQuery, int id); + protected abstract string MakeQueryOnTypeTable(List queryFields, int id); + protected virtual bool IsSupportedType(string type) { return true; diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MsSqlGQLSupportedTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MsSqlGQLSupportedTypesTests.cs index aab48628bd..66832b958c 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MsSqlGQLSupportedTypesTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MsSqlGQLSupportedTypesTests.cs @@ -2,8 +2,10 @@ // Licensed under the MIT License. using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; +using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedHotChocolateTypes; namespace Azure.DataApiBuilder.Service.Tests.SqlTests.GraphQLSupportedTypesTests { @@ -21,13 +23,38 @@ public static async Task SetupAsync(TestContext context) await InitializeTestFixture(); } - protected override string MakeQueryOnTypeTable(List columnsToQuery, int id) + /// + /// MSSQL Single Type Tests. + /// + /// GraphQL Type + /// Comparison operator: gt, lt, gte, lte, etc. + /// Value to be set in "expected value" sql query. + /// GraphQL input value supplied. + /// Query operator for "expected value" sql query. + [DataRow(SINGLE_TYPE, "gt", "-9.3", "-9.3", ">")] + [DataRow(SINGLE_TYPE, "gte", "-9.2", "-9.2", ">=")] + [DataRow(SINGLE_TYPE, "lt", ".33", "0.33", "<")] + [DataRow(SINGLE_TYPE, "lte", ".33", "0.33", "<=")] + [DataRow(SINGLE_TYPE, "neq", "9.2", "9.2", "!=")] + [DataRow(SINGLE_TYPE, "eq", "'0.33'", "0.33", "=")] + [DataTestMethod] + public async Task MSSQL_real_graphql_single_filter_expectedValues( + string type, + string filterOperator, + string sqlValue, + string gqlValue, + string queryOperator) + { + await QueryTypeColumnFilterAndOrderBy(type, filterOperator, sqlValue, gqlValue, queryOperator); + } + + protected override string MakeQueryOnTypeTable(List queryFields, int id) { - return MakeQueryOnTypeTable(columnsToQuery, filterValue: id.ToString(), filterField: "id"); + return MakeQueryOnTypeTable(queryFields, filterValue: id.ToString(), filterField: "id"); } protected override string MakeQueryOnTypeTable( - List queriedColumns, + List queryFields, string filterValue = "1", string filterOperator = "=", string filterField = "1", @@ -36,7 +63,7 @@ protected override string MakeQueryOnTypeTable( { string format = limit.Equals("1") ? "WITHOUT_ARRAY_WRAPPER, INCLUDE_NULL_VALUES" : "INCLUDE_NULL_VALUES"; return @" - SELECT TOP " + limit + " " + string.Join(", ", queriedColumns) + @" + SELECT TOP " + limit + " " + string.Join(", ", queryFields.Select(field => $"{field.BackingColumnName} AS {field.Alias}")) + @" FROM type_table AS [table0] WHERE " + filterField + " " + filterOperator + " " + filterValue + @" ORDER BY " + orderBy + @" asc diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs index 36f47fcb43..04ce926ad6 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/MySqlGQLSupportedTypesTests.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedDateTimeTypes; @@ -24,27 +26,135 @@ public static async Task SetupAsync(TestContext context) await InitializeTestFixture(); } - protected override string MakeQueryOnTypeTable(List columnsToQuery, int id) + /// + /// Validates that GraphQL mutation input for: + /// GraphQL Field Name (Type): datetime_types (DateTime) + /// MySQL Field Name (Type): datetime_types (datetime) + /// succeeds when the input is a valid DateTime string as described by MySQL documentation. + /// The field under test is of MySQL type datetime, with no fractional second precision. + /// MySQL converts the supplied datetime value to UTC before storing it in the database. + /// + /// + /// The DATETIME type is used for values that contain both date and time parts. + /// MySQL retrieves and displays DATETIME values in 'YYYY-MM-DD hh:mm:ss' format. + /// The supported range is '1000-01-01 00:00:00' to '9999-12-31 23:59:59'. + /// + /// + /// Inserting a TIME, DATE, or TIMESTAMP value with a fractional seconds part into a column + /// of the same type but having fewer fractional digits results in rounding. + /// + /// + /// - The (time zone) offset is not displayed when selecting a datetime value, + /// even if one was used when inserting it. + /// - The date and time parts can be separated by T rather than a space. + /// For example, '2012-12-31 11:30:45' '2012-12-31T11:30:45' are equivalent. + /// + /// + /// UTC offset should be formatted as Z and not +00:00 and lower case z and t should be converted to Z and T. + /// + /// Unescaped string used as value for GraphQL input field datetime_types + /// Expected result the HotChocolate returns from resolving database response. + // Date and time + [DataRow("1000-01-01 00:00:00", "1000-01-01T00:00:00.000Z", DisplayName = "Datetime value separated by space.")] + [DataRow("9999-12-31T23:59:59", "9999-12-31T23:59:59.000Z", DisplayName = "Datetime value separated by T.")] + [DataRow("9999-12-31 23:59:59Z", "9999-12-31T23:59:59.000Z", DisplayName = "Datetime value specified with UTC offset Z as resolved by HotChocolate.")] + [DataRow("9999-12-31 23:59:59+00:00", "9999-12-31T23:59:59.000Z", DisplayName = "Datetime value specified with UTC offset with no datetime change when stored in db.")] + [DataRow("9999-12-31 23:59:59+03:00", "9999-12-31T20:59:59.000Z", DisplayName = "Timezone offset UTC+03:00 accepted by MySQL because UTC value is in supported datetime range.")] + [DataRow("9999-12-31 20:59:59-03:00", "9999-12-31T23:59:59.000Z", DisplayName = "Timezone offset UTC-03:00 accepted by MySQL because UTC value is in supported datetime range.")] + // Fractional seconds rounded up/down when mysql column datetime doesn't specify fractional seconds + // e.g. column not defined as datetime({1-6}) + [DataRow("9999-12-31 23:59:59.499999", "9999-12-31T23:59:59.000Z", DisplayName = "Fractional seconds rounded down because fractional seconds are passed to column with datatype datetime(0).")] + [DataRow("2024-12-31 23:59:59.999999", "2025-01-01T00:00:00.000Z", DisplayName = "Fractional seconds rounded up because fractional seconds are passed to column with datatype datetime(0).")] + // Only date + [DataRow("9999-12-31", "9999-12-31T00:00:00.000Z", DisplayName = "Max date for datetime column stored with zeroed out time.")] + [DataRow("1000-01-01", "1000-01-01T00:00:00.000Z", DisplayName = "Min date for datetime column stored with zeroed out time.")] + [DataTestMethod] + public async Task InsertMutationInput_DateTimeTypes_ValidRange_ReturnsExpectedValues(string dateTimeGraphQLInput, string expectedResult) + { + // Arrange + const string DATETIME_FIELD = "datetime_types"; + string graphQLMutationName = "createSupportedType"; + string gqlMutation = "mutation{ createSupportedType (item: {" + DATETIME_FIELD + ": \"" + dateTimeGraphQLInput + "\" }){ typeid, " + DATETIME_FIELD + " } }"; + + // Act + JsonElement actual = await ExecuteGraphQLRequestAsync(gqlMutation, graphQLMutationName, isAuthenticated: true); + + // Assert + Assert.IsTrue( + condition: actual.GetProperty("typeid").TryGetInt32(out _), + message: "Error: GraphQL mutation result indicates issue during record creation because primary key 'typeid' was not resolved."); + + Assert.AreEqual( + expected: expectedResult, + actual: actual.GetProperty(DATETIME_FIELD).GetString(), + message: "Unexpected datetime value."); + } + + /// + /// For MySQL, values passed to columns with datatype datetime(0) (no fractional seconds) that only include time + /// are auto-populated with the current date. + /// Based on the supplied time (assumed to be UTC), gets the current date. This calculation must fetch + /// DateTime.UtcNow, otherwise the test machine's timezone will be used and result in an unexpected date. + /// + /// Unescaped string used as value for GraphQL input field datetime_types + /// Expected result the HotChocolate returns from resolving database response. + [DataRow("23:59:59.499999", "23:59:59.000Z", DisplayName = "hh:mm::ss.ffffff for datetime column stored with zeroed out date and rounded down fractional seconds.")] + [DataRow("23:59:59", "23:59:59.000Z", DisplayName = "hh:mm:ss for datetime column stored with zeroed out date.")] + [DataRow("23:59", "23:59:00.000Z", DisplayName = "hh:mm for datetime column stored with zeroed out date and seconds.")] + [DataTestMethod] + public async Task InsertMutationInput_DateTimeTypes_TimeOnly_ValidRange_ReturnsExpectedValues(string dateTimeGraphQLInput, string expectedResult) + { + // Arrange + expectedResult = DateTime.UtcNow.ToString("yyyy-MM-ddT") + expectedResult; + await InsertMutationInput_DateTimeTypes_ValidRange_ReturnsExpectedValues(dateTimeGraphQLInput, expectedResult); + } + + /// + /// MySql Single Type Tests. + /// + /// GraphQL Data Type + /// Comparison operator: gt, lt, gte, lte, etc. + /// Value to be set in "expected value" sql query. + /// GraphQL input value supplied. + /// Query operator for "expected value" sql query. + [DataRow(SINGLE_TYPE, "gt", "-9.3", "-9.3", ">")] + [DataRow(SINGLE_TYPE, "gte", "-9.2", "-9.2", ">=")] + [DataRow(SINGLE_TYPE, "lt", ".33", "0.33", "<")] + [DataRow(SINGLE_TYPE, "lte", ".33", "0.33", "<=")] + [DataRow(SINGLE_TYPE, "neq", "9.2", "9.2", "!=")] + [DataRow(SINGLE_TYPE, "eq", "'0.33'", "0.33", "=")] + [DataTestMethod] + public async Task MySql_real_graphql_single_filter_expectedValues( + string graphqlDataType, + string filterOperator, + string sqlValue, + string gqlValue, + string queryOperator) + { + await QueryTypeColumnFilterAndOrderBy(graphqlDataType, filterOperator, sqlValue, gqlValue, queryOperator); + } + + protected override string MakeQueryOnTypeTable(List queryFields, int id) { - return MakeQueryOnTypeTable(columnsToQuery, filterValue: id.ToString(), filterField: "id"); + return MakeQueryOnTypeTable(queryFields, filterValue: id.ToString(), filterField: "id"); } protected override string MakeQueryOnTypeTable( - List queriedColumns, + List queryFields, string filterValue = "1", string filterOperator = "=", string filterField = "1", string orderBy = "id", string limit = "1") { - string columnsToQuery = string.Join(", ", queriedColumns.Select(c => $"\"{c}\" , {ProperlyFormatTypeTableColumn(c)}")); - string formattedSelect = limit.Equals("1") ? "SELECT JSON_OBJECT(" + columnsToQuery + @") AS `data`" : - "SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT(" + columnsToQuery + @")), '[]') AS `data`"; + string jsonResultProperties = string.Join(", ", queryFields.Select(field => $"\"{field.Alias}\" , {ProperlyFormatTypeTableColumn(field.BackingColumnName)}")); + string formattedSelect = limit.Equals("1") ? "SELECT JSON_OBJECT(" + jsonResultProperties + @") AS `data`" : + "SELECT COALESCE(JSON_ARRAYAGG(JSON_OBJECT(" + jsonResultProperties + @")), '[]') AS `data`"; return @" " + formattedSelect + @" FROM ( - SELECT " + string.Join(", ", queriedColumns) + @" + SELECT " + string.Join(", ", queryFields.Select(field => field.BackingColumnName)) + @" FROM type_table AS `table0` WHERE " + filterField + " " + filterOperator + " " + filterValue + @" ORDER BY " + orderBy + @" asc @@ -63,6 +173,7 @@ protected override bool IsSupportedType(string type) DATETIME2_TYPE => false, DATETIMEOFFSET_TYPE => false, TIME_TYPE => false, + LOCALTIME_TYPE => false, _ => true }; } diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs index 79e5684b83..dc1e8e7d51 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs @@ -24,13 +24,39 @@ public static async Task SetupAsync(TestContext context) await InitializeTestFixture(); } - protected override string MakeQueryOnTypeTable(List columnsToQuery, int id) + /// + /// Postgres Single Type Test. + /// Postgres requires conversion of a float value, ex: 0.33 to 'real' type otherwise precision is lost. + /// + /// GraphQL Type + /// Comparison operator: gt, lt, gte, lte, etc. + /// Value to be set in "expected value" sql query. + /// GraphQL input value supplied. + /// Query operator for "expected value" sql query. + [DataRow(SINGLE_TYPE, "gt", "real '-9.3'", "-9.3", ">")] + [DataRow(SINGLE_TYPE, "gte", "real '-9.2'", "-9.2", ">=")] + [DataRow(SINGLE_TYPE, "lt", "real '.33'", "0.33", "<")] + [DataRow(SINGLE_TYPE, "lte", "real '.33'", "0.33", "<=")] + [DataRow(SINGLE_TYPE, "neq", "real '9.2'", "9.2", "!=")] + [DataRow(SINGLE_TYPE, "eq", "real '0.33'", "0.33", "=")] + [DataTestMethod] + public async Task PG_real_graphql_single_filter_expectedValues( + string type, + string filterOperator, + string sqlValue, + string gqlValue, + string queryOperator) + { + await QueryTypeColumnFilterAndOrderBy(type, filterOperator, sqlValue, gqlValue, queryOperator); + } + + protected override string MakeQueryOnTypeTable(List queryFields, int id) { - return MakeQueryOnTypeTable(columnsToQuery, filterValue: id.ToString(), filterField: "id"); + return MakeQueryOnTypeTable(queryFields, filterValue: id.ToString(), filterField: "id"); } protected override string MakeQueryOnTypeTable( - List queriedColumns, + List queryFields, string filterValue = "1", string filterOperator = "=", string filterField = "1", @@ -42,7 +68,7 @@ protected override string MakeQueryOnTypeTable( return @" " + formattedSelect + @" FROM - (SELECT " + string.Join(", ", queriedColumns.Select(c => ProperlyFormatTypeTableColumn(c) + $" AS {c}")) + @" + (SELECT " + string.Join(", ", queryFields.Select(field => ProperlyFormatTypeTableColumn(field.BackingColumnName) + $" AS {field.Alias}")) + @" FROM public.type_table AS table0 WHERE " + filterField + " " + filterOperator + " " + filterValue + @" ORDER BY " + orderBy + @" asc @@ -60,6 +86,7 @@ protected override bool IsSupportedType(string type) DATETIME2_TYPE => false, DATETIMEOFFSET_TYPE => false, TIME_TYPE => false, + LOCALTIME_TYPE => false, _ => true }; }