Skip to content

Commit 7600de6

Browse files
anushakolanCopilot
andauthored
Use config parameter descriptions in GraphQL stored procedure args (#3733)
## Why make this change? - Closes #3500. - GraphQL stored-procedure argument descriptions were not honoring runtime config parameter descriptions and could show generic or database-only text. This made schema docs less useful for API consumers. - Additional discussion threads: #3506 ## What is this change? - Updated stored-procedure GraphQL argument description resolution to use this precedence: - `source.parameters[].description` from runtime config (when present) - database parameter description (when present) - fallback text (`parameters for <stored-procedure> stored-procedure`) - Added regression tests to verify config-description precedence and fallback behavior. - GraphQL spec reference (Descriptions): https://spec.graphql.org/October2021/#sec-Descriptions ## How was this tested? - [ ] Integration Tests - [x] Unit Tests Focused unit test run: ```bash dotnet test .\src\Service.Tests\Azure.DataApiBuilder.Service.Tests.csproj --filter "StoredProcedure_ParameterDescription_UsesConfigDescription|StoredProcedure_ParameterDescription_FallsBackToDatabaseDescription|StoredProcedure_RequiredWithDefault_KeepsDefaultValue|StoredProcedure_RequiredFlag_ProducesNonNullType" ``` ## Sample Request(s) - Example GraphQL introspection request: ```graphql query { __type(name: "Mutation") { fields { name args { name description } } } } ``` - Example CLI usage: ```bash dotnet "src/out/engine/net10.0/Azure.DataApiBuilder.Service.dll" --ConfigFileName "dab-config.verify-3500.json" ``` --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 032eff9 commit 7600de6

4 files changed

Lines changed: 344 additions & 3 deletions

File tree

src/Core/Configurations/RuntimeConfigValidator.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,41 @@ public void ValidateEntityConfiguration(RuntimeConfig runtimeConfig)
10111011
ValidateNameRequirements(entity.GraphQL.Singular);
10121012
ValidateNameRequirements(entity.GraphQL.Plural);
10131013
}
1014+
1015+
}
1016+
}
1017+
1018+
/// <summary>
1019+
/// Validates that no stored-procedure entity in the config declares duplicate parameter names.
1020+
/// Duplicate names produce inconsistent behavior across GraphQL, OpenAPI, and MCP because each
1021+
/// consumer resolves duplicates differently (first-wins vs. last-wins). This check runs in both
1022+
/// development and production mode so that ambiguous configs are rejected at startup regardless
1023+
/// of the host mode.
1024+
/// </summary>
1025+
/// <param name="runtimeConfig">The runtime configuration.</param>
1026+
public void ValidateStoredProcedureDuplicateParameters(RuntimeConfig runtimeConfig)
1027+
{
1028+
foreach ((string entityName, Entity entity) in runtimeConfig.Entities)
1029+
{
1030+
if (entity.Source.Type is not EntitySourceType.StoredProcedure
1031+
|| entity.Source.Parameters is null)
1032+
{
1033+
continue;
1034+
}
1035+
1036+
HashSet<string> seenParamNames = new(StringComparer.Ordinal);
1037+
foreach (ParameterMetadata param in entity.Source.Parameters)
1038+
{
1039+
if (!seenParamNames.Add(param.Name))
1040+
{
1041+
HandleOrRecordException(new DataApiBuilderException(
1042+
message: $"Entity '{entityName}' has duplicate parameter name '{param.Name}' in its stored procedure parameters configuration. " +
1043+
"Parameter names must be unique.",
1044+
statusCode: HttpStatusCode.ServiceUnavailable,
1045+
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError));
1046+
break;
1047+
}
1048+
}
10141049
}
10151050
}
10161051

