diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json
index f8a06fe61c..f6edb4df32 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 991a6fc64d..fbb397e22c 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 3ff9e58531..d0ecbc1ce3 100644
--- a/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs
+++ b/src/Service.Tests/Configuration/RuntimeConfigLoaderTests.cs
@@ -504,4 +504,104 @@ 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"": {}
+ }";
+
+ RuntimeConfig runtimeConfig = LoadConfig(configJson);
+
+ 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"": {}
+ }";
+
+ RuntimeConfig runtimeConfig = LoadConfig(configJson);
+
+ 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 honours the explicit value set in the config file.
+ ///
+ [DataTestMethod]
+ [DataRow(true, DisplayName = "Explicit true is respected")]
+ [DataRow(false, DisplayName = "Explicit false is respected")]
+ public void EnableAggregation_WhenExplicitlySet_ReturnsConfiguredValue(bool explicitValue)
+ {
+ 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"": {explicitValue.ToString().ToLower()}
+ }},
+ ""host"": {{
+ ""authentication"": {{ ""provider"": ""StaticWebApps"" }}
+ }}
+ }},
+ ""entities"": {{}}
+ }}";
+
+ RuntimeConfig runtimeConfig = LoadConfig(configJson);
+
+ Assert.AreEqual(explicitValue, runtimeConfig.EnableAggregation,
+ $"EnableAggregation should be {explicitValue} when explicitly set to {explicitValue} in config.");
+ }
+
+ ///
+ /// Loads a from a JSON string using a mock file system.
+ ///
+ private static RuntimeConfig LoadConfig(string configJson)
+ {
+ IFileSystem fs = new MockFileSystem(new Dictionary
+ {
+ { "dab-config.json", new MockFileData(configJson) }
+ });
+
+ FileSystemRuntimeConfigLoader loader = new(fs);
+ Assert.IsTrue(loader.TryLoadConfig("dab-config.json", out RuntimeConfig config), "Config should load successfully.");
+ return config;
+ }
}
diff --git a/src/Service.Tests/GraphQLBuilder/QueryBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/QueryBuilderTests.cs
index c257a86054..93a3df9778 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,74 @@ 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 correctly adds or omits the groupBy field on the
+ /// connection type based on whether the database type is in
+ /// .
+ /// MSSQL and DWSQL are supported; other types (e.g. PostgreSQL) are not.
+ ///
+ [DataTestMethod]
+ [DataRow(DatabaseType.MSSQL, true, DisplayName = "MSSQL: groupBy field present when aggregation enabled")]
+ [DataRow(DatabaseType.DWSQL, true, DisplayName = "DWSQL: groupBy field present when aggregation enabled")]
+ [DataRow(DatabaseType.PostgreSQL, false, DisplayName = "PostgreSQL: groupBy field absent (not in AggregationEnabledDatabaseTypes)")]
+ [DataRow(DatabaseType.MySQL, false, DisplayName = "MySQL: groupBy field absent (not in AggregationEnabledDatabaseTypes)")]
+ [TestCategory("Query Builder - Aggregation")]
+ public void Build_WithAggregationEnabled_GroupByPresenceMatchesDatabaseSupport(
+ DatabaseType dbType,
+ bool expectGroupBy)
+ {
+ // Arrange
+ DocumentNode root = Utf8GraphQLParser.Parse(BOOK_WITH_NUMERIC_FIELDS_GQL);
+ Dictionary entityNameToDatabaseType = new()
+ {
+ { "Book", dbType }
+ };
+
+ // 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");
+ if (expectGroupBy)
+ {
+ Assert.IsNotNull(groupByField, $"groupBy field should exist on BookConnection for {dbType}");
+ }
+ else
+ {
+ Assert.IsNull(groupByField, $"groupBy field should NOT exist on BookConnection for {dbType} (not in AggregationEnabledDatabaseTypes)");
+ }
+ }
+
public static ObjectTypeDefinitionNode GetQueryNode(DocumentNode queryRoot)
{
return (ObjectTypeDefinitionNode)queryRoot.Definitions.First(d => d is ObjectTypeDefinitionNode node && node.Name.Value == "Query");