Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/Core/Configurations/RuntimeConfigValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,41 @@ public void ValidateEntityConfiguration(RuntimeConfig runtimeConfig)
ValidateNameRequirements(entity.GraphQL.Singular);
ValidateNameRequirements(entity.GraphQL.Plural);
}

}
}

/// <summary>
/// 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.
/// </summary>
/// <param name="runtimeConfig">The runtime configuration.</param>
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)
{
continue;
}

HashSet<string> 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.",
statusCode: HttpStatusCode.ServiceUnavailable,
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError));
break;
}
}
}
}

Expand Down Expand Up @@ -1915,6 +1950,9 @@ private static bool IsLoggerFilterValid(string loggerFilter)
/// <param name="runtimeConfig">The runtime configuration.</param>
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);
Expand Down
10 changes: 7 additions & 3 deletions src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,17 @@ public static FieldDefinitionNode GenerateStoredProcedureSchema(
parameterTypeNode = new NonNullTypeNode((INullableTypeNode)parameterTypeNode);
}

string parameterDescription = !string.IsNullOrWhiteSpace(paramMetadata?.Description)
Comment thread
anushakolan marked this conversation as resolved.
? 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<DirectiveNode>())
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// 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
{
/// <summary>
/// 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
/// </summary>
[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();
}

/// <summary>
/// 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 <c>get_book_by_id</c> stored procedure (defined in the MsSql
/// test schema) with a config-side description override on its <c>id</c> parameter.
/// </summary>
[TestMethod]
public async Task StoredProcedure_GraphQLArgDescription_UsesConfigDescriptionAfterMetadataInit()
{
Comment thread
anushakolan marked this conversation as resolved.
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<ParameterMetadata>
{
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<string, Entity> entityMap = new() { [entityName] = tamperedEntity };
RuntimeConfig tamperedConfig = _baseConfig with { Entities = new(entityMap) };
RuntimeConfigProvider tamperedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(tamperedConfig);
try
{
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);
}
finally
{
RuntimeConfigProvider sharedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(_baseConfig);
SetUpSQLMetadataProvider(sharedProvider);
await _sqlMetadataProvider.InitializeAsync();
}
}

/// <summary>
/// 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.
/// </summary>
[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<ParameterMetadata> { 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<string, Entity> entityMap = new() { [entityName] = tamperedEntity };
RuntimeConfig tamperedConfig = _baseConfig with { Entities = new(entityMap) };
RuntimeConfigProvider tamperedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(tamperedConfig);
try
{
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: $"parameters for {entityName} stored-procedure",
actual: idArg.Description!.Value);
}
finally
{
RuntimeConfigProvider sharedProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(_baseConfig);
SetUpSQLMetadataProvider(sharedProvider);
await _sqlMetadataProvider.InitializeAsync();
}
}
}
}
136 changes: 136 additions & 0 deletions src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,142 @@ public void StoredProcedure_Description_UsesDefaultWhenEntityDescriptionIsNull()
Assert.AreEqual(expectedDescription, field.Description?.Value);
}

[TestMethod]
public void StoredProcedure_ParameterDescription_FallsBackToDefinitionDescriptionWhenNoConfigDescription()
{
const string parameterName = "title";
const string definitionDescription = "Title description on the parameter definition";

DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spParamDescFallback")
{
SourceType = EntitySourceType.StoredProcedure,
StoredProcedureDefinition = new()
{
Parameters = new()
{
{ parameterName, new() { SystemType = typeof(string), Description = definitionDescription } }
}
}
};
spDbObj.SourceDefinition.Columns.TryAdd("col1", new() { SystemType = typeof(string) });

FieldDefinitionNode field = BuildSchemaAndGetExecuteField(
spDbObj: spDbObj,
configParameters: new List<ParameterMetadata>(),
graphQLTypeName: "SpParamDescFallbackType",
entityName: "SpParamDescFallback");

InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName);
Assert.IsNotNull(arg.Description);
Assert.AreEqual(definitionDescription, arg.Description!.Value);
}

[TestMethod]
public void StoredProcedure_ParameterDescription_FallsBackToDefaultText()
Comment thread
anushakolan marked this conversation as resolved.
{
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<ParameterMetadata>(),
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);
}

[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<ParameterMetadata> 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<ParameterMetadata> 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);
}

Comment thread
Copilot marked this conversation as resolved.
/// <summary>
/// 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
Expand Down
Loading