@@ -1915,6 +1950,9 @@ private static bool IsLoggerFilterValid(string loggerFilter)
19151950
/// <param name="runtimeConfig">The runtime configuration.</param>
19161951
public void ValidateEntityAndAutoentityConfigurations(RuntimeConfig runtimeConfig)
19171952
{
1953+
// Runs in both modes: duplicate SP parameter names cause silent inconsistency at runtime.
1954+
ValidateStoredProcedureDuplicateParameters(runtimeConfig);
1955+
19181956
if (runtimeConfig.IsDevelopmentMode())
19191957
{
19201958
ValidateEntityConfiguration(runtimeConfig);

src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,17 @@ public static FieldDefinitionNode GenerateStoredProcedureSchema(
8383
parameterTypeNode = new NonNullTypeNode((INullableTypeNode)parameterTypeNode);
8484
}
8585

86+
string parameterDescription = !string.IsNullOrWhiteSpace(paramMetadata?.Description)
87+
? paramMetadata.Description
88+
: !string.IsNullOrWhiteSpace(definition.Description)
89+
? definition.Description
90+
: $"parameters for {name.Value} stored-procedure";
91+
8692
inputValues.Add(
8793
new(
8894
location: null,
8995
name: new(param),
90-
description: definition.Description != null
91-
? new StringValueNode(definition.Description)
92-
: new StringValueNode($"parameters for {name.Value} stored-procedure"),
96+
description: new StringValueNode(parameterDescription),
9397
type: parameterTypeNode,
9498
defaultValue: defaultValueNode,
9599
directives: new List<DirectiveNode>())
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Threading.Tasks;
7+
using Azure.DataApiBuilder.Config.DatabasePrimitives;
8+
using Azure.DataApiBuilder.Config.ObjectModel;
9+
using Azure.DataApiBuilder.Core.Configurations;
10+
using Azure.DataApiBuilder.Service.GraphQLBuilder;
11+
using Azure.DataApiBuilder.Service.Tests.SqlTests;
12+
using HotChocolate.Language;
13+
using Microsoft.VisualStudio.TestTools.UnitTesting;
14+
15+
namespace Azure.DataApiBuilder.Service.Tests.GraphQLBuilder.Sql
16+
{
17+
/// <summary>
18+
/// Integration tests that verify stored-procedure parameter descriptions flow
19+
/// end-to-end through the full production pipeline:
20+
/// config parameters.description
21+
/// → SqlMetadataProvider.FillSchemaForStoredProcedureAsync (merges onto ParameterDefinition)
22+
/// → GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema (reads description)
23+
/// → GraphQL argument description
24+
/// </summary>
25+
[TestClass, TestCategory(TestCategory.MSSQL)]
26+
public class StoredProcedureBuilderDescriptionMsSqlIntegrationTests : SqlTestBase
27+
{
28+
private static RuntimeConfig _baseConfig;
29+
30+
[ClassInitialize]
31+
public static async Task SetupAsync(TestContext context)
32+
{
33+
DatabaseEngine = TestCategory.MSSQL;
34+
await InitializeTestFixture();
35+
_baseConfig = SqlTestHelper.SetupRuntimeConfig();
36+
}
37+
38+
/// <summary>
39+
/// Verifies that a description configured on a stored-procedure parameter in the
40+
/// runtime config is propagated through the SQL metadata provider and reflected in
41+
/// the generated GraphQL argument description.
42+
///
43+
/// Uses the existing <c>get_book_by_id</c> stored procedure (defined in the MsSql
44+
/// test schema) with a config-side description override on its <c>id</c> parameter.
45+
/// </summary>
46+
[TestMethod]
47+
public async Task StoredProcedure_GraphQLArgDescription_UsesConfigDescriptionAfterMetadataInit()
48+
{
49+
const string entityName = "GetBookWithParamDesc";
50+
const string configDescription = "The unique identifier for the book (from config)";
51+
52+
Entity tamperedEntity = new(
53+
Source: new(
54+
"get_book_by_id",
55+
EntitySourceType.StoredProcedure,
56+
Parameters: new List<ParameterMetadata>
57+
{
58+
new() { Name = "id", Description = configDescription }
59+
},
60+
KeyFields: null),
61+
GraphQL: new(entityName, entityName, Enabled: true, Operation: GraphQLOperation.Query),
62+
Rest: new(Enabled: false),
63+
Fields: null,
64+
Permissions: new[]
65+
{
66+
new EntityPermission(
67+
Role: "anonymous",
68+
Actions: new[]
69+
{
70+
new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null)
71+
})
72+
},
73+
Relationships: null,
74+
Mappings: null,
75+
Mcp: null);
76+
77+
Dictionary<string, Entity> entityMap = new() { [entityName] = tamperedEntity };
78+
RuntimeConfig tamperedConfig = _baseConfig with { Entities = new(entityMap) };
79+
RuntimeConfigProvider tamperedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(tamperedConfig);
80+
try
81+
{
82+
SetUpSQLMetadataProvider(tamperedProvider);
83+
await _sqlMetadataProvider.InitializeAsync();
84+
85+
DatabaseObject dbObject = _sqlMetadataProvider.EntityToDatabaseObject[entityName];
86+
FieldDefinitionNode field = GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema(
87+
name: new NameNode(entityName),
88+
entity: tamperedEntity,
89+
dbObject: dbObject);
90+
91+
InputValueDefinitionNode idArg = field.Arguments.First(a => a.Name.Value == "id");
92+
Assert.IsNotNull(idArg.Description);
93+
Assert.AreEqual(expected: configDescription, actual: idArg.Description!.Value);
94+
}
95+
finally
96+
{
97+
RuntimeConfigProvider sharedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(_baseConfig);
98+
SetUpSQLMetadataProvider(sharedProvider);
99+
await _sqlMetadataProvider.InitializeAsync();
100+
}
101+
}
102+
103+
/// <summary>
104+
/// Verifies that when no description is set on a stored-procedure parameter in the
105+
/// runtime config the generated GraphQL argument falls back to the default
106+
/// description text. Exercises the same full pipeline as the positive-case test.
107+
/// </summary>
108+
[TestMethod]
109+
public async Task StoredProcedure_GraphQLArgDescription_FallsBackToDefaultTextWhenNoConfigDescription()
110+
{
111+
const string entityName = "GetBookNoDesc";
112+
113+
Entity tamperedEntity = new(
114+
Source: new(
115+
"get_book_by_id",
116+
EntitySourceType.StoredProcedure,
117+
Parameters: new List<ParameterMetadata> { new() { Name = "id" } },
118+
KeyFields: null),
119+
GraphQL: new(entityName, entityName, Enabled: true, Operation: GraphQLOperation.Query),
120+
Rest: new(Enabled: false),
121+
Fields: null,
122+
Permissions: new[]
123+
{
124+
new EntityPermission(
125+
Role: "anonymous",
126+
Actions: new[]
127+
{
128+
new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null)
129+
})
130+
},
131+
Relationships: null,
132+
Mappings: null,
133+
Mcp: null);
134+
135+
Dictionary<string, Entity> entityMap = new() { [entityName] = tamperedEntity };
136+
RuntimeConfig tamperedConfig = _baseConfig with { Entities = new(entityMap) };
137+
RuntimeConfigProvider tamperedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(tamperedConfig);
138+
try
139+
{
140+
SetUpSQLMetadataProvider(tamperedProvider);
141+
await _sqlMetadataProvider.InitializeAsync();
142+
143+
DatabaseObject dbObject = _sqlMetadataProvider.EntityToDatabaseObject[entityName];
144+
FieldDefinitionNode field = GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema(
145+
name: new NameNode(entityName),
146+
entity: tamperedEntity,
147+
dbObject: dbObject);
148+
149+
InputValueDefinitionNode idArg = field.Arguments.First(a => a.Name.Value == "id");
150+
Assert.IsNotNull(idArg.Description);
151+
Assert.AreEqual(
152+
expected: $"parameters for {entityName} stored-procedure",
153+
actual: idArg.Description!.Value);
154+
}
155+
finally
156+
{
157+
RuntimeConfigProvider sharedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(_baseConfig);
158+
SetUpSQLMetadataProvider(sharedProvider);
159+
await _sqlMetadataProvider.InitializeAsync();
160+
}
161+
}
162+
}
163+
}

src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,142 @@ public void StoredProcedure_Description_UsesDefaultWhenEntityDescriptionIsNull()
397397
Assert.AreEqual(expectedDescription, field.Description?.Value);
398398
}
399399

