diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 4561b06952..25dd39bc95 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -244,6 +244,11 @@ "description": "Maximum allowed depth of a GraphQL query.", "default": null }, + "enable-aggregation": { + "$ref": "#/$defs/boolean-or-string", + "description": "Allow enabling/disabling aggregation (groupBy, sum, avg, min, max, count) for supported database types (MSSQL, DWSQL).", + "default": true + }, "multiple-mutations": { "type": "object", "description": "Configuration properties for multiple mutation operations", diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index d6bdefc1b8..5d18ec75da 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -194,12 +194,14 @@ Runtime.GraphQL is null || public string DefaultDataSourceName { get; set; } /// - /// Retrieves the value of runtime.graphql.aggregation.enabled property if present, default is true. + /// Retrieves the value of runtime.graphql.enable-aggregation property if present, default is true. + /// Returns true when runtime section is absent, when graphql section is absent, + /// or when enable-aggregation is explicitly set to true. /// [JsonIgnore] public bool EnableAggregation => - Runtime is not null && - Runtime.GraphQL is not null && + Runtime is null || + Runtime.GraphQL is null || Runtime.GraphQL.EnableAggregation; [JsonIgnore] diff --git a/src/Core/Services/GraphQLSchemaCreator.cs b/src/Core/Services/GraphQLSchemaCreator.cs index 90e918c833..229b8b0855 100644 --- a/src/Core/Services/GraphQLSchemaCreator.cs +++ b/src/Core/Services/GraphQLSchemaCreator.cs @@ -80,12 +80,14 @@ public GraphQLSchemaCreator( /// /// Executed when a hot-reload event occurs. Pulls the latest /// runtimeconfig object from the provider and updates the flag indicating - /// whether multiple create operations are enabled, and the entities based on the new config. + /// whether multiple create operations are enabled, whether aggregation is enabled, + /// and the entities based on the new config. /// protected void OnConfigChanged(object? sender, HotReloadEventArgs args) { RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig(); _isMultipleCreateOperationEnabled = runtimeConfig.IsMultipleCreateOperationEnabled(); + _isAggregationEnabled = runtimeConfig.EnableAggregation; _entities = runtimeConfig.Entities; } diff --git a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs index d724753f97..024c84c409 100644 --- a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs +++ b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs @@ -283,4 +283,159 @@ public async Task ChildConfigLoadFailureHaltsParentConfigLoading() } } } + + /// + /// Tests that EnableAggregation returns true by default when runtime.graphql section is absent. + /// This is a regression test for the bug where EnableAggregation returned false (disabled) + /// when Runtime.GraphQL was null, even though the default value for EnableAggregation is true. + /// + [TestMethod] + public void EnableAggregation_WhenGraphQLSectionAbsent_DefaultsToTrue() + { + // Arrange: a minimal config with no runtime.graphql section + string configJson = @"{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""Server=tcp:127.0.0.1,1433;"" + }, + ""runtime"": { + ""host"": { + ""authentication"": { ""provider"": ""StaticWebApps"" } + } + }, + ""entities"": {} + }"; + + IFileSystem fs = new MockFileSystem(new Dictionary() + { + { "dab-config.json", new MockFileData(configJson) } + }); + + FileSystemRuntimeConfigLoader loader = new(fs); + + // Act + bool loaded = loader.TryLoadConfig("dab-config.json", out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(loaded, "Config should load successfully."); + Assert.IsNull(runtimeConfig.Runtime?.GraphQL, "GraphQL section should be null for this config."); + Assert.IsTrue(runtimeConfig.EnableAggregation, + "EnableAggregation should default to true when runtime.graphql section is absent."); + } + + /// + /// Tests that EnableAggregation returns true by default when runtime section is absent. + /// + [TestMethod] + public void EnableAggregation_WhenRuntimeSectionAbsent_DefaultsToTrue() + { + // Arrange: a minimal config with no runtime section at all + string configJson = @"{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""Server=tcp:127.0.0.1,1433;"" + }, + ""entities"": {} + }"; + + IFileSystem fs = new MockFileSystem(new Dictionary() + { + { "dab-config.json", new MockFileData(configJson) } + }); + + FileSystemRuntimeConfigLoader loader = new(fs); + + // Act + bool loaded = loader.TryLoadConfig("dab-config.json", out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(loaded, "Config should load successfully."); + Assert.IsNull(runtimeConfig.Runtime, "Runtime section should be null for this config."); + Assert.IsTrue(runtimeConfig.EnableAggregation, + "EnableAggregation should default to true when runtime section is absent."); + } + + /// + /// Tests that EnableAggregation returns false when explicitly disabled in config. + /// + [TestMethod] + public void EnableAggregation_WhenExplicitlyDisabled_ReturnsFalse() + { + // Arrange: a config with enable-aggregation explicitly set to false + string configJson = @"{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""Server=tcp:127.0.0.1,1433;"" + }, + ""runtime"": { + ""graphql"": { + ""enabled"": true, + ""enable-aggregation"": false + }, + ""host"": { + ""authentication"": { ""provider"": ""StaticWebApps"" } + } + }, + ""entities"": {} + }"; + + IFileSystem fs = new MockFileSystem(new Dictionary() + { + { "dab-config.json", new MockFileData(configJson) } + }); + + FileSystemRuntimeConfigLoader loader = new(fs); + + // Act + bool loaded = loader.TryLoadConfig("dab-config.json", out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(loaded, "Config should load successfully."); + Assert.IsFalse(runtimeConfig.EnableAggregation, + "EnableAggregation should be false when explicitly set to false in config."); + } + + /// + /// Tests that EnableAggregation returns true when explicitly enabled in config. + /// + [TestMethod] + public void EnableAggregation_WhenExplicitlyEnabled_ReturnsTrue() + { + // Arrange: a config with enable-aggregation explicitly set to true + string configJson = @"{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""Server=tcp:127.0.0.1,1433;"" + }, + ""runtime"": { + ""graphql"": { + ""enabled"": true, + ""enable-aggregation"": true + }, + ""host"": { + ""authentication"": { ""provider"": ""StaticWebApps"" } + } + }, + ""entities"": {} + }"; + + IFileSystem fs = new MockFileSystem(new Dictionary() + { + { "dab-config.json", new MockFileData(configJson) } + }); + + FileSystemRuntimeConfigLoader loader = new(fs); + + // Act + bool loaded = loader.TryLoadConfig("dab-config.json", out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(loaded, "Config should load successfully."); + Assert.IsTrue(runtimeConfig.EnableAggregation, + "EnableAggregation should be true when explicitly set to true in config."); + } } diff --git a/src/Service.Tests/GraphQLBuilder/QueryBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/QueryBuilderTests.cs index c257a86054..56cac93c8a 100644 --- a/src/Service.Tests/GraphQLBuilder/QueryBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/QueryBuilderTests.cs @@ -18,6 +18,16 @@ public class QueryBuilderTests { private const int NUMBER_OF_ARGUMENTS = 4; + /// + /// GQL schema for a Book entity with numeric fields, used for aggregation tests. + /// + private const string BOOK_WITH_NUMERIC_FIELDS_GQL = @" +type Book @model(name:""Book"") { + id: ID! + price: Float! + title: String +}"; + private Dictionary _entityPermissions; /// @@ -37,6 +47,14 @@ public void SetupEntityPermissionsMap() ); } + private static Dictionary CreateBookEntityPermissions() + { + return GraphQLTestHelpers.CreateStubEntityPermissionsMap( + new string[] { "Book" }, + new EntityActionOperation[] { EntityActionOperation.Read }, + new string[] { "anonymous" }); + } + [DataTestMethod] [TestCategory("Query Generation")] [TestCategory("Single item access")] @@ -538,6 +556,95 @@ public void GenerateReturnType_IncludesGroupByField() Assert.AreEqual("BookGroupBy", groupByType.Name.Value, "should return GroupBy type"); } + /// + /// Tests that the return type does NOT include the groupBy field when aggregation is disabled. + /// + [TestMethod] + [TestCategory("Query Builder - Return Type")] + public void GenerateReturnType_ExcludesGroupByField_WhenAggregationDisabled() + { + // Arrange + NameNode entityName = new("Book"); + + // Act + ObjectTypeDefinitionNode returnType = QueryBuilder.GenerateReturnType(entityName, isAggregationEnabled: false); + + // Assert + FieldDefinitionNode groupByField = returnType.Fields.FirstOrDefault(f => f.Name.Value == "groupBy"); + Assert.IsNull(groupByField, "groupBy field should NOT exist when aggregation is disabled"); + } + + /// + /// Tests that QueryBuilder.Build adds a groupBy field to the connection type + /// for MSSQL entities when aggregation is enabled. This verifies the fix for the + /// regression where aggregation features were not accessible in the schema. + /// + [TestMethod] + [TestCategory("Query Builder - Aggregation")] + public void Build_WithMssqlAndAggregationEnabled_AddsGroupByToConnectionType() + { + // Arrange + DocumentNode root = Utf8GraphQLParser.Parse(BOOK_WITH_NUMERIC_FIELDS_GQL); + Dictionary entityNameToDatabaseType = new() + { + { "Book", DatabaseType.MSSQL } + }; + + // Act + DocumentNode queryRoot = QueryBuilder.Build( + root, + entityNameToDatabaseType, + new(new Dictionary { { "Book", GraphQLTestHelpers.GenerateEmptyEntity() } }), + inputTypes: new(), + entityPermissionsMap: CreateBookEntityPermissions(), + _isAggregationEnabled: true + ); + + // Assert: find BookConnection type + ObjectTypeDefinitionNode bookConnection = queryRoot.Definitions + .OfType() + .FirstOrDefault(d => d.Name.Value == "BookConnection"); + Assert.IsNotNull(bookConnection, "BookConnection type should exist"); + + FieldDefinitionNode groupByField = bookConnection.Fields.FirstOrDefault(f => f.Name.Value == "groupBy"); + Assert.IsNotNull(groupByField, "groupBy field should exist on BookConnection when aggregation is enabled for MSSQL"); + } + + /// + /// Tests that QueryBuilder.Build does NOT add a groupBy field to the connection type + /// for PostgreSQL entities, since aggregation is only enabled for MSSQL and DWSQL. + /// + [TestMethod] + [TestCategory("Query Builder - Aggregation")] + public void Build_WithPostgreSqlAndAggregationEnabled_DoesNotAddGroupByToConnectionType() + { + // Arrange + DocumentNode root = Utf8GraphQLParser.Parse(BOOK_WITH_NUMERIC_FIELDS_GQL); + Dictionary entityNameToDatabaseType = new() + { + { "Book", DatabaseType.PostgreSQL } + }; + + // Act + DocumentNode queryRoot = QueryBuilder.Build( + root, + entityNameToDatabaseType, + new(new Dictionary { { "Book", GraphQLTestHelpers.GenerateEmptyEntity() } }), + inputTypes: new(), + entityPermissionsMap: CreateBookEntityPermissions(), + _isAggregationEnabled: true + ); + + // Assert: find BookConnection type + ObjectTypeDefinitionNode bookConnection = queryRoot.Definitions + .OfType() + .FirstOrDefault(d => d.Name.Value == "BookConnection"); + Assert.IsNotNull(bookConnection, "BookConnection type should exist"); + + FieldDefinitionNode groupByField = bookConnection.Fields.FirstOrDefault(f => f.Name.Value == "groupBy"); + Assert.IsNull(groupByField, "groupBy field should NOT exist on BookConnection for PostgreSQL (not in AggregationEnabledDatabaseTypes)"); + } + public static ObjectTypeDefinitionNode GetQueryNode(DocumentNode queryRoot) { return (ObjectTypeDefinitionNode)queryRoot.Definitions.First(d => d is ObjectTypeDefinitionNode node && node.Name.Value == "Query");