diff --git a/src/Core/Resolvers/PostgreSqlExecutor.cs b/src/Core/Resolvers/PostgreSqlExecutor.cs index 70fa0f1079..3c525329c7 100644 --- a/src/Core/Resolvers/PostgreSqlExecutor.cs +++ b/src/Core/Resolvers/PostgreSqlExecutor.cs @@ -84,6 +84,11 @@ private void ConfigurePostgreSqlQueryExecutor() { NpgsqlConnectionStringBuilder builder = new(dataSource.ConnectionString); + if (string.IsNullOrEmpty(builder.Timezone)) + { + builder.Timezone = "UTC"; + } + if (_runtimeConfigProvider.IsLateConfigured) { builder.SslMode = SslMode.VerifyFull; diff --git a/src/Core/Services/ExecutionHelper.cs b/src/Core/Services/ExecutionHelper.cs index 79bdc6af9c..2932886966 100644 --- a/src/Core/Services/ExecutionHelper.cs +++ b/src/Core/Services/ExecutionHelper.cs @@ -63,7 +63,7 @@ public async ValueTask ExecuteQueryAsync(IMiddlewareContext context) IQueryEngine queryEngine = _queryEngineFactory.GetQueryEngine(ds.DatabaseType); IDictionary parameters = GetParametersFromContext(context); - + if (context.Selection.Type.IsListType()) { Tuple, IMetadata?> result = @@ -395,9 +395,49 @@ private static bool TryGetPropertyFromParent( SupportedHotChocolateTypes.SINGLE_TYPE => value is IntValueNode intValueNode ? intValueNode.ToSingle() : ((FloatValueNode)value).ToSingle(), SupportedHotChocolateTypes.FLOAT_TYPE => value is IntValueNode intValueNode ? intValueNode.ToDouble() : ((FloatValueNode)value).ToDouble(), SupportedHotChocolateTypes.DECIMAL_TYPE => value is IntValueNode intValueNode ? intValueNode.ToDecimal() : ((FloatValueNode)value).ToDecimal(), + SupportedHotChocolateTypes.DATETIME_TYPE => ParseDateTimeValue(value.Value), SupportedHotChocolateTypes.UUID_TYPE => Guid.TryParse(value.Value!.ToString(), out Guid guidValue) ? guidValue : value.Value, _ => value.Value }; + + static object? ParseDateTimeValue(object? raw) + { + if (raw is null) + { + return null; + } + + if (raw is DateTime dt) + { + return dt.Kind switch + { + DateTimeKind.Utc => dt, + DateTimeKind.Unspecified => DateTime.SpecifyKind(dt, DateTimeKind.Utc), + _ => dt.ToUniversalTime() + }; + } + + if (raw is DateTimeOffset dto) + { + return dto.UtcDateTime; + } + + if (raw is string s) + { + // HotChocolate DateTime inputs are supplied as strings; parse them so DB providers + // (notably PostgreSQL) receive a typed UTC parameter instead of text. + if (DateTimeOffset.TryParse( + s, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, + out DateTimeOffset parsedDto)) + { + return parsedDto.UtcDateTime; + } + } + + return raw; + } } /// diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs index 8b6e399d45..aaf19246d5 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs @@ -86,6 +86,29 @@ public async Task PGSQL_real_graphql_in_filter_expectedValues( await QueryTypeColumnFilterAndOrderBy(type, "in", sqlValue, gqlValue, "IN"); } + /// + /// PostgreSQL datetime filter tests with timezone offsets. + /// Verifies that GraphQL datetime arguments are normalized to UTC before filtering. + /// + [DataRow(DATETIME_TYPE, "eq", "'1999-01-08 10:23:54'", "\"1999-01-08T05:23:54-05:00\"", "=", + DisplayName = "DateTime filter eq converts -05:00 offset to UTC.")] + [DataRow(DATETIME_TYPE, "gte", "'1999-01-08 10:23:54'", "\"1999-01-08T05:23:54-05:00\"", ">=", + DisplayName = "DateTime filter gte converts -05:00 offset to UTC.")] + [DataRow(DATETIME_TYPE, "eq", "'1999-01-08 10:23:54'", "\"1999-01-08T15:53:54+05:30\"", "=", + DisplayName = "DateTime filter eq converts +05:30 offset to UTC.")] + [DataRow(DATETIME_TYPE, "eq", "'1999-01-08 10:23:54'", "\"1999-01-08T10:23:54Z\"", "=", + DisplayName = "DateTime filter eq preserves UTC input.")] + [DataTestMethod] + public async Task PGSQL_real_graphql_datetime_filter_offset_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(queryFields, filterValue: id.ToString(), filterField: "id"); diff --git a/src/Service.Tests/UnitTests/ExecutionHelperUnitTests.cs b/src/Service.Tests/UnitTests/ExecutionHelperUnitTests.cs new file mode 100644 index 0000000000..5c1547cb25 --- /dev/null +++ b/src/Service.Tests/UnitTests/ExecutionHelperUnitTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Azure.DataApiBuilder.Service.Services; +using HotChocolate.Execution; +using HotChocolate.Language; +using HotChocolate.Types; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Azure.DataApiBuilder.Service.Tests.UnitTests; + +[TestClass] +public class ExecutionHelperUnitTests +{ + [TestMethod] + public void ExtractValueFromIValueNode_DateTimeLiteral_ReturnsUtcDateTime() + { + Mock argumentSchema = CreateArgumentSchema(new DateTimeType()); + Mock variables = new(); + + object result = ExecutionHelper.ExtractValueFromIValueNode( + new StringValueNode("2026-04-22T10:15:30-07:00"), + argumentSchema.Object, + variables.Object); + + Assert.IsInstanceOfType(result); + Assert.AreEqual( + new DateTime(2026, 4, 22, 17, 15, 30, DateTimeKind.Utc), + (DateTime)result); + } + + [TestMethod] + public void ExtractValueFromIValueNode_DateTimeVariable_ReturnsUtcDateTime() + { + Mock argumentSchema = CreateArgumentSchema(new DateTimeType()); + Mock variables = new(); + variables + .Setup(v => v.GetValue("createdAt")) + .Returns(new StringValueNode("2026-04-22T10:15:30Z")); + + object result = ExecutionHelper.ExtractValueFromIValueNode( + new VariableNode("createdAt"), + argumentSchema.Object, + variables.Object); + + Assert.IsInstanceOfType(result); + Assert.AreEqual( + new DateTime(2026, 4, 22, 10, 15, 30, DateTimeKind.Utc), + (DateTime)result); + } + + private static Mock CreateArgumentSchema(IInputType type) + { + Mock argumentSchema = new(); + argumentSchema.SetupGet(a => a.Type).Returns(type); + return argumentSchema; + } +} \ No newline at end of file