diff --git a/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs b/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs index 372005a4ab..3544f6a2da 100644 --- a/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs +++ b/src/Core/Resolvers/Sql Query Structures/BaseSqlQueryStructure.cs @@ -85,6 +85,32 @@ public BaseSqlQueryStructure( } } + /// + public override string MakeDbConnectionParam(object? value, string? paramName = null, bool lengthOverride = false) + { + if (MetadataProvider.GetDatabaseType() is DatabaseType.PostgreSQL && + !string.IsNullOrEmpty(paramName) && + value is string stringValue && + GetUnderlyingSourceDefinition().Columns.ContainsKey(paramName)) + { + Type columnSystemType = GetColumnSystemType(paramName); + if (columnSystemType != typeof(string)) + { + value = GetParamAsSystemType(stringValue, paramName, columnSystemType); + } + + // Npgsql requires DateTime with Kind=Unspecified for 'timestamp without time zone' columns. + // ParseParamAsSystemType returns Kind=Utc (via .UtcDateTime), which causes PostgreSQL to + // apply a UTC-to-local offset during comparison, producing incorrect filter results. + if (value is DateTime dtValue && dtValue.Kind == DateTimeKind.Utc && columnSystemType == typeof(DateTime)) + { + value = DateTime.SpecifyKind(dtValue, DateTimeKind.Unspecified); + } + } + + return base.MakeDbConnectionParam(value, paramName, lengthOverride); + } + /// /// For UPDATE (OVERWRITE) operation /// Adds result of (SourceDefinition.Columns minus MutationFields) to UpdateOperations with null values @@ -422,9 +448,9 @@ protected List GenerateOutputColumns() /// Tries to parse the string parameter to the given system type /// Useful for inferring parameter types for columns or procedure parameters /// - /// - /// - /// + /// The string value to parse. + /// The target system type for the parsed value. + /// The parameter parsed as the requested system type. /// protected static object ParseParamAsSystemType(string param, Type systemType) { diff --git a/src/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs index a7b51d8827..d449c396c0 100644 --- a/src/Core/Services/GraphQLSchemaCreator.cs +++ b/src/Core/Services/GraphQLSchemaCreator.cs @@ -295,9 +295,9 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction Dictionary> rolesAllowedForFields = new(); SourceDefinition sourceDefinition = sqlMetadataProvider.GetSourceDefinition(entityName); bool isStoredProcedure = entity.Source.Type is EntitySourceType.StoredProcedure; + EntityActionOperation operation = isStoredProcedure ? EntityActionOperation.Execute : EntityActionOperation.Read; foreach (string column in sourceDefinition.Columns.Keys) { - EntityActionOperation operation = isStoredProcedure ? EntityActionOperation.Execute : EntityActionOperation.Read; IEnumerable roles = _authorizationResolver.GetRolesForField(entityName, field: column, operation: operation); if (!rolesAllowedForFields.TryAdd(key: column, value: roles)) { @@ -309,7 +309,6 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction } } - // The roles allowed for Fields are the roles allowed to READ the fields, so any role that has a read definition for the field. // Only add objectTypeDefinition for GraphQL if it has a role definition defined for access. if (rolesAllowedForEntity.Any()) { @@ -397,23 +396,18 @@ private DocumentNode GenerateSqlGraphQLObjects(RuntimeEntities entities, Diction GenerateSourceTargetLinkingObjectDefinitions(objectTypes, linkingObjectTypes); } - // Return a list of all the object types to be exposed in the schema. - Dictionary fields = new(); - - // Add the DBOperationResult type to the schema NameNode nameNode = new(value: GraphQLUtils.DB_OPERATION_RESULT_TYPE); - FieldDefinitionNode field = GetDbOperationResultField(); - - fields.TryAdd(GraphQLUtils.DB_OPERATION_RESULT_FIELD_NAME, field); + // Add the DBOperationResult type to the schema objectTypes.Add(GraphQLUtils.DB_OPERATION_RESULT_TYPE, new ObjectTypeDefinitionNode( location: null, name: nameNode, description: null, new List(), new List(), - fields.Values.ToImmutableList())); + ImmutableList.Create(GetDbOperationResultField()))); + // Return a list of all the object types to be exposed in the schema. List nodes = new(objectTypes.Values); nodes.AddRange(enumTypes.Values); return new DocumentNode(nodes); @@ -748,7 +742,7 @@ private static FieldDefinitionNode GetDbOperationResultField() DocumentNode cosmosResult = GenerateCosmosGraphQLObjects(cosmosDataSourceNames, inputObjects); DocumentNode sqlResult = GenerateSqlGraphQLObjects(sql, inputObjects); // Create Root node with definitions from both cosmos and sql. - DocumentNode root = new(cosmosResult.Definitions.Concat(sqlResult.Definitions).ToImmutableList()); + DocumentNode root = cosmosResult.WithDefinitions(cosmosResult.Definitions.Concat(sqlResult.Definitions).ToImmutableList()); // Merge the inputobjectType definitions from cosmos and sql onto the root. return (root.WithDefinitions(root.Definitions.Concat(inputObjects.Values).ToImmutableList()), inputObjects); diff --git a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs index fda9bdba43..db10f9fca3 100644 --- a/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Core/Services/MetadataProviders/SqlMetadataProvider.cs @@ -158,7 +158,7 @@ public virtual string GetSchemaName(string entityName) { if (!EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? databaseObject)) { - throw new DataApiBuilderException(message: $"Table Definition for {entityName} has not been inferred.", + throw new DataApiBuilderException(message: $"Database object for entity '{entityName}' has not been inferred.", statusCode: HttpStatusCode.InternalServerError, subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } @@ -176,7 +176,7 @@ public string GetDatabaseObjectName(string entityName) { if (!EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? databaseObject)) { - throw new DataApiBuilderException(message: $"Table Definition for {entityName} has not been inferred.", + throw new DataApiBuilderException(message: $"Database object for entity '{entityName}' has not been inferred.", statusCode: HttpStatusCode.InternalServerError, subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } @@ -189,7 +189,7 @@ public SourceDefinition GetSourceDefinition(string entityName) { if (!EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? databaseObject)) { - throw new DataApiBuilderException(message: $"Table Definition for {entityName} has not been inferred.", + throw new DataApiBuilderException(message: $"Source definition for entity '{entityName}' has not been inferred.", statusCode: HttpStatusCode.InternalServerError, subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } @@ -202,7 +202,7 @@ public StoredProcedureDefinition GetStoredProcedureDefinition(string entityName) { if (!EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? databaseObject)) { - throw new DataApiBuilderException(message: $"Stored Procedure Definition for {entityName} has not been inferred.", + throw new DataApiBuilderException(message: $"Stored procedure definition for entity '{entityName}' has not been inferred.", statusCode: HttpStatusCode.InternalServerError, subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } @@ -1733,7 +1733,7 @@ private async Task ValidateDatabaseConnection() /// /// Using a data adapter, obtains the schema of the given table name - /// and adds the corresponding entity in the data set. + /// and adds the corresponding DataTable to the entities data set. /// private async Task FillSchemaForTableAsync( string schemaName, diff --git a/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs b/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs index c66c64a18d..1f29946fb4 100644 --- a/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs +++ b/src/Service.GraphQLBuilder/GraphQLTypes/SupportedTypes.cs @@ -6,7 +6,7 @@ namespace Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes /// /// Only used to group the supported type names under a class with a relevant name. /// The type names mentioned here are Hotchocolate scalar built in types. - /// The corresponding SQL type name may be different for e.g. UUID maps to Guid as the SQL type. + /// The corresponding SQL type name may be different for e.g. UUID maps to Guid as the .NET type. /// public static class SupportedHotChocolateTypes { @@ -32,7 +32,6 @@ public static class SupportedHotChocolateTypes // new name so the generated schema does not depend on a deprecated scalar. public const string BYTEARRAY_TYPE = "Base64String"; public const string DATETIME_TYPE = "DateTime"; - public const string DATETIMEOFFSET_TYPE = "DateTimeOffset"; public const string LOCALTIME_TYPE = "LocalTime"; public const string TIME_TYPE = "Time"; } @@ -46,6 +45,7 @@ public static class SupportedDateTimeTypes public const string DATE_TYPE = "date"; public const string SMALLDATETIME_TYPE = "smalldatetime"; public const string DATETIME2_TYPE = "datetime2"; + public const string DATETIMEOFFSET_TYPE = "DateTimeOffset"; } /// diff --git a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs index 622376dc13..2de8f1f09f 100644 --- a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs +++ b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs @@ -39,8 +39,8 @@ public enum AggregationType /// Runtime config information for the table. /// Key/Value Collection mapping entity name to the entity object, /// currently used to lookup relationship metadata. - /// Roles to add to authorize directive at the object level (applies to query/read ops). - /// Roles to add to authorize directive at the field level (applies to mutations). + /// Roles to add to authorize directive at the object level. + /// Roles to add to authorize directive at the field level. /// A GraphQL object type to be provided to a Hot Chocolate GraphQL document. public static ObjectTypeDefinitionNode GenerateObjectTypeDefinitionForDatabaseObject( string entityName, @@ -179,7 +179,7 @@ private static ObjectTypeDefinitionNode CreateObjectTypeDefinitionForTableOrView List directives = new(); if (sourceDefinition.PrimaryKey.Contains(columnName)) { - directives.Add(new DirectiveNode(PrimaryKeyDirectiveType.DirectiveName, new ArgumentNode("databaseType", column.SystemType.Name))); + directives.Add(new DirectiveNode(PrimaryKeyDirectiveType.DirectiveName)); } if (column.IsReadOnly) diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 247685793f..8903f7ab77 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -1847,8 +1847,8 @@ public async Task TestSqlMetadataValidationForEntitiesWithInvalidSource() List exceptionMessagesList = configValidator.ConfigValidationExceptions.Select(x => x.Message).ToList(); Assert.IsTrue(exceptionMessagesList.Contains("The entity Book does not have a valid source object.")); Assert.IsTrue(exceptionMessagesList.Contains("The entity Publisher does not have a valid source object.")); - Assert.IsTrue(exceptionMessagesList.Contains("Table Definition for Book has not been inferred.")); - Assert.IsTrue(exceptionMessagesList.Contains("Table Definition for Publisher has not been inferred.")); + Assert.IsTrue(exceptionMessagesList.Contains("Database object for entity 'Book' has not been inferred.")); + Assert.IsTrue(exceptionMessagesList.Contains("Database object for entity 'Publisher' has not been inferred.")); Assert.IsTrue(exceptionMessagesList.Contains("Could not infer database object for source entity: Publisher in relationship: books. Check if the entity: Publisher is correctly defined in the config.")); Assert.IsTrue(exceptionMessagesList.Contains("Could not infer database object for target entity: Book in relationship: books. Check if the entity: Book is correctly defined in the config.")); } diff --git a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs index b9459668d7..d06c5dea3c 100644 --- a/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLSupportedTypesTests/PostgreSqlGQLSupportedTypesTests.cs @@ -67,7 +67,7 @@ public async Task PG_real_graphql_single_filter_expectedValues( [DataRow(BOOLEAN_TYPE, "'false'", "false")] [DataRow(STRING_TYPE, "lksa;jdflasdf;alsdflksdfkldj", "\"lksa;jdflasdf;alsdflksdfkldj\"")] [DataTestMethod] - public async Task PGSQL_real_graphql_in_filter_expectedValues( + public async Task PGSQL_graphql_in_filter_expectedValues( string type, string sqlValue, string gqlValue) @@ -99,7 +99,7 @@ protected override string MakeQueryOnTypeTable( string orderBy = "id", string limit = "1") { - string formattedSelect = limit.Equals("1") ? "SELECT to_jsonb(subq3) AS DATA" : "SELECT json_agg(to_jsonb(subq3)) AS DATA"; + string formattedSelect = limit.Equals("1") ? "SELECT to_jsonb(subq3) AS DATA" : "SELECT COALESCE(json_agg(to_jsonb(subq3)), '[]'::json) AS DATA"; return @" " + formattedSelect + @" @@ -141,15 +141,5 @@ private static string ProperlyFormatTypeTableColumn(string columnName) return columnName; } } - - /// - /// Bypass DateTime GQL tests for PostreSql - /// - [DataTestMethod] - [Ignore] - public new void QueryTypeColumnFilterAndOrderByDateTime(string type, string filterOperator, string sqlValue, string gqlValue, string queryOperator) - { - Assert.Inconclusive("Test skipped for PostgreSql."); - } } }