400+
[TestMethod]
401+
public void StoredProcedure_ParameterDescription_FallsBackToDefinitionDescriptionWhenNoConfigDescription()
402+
{
403+
const string parameterName = "title";
404+
const string definitionDescription = "Title description on the parameter definition";
405+
406+
DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spParamDescFallback")
407+
{
408+
SourceType = EntitySourceType.StoredProcedure,
409+
StoredProcedureDefinition = new()
410+
{
411+
Parameters = new()
412+
{
413+
{ parameterName, new() { SystemType = typeof(string), Description = definitionDescription } }
414+
}
415+
}
416+
};
417+
spDbObj.SourceDefinition.Columns.TryAdd("col1", new() { SystemType = typeof(string) });
418+
419+
FieldDefinitionNode field = BuildSchemaAndGetExecuteField(
420+
spDbObj: spDbObj,
421+
configParameters: new List<ParameterMetadata>(),
422+
graphQLTypeName: "SpParamDescFallbackType",
423+
entityName: "SpParamDescFallback");
424+
425+
InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName);
426+
Assert.IsNotNull(arg.Description);
427+
Assert.AreEqual(definitionDescription, arg.Description!.Value);
428+
}
429+
430+
[TestMethod]
431+
public void StoredProcedure_ParameterDescription_FallsBackToDefaultText()
432+
{
433+
const string parameterName = "title";
434+
const string graphQLTypeName = "SpParamDescDefaultTextType";
435+
const string entityName = "SpParamDescDefaultText";
436+
437+
DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spParamDescDefaultText")
438+
{
439+
SourceType = EntitySourceType.StoredProcedure,
440+
StoredProcedureDefinition = new()
441+
{
442+
Parameters = new() { { parameterName, new() { SystemType = typeof(string) } } }
443+
}
444+
};
445+
spDbObj.SourceDefinition.Columns.TryAdd("col1", new() { SystemType = typeof(string) });
446+
447+
FieldDefinitionNode field = BuildSchemaAndGetExecuteField(
448+
spDbObj: spDbObj,
449+
configParameters: new List<ParameterMetadata>(),
450+
graphQLTypeName: graphQLTypeName,
451+
entityName: entityName);
452+
453+
InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName);
454+
Assert.IsNotNull(arg.Description);
455+
Assert.AreEqual($"parameters for {graphQLTypeName} stored-procedure", arg.Description!.Value);
456+
}
457+
458+
[DataTestMethod]
459+
[DataRow("", DisplayName = "Empty config description falls back to definition description")]
460+
[DataRow(" ", DisplayName = "Whitespace config description falls back to definition description")]
461+
public void StoredProcedure_ParameterDescription_WhitespaceConfigDescriptionFallsBackToDefinitionDescription(string whitespaceDescription)
462+
{
463+
const string parameterName = "title";
464+
const string definitionDescription = "Title description on the parameter definition";
465+
466+
DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spParamDescWhitespace")
467+
{
468+
SourceType = EntitySourceType.StoredProcedure,
469+
StoredProcedureDefinition = new()
470+
{
471+
Parameters = new()
472+
{
473+
{ parameterName, new() { SystemType = typeof(string), Description = definitionDescription } }
474+
}
475+
}
476+
};
477+
spDbObj.SourceDefinition.Columns.TryAdd("col1", new() { SystemType = typeof(string) });
478+
479+
List<ParameterMetadata> configParameters = new()
480+
{
481+
new ParameterMetadata { Name = parameterName, Description = whitespaceDescription }
482+
};
483+
484+
FieldDefinitionNode field = BuildSchemaAndGetExecuteField(
485+
spDbObj: spDbObj,
486+
configParameters: configParameters,
487+
graphQLTypeName: "SpParamDescWhitespaceType",
488+
entityName: "SpParamDescWhitespace");
489+
490+
InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName);
491+
Assert.IsNotNull(arg.Description);
492+
Assert.AreEqual(definitionDescription, arg.Description!.Value);
493+
}
494+
495+
[DataTestMethod]
496+
[DataRow("", "", DisplayName = "Both empty — falls back to default text")]
497+
[DataRow(" ", " ", DisplayName = "Both whitespace — falls back to default text")]
498+
[DataRow("", " ", DisplayName = "Empty config, whitespace definition — falls back to default text")]
499+
[DataRow(" ", "", DisplayName = "Whitespace config, empty definition — falls back to default text")]
500+
public void StoredProcedure_ParameterDescription_BothWhitespaceFallsBackToDefaultText(
501+
string whitespaceConfigDescription, string whitespaceDefinitionDescription)
502+
{
503+
const string parameterName = "title";
504+
const string graphQLTypeName = "SpParamDescBothWhitespaceType";
505+
const string entityName = "SpParamDescBothWhitespace";
506+
507+
DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spParamDescBothWhitespace")
508+
{
509+
SourceType = EntitySourceType.StoredProcedure,
510+
StoredProcedureDefinition = new()
511+
{
512+
Parameters = new()
513+
{
514+
{ parameterName, new() { SystemType = typeof(string), Description = whitespaceDefinitionDescription } }
515+
}
516+
}
517+
};
518+
spDbObj.SourceDefinition.Columns.TryAdd("col1", new() { SystemType = typeof(string) });
519+
520+
List<ParameterMetadata> configParameters = new()
521+
{
522+
new ParameterMetadata { Name = parameterName, Description = whitespaceConfigDescription }
523+
};
524+
525+
FieldDefinitionNode field = BuildSchemaAndGetExecuteField(
526+
spDbObj: spDbObj,
527+
configParameters: configParameters,
528+
graphQLTypeName: graphQLTypeName,
529+
entityName: entityName);
530+
531+
InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName);
532+
Assert.IsNotNull(arg.Description);
533+
Assert.AreEqual($"parameters for {graphQLTypeName} stored-procedure", arg.Description!.Value);
534+
}
535+
400536
/// <summary>
401537
/// Helper that builds a query schema for a stored-procedure entity and returns
402538
/// the generated execute* field so individual tests can assert on its argument

0 commit comments

Comments
 (0)