Skip to content
Open
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
34 changes: 25 additions & 9 deletions src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ public static class GraphQLStoredProcedureBuilder
/// It uses the parameters to build the arguments and returns a list
/// of the StoredProcedure GraphQL object.
/// </summary>
/// <remarks>
/// Each input argument's GraphQL type is wrapped in <see cref="NonNullTypeNode"/> when the
/// parameter is required, so introspection (<c>String!</c> vs <c>String</c>) reflects whether
/// the caller must supply a value. A parameter is treated as required unless the runtime
/// config explicitly sets <c>required: false</c> for it.
/// </remarks>
/// <param name="name">Name used for InputValueDefinition name.</param>
/// <param name="entity">Entity's runtime config metadata.</param>
/// <param name="dbObject">Stored procedure database schema metadata.</param>
Expand Down Expand Up @@ -55,16 +61,26 @@ public static FieldDefinitionNode GenerateStoredProcedureSchema(
// Without database metadata, there is no way to know to cast 1 to a decimal versus an integer.

IValueNode? defaultValueNode = null;
if (entity.Source.Parameters is not null)
ParameterMetadata? paramMetadata = entity.Source.Parameters?
.FirstOrDefault(p => p.Name == param);

if (paramMetadata is not null && paramMetadata.Default is not null)
{
ParameterMetadata? paramMetadata = entity.Source.Parameters
.FirstOrDefault(p => p.Name == param);
Tuple<string, IValueNode> defaultGraphQLValue = ConvertValueToGraphQLType(paramMetadata.Default.ToString()!, parameterDefinition: spdef.Parameters[param]);
defaultValueNode = defaultGraphQLValue.Item2;
}

if (paramMetadata is not null && paramMetadata.Default is not null)
{
Tuple<string, IValueNode> defaultGraphQLValue = ConvertValueToGraphQLType(paramMetadata.Default.ToString()!, parameterDefinition: spdef.Parameters[param]);
defaultValueNode = defaultGraphQLValue.Item2;
}
// Default to required so the schema doesn't silently mark a mandatory parameter as
// optional. T-SQL nullability does not indicate whether a caller must supply a value,
// so we only relax this when the config explicitly opts out.
bool isRequired = paramMetadata?.Required ?? true;

ITypeNode parameterTypeNode = new NamedTypeNode(
SchemaConverter.GetGraphQLTypeFromSystemType(type: definition.SystemType));

if (isRequired)
{
parameterTypeNode = new NonNullTypeNode((INullableTypeNode)parameterTypeNode);
}

inputValues.Add(
Expand All @@ -74,7 +90,7 @@ public static FieldDefinitionNode GenerateStoredProcedureSchema(
description: definition.Description != null
? new StringValueNode(definition.Description)
: new StringValueNode($"parameters for {name.Value} stored-procedure"),
type: new NamedTypeNode(SchemaConverter.GetGraphQLTypeFromSystemType(type: definition.SystemType)),
type: parameterTypeNode,
defaultValue: defaultValueNode,
directives: new List<DirectiveNode>())
);
Expand Down
149 changes: 149 additions & 0 deletions src/Service.Tests/GraphQLBuilder/Sql/StoredProcedureBuilderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -249,5 +249,154 @@ public static void ValidateStoredProcedureSchema(
string mismatchedTypeErrorMsg = $"Failure: Parameter '{parameterName}' is type '{actualGraphQLType}' but should be type '{expectedGraphQLType}'";
Assert.AreEqual(expected: expectedGraphQLType, actual: actualGraphQLType, message: mismatchedTypeErrorMsg);
}

/// <summary>
/// Validates that stored-procedure input arguments are emitted with the correct GraphQL
/// nullability based on the parameter's required flag:
/// <list type="bullet">
/// <item><description>A parameter listed in config with <c>required: true</c> is wrapped in <see cref="NonNullTypeNode"/>.</description></item>
/// <item><description>A parameter listed in config with <c>required: false</c> stays a nullable <see cref="NamedTypeNode"/>.</description></item>
/// <item><description>A parameter discovered from database metadata but not declared in config defaults to required and is wrapped in <see cref="NonNullTypeNode"/>.</description></item>
/// </list>
/// </summary>
[DataTestMethod]
[DataRow("requiredParam", true, false, true, DisplayName = "Config required=true -> NonNull")]
[DataRow("optionalParam", true, true, false, DisplayName = "Config required=false -> nullable")]
[DataRow("dbOnlyParam", false, false, true, DisplayName = "Param not in config -> defaults to NonNull (required)")]
public void StoredProcedure_RequiredFlag_ProducesNonNullType(
string parameterName,
bool listInConfig,
bool configRequiredFalse,
bool expectsNonNull)
{
DatabaseObject spDbObj = new DatabaseStoredProcedure(schemaName: "dbo", tableName: "spReqTest")
{
SourceType = EntitySourceType.StoredProcedure,
StoredProcedureDefinition = new()
{
Parameters = new() { { parameterName, new() { SystemType = typeof(string) } } }
}
};
spDbObj.SourceDefinition.Columns.TryAdd("col1", new() { SystemType = typeof(string) });

List<ParameterMetadata> configParameters = new();
if (listInConfig)
{
configParameters.Add(new ParameterMetadata
{
Name = parameterName,
Required = !configRequiredFalse
});
}

FieldDefinitionNode field = BuildSchemaAndGetExecuteField(
spDbObj: spDbObj,
configParameters: configParameters,
graphQLTypeName: "SpReqTestType",
entityName: "SpReqTest");

InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName);

if (expectsNonNull)
{
Assert.IsInstanceOfType(arg.Type, typeof(NonNullTypeNode),
$"Expected '{parameterName}' to be NonNullTypeNode but was '{arg.Type.GetType().Name}'.");
}
else
{
Assert.IsInstanceOfType(arg.Type, typeof(NamedTypeNode),
$"Expected '{parameterName}' to be nullable NamedTypeNode but was '{arg.Type.GetType().Name}'.");
}

// Underlying named type should remain the same regardless of nullability wrapping.
Assert.AreEqual(STRING_TYPE, arg.Type.NamedType().Name.Value);
}

/// <summary>
/// Validates that a required parameter with a config-supplied default value still emits a
/// NON_NULL input argument and preserves the default value on the GraphQL schema.
/// </summary>
[TestMethod]
public void StoredProcedure_RequiredWithDefault_KeepsDefaultValue()
{
const string parameterName = "title";

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

List<ParameterMetadata> configParameters = new()
{
new ParameterMetadata
{
Name = parameterName,
Required = true,
Default = "Demo Title"
}
};

FieldDefinitionNode field = BuildSchemaAndGetExecuteField(
spDbObj: spDbObj,
configParameters: configParameters,
graphQLTypeName: "SpReqDefaultType",
entityName: "SpReqDefault");

InputValueDefinitionNode arg = field.Arguments.First(a => a.Name.Value == parameterName);

Assert.IsInstanceOfType(arg.Type, typeof(NonNullTypeNode), "Required parameter should be NonNullTypeNode.");
Assert.IsNotNull(arg.DefaultValue, "Default value should be preserved on NON_NULL input argument.");
Assert.IsInstanceOfType(arg.DefaultValue, typeof(StringValueNode));
Assert.AreEqual("Demo Title", ((StringValueNode)arg.DefaultValue!).Value);
}

/// <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
/// type nodes and default values.
/// </summary>
private static FieldDefinitionNode BuildSchemaAndGetExecuteField(
DatabaseObject spDbObj,
List<ParameterMetadata> configParameters,
string graphQLTypeName,
string entityName)
{
Entity spEntity = GraphQLTestHelpers.GenerateStoredProcedureEntity(
graphQLTypeName: graphQLTypeName,
graphQLOperation: GraphQLOperation.Query,
parameters: configParameters);

ObjectTypeDefinitionNode objectType = CreateGraphQLTypeForEntity(spEntity, entityName, spDbObj);

DocumentNode root = CreateGraphQLDocument(new Dictionary<string, ObjectTypeDefinitionNode>
{
{ entityName, objectType }
});

Dictionary<string, EntityMetadata> permissions = GraphQLTestHelpers.CreateStubEntityPermissionsMap(
entityNames: new[] { entityName },
operations: new[] { EntityActionOperation.Execute },
roles: SchemaConverterTests.GetRolesAllowedForEntity());

Dictionary<string, Entity> entities = new() { { entityName, spEntity } };
Dictionary<string, DatabaseType> entityToDatabaseName = new() { { entityName, DatabaseType.MSSQL } };
Dictionary<string, DatabaseObject> dbObjects = new() { { entityName, spDbObj } };

DocumentNode queryRoot = QueryBuilder.Build(
root,
entityToDatabaseName,
entities: new(entities),
inputTypes: null,
entityPermissionsMap: permissions,
dbObjects: dbObjects);

ObjectTypeDefinitionNode queryNode = QueryBuilderTests.GetQueryNode(queryRoot);
return queryNode.Fields.First(f => f.Name.Value.StartsWith($"execute{graphQLTypeName}"));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -866,9 +866,7 @@ public async Task TestStoredProcedureQueryWithResultsContainingNull()
/// <summary>
/// Checks failure on providing arguments with no default in runtimeconfig.
/// In this test, there is no default value for the argument 'id' in runtimeconfig, nor is it specified in the query.
/// Stored procedure expects id argument to be provided.
/// This test validates the "Development Mode" error message which denotes the
/// specific missing parameter and stored procedure name.
/// GraphQL validation should reject the request because required argument 'id' is missing.
/// </summary>
[TestMethod]
public async Task TestStoredProcedureQueryWithNoDefaultInConfig()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to add tests for PostgresSQL and MySQL too?

Expand All @@ -883,7 +881,7 @@ public async Task TestStoredProcedureQueryWithNoDefaultInConfig()
JsonElement result = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false);
SqlTestHelper.TestForErrorInGraphQLResponse(
response: result.ToString(),
message: "Procedure or function 'get_publisher_by_id' expects parameter '@id', which was not supplied.");
message: "The argument `id` is required.");
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -732,8 +732,7 @@ public async Task QueryAgainstSPWithOnlyTypenameInSelectionSet()
/// <summary>
/// Checks failure on providing arguments with no default in runtimeconfig.
/// In this test, there is no default value for the argument 'id' in runtimeconfig, nor is it specified in the query.
/// Stored procedure expects id argument to be provided.
/// The expected error message contents align with the expected "Development" mode response.
/// GraphQL validation should reject the request because required argument 'id' is missing.
/// </summary>
[TestMethod]
public async Task TestStoredProcedureQueryWithNoDefaultInConfig()
Expand All @@ -746,7 +745,7 @@ public async Task TestStoredProcedureQueryWithNoDefaultInConfig()
}";

JsonElement result = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false);
SqlTestHelper.TestForErrorInGraphQLResponse(result.ToString(), message: "Procedure or function 'get_publisher_by_id' expects parameter '@id', which was not supplied.");
SqlTestHelper.TestForErrorInGraphQLResponse(result.ToString(), message: "The argument `id` is required.");
}

/// <summary>
Expand Down