From 558c2edbd51bdfc0e85b489bef15098c9f182ade Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Tue, 21 Jul 2026 13:53:38 -0700 Subject: [PATCH 1/6] Use config parameter descriptions in GraphQL stored procedure args --- .../GraphQLStoredProcedureBuilder.cs | 10 ++- .../Sql/StoredProcedureBuilderTests.cs | 70 +++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs index ad3786fd36..cb1799e248 100644 --- a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs +++ b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs @@ -83,13 +83,17 @@ public static FieldDefinitionNode GenerateStoredProcedureSchema( parameterTypeNode = new NonNullTypeNode((INullableTypeNode)parameterTypeNode); } + string parameterDescription = !string.IsNullOrWhiteSpace(paramMetadata?.Description) + ? paramMetadata.Description + : !string.IsNullOrWhiteSpace(definition.Description) + ? definition.Description + : $"parameters for {name.Value} stored-procedure"; + inputValues.Add( new( location: null, name: new(param), - description: definition.Description != null - ? new StringValueNode(definition.Description) - : new StringValueNode($"parameters for {name.Value} stored-procedure"), + description: new StringValueNode(parameterDescription), type: parameterTypeNode, defaultValue: defaultValueNode, directives: new List()) diff --git a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs index 8530a3d6b2..5f2a60cb5a 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs @@ -355,6 +355,76 @@ public void StoredProcedure_RequiredWithDefault_KeepsDefaultValue() Assert.AreEqual("Demo Title", ((StringValueNode)arg.DefaultValue!).Value); } + [TestMethod] + public void StoredProcedure_ParameterDescription_UsesConfigDescription() + { + const string parameterName = "title"; + const string configDescription = "Title from runtime config"; + const string dbDescription = "Title from database metadata"; + + DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spParamDesc") + { + SourceType = EntitySourceType.StoredProcedure, + StoredProcedureDefinition = new() + { + Parameters = new() + { + { parameterName, new() { SystemType = typeof(string), Description = dbDescription } } + } + } + }; + spDbObj.SourceDefinition.Columns.TryAdd("col1", new() { SystemType = typeof(string) }); + + List configParameters = new() + { + new ParameterMetadata + { + Name = parameterName, + Description = configDescription + } + }; + + FieldDefinitionNode field = BuildSchemaAndGetExecuteField( + spDbObj: spDbObj, + configParameters: configParameters, + graphQLTypeName: "SpParamDescType", + entityName: "SpParamDesc"); + + InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName); + Assert.IsNotNull(arg.Description); + Assert.AreEqual(configDescription, arg.Description!.Value); + } + + [TestMethod] + public void StoredProcedure_ParameterDescription_FallsBackToDatabaseDescription() + { + const string parameterName = "title"; + const string dbDescription = "Title from database metadata"; + + DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spParamDescFallback") + { + SourceType = EntitySourceType.StoredProcedure, + StoredProcedureDefinition = new() + { + Parameters = new() + { + { parameterName, new() { SystemType = typeof(string), Description = dbDescription } } + } + } + }; + spDbObj.SourceDefinition.Columns.TryAdd("col1", new() { SystemType = typeof(string) }); + + FieldDefinitionNode field = BuildSchemaAndGetExecuteField( + spDbObj: spDbObj, + configParameters: new List(), + graphQLTypeName: "SpParamDescFallbackType", + entityName: "SpParamDescFallback"); + + InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName); + Assert.IsNotNull(arg.Description); + Assert.AreEqual(dbDescription, arg.Description!.Value); + } + /// /// Helper that builds a query schema for a stored-procedure entity and returns /// the generated execute* field so individual tests can assert on its argument From cc39f40411edf8784fed99a58b98914bf9720870 Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Tue, 21 Jul 2026 14:19:49 -0700 Subject: [PATCH 2/6] Added additional tests Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Sql/StoredProcedureBuilderTests.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs index 5f2a60cb5a..1220fcfc9b 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs @@ -425,6 +425,34 @@ public void StoredProcedure_ParameterDescription_FallsBackToDatabaseDescription( Assert.AreEqual(dbDescription, arg.Description!.Value); } + [TestMethod] + public void StoredProcedure_ParameterDescription_FallsBackToDefaultText() + { + const string parameterName = "title"; + const string graphQLTypeName = "SpParamDescDefaultTextType"; + const string entityName = "SpParamDescDefaultText"; + + DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spParamDescDefaultText") + { + SourceType = EntitySourceType.StoredProcedure, + StoredProcedureDefinition = new() + { + Parameters = new() { { parameterName, new() { SystemType = typeof(string) } } } + } + }; + spDbObj.SourceDefinition.Columns.TryAdd("col1", new() { SystemType = typeof(string) }); + + FieldDefinitionNode field = BuildSchemaAndGetExecuteField( + spDbObj: spDbObj, + configParameters: new List(), + graphQLTypeName: graphQLTypeName, + entityName: entityName); + + InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName); + Assert.IsNotNull(arg.Description); + Assert.AreEqual($"parameters for {graphQLTypeName} stored-procedure", arg.Description!.Value); + } + /// /// Helper that builds a query schema for a stored-procedure entity and returns /// the generated execute* field so individual tests can assert on its argument From 9c77906064dc93f2f95efd0489ae2ea0cb7f21bd Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Tue, 4 Aug 2026 19:04:57 -0700 Subject: [PATCH 3/6] fix(graphql): surface SP param descriptions in GraphQL schema; add duplicate param validation --- .../Configurations/RuntimeConfigValidator.cs | 22 +++ ...BuilderDescriptionMsSqlIntegrationTests.cs | 154 ++++++++++++++++++ .../Sql/StoredProcedureBuilderTests.cs | 94 ++++++++++- src/Service/Startup.cs | 1 - 4 files changed, 264 insertions(+), 7 deletions(-) create mode 100644 src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderDescriptionMsSqlIntegrationTests.cs diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 9ccd734c08..9265d27fc2 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -1011,6 +1011,28 @@ public void ValidateEntityConfiguration(RuntimeConfig runtimeConfig) ValidateNameRequirements(entity.GraphQL.Singular); ValidateNameRequirements(entity.GraphQL.Plural); } + + // Validate that stored procedure config parameters do not contain duplicate names. + // Duplicate names produce inconsistent behavior: GraphQL uses the first match (FirstOrDefault), + // while metadata hydration overwrites with the last match — so descriptions, required, and + // default values can silently diverge between GraphQL, OpenAPI, and MCP responses. + if (entity.Source.Type is EntitySourceType.StoredProcedure + && entity.Source.Parameters is not null) + { + HashSet seenParamNames = new(StringComparer.OrdinalIgnoreCase); + foreach (ParameterMetadata param in entity.Source.Parameters) + { + if (!seenParamNames.Add(param.Name)) + { + HandleOrRecordException(new DataApiBuilderException( + message: $"Entity '{entityName}' has duplicate parameter name '{param.Name}' in its stored procedure parameters configuration. " + + "Parameter names must be unique (case-insensitive).", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + break; + } + } + } } } diff --git a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderDescriptionMsSqlIntegrationTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderDescriptionMsSqlIntegrationTests.cs new file mode 100644 index 0000000000..702707e8e2 --- /dev/null +++ b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderDescriptionMsSqlIntegrationTests.cs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Service.GraphQLBuilder; +using Azure.DataApiBuilder.Service.Tests.SqlTests; +using HotChocolate.Language; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.GraphQLBuilder.Sql +{ + /// + /// Integration tests that verify stored-procedure parameter descriptions flow + /// end-to-end through the full production pipeline: + /// config parameters.description + /// → SqlMetadataProvider.FillSchemaForStoredProcedureAsync (merges onto ParameterDefinition) + /// → GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema (reads description) + /// → GraphQL argument description + /// + [TestClass, TestCategory(TestCategory.MSSQL)] + public class StoredProcedureBuilderDescriptionMsSqlIntegrationTests : SqlTestBase + { + private static RuntimeConfig _baseConfig; + + [ClassInitialize] + public static async Task SetupAsync(TestContext context) + { + DatabaseEngine = TestCategory.MSSQL; + await InitializeTestFixture(); + _baseConfig = SqlTestHelper.SetupRuntimeConfig(); + } + + /// + /// Verifies that a description configured on a stored-procedure parameter in the + /// runtime config is propagated through the SQL metadata provider and reflected in + /// the generated GraphQL argument description. + /// + /// Uses the existing get_book_by_id stored procedure (defined in the MsSql + /// test schema) with a config-side description override on its id parameter. + /// + [TestMethod] + public async Task StoredProcedure_GraphQLArgDescription_UsesConfigDescriptionAfterMetadataInit() + { + const string entityName = "GetBookWithParamDesc"; + const string configDescription = "The unique identifier for the book (from config)"; + + Entity tamperedEntity = new( + Source: new( + "get_book_by_id", + EntitySourceType.StoredProcedure, + Parameters: new List + { + new() { Name = "id", Description = configDescription } + }, + KeyFields: null), + GraphQL: new(entityName, entityName, Enabled: true, Operation: GraphQLOperation.Query), + Rest: new(Enabled: false), + Fields: null, + Permissions: new[] + { + new EntityPermission( + Role: "anonymous", + Actions: new[] + { + new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) + }, + Relationships: null, + Mappings: null, + Mcp: null); + + Dictionary entityMap = new() { [entityName] = tamperedEntity }; + RuntimeConfig tamperedConfig = _baseConfig with { Entities = new(entityMap) }; + RuntimeConfigProvider tamperedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(tamperedConfig); + SetUpSQLMetadataProvider(tamperedProvider); + await _sqlMetadataProvider.InitializeAsync(); + + DatabaseObject dbObject = _sqlMetadataProvider.EntityToDatabaseObject[entityName]; + FieldDefinitionNode field = GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema( + name: new NameNode(entityName), + entity: tamperedEntity, + dbObject: dbObject); + + InputValueDefinitionNode idArg = field.Arguments.First(a => a.Name.Value == "id"); + Assert.IsNotNull(idArg.Description); + Assert.AreEqual(expected: configDescription, actual: idArg.Description!.Value); + } + + /// + /// Verifies that when no description is set on a stored-procedure parameter in the + /// runtime config the generated GraphQL argument falls back to the default + /// description text. Exercises the same full pipeline as the positive-case test. + /// + [TestMethod] + public async Task StoredProcedure_GraphQLArgDescription_FallsBackToDefaultTextWhenNoConfigDescription() + { + const string entityName = "GetBookNoDesc"; + + Entity tamperedEntity = new( + Source: new( + "get_book_by_id", + EntitySourceType.StoredProcedure, + Parameters: new List { new() { Name = "id" } }, + KeyFields: null), + GraphQL: new(entityName, entityName, Enabled: true, Operation: GraphQLOperation.Query), + Rest: new(Enabled: false), + Fields: null, + Permissions: new[] + { + new EntityPermission( + Role: "anonymous", + Actions: new[] + { + new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) + }) + }, + Relationships: null, + Mappings: null, + Mcp: null); + + Dictionary entityMap = new() { [entityName] = tamperedEntity }; + RuntimeConfig tamperedConfig = _baseConfig with { Entities = new(entityMap) }; + RuntimeConfigProvider tamperedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(tamperedConfig); + SetUpSQLMetadataProvider(tamperedProvider); + await _sqlMetadataProvider.InitializeAsync(); + + try + { + DatabaseObject dbObject = _sqlMetadataProvider.EntityToDatabaseObject[entityName]; + FieldDefinitionNode field = GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema( + name: new NameNode(entityName), + entity: tamperedEntity, + dbObject: dbObject); + + InputValueDefinitionNode idArg = field.Arguments.First(a => a.Name.Value == "id"); + Assert.IsNotNull(idArg.Description); + Assert.AreEqual( + expected: $"parameters for {entityName} stored-procedure", + actual: idArg.Description!.Value); + } + finally + { + RuntimeConfigProvider sharedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(_baseConfig); + SetUpSQLMetadataProvider(sharedProvider); + await _sqlMetadataProvider.InitializeAsync(); + } + } + } +} diff --git a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs index ab625a336c..3fe8f80931 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs @@ -6,14 +6,18 @@ using System.Linq; using System.Net; using System.Text.Json; +using System.Threading.Tasks; using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Service.Exceptions; +using Azure.DataApiBuilder.Service.GraphQLBuilder; using Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations; using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries; using Azure.DataApiBuilder.Service.GraphQLBuilder.Sql; using Azure.DataApiBuilder.Service.Tests.GraphQLBuilder.Helpers; +using Azure.DataApiBuilder.Service.Tests.SqlTests; using HotChocolate.Language; using Microsoft.VisualStudio.TestTools.UnitTesting; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedHotChocolateTypes; @@ -402,7 +406,7 @@ public void StoredProcedure_ParameterDescription_UsesConfigDescription() { const string parameterName = "title"; const string configDescription = "Title from runtime config"; - const string dbDescription = "Title from database metadata"; + const string definitionDescription = "Description already on the parameter definition (config should win)"; DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spParamDesc") { @@ -411,7 +415,7 @@ public void StoredProcedure_ParameterDescription_UsesConfigDescription() { Parameters = new() { - { parameterName, new() { SystemType = typeof(string), Description = dbDescription } } + { parameterName, new() { SystemType = typeof(string), Description = definitionDescription } } } } }; @@ -438,10 +442,10 @@ public void StoredProcedure_ParameterDescription_UsesConfigDescription() } [TestMethod] - public void StoredProcedure_ParameterDescription_FallsBackToDatabaseDescription() + public void StoredProcedure_ParameterDescription_FallsBackToDefinitionDescriptionWhenNoConfigDescription() { const string parameterName = "title"; - const string dbDescription = "Title from database metadata"; + const string definitionDescription = "Title description on the parameter definition"; DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spParamDescFallback") { @@ -450,7 +454,7 @@ public void StoredProcedure_ParameterDescription_FallsBackToDatabaseDescription( { Parameters = new() { - { parameterName, new() { SystemType = typeof(string), Description = dbDescription } } + { parameterName, new() { SystemType = typeof(string), Description = definitionDescription } } } } }; @@ -464,7 +468,7 @@ public void StoredProcedure_ParameterDescription_FallsBackToDatabaseDescription( InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName); Assert.IsNotNull(arg.Description); - Assert.AreEqual(dbDescription, arg.Description!.Value); + Assert.AreEqual(definitionDescription, arg.Description!.Value); } [TestMethod] @@ -495,6 +499,84 @@ public void StoredProcedure_ParameterDescription_FallsBackToDefaultText() Assert.AreEqual($"parameters for {graphQLTypeName} stored-procedure", arg.Description!.Value); } + [DataTestMethod] + [DataRow("", DisplayName = "Empty config description falls back to definition description")] + [DataRow(" ", DisplayName = "Whitespace config description falls back to definition description")] + public void StoredProcedure_ParameterDescription_WhitespaceConfigDescriptionFallsBackToDefinitionDescription(string whitespaceDescription) + { + const string parameterName = "title"; + const string definitionDescription = "Title description on the parameter definition"; + + DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spParamDescWhitespace") + { + SourceType = EntitySourceType.StoredProcedure, + StoredProcedureDefinition = new() + { + Parameters = new() + { + { parameterName, new() { SystemType = typeof(string), Description = definitionDescription } } + } + } + }; + spDbObj.SourceDefinition.Columns.TryAdd("col1", new() { SystemType = typeof(string) }); + + List configParameters = new() + { + new ParameterMetadata { Name = parameterName, Description = whitespaceDescription } + }; + + FieldDefinitionNode field = BuildSchemaAndGetExecuteField( + spDbObj: spDbObj, + configParameters: configParameters, + graphQLTypeName: "SpParamDescWhitespaceType", + entityName: "SpParamDescWhitespace"); + + InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName); + Assert.IsNotNull(arg.Description); + Assert.AreEqual(definitionDescription, arg.Description!.Value); + } + + [DataTestMethod] + [DataRow("", "", DisplayName = "Both empty — falls back to default text")] + [DataRow(" ", " ", DisplayName = "Both whitespace — falls back to default text")] + [DataRow("", " ", DisplayName = "Empty config, whitespace definition — falls back to default text")] + [DataRow(" ", "", DisplayName = "Whitespace config, empty definition — falls back to default text")] + public void StoredProcedure_ParameterDescription_BothWhitespaceFallsBackToDefaultText( + string whitespaceConfigDescription, string whitespaceDefinitionDescription) + { + const string parameterName = "title"; + const string graphQLTypeName = "SpParamDescBothWhitespaceType"; + const string entityName = "SpParamDescBothWhitespace"; + + DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spParamDescBothWhitespace") + { + SourceType = EntitySourceType.StoredProcedure, + StoredProcedureDefinition = new() + { + Parameters = new() + { + { parameterName, new() { SystemType = typeof(string), Description = whitespaceDefinitionDescription } } + } + } + }; + spDbObj.SourceDefinition.Columns.TryAdd("col1", new() { SystemType = typeof(string) }); + + List configParameters = new() + { + new ParameterMetadata { Name = parameterName, Description = whitespaceConfigDescription } + }; + + FieldDefinitionNode field = BuildSchemaAndGetExecuteField( + spDbObj: spDbObj, + configParameters: configParameters, + graphQLTypeName: graphQLTypeName, + entityName: entityName); + + InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName); + Assert.IsNotNull(arg.Description); + Assert.AreEqual($"parameters for {graphQLTypeName} stored-procedure", arg.Description!.Value); + } + /// /// Helper that builds a query schema for a stored-procedure entity and returns /// the generated execute* field so individual tests can assert on its argument diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 74ae47d916..b41550bf2e 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -1153,7 +1153,6 @@ private void ConfigureAuthentication(IServiceCollection services, RuntimeConfigP { EasyAuthType easyAuthType = EnumExtensions.Deserialize(runtimeConfig.Runtime.Host.Authentication.Provider); bool isProductionMode = mode != HostMode.Development; - bool isProductionMode = mode != HostMode.Development; bool appServiceEnvironmentDetected = AppServiceAuthenticationInfo.AreExpectedAppServiceEnvVarsPresent(); bool swaEnvironmentDetected = StaticWebAppsAuthentication.AreExpectedSWAEnvVarsPresent(); From 3506322c3a7f170885e8363d1516c2eab49fd6ae Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Wed, 5 Aug 2026 13:16:26 -0700 Subject: [PATCH 4/6] fix: remove unused usings in StoredProcedureBuilderTests --- .../GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs index 3fe8f80931..b74d0862d4 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs @@ -6,18 +6,14 @@ using System.Linq; using System.Net; using System.Text.Json; -using System.Threading.Tasks; using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; -using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Service.Exceptions; -using Azure.DataApiBuilder.Service.GraphQLBuilder; using Azure.DataApiBuilder.Service.GraphQLBuilder.Mutations; using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries; using Azure.DataApiBuilder.Service.GraphQLBuilder.Sql; using Azure.DataApiBuilder.Service.Tests.GraphQLBuilder.Helpers; -using Azure.DataApiBuilder.Service.Tests.SqlTests; using HotChocolate.Language; using Microsoft.VisualStudio.TestTools.UnitTesting; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedHotChocolateTypes; From a900b1987c1fa11dabc9b0ad7977173bdc7287dd Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Wed, 5 Aug 2026 16:43:12 -0700 Subject: [PATCH 5/6] fix: address PR review comments - Ordinal comparer, restore provider in finally, remove artificial unit test --- .../Configurations/RuntimeConfigValidator.cs | 4 +- ...BuilderDescriptionMsSqlIntegrationTests.cs | 25 ++++++++---- .../Sql/StoredProcedureBuilderTests.cs | 40 ------------------- 3 files changed, 19 insertions(+), 50 deletions(-) diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 9265d27fc2..05daff10f3 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -1019,14 +1019,14 @@ public void ValidateEntityConfiguration(RuntimeConfig runtimeConfig) if (entity.Source.Type is EntitySourceType.StoredProcedure && entity.Source.Parameters is not null) { - HashSet seenParamNames = new(StringComparer.OrdinalIgnoreCase); + HashSet seenParamNames = new(StringComparer.Ordinal); foreach (ParameterMetadata param in entity.Source.Parameters) { if (!seenParamNames.Add(param.Name)) { HandleOrRecordException(new DataApiBuilderException( message: $"Entity '{entityName}' has duplicate parameter name '{param.Name}' in its stored procedure parameters configuration. " + - "Parameter names must be unique (case-insensitive).", + "Parameter names must be unique.", statusCode: HttpStatusCode.ServiceUnavailable, subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); break; diff --git a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderDescriptionMsSqlIntegrationTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderDescriptionMsSqlIntegrationTests.cs index 702707e8e2..5cb8d56215 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderDescriptionMsSqlIntegrationTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderDescriptionMsSqlIntegrationTests.cs @@ -80,15 +80,24 @@ public async Task StoredProcedure_GraphQLArgDescription_UsesConfigDescriptionAft SetUpSQLMetadataProvider(tamperedProvider); await _sqlMetadataProvider.InitializeAsync(); - DatabaseObject dbObject = _sqlMetadataProvider.EntityToDatabaseObject[entityName]; - FieldDefinitionNode field = GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema( - name: new NameNode(entityName), - entity: tamperedEntity, - dbObject: dbObject); + try + { + DatabaseObject dbObject = _sqlMetadataProvider.EntityToDatabaseObject[entityName]; + FieldDefinitionNode field = GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema( + name: new NameNode(entityName), + entity: tamperedEntity, + dbObject: dbObject); - InputValueDefinitionNode idArg = field.Arguments.First(a => a.Name.Value == "id"); - Assert.IsNotNull(idArg.Description); - Assert.AreEqual(expected: configDescription, actual: idArg.Description!.Value); + InputValueDefinitionNode idArg = field.Arguments.First(a => a.Name.Value == "id"); + Assert.IsNotNull(idArg.Description); + Assert.AreEqual(expected: configDescription, actual: idArg.Description!.Value); + } + finally + { + RuntimeConfigProvider sharedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(_baseConfig); + SetUpSQLMetadataProvider(sharedProvider); + await _sqlMetadataProvider.InitializeAsync(); + } } /// diff --git a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs index b74d0862d4..f1b481acf4 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs @@ -397,46 +397,6 @@ public void StoredProcedure_Description_UsesDefaultWhenEntityDescriptionIsNull() Assert.AreEqual(expectedDescription, field.Description?.Value); } - [TestMethod] - public void StoredProcedure_ParameterDescription_UsesConfigDescription() - { - const string parameterName = "title"; - const string configDescription = "Title from runtime config"; - const string definitionDescription = "Description already on the parameter definition (config should win)"; - - DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spParamDesc") - { - SourceType = EntitySourceType.StoredProcedure, - StoredProcedureDefinition = new() - { - Parameters = new() - { - { parameterName, new() { SystemType = typeof(string), Description = definitionDescription } } - } - } - }; - spDbObj.SourceDefinition.Columns.TryAdd("col1", new() { SystemType = typeof(string) }); - - List configParameters = new() - { - new ParameterMetadata - { - Name = parameterName, - Description = configDescription - } - }; - - FieldDefinitionNode field = BuildSchemaAndGetExecuteField( - spDbObj: spDbObj, - configParameters: configParameters, - graphQLTypeName: "SpParamDescType", - entityName: "SpParamDesc"); - - InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName); - Assert.IsNotNull(arg.Description); - Assert.AreEqual(configDescription, arg.Description!.Value); - } - [TestMethod] public void StoredProcedure_ParameterDescription_FallsBackToDefinitionDescriptionWhenNoConfigDescription() { From 19c07adcd9cb963b6f5f95aedde99054aa4d849c Mon Sep 17 00:00:00 2001 From: Anusha Kolan Date: Wed, 5 Aug 2026 17:40:54 -0700 Subject: [PATCH 6/6] fix: move duplicate SP param check to run in both dev and production modes; fix try/finally in integration tests --- .../Configurations/RuntimeConfigValidator.cs | 50 ++++++++++++------- ...BuilderDescriptionMsSqlIntegrationTests.cs | 12 ++--- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index 05daff10f3..c9d982a388 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -1012,25 +1012,38 @@ public void ValidateEntityConfiguration(RuntimeConfig runtimeConfig) ValidateNameRequirements(entity.GraphQL.Plural); } - // Validate that stored procedure config parameters do not contain duplicate names. - // Duplicate names produce inconsistent behavior: GraphQL uses the first match (FirstOrDefault), - // while metadata hydration overwrites with the last match — so descriptions, required, and - // default values can silently diverge between GraphQL, OpenAPI, and MCP responses. - if (entity.Source.Type is EntitySourceType.StoredProcedure - && entity.Source.Parameters is not null) + } + } + + /// + /// Validates that no stored-procedure entity in the config declares duplicate parameter names. + /// Duplicate names produce inconsistent behavior across GraphQL, OpenAPI, and MCP because each + /// consumer resolves duplicates differently (first-wins vs. last-wins). This check runs in both + /// development and production mode so that ambiguous configs are rejected at startup regardless + /// of the host mode. + /// + /// The runtime configuration. + public void ValidateStoredProcedureDuplicateParameters(RuntimeConfig runtimeConfig) + { + foreach ((string entityName, Entity entity) in runtimeConfig.Entities) + { + if (entity.Source.Type is not EntitySourceType.StoredProcedure + || entity.Source.Parameters is null) { - HashSet seenParamNames = new(StringComparer.Ordinal); - foreach (ParameterMetadata param in entity.Source.Parameters) + continue; + } + + HashSet seenParamNames = new(StringComparer.Ordinal); + foreach (ParameterMetadata param in entity.Source.Parameters) + { + if (!seenParamNames.Add(param.Name)) { - if (!seenParamNames.Add(param.Name)) - { - HandleOrRecordException(new DataApiBuilderException( - message: $"Entity '{entityName}' has duplicate parameter name '{param.Name}' in its stored procedure parameters configuration. " + - "Parameter names must be unique.", - statusCode: HttpStatusCode.ServiceUnavailable, - subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); - break; - } + HandleOrRecordException(new DataApiBuilderException( + message: $"Entity '{entityName}' has duplicate parameter name '{param.Name}' in its stored procedure parameters configuration. " + + "Parameter names must be unique.", + statusCode: HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError)); + break; } } } @@ -1937,6 +1950,9 @@ private static bool IsLoggerFilterValid(string loggerFilter) /// The runtime configuration. public void ValidateEntityAndAutoentityConfigurations(RuntimeConfig runtimeConfig) { + // Runs in both modes: duplicate SP parameter names cause silent inconsistency at runtime. + ValidateStoredProcedureDuplicateParameters(runtimeConfig); + if (runtimeConfig.IsDevelopmentMode()) { ValidateEntityConfiguration(runtimeConfig); diff --git a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderDescriptionMsSqlIntegrationTests.cs b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderDescriptionMsSqlIntegrationTests.cs index 5cb8d56215..4f86ca3bb2 100644 --- a/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderDescriptionMsSqlIntegrationTests.cs +++ b/src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderDescriptionMsSqlIntegrationTests.cs @@ -77,11 +77,11 @@ public async Task StoredProcedure_GraphQLArgDescription_UsesConfigDescriptionAft Dictionary entityMap = new() { [entityName] = tamperedEntity }; RuntimeConfig tamperedConfig = _baseConfig with { Entities = new(entityMap) }; RuntimeConfigProvider tamperedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(tamperedConfig); - SetUpSQLMetadataProvider(tamperedProvider); - await _sqlMetadataProvider.InitializeAsync(); - try { + SetUpSQLMetadataProvider(tamperedProvider); + await _sqlMetadataProvider.InitializeAsync(); + DatabaseObject dbObject = _sqlMetadataProvider.EntityToDatabaseObject[entityName]; FieldDefinitionNode field = GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema( name: new NameNode(entityName), @@ -135,11 +135,11 @@ public async Task StoredProcedure_GraphQLArgDescription_FallsBackToDefaultTextWh Dictionary entityMap = new() { [entityName] = tamperedEntity }; RuntimeConfig tamperedConfig = _baseConfig with { Entities = new(entityMap) }; RuntimeConfigProvider tamperedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(tamperedConfig); - SetUpSQLMetadataProvider(tamperedProvider); - await _sqlMetadataProvider.InitializeAsync(); - try { + SetUpSQLMetadataProvider(tamperedProvider); + await _sqlMetadataProvider.InitializeAsync(); + DatabaseObject dbObject = _sqlMetadataProvider.EntityToDatabaseObject[entityName]; FieldDefinitionNode field = GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema( name: new NameNode(entityName),