diff --git a/ConfigGenerators/MsSqlCommands.txt b/ConfigGenerators/MsSqlCommands.txt index f8e7801224..5428861db2 100644 --- a/ConfigGenerators/MsSqlCommands.txt +++ b/ConfigGenerators/MsSqlCommands.txt @@ -24,8 +24,13 @@ add Journal --config "dab-config.MsSql.json" --source "journals" --rest true --g add ArtOfWar --config "dab-config.MsSql.json" --source "aow" --rest true --permissions "anonymous:*" add series --config "dab-config.MsSql.json" --source "series" --permissions "anonymous:*" add Sales --config "dab-config.MsSql.json" --source "sales" --permissions "anonymous:*" --rest true --graphql true -add GetBooks --config "dab-config.MsSql.json" --source "get_books" --source.type "stored-procedure" --permissions "anonymous:read" --rest true --graphql false +add GetBooks --config "dab-config.MsSql.json" --source "get_books" --source.type "stored-procedure" --permissions "anonymous:read" --rest true --graphql true add GetBook --config "dab-config.MsSql.json" --source "get_book_by_id" --source.type "stored-procedure" --permissions "anonymous:read" --rest true --graphql false +add GetPublisher --config "dab-config.MsSql.json" --source "get_publisher_by_id" --source.type "stored-procedure" --source.params "id:1" --permissions "anonymous:read" --rest true --graphql true +add InsertBook --config "dab-config.MsSql.json" --source "insert_book" --source.type "stored-procedure" --source.params "title:randomX,publisher_id:1234" --permissions "anonymous:create" --rest true --graphql true +add CountBooks --config "dab-config.MsSql.json" --source "count_books" --source.type "stored-procedure" --permissions "anonymous:read" --rest true --graphql true +add DeleteLastInsertedBook --config "dab-config.MsSql.json" --source "delete_last_inserted_book" --source.type "stored-procedure" --permissions "anonymous:delete" --rest true --graphql true +add UpdateBookTitle --config "dab-config.MsSql.json" --source "update_book_title" --source.type "stored-procedure" --source.params "id:1,title:Testing Tonight" --permissions "anonymous:update" --rest true --graphql true update Publisher --config "dab-config.MsSql.json" --permissions "authenticated:create,read,update,delete" --rest true --graphql true --relationship books --target.entity Book --cardinality many update Publisher --config "dab-config.MsSql.json" --permissions "policy_tester_01:create,delete" update Publisher --config "dab-config.MsSql.json" --permissions "policy_tester_01:update" --fields.include "*" @@ -106,6 +111,11 @@ update Journal --config "dab-config.MsSql.json" --permissions "policy_tester_upd update Journal --config "dab-config.MsSql.json" --permissions "policy_tester_update_noread:delete" --fields.include "*" --policy-database "@item.id eq 1" update Journal --config "dab-config.MsSql.json" --permissions "authorizationHandlerTester:read" update ArtOfWar --config "dab-config.MsSql.json" --permissions "authenticated:*" --map "DetailAssessmentAndPlanning:始計,WagingWar:作戰,StrategicAttack:謀攻,NoteNum:┬─┬ノ( º _ ºノ)" -update GetBook --config "dab-config.MsSql.json" --permissions "authenticated:*" -update GetBooks --config "dab-config.MsSql.json" --permissions "authenticated:*" +update GetBook --config "dab-config.MsSql.json" --permissions "authenticated:read" +update GetPublisher --config "dab-config.MsSql.json" --permissions "authenticated:read" +update GetBooks --config "dab-config.MsSql.json" --permissions "authenticated:read" +update InsertBook --config "dab-config.MsSql.json" --permissions "authenticated:create" +update CountBooks --config "dab-config.MsSql.json" --permissions "authenticated:read" +update DeleteLastInsertedBook --config "dab-config.MsSql.json" --permissions "authenticated:delete" +update UpdateBookTitle --config "dab-config.MsSql.json" --permissions "authenticated:update" update Sales --config "dab-config.MsSql.json" --permissions "authenticated:*" diff --git a/ConfigGenerators/dab-config.sql.reference.json b/ConfigGenerators/dab-config.sql.reference.json index 43fa9b9290..04fed2efa5 100644 --- a/ConfigGenerators/dab-config.sql.reference.json +++ b/ConfigGenerators/dab-config.sql.reference.json @@ -916,7 +916,7 @@ "object": "get_books" }, "rest": true, - "graphql": false, + "graphql": true, "permissions": [ { "role": "anonymous", @@ -924,7 +924,7 @@ }, { "role": "authenticated", - "actions": [ "*" ] + "actions": [ "read" ] } ] }, @@ -942,7 +942,7 @@ }, { "role": "authenticated", - "actions": [ "*" ] + "actions": [ "read" ] } ] }, @@ -968,6 +968,131 @@ ] } ] + }, + "InsertBook": { + "source": { + "type": "stored-procedure", + "object": "insert_book", + "parameters": { + "title": "randomX", + "publisher_id": 1234 + }, + "key-fields": [] + }, + "rest": true, + "permissions": [ + { + "role": "anonymous", + "actions": [ + "create" + ] + }, + { + "role": "authenticated", + "actions": [ + "create" + ] + } + ], + "graphql": true + }, + "CountBooks": { + "source": { + "type": "stored-procedure", + "object": "count_books", + "key-fields": [] + }, + "rest": true, + "permissions": [ + { + "role": "anonymous", + "actions": [ + "read" + ] + }, + { + "role": "authenticated", + "actions": [ + "read" + ] + } + ], + "graphql": true + }, + "DeleteLastInsertedBook": { + "source": { + "type": "stored-procedure", + "object": "delete_last_inserted_book", + "key-fields": [] + }, + "rest": true, + "permissions": [ + { + "role": "anonymous", + "actions": [ + "delete" + ] + }, + { + "role": "authenticated", + "actions": [ + "delete" + ] + } + ], + "graphql": true + }, + "UpdateBookTitle": { + "source": { + "type": "stored-procedure", + "object": "update_book_title", + "parameters": { + "id": 1, + "title": "Testing Tonight" + }, + "key-fields": [] + }, + "rest": true, + "permissions": [ + { + "role": "anonymous", + "actions": [ + "update" + ] + }, + { + "role": "authenticated", + "actions": [ + "update" + ] + } + ], + "graphql": true + }, + "GetPublisher": { + "source": { + "type": "stored-procedure", + "object": "get_publisher_by_id", + "parameters": { + "id": 1 + } + }, + "rest": true, + "permissions": [ + { + "role": "anonymous", + "actions": [ + "read" + ] + }, + { + "role": "authenticated", + "actions": [ + "read" + ] + } + ], + "graphql": true } } } diff --git a/docs/views-and-stored-procedures.md b/docs/views-and-stored-procedures.md index 2d6ebcb93d..958b5038f2 100644 --- a/docs/views-and-stored-procedures.md +++ b/docs/views-and-stored-procedures.md @@ -71,7 +71,10 @@ the `dab-config.json` file will look like the following: The `parameters` object is optional, and is used to provide default values to be passed to the stored procedure parameters, if those are not provided in the HTTP request. -**ATTENTION**: Only the first result set returned by the stored procedure will be used by Data API Builder. +**ATTENTION**: +1. Only the first result set returned by the stored procedure will be used by Data API Builder. +2. Currently we only support simple StoredProcedures,i.e. stored procedure that requires only 1 CRUD action to execute. +3. If more than one CRUD action is specified in the config, runtime initialization will fail due to config validation error. Please note that **you should configure the permission accordingly with the stored procedure behavior**. For example, if a Stored Procedure create a new item in the database, it is recommended to allow only the action `create` for such stored procedure. If, like in the sample, a stored procedure returns some data, it is recommended to allow only the action `read`. In general the recommendation is to align the allowed actions to what the stored procedure does, so to provide a consistent experience to the developer. diff --git a/src/Cli/src/ConfigGenerator.cs b/src/Cli/src/ConfigGenerator.cs index 589cfca5a1..14e1b0c0fd 100644 --- a/src/Cli/src/ConfigGenerator.cs +++ b/src/Cli/src/ConfigGenerator.cs @@ -661,7 +661,7 @@ private static bool TryGetUpdatedSourceObjectWithOptions( } // If given SourceParameter is null or is Empty, no update is required. - // Else updatedSourceParameters will contain the parsed dictionary of parameters. + // Else updatedSourceParameters will contain the parsed dictionary of parameters. if (options.SourceParameters is not null && options.SourceParameters.Any() && !TryParseSourceParameterDictionary(options.SourceParameters, out updatedSourceParameters)) { diff --git a/src/Cli/src/Utils.cs b/src/Cli/src/Utils.cs index 7a75a927c8..e9f105192b 100644 --- a/src/Cli/src/Utils.cs +++ b/src/Cli/src/Utils.cs @@ -645,7 +645,7 @@ private static object ParseStringValue(string stringValue) { return floatingValue; } - else if (Boolean.TryParse(stringValue, out bool booleanValue)) + else if (bool.TryParse(stringValue, out bool booleanValue)) { return booleanValue; } diff --git a/src/Config/DatabaseObject.cs b/src/Config/DatabaseObject.cs index 6ec88e5b07..8724bd1f76 100644 --- a/src/Config/DatabaseObject.cs +++ b/src/Config/DatabaseObject.cs @@ -43,6 +43,24 @@ public override int GetHashCode() { return HashCode.Combine(SchemaName, Name); } + + /// + /// Get the underlying SourceDefinition based on database object source type + /// + public SourceDefinition SourceDefinition + { + get + { + return SourceType switch + { + SourceType.Table => ((DatabaseTable)this).TableDefinition, + SourceType.View => ((DatabaseView)this).ViewDefinition, + SourceType.StoredProcedure => ((DatabaseStoredProcedure)this).StoredProcedureDefinition, + _ => throw new Exception( + message: $"Unsupported SourceType. It can either be Table,View, or Stored Procedure.") + }; + } + } } /// @@ -77,7 +95,7 @@ public DatabaseStoredProcedure(string schemaName, string tableName) public StoredProcedureDefinition StoredProcedureDefinition { get; set; } = null!; } - public class StoredProcedureDefinition + public class StoredProcedureDefinition : SourceDefinition { /// /// The list of input parameters @@ -161,6 +179,13 @@ public class ColumnDefinition public bool IsAutoGenerated { get; set; } public bool IsNullable { get; set; } public object? DefaultValue { get; set; } + + public ColumnDefinition() { } + + public ColumnDefinition(Type systemType) + { + this.SystemType = systemType; + } } public class ForeignKeyDefinition diff --git a/src/Config/Entity.cs b/src/Config/Entity.cs index 74bfb62252..7015dbc460 100644 --- a/src/Config/Entity.cs +++ b/src/Config/Entity.cs @@ -140,21 +140,21 @@ public void TryPopulateSourceFields() throw new JsonException(message: "Must specify entity source."); } - JsonElement sourceJson = (JsonElement)Source; + JsonElement sourceJson = JsonSerializer.SerializeToElement(Source); // In the case of a simple, string source, we assume the source type is a table; parameters and key fields left null // Note: engine supports views backing entities labeled as Tables, as long as their primary key can be inferred if (sourceJson.ValueKind is JsonValueKind.String) { ObjectType = SourceType.Table; - SourceName = JsonSerializer.Deserialize((JsonElement)Source)!; + SourceName = JsonSerializer.Deserialize(sourceJson)!; Parameters = null; KeyFields = null; } else if (sourceJson.ValueKind is JsonValueKind.Object) { DatabaseObjectSource? objectSource - = JsonSerializer.Deserialize((JsonElement)Source, + = JsonSerializer.Deserialize(sourceJson, options: RuntimeConfig.SerializerOptions); if (objectSource is null) diff --git a/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs new file mode 100644 index 0000000000..ecaf3cf5dd --- /dev/null +++ b/src/Service.GraphQLBuilder/GraphQLStoredProcedureBuilder.cs @@ -0,0 +1,97 @@ +using System.Text.Json; +using Azure.DataApiBuilder.Config; +using HotChocolate.Language; +using HotChocolate.Types; +using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLUtils; + +namespace Azure.DataApiBuilder.Service.GraphQLBuilder +{ + public static class GraphQLStoredProcedureBuilder + { + /// + /// Helper function to create StoredProcedure Schema for GraphQL. + /// It uses the parameters to build the arguments and returns a list + /// of the StoredProcedure GraphQL object. + /// + public static FieldDefinitionNode GenerateStoredProcedureSchema( + NameNode name, + Entity entity, + IEnumerable? rolesAllowed = null) + { + List inputValues = new(); + List fieldDefinitionNodeDirectives = new(); + + if (entity.Parameters is not null) + { + foreach (string param in entity.Parameters.Keys) + { + Tuple defaultGraphQLValue = GetGraphQLTypeAndNodeTypeFromStringValue(entity.Parameters[param].ToString()!); + inputValues.Add( + new( + location: null, + new(param), + new StringValueNode($"parameters for {name.Value} stored-procedure"), + new NamedTypeNode(defaultGraphQLValue.Item1), + defaultValue: defaultGraphQLValue.Item2, + new List()) + ); + } + } + + if (CreateAuthorizationDirectiveIfNecessary( + rolesAllowed, + out DirectiveNode? authorizeDirective)) + { + fieldDefinitionNodeDirectives.Add(authorizeDirective!); + } + + return new( + location: null, + new NameNode(name.Value), + new StringValueNode($"Execute Stored-Procedure {name.Value} and get results from the database"), + inputValues, + new NonNullTypeNode(new ListTypeNode(new NonNullTypeNode(new NamedTypeNode(name)))), + fieldDefinitionNodeDirectives + ); + } + + /// + /// Takes the result from DB as JsonDocument and formats it in a way that can be filtered by column + /// name. It parses the Json document into a list of Dictionary with key as result_column_name + /// with it's corresponding value. + /// returns an empty list in case of no result + /// or stored-procedure is trying to read from DB without READ permission. + /// + public static List FormatStoredProcedureResultAsJsonList(bool IsReadAllowed, JsonDocument jsonDocument) + { + if (jsonDocument is null || !IsReadAllowed) + { + return new List(); + } + + List resultJson = new(); + List> resultList = JsonSerializer.Deserialize>>(jsonDocument.RootElement.ToString())!; + foreach (Dictionary result in resultList) + { + resultJson.Add(JsonDocument.Parse(JsonSerializer.Serialize(result))); + } + + return resultJson; + } + + /// + /// Helper method to create a default result field for stored-procedure which does not + /// return any row. + /// + public static FieldDefinitionNode GetDefaultResultFieldForStoredProcedure() + { + return new( + location: null, + new("result"), + description: new StringValueNode("Contains output of stored-procedure execution"), + new List(), + new StringType().ToTypeNode(), + new List()); + } + } +} diff --git a/src/Service.GraphQLBuilder/GraphQLUtils.cs b/src/Service.GraphQLBuilder/GraphQLUtils.cs index b42c19d8d3..48715f9293 100644 --- a/src/Service.GraphQLBuilder/GraphQLUtils.cs +++ b/src/Service.GraphQLBuilder/GraphQLUtils.cs @@ -204,5 +204,26 @@ public static ObjectType UnderlyingGraphQLEntityType(IType type) return UnderlyingGraphQLEntityType(type.InnerType()); } + + /// + /// Parse a given string value to supported GraphQL Type and GraphQLValueNode + /// + public static Tuple GetGraphQLTypeAndNodeTypeFromStringValue(string stringValue) + { + if (int.TryParse(stringValue, out int integerValue)) + { + return new(LONG_TYPE, new IntValueNode(integerValue)); + } + else if (double.TryParse(stringValue, out double floatingValue)) + { + return new(FLOAT_TYPE, new FloatValueNode(floatingValue)); + } + else if (bool.TryParse(stringValue, out bool booleanValue)) + { + return new(BOOLEAN_TYPE, new BooleanValueNode(booleanValue)); + } + + return new(STRING_TYPE, new StringValueNode(stringValue)); + } } } diff --git a/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs b/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs index 435fd494ef..d6358e6e53 100644 --- a/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs +++ b/src/Service.GraphQLBuilder/Mutations/MutationBuilder.cs @@ -39,6 +39,20 @@ public static DocumentNode Build( NameNode name = objectTypeDefinitionNode.Name; string dbEntityName = ObjectTypeToEntityName(objectTypeDefinitionNode); + // For stored procedures, only one mutation is created in the schema + // unlike table/views where we create one for each CUD operation. + if (entities[dbEntityName].ObjectType is SourceType.StoredProcedure) + { + // If the role has actions other than READ, a schema for mutation will be generated. + Operation storedProcedureOperation = GetOperationTypeForStoredProcedure(dbEntityName, entityPermissionsMap); + if (storedProcedureOperation is not Operation.Read) + { + AddMutationsForStoredProcedure(dbEntityName, storedProcedureOperation, entityPermissionsMap, name, entities, mutationFields); + } + + continue; + } + AddMutations(dbEntityName, operation: Operation.Create, entityPermissionsMap, name, inputs, objectTypeDefinitionNode, root, databaseType, entities, mutationFields); AddMutations(dbEntityName, operation: Operation.Update, entityPermissionsMap, name, inputs, objectTypeDefinitionNode, root, databaseType, entities, mutationFields); AddMutations(dbEntityName, operation: Operation.Delete, entityPermissionsMap, name, inputs, objectTypeDefinitionNode, root, databaseType, entities, mutationFields); @@ -56,6 +70,21 @@ public static DocumentNode Build( return new(definitionNodes); } + /// + /// Tries to fetch the Operation Type for Stored Procedure. + /// Stored Procedure currently supports exactly 1 CRUD operation at a time. + /// This check is done during initialization as part of config validation. + /// + private static Operation GetOperationTypeForStoredProcedure( + string dbEntityName, + Dictionary? entityPermissionsMap) + { + List operations = entityPermissionsMap![dbEntityName].OperationToRolesMap.Keys.ToList(); + + // Stored Procedure will have only CRUD action. + return operations.First(); + } + /// /// Helper function to create mutation definitions. /// @@ -103,6 +132,26 @@ List mutationFields } } + /// + /// Helper method to add the new StoredProcedure in the mutation fields + /// of GraphQL Schema + /// + private static void AddMutationsForStoredProcedure( + string dbEntityName, + Operation operation, + Dictionary? entityPermissionsMap, + NameNode name, + IDictionary entities, + List mutationFields + ) + { + IEnumerable rolesAllowedForMutation = IAuthorizationResolver.GetRolesForOperation(dbEntityName, operation: operation, entityPermissionsMap); + if (rolesAllowedForMutation.Count() > 0) + { + mutationFields.Add(GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema(name, entities[dbEntityName], rolesAllowedForMutation)); + } + } + public static Operation DetermineMutationOperationTypeBasedOnInputType(string inputTypeName) { return inputTypeName switch diff --git a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs index 3e637ac02d..2616195a14 100644 --- a/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs +++ b/src/Service.GraphQLBuilder/Queries/QueryBuilder.cs @@ -50,15 +50,23 @@ public static DocumentNode Build( Entity entity = entities[entityName]; ObjectTypeDefinitionNode returnType = GenerateReturnType(name); - returnTypes.Add(returnType); IEnumerable rolesAllowedForRead = IAuthorizationResolver.GetRolesForOperation(entityName, operation: Operation.Read, entityPermissionsMap); if (rolesAllowedForRead.Count() > 0) { - queryFields.Add(GenerateGetAllQuery(objectTypeDefinitionNode, name, returnType, inputTypes, entity, rolesAllowedForRead)); - queryFields.Add(GenerateByPKQuery(objectTypeDefinitionNode, name, databaseType, entity, rolesAllowedForRead)); + if (entity.ObjectType is SourceType.StoredProcedure) + { + queryFields.Add(GraphQLStoredProcedureBuilder.GenerateStoredProcedureSchema(name, entity, rolesAllowedForRead)); + } + else + { + queryFields.Add(GenerateGetAllQuery(objectTypeDefinitionNode, name, returnType, inputTypes, entity, rolesAllowedForRead)); + queryFields.Add(GenerateByPKQuery(objectTypeDefinitionNode, name, databaseType, entity, rolesAllowedForRead)); + } } + + returnTypes.Add(returnType); } } diff --git a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs index d530277549..bb568eeda7 100644 --- a/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs +++ b/src/Service.GraphQLBuilder/Sql/SchemaConverter.cs @@ -9,6 +9,7 @@ using HotChocolate.Language; using HotChocolate.Types; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming; +using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLStoredProcedureBuilder; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLTypes.SupportedTypes; namespace Azure.DataApiBuilder.Service.GraphQLBuilder.Sql @@ -16,7 +17,7 @@ namespace Azure.DataApiBuilder.Service.GraphQLBuilder.Sql public static class SchemaConverter { /// - /// Generate a GraphQL object type from a SQL table definition, combined with the runtime config entity information + /// Generate a GraphQL object type from a SQL table/view/stored-procedure definition, combined with the runtime config entity information /// /// Name of the entity in the runtime config to generate the GraphQL object type for. /// SQL database object information. @@ -36,25 +37,33 @@ public static ObjectTypeDefinitionNode FromDatabaseObject( { Dictionary fields = new(); List objectTypeDirectives = new(); - SourceDefinition sourceDefinition = - databaseObject.SourceType is SourceType.Table ? - ((DatabaseTable)databaseObject).TableDefinition : - ((DatabaseView)databaseObject).ViewDefinition; + SourceDefinition sourceDefinition = databaseObject.SourceDefinition; + NameNode nameNode = new(value: GetDefinedSingularName(entityName, configEntity)); + + // When the result set is not defined, it could be a mutation operation with no returning columns + // Here we create a field called result which will be an empty array. + if (databaseObject.SourceType is SourceType.StoredProcedure && ((StoredProcedureDefinition)sourceDefinition).Columns.Count == 0) + { + FieldDefinitionNode field = GetDefaultResultFieldForStoredProcedure(); + + fields.TryAdd("result", field); + } + foreach ((string columnName, ColumnDefinition column) in sourceDefinition.Columns) { List directives = new(); - if (sourceDefinition.PrimaryKey.Contains(columnName)) + if (databaseObject.SourceType is not SourceType.StoredProcedure && sourceDefinition.PrimaryKey.Contains(columnName)) { directives.Add(new DirectiveNode(PrimaryKeyDirectiveType.DirectiveName, new ArgumentNode("databaseType", column.SystemType.Name))); } - if (column.IsAutoGenerated) + if (databaseObject.SourceType is not SourceType.StoredProcedure && column.IsAutoGenerated) { directives.Add(new DirectiveNode(AutoGeneratedDirectiveType.DirectiveName)); } - if (column.DefaultValue is not null) + if (databaseObject.SourceType is not SourceType.StoredProcedure && column.DefaultValue is not null) { IValueNode arg = column.DefaultValue switch { @@ -86,7 +95,6 @@ databaseObject.SourceType is SourceType.Table ? // Roles will not be null here if TryGetValue evaluates to true, so here we check if there are any roles to process. if (roles.Count() > 0) { - if (GraphQLUtils.CreateAuthorizationDirectiveIfNecessary( roles, out DirectiveNode? authZDirective)) @@ -105,6 +113,17 @@ databaseObject.SourceType is SourceType.Table ? fields.Add(columnName, field); } + else if (databaseObject.SourceType is SourceType.StoredProcedure) + { + // When no roles have permission to access the columns + // we create a default result set with no data. i.e, the stored-procedure is + // executed but user see empty results because it is trying to query the DB without read permission. + if (databaseObject.SourceType is SourceType.StoredProcedure) + { + FieldDefinitionNode field = GetDefaultResultFieldForStoredProcedure(); + fields.TryAdd("result", field); + } + } } } @@ -198,7 +217,7 @@ databaseObject.SourceType is SourceType.Table ? // if the top-level entity name is already plural. return new ObjectTypeDefinitionNode( location: null, - name: new(value: GetDefinedSingularName(entityName, configEntity)), + name: nameNode, description: null, objectTypeDirectives, new List(), diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs index bc6c7d01f1..00d64e1a33 100644 --- a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs @@ -108,6 +108,91 @@ public async Task InsertMutationForConstantdefaultValue(string dbQuery) SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); } + /// + /// Do: Inserts new book in the books table with given publisher_id + /// Check: If the new book is inserted into the DB and + /// verifies the response. + /// + public async Task TestStoredProcedureMutationForInsertion(string dbQuery) + { + string graphQLMutationName = "InsertBook"; + string graphQLMutation = @" + mutation { + InsertBook(title: ""Random Book"", publisher_id: 1234 ) { + result + } + } + "; + + string currentDbResponse = await GetDatabaseResultAsync(dbQuery); + JsonDocument currentResult = JsonDocument.Parse(currentDbResponse); + Assert.AreEqual(currentResult.RootElement.GetProperty("count").GetInt64(), 0); + JsonElement graphQLResponse = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + + // Stored Procedure didn't return anything + SqlTestHelper.PerformTestEqualJsonStrings("[]", graphQLResponse.ToString()); + + // check to verify new element is inserted + string updatedDbResponse = await GetDatabaseResultAsync(dbQuery); + JsonDocument updatedResult = JsonDocument.Parse(updatedDbResponse); + Assert.AreEqual(updatedResult.RootElement.GetProperty("count").GetInt64(), 1); + } + + /// + /// Do: Deletes book from the books table with given id + /// Check: If the intended book is deleted from the DB and + /// verifies the response. + /// + public async Task TestStoredProcedureMutationForDeletion(string dbQueryToVerifyDeletion) + { + string graphQLMutationName = "DeleteLastInsertedBook"; + string graphQLMutation = @" + mutation { + DeleteLastInsertedBook { + result + } + } + "; + + string currentDbResponse = await GetDatabaseResultAsync(dbQueryToVerifyDeletion); + JsonDocument currentResult = JsonDocument.Parse(currentDbResponse); + Assert.AreEqual(currentResult.RootElement.GetProperty("maxId").GetInt64(), 14); + JsonElement graphQLResponse = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + + // Stored Procedure didn't return anything + SqlTestHelper.PerformTestEqualJsonStrings("[]", graphQLResponse.ToString()); + + // check to verify new element is inserted + string updatedDbResponse = await GetDatabaseResultAsync(dbQueryToVerifyDeletion); + JsonDocument updatedResult = JsonDocument.Parse(updatedDbResponse); + Assert.AreEqual(updatedResult.RootElement.GetProperty("maxId").GetInt64(), 13); + } + + /// + /// Do: updates a book title from the books table with given id + /// and new title. + /// Check: The book title should be updated with the given id + /// DB is queried to verify the result. + /// + public async Task TestStoredProcedureMutationForUpdate(string dbQuery) + { + string graphQLMutationName = "UpdateBookTitle"; + string graphQLMutation = @" + mutation { + UpdateBookTitle(id: 14, title: ""Before Midnight"") { + result + } + } + "; + + string beforeUpdate = await GetDatabaseResultAsync(dbQuery); + SqlTestHelper.PerformTestEqualJsonStrings("{\"id\":14,\"title\":\"Before Sunset\",\"publisher_id\":1234}", beforeUpdate); + JsonElement graphQLResponse = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); + SqlTestHelper.PerformTestEqualJsonStrings("[]", graphQLResponse.ToString()); + string afterUpdate = await GetDatabaseResultAsync(dbQuery); + SqlTestHelper.PerformTestEqualJsonStrings("{\"id\":14,\"title\":\"Before Midnight\",\"publisher_id\":1234}", afterUpdate); + } + /// /// Do: Inserts new stock price with default current timestamp as the value of /// 'instant' column and returns the inserted row. diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/MsSqlGraphQLMutationTests.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/MsSqlGraphQLMutationTests.cs index 25c85f9c6a..330a3fcc6c 100644 --- a/src/Service.Tests/SqlTests/GraphQLMutationTests/MsSqlGraphQLMutationTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/MsSqlGraphQLMutationTests.cs @@ -132,6 +132,65 @@ ORDER BY [id] asc await InsertMutationForConstantdefaultValue(msSqlQuery); } + /// + /// Do: insert new Book and return nothing + /// Check: if the intended book is inserted in books table + /// + [TestMethod] + public async Task TestStoredProcedureMutationForInsertion() + { + string msSqlQuery = @" + SELECT COUNT(*) AS [count] + FROM [books] AS [table0] + WHERE [table0].[title] = 'Random Book' + AND [table0].[publisher_id] = 1234 + FOR JSON PATH, + INCLUDE_NULL_VALUES, + WITHOUT_ARRAY_WRAPPER + "; + + await TestStoredProcedureMutationForInsertion(msSqlQuery); + } + + /// + /// Do: deletes a Book and return nothing + /// Check: the intended book is deleted + /// + [TestMethod] + public async Task TestStoredProcedureMutationForDeletion() + { + string dbQueryToVerifyDeletion = @" + SELECT MAX(table0.id) AS [maxId] + FROM [books] AS [table0] + FOR JSON PATH, + INCLUDE_NULL_VALUES, + WITHOUT_ARRAY_WRAPPER + "; + + await TestStoredProcedureMutationForDeletion(dbQueryToVerifyDeletion); + } + + /// + /// Do: Book title updation and return the updated row + /// Check: if the result returned from the mutation is correct + /// + [TestMethod] + public async Task TestStoredProcedureMutationForUpdate() + { + string dbQuery = @" + SELECT id, title, publisher_id + FROM [books] AS [table0] + WHERE + [table0].[id] = 14 + AND [table0].[publisher_id] = 1234 + FOR JSON PATH, + INCLUDE_NULL_VALUES, + WITHOUT_ARRAY_WRAPPER + "; + + await TestStoredProcedureMutationForUpdate(dbQuery); + } + /// [TestMethod] public async Task InsertMutationForVariableNotNullDefault() diff --git a/src/Service.Tests/SqlTests/GraphQLPaginationTests/GraphQLPaginationTestBase.cs b/src/Service.Tests/SqlTests/GraphQLPaginationTests/GraphQLPaginationTestBase.cs index 8de1e74779..902adb1a0b 100644 --- a/src/Service.Tests/SqlTests/GraphQLPaginationTests/GraphQLPaginationTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLPaginationTests/GraphQLPaginationTestBase.cs @@ -129,9 +129,17 @@ public async Task RequestNoParamFullConnection() { ""id"": 12, ""title"": ""Time to Eat 2"" + }, + { + ""id"": 13, + ""title"": ""Before Sunrise"" + }, + { + ""id"": 14, + ""title"": ""Before Sunset"" } ], - ""endCursor"": """ + SqlPaginationUtil.Base64Encode("[{\"Value\":12,\"Direction\":0,\"TableSchema\":\"\",\"TableName\":\"\",\"ColumnName\":\"id\"}]") + @""", + ""endCursor"": """ + SqlPaginationUtil.Base64Encode("[{\"Value\":14,\"Direction\":0,\"TableSchema\":\"\",\"TableName\":\"\",\"ColumnName\":\"id\"}]") + @""", ""hasNextPage"": false }"; @@ -287,10 +295,14 @@ public async Task RequestNestedPaginationQueries() { ""id"": 2, ""title"": ""Also Awesome book"" + }, + { + ""id"": 13, + ""title"": ""Before Sunrise"" } ], - ""endCursor"": """ + SqlPaginationUtil.Base64Encode("[{\"Value\":2,\"Direction\":0,\"TableSchema\":\"\",\"TableName\":\"\",\"ColumnName\":\"id\"}]") + @""", - ""hasNextPage"": false + ""endCursor"": """ + SqlPaginationUtil.Base64Encode("[{\"Value\":13,\"Direction\":0,\"TableSchema\":\"\",\"TableName\":\"\",\"ColumnName\":\"id\"}]") + @""", + ""hasNextPage"": true } } }, @@ -356,12 +368,12 @@ public async Task RequestPaginatedQueryFromMutationResult() ""title"": ""Also Awesome book"" }, { - ""id"": 5001, - ""title"": ""Books, Pages, and Pagination. The Book"" + ""id"": 13, + ""title"": ""Before Sunrise"" } ], - ""endCursor"": """ + SqlPaginationUtil.Base64Encode("[{\"Value\":5001,\"Direction\":0,\"TableSchema\":\"\",\"TableName\":\"\",\"ColumnName\":\"id\"}]") + @""", - ""hasNextPage"": false + ""endCursor"": """ + SqlPaginationUtil.Base64Encode("[{\"Value\":13,\"Direction\":0,\"TableSchema\":\"\",\"TableName\":\"\",\"ColumnName\":\"id\"}]") + @""", + ""hasNextPage"": true } } }"; diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/GraphQLQueryTestBase.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/GraphQLQueryTestBase.cs index 3dc92cf814..6d30970019 100644 --- a/src/Service.Tests/SqlTests/GraphQLQueryTests/GraphQLQueryTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/GraphQLQueryTestBase.cs @@ -88,7 +88,7 @@ public virtual async Task MultipleResultJoinQuery() { string graphQLQueryName = "books"; string graphQLQuery = @"{ - books(first: 100) { + books(first: 12) { items { id title @@ -587,6 +587,9 @@ public virtual async Task TestFirstParamForListQueries() }, { ""title"": ""Also Awesome book"" + }, + { + ""title"": ""Before Sunrise"" } ] } @@ -629,6 +632,12 @@ public virtual async Task TestFilterParamForListQueries() ""items"": [ { ""id"": 1 + }, + { + ""id"": 13 + }, + { + ""id"": 14 } ] } @@ -641,6 +650,12 @@ public virtual async Task TestFilterParamForListQueries() ""items"": [ { ""id"": 1 + }, + { + ""id"": 13 + }, + { + ""id"": 14 } ] } @@ -955,6 +970,63 @@ public virtual async Task TestQueryOnBasicView(string dbQuery) SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.GetProperty("items").ToString()); } + /// + /// Simple Stored Procedure to check SELECT query returning single row + /// + public async Task TestStoredProcedureQueryForGettingSingleRow(string dbQuery) + { + string graphQLQueryName = "GetPublisher"; + string graphQLQuery = @"{ + GetPublisher(id: 1234) { + id + name + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + string expected = await GetDatabaseResultAsync(dbQuery, false); + + SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); + } + + /// + /// Simple Stored Procedure to check SELECT query returning multiple rows + /// + public async Task TestStoredProcedureQueryForGettingMultipleRows(string dbQuery) + { + string graphQLQueryName = "GetBooks"; + string graphQLQuery = @"{ + GetBooks { + id + title + publisher_id + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + string expected = await GetDatabaseResultAsync(dbQuery, false); + + SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); + } + + /// + /// Simple Stored Procedure to check COUNT operation + /// + public async Task TestStoredProcedureQueryForGettingTotalNumberOfRows(string dbQuery) + { + string graphQLQueryName = "CountBooks"; + string graphQLQuery = @"{ + CountBooks { + total_books + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + string expected = await GetDatabaseResultAsync(dbQuery, false); + + SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); + } + /// /// Query a composite view (contains columns from multiple tables) /// @@ -998,6 +1070,24 @@ public virtual async Task TestInvalidFirstParamQuery() SqlTestHelper.TestForErrorInGraphQLResponse(result.ToString(), statusCode: $"{DataApiBuilderException.SubStatusCodes.BadRequest}"); } + /// + /// Checks failure on providing invalid arguments in graphQL Query + /// + public async Task TestStoredProcedureQueryWithInvalidArgumentType() + { + string graphQLQueryName = "GetBook"; + string graphQLQuery = @"{ + GetBook(id: ""3"") { + id + title + publisher_id + } + }"; + + JsonElement result = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + SqlTestHelper.TestForErrorInGraphQLResponse(result.ToString(), message: "The specified argument value does not match the argument type."); + } + [TestMethod] public virtual async Task TestInvalidFilterParamQuery() { diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs index b382bef79c..d943315c5e 100644 --- a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs @@ -273,6 +273,36 @@ public async Task TestQueryOnBasicView() await base.TestQueryOnBasicView(msSqlQuery); } + /// + /// Test to execute stored-procedure in graphQL that returns a single row + /// + [TestMethod] + public async Task TestStoredProcedureQueryForGettingSingleRow() + { + string msSqlQuery = $"EXEC dbo.get_publisher_by_id @id=1234"; + await TestStoredProcedureQueryForGettingSingleRow(msSqlQuery); + } + + /// + /// Test to execute stored-procedure in graphQL that returns a list(multiple rows) + /// + [TestMethod] + public async Task TestStoredProcedureQueryForGettingMultipleRows() + { + string msSqlQuery = $"EXEC dbo.get_books"; + await TestStoredProcedureQueryForGettingMultipleRows(msSqlQuery); + } + + /// + /// Test to execute stored-procedure in graphQL that counts the total number of rows + /// + [TestMethod] + public async Task TestStoredProcedureQueryForGettingTotalNumberOfRows() + { + string msSqlQuery = $"EXEC dbo.count_books"; + await TestStoredProcedureQueryForGettingTotalNumberOfRows(msSqlQuery); + } + [TestMethod] public async Task TestQueryOnCompositeView() { diff --git a/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs index c0dd0ead2e..5d06a71504 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs @@ -1028,7 +1028,7 @@ await SetupAndRunRestApiTest( entityNameOrPath: _integrationProcedureFindOne_EntityName, sqlQuery: string.Empty, exceptionExpected: true, - expectedErrorMessage: "Invalid request. Missing required procedure parameters: id", + expectedErrorMessage: $"Invalid request. Missing required procedure parameters: id for entity: {_integrationProcedureFindOne_EntityName}", expectedStatusCode: HttpStatusCode.BadRequest ); } @@ -1047,7 +1047,7 @@ await SetupAndRunRestApiTest( entityNameOrPath: _integrationProcedureFindMany_EntityName, sqlQuery: string.Empty, exceptionExpected: true, - expectedErrorMessage: "Invalid request. Contained unexpected fields: param", + expectedErrorMessage: $"Invalid request. Contained unexpected fields: param for entity: {_integrationProcedureFindMany_EntityName}", expectedStatusCode: HttpStatusCode.BadRequest ); @@ -1058,7 +1058,7 @@ await SetupAndRunRestApiTest( entityNameOrPath: _integrationProcedureFindOne_EntityName, sqlQuery: string.Empty, exceptionExpected: true, - expectedErrorMessage: "Invalid request. Contained unexpected fields: param", + expectedErrorMessage: $"Invalid request. Contained unexpected fields: param for entity: {_integrationProcedureFindOne_EntityName}", expectedStatusCode: HttpStatusCode.BadRequest ); } diff --git a/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs b/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs index ed2bf92fa4..854a46b00a 100644 --- a/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs +++ b/src/Service.Tests/Unittests/ConfigValidationUnitTests.cs @@ -48,6 +48,63 @@ public void InaccessibleFieldRequestedByPolicy(string dbPolicy) Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode); } + /// + /// Test method to validate that only 1 CRUD operation is supported for stored procedure. + /// + [DataTestMethod] + [DataRow(new object[] { "create", "read" }, false, DisplayName = "Stored-procedure with create-read permission")] + [DataRow(new object[] { "update", "read" }, false, DisplayName = "Stored-procedure with update-read permission")] + [DataRow(new object[] { "delete", "read" }, false, DisplayName = "Stored-procedure with delete-read permission")] + [DataRow(new object[] { "create" }, true, DisplayName = "Stored-procedure with only create permission")] + [DataRow(new object[] { "read" }, true, DisplayName = "Stored-procedure with only read permission")] + [DataRow(new object[] { "update" }, true, DisplayName = "Stored-procedure with only update permission")] + [DataRow(new object[] { "delete" }, true, DisplayName = "Stored-procedure with only delete permission")] + [DataRow(new object[] { "update", "create" }, false, DisplayName = "Stored-procedure with update-create permission")] + [DataRow(new object[] { "delete", "read", "update" }, false, DisplayName = "Stored-procedure with delete-read-update permission")] + public void InvalidCRUDForStoredProcedure(object[] operations, bool isValid) + { + RuntimeConfig runtimeConfig = AuthorizationHelpers.InitRuntimeConfig( + entityName: AuthorizationHelpers.TEST_ENTITY, + roleName: AuthorizationHelpers.TEST_ROLE + ); + + PermissionSetting permissionForEntity = new( + role: AuthorizationHelpers.TEST_ROLE, + operations: operations); + + object entitySource = new DatabaseObjectSource( + Type: SourceType.StoredProcedure, + Name: "sourceName", + Parameters: null, + KeyFields: null + ); + + Entity testEntity = new( + Source: entitySource, + Rest: true, + GraphQL: true, + Permissions: new PermissionSetting[] { permissionForEntity }, + Relationships: null, + Mappings: null + ); + runtimeConfig.Entities[AuthorizationHelpers.TEST_ENTITY] = testEntity; + RuntimeConfigValidator configValidator = AuthenticationConfigValidatorUnitTests.GetMockConfigValidator(ref runtimeConfig); + + try + { + configValidator.ValidatePermissionsInConfig(runtimeConfig); + Assert.AreEqual(true, isValid); + } + catch (DataApiBuilderException ex) + { + Assert.AreEqual(false, isValid); + Assert.AreEqual("Invalid Operations for Entity: SampleEntity. " + + $"StoredProcedure can process only one CRUD (Create/Read/Update/Delete) operation.", ex.Message); + Assert.AreEqual(HttpStatusCode.ServiceUnavailable, ex.StatusCode); + Assert.AreEqual(DataApiBuilderException.SubStatusCodes.ConfigValidationError, ex.SubStatusCode); + } + } + /// /// Test method to validate that an appropriate exception is thrown when there is an invalid action /// supplied in the runtimeconfig. diff --git a/src/Service/Configurations/RuntimeConfigValidator.cs b/src/Service/Configurations/RuntimeConfigValidator.cs index 13c6b62b34..2483b27e6f 100644 --- a/src/Service/Configurations/RuntimeConfigValidator.cs +++ b/src/Service/Configurations/RuntimeConfigValidator.cs @@ -9,6 +9,7 @@ using Azure.DataApiBuilder.Service.Authorization; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.GraphQLBuilder; +using Azure.DataApiBuilder.Service.Models; using Azure.DataApiBuilder.Service.Services; using Microsoft.Extensions.Logging; using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLNaming; @@ -164,6 +165,8 @@ public static void ValidateDatabaseType( /// All these entities will create queries with the following field names /// pk query name: book_by_pk /// List query name: books + /// NOTE: we don't do this check for storedProcedure, because the name of the query is same + /// as that provided in the config, and two different entity can't have same name in the config. /// /// Entity definitions /// @@ -173,7 +176,10 @@ public static void ValidateEntitiesDoNotGenerateDuplicateQueries(IDictionary operationsList = new(); foreach (Object action in actions) { if (action is null) @@ -339,7 +347,8 @@ public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) // Evaluate actionOp as the current operation to be validated. Operation actionOp; - if (((JsonElement)action!).ValueKind is JsonValueKind.String) + JsonElement actionJsonElement = JsonSerializer.SerializeToElement(action); + if ((actionJsonElement!).ValueKind is JsonValueKind.String) { string actionName = action.ToString()!; if (AuthorizationResolver.WILDCARD.Equals(actionName)) @@ -425,6 +434,22 @@ public void ValidatePermissionsInConfig(RuntimeConfig runtimeConfig) subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); } } + + operationsList.Add(actionOp); + } + + // Only one of the CRUD actions is allowed for stored procedure. + if (entity.ObjectType is SourceType.StoredProcedure) + { + if ((operationsList.Count > 1) + || (operationsList.Count is 1 && operationsList[0] is Operation.All)) + { + throw new DataApiBuilderException( + message: $"Invalid Operations for Entity: {entityName}. " + + $"StoredProcedure can process only one CRUD (Create/Read/Update/Delete) operation.", + statusCode: System.Net.HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); + } } } } @@ -550,6 +575,42 @@ public void ValidateRelationshipsInConfig(RuntimeConfig runtimeConfig, ISqlMetad } } + /// + /// Validates the parameters given in the config are consistent with the DB i.e., config has all + /// the parameters that are specified for the stored procedure in DB. + /// + public void ValidateStoredProceduresInConfig(RuntimeConfig runtimeConfig, ISqlMetadataProvider sqlMetadataProvider) + { + foreach ((string entityName, Entity entity) in runtimeConfig.Entities) + { + // We are only doing this pre-check for GraphQL because for GraphQL we need the correct schema while making request + // so if the schema is not correct we will halt the engine + // but for rest we can do it when a request is made and only fail that particular request. + entity.TryPopulateSourceFields(); + if (entity.ObjectType is SourceType.StoredProcedure && + entity.GraphQL is not null && !(entity.GraphQL is bool graphQLEnabled && !graphQLEnabled)) + { + DatabaseObject dbObject = sqlMetadataProvider.EntityToDatabaseObject[entityName]; + StoredProcedureRequestContext sqRequestContext = new( + entityName, + dbObject, + JsonSerializer.SerializeToElement(entity.Parameters), + Operation.All); + try + { + RequestValidator.ValidateStoredProcedureRequestContext(sqRequestContext, sqlMetadataProvider); + } + catch (DataApiBuilderException e) + { + throw new DataApiBuilderException( + message: e.Message, + statusCode: System.Net.HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError); + } + } + } + } + /// /// Pre-processes the permissions section of the runtime config object. /// For eg. removing the @item. directives, checking for invalid characters in claimTypes etc. diff --git a/src/Service/MsSqlBooks.sql b/src/Service/MsSqlBooks.sql index 8f6b085f01..d964957f6f 100644 --- a/src/Service/MsSqlBooks.sql +++ b/src/Service/MsSqlBooks.sql @@ -6,6 +6,11 @@ DROP VIEW IF EXISTS books_publishers_view_composite; DROP VIEW IF EXISTS books_publishers_view_composite_insertable; DROP PROCEDURE IF EXISTS get_books; DROP PROCEDURE IF EXISTS get_book_by_id; +DROP PROCEDURE IF EXISTS get_publisher_by_id; +DROP PROCEDURE IF EXISTS insert_book; +DROP PROCEDURE IF EXISTS count_books; +DROP PROCEDURE IF EXISTS delete_last_inserted_book; +DROP PROCEDURE IF EXISTS update_book_title; DROP TABLE IF EXISTS book_author_link; DROP TABLE IF EXISTS reviews; DROP TABLE IF EXISTS authors; @@ -251,7 +256,9 @@ VALUES (1, 'Awesome book', 1234), (9, 'Policy-Test-01', 1940), (10, 'Policy-Test-02', 1940), (11, 'Policy-Test-04', 1941), -(12, 'Time to Eat 2', 1941); +(12, 'Time to Eat 2', 1941), +(13, 'Before Sunrise', 1234), +(14, 'Before Sunset', 1234); SET IDENTITY_INSERT books OFF SET IDENTITY_INSERT book_website_placements ON @@ -312,5 +319,23 @@ EXEC('CREATE VIEW books_publishers_view_composite_insertable as SELECT EXEC('CREATE PROCEDURE get_book_by_id @id int AS SELECT * FROM dbo.books WHERE id = @id'); +EXEC('CREATE PROCEDURE get_publisher_by_id @id int AS + SELECT * FROM dbo.publishers + WHERE id = @id'); EXEC('CREATE PROCEDURE get_books AS SELECT * FROM dbo.books'); +EXEC('CREATE PROCEDURE insert_book @title varchar(max), @publisher_id int AS + INSERT INTO dbo.books(title, publisher_id) VALUES (@title, @publisher_id)'); +EXEC('CREATE PROCEDURE count_books AS + SELECT COUNT(*) AS total_books FROM dbo.books'); +EXEC('CREATE PROCEDURE delete_last_inserted_book AS + BEGIN + DELETE FROM dbo.books + WHERE + id = (select max(id) from dbo.books) + END'); +EXEC('CREATE PROCEDURE update_book_title @id int, @title varchar(max) AS + BEGIN + UPDATE dbo.books SET title = @title WHERE id = @id + SELECT * from dbo.books WHERE id = @id + END'); diff --git a/src/Service/MySqlBooks.sql b/src/Service/MySqlBooks.sql index 4b900e7e33..27dc2d9e49 100644 --- a/src/Service/MySqlBooks.sql +++ b/src/Service/MySqlBooks.sql @@ -221,7 +221,22 @@ ON DELETE CASCADE; INSERT INTO publishers(id, name) VALUES (1234, 'Big Company'), (2345, 'Small Town Publisher'), (2323, 'TBD Publishing One'), (2324, 'TBD Publishing Two Ltd'), (1940, 'Policy Publisher 01'), (1941, 'Policy Publisher 02'); INSERT INTO authors(id, name, birthdate) VALUES (123, 'Jelte', '2001-01-01'), (124, 'Aniruddh', '2002-02-02'), (125, 'Aniruddh', '2001-01-01'), (126, 'Aaron', '2001-01-01'); -INSERT INTO books(id, title, publisher_id) VALUES (1, 'Awesome book', 1234), (2, 'Also Awesome book', 1234), (3, 'Great wall of china explained', 2345), (4, 'US history in a nutshell', 2345), (5, 'Chernobyl Diaries', 2323), (6, 'The Palace Door', 2324), (7, 'The Groovy Bar', 2324), (8, 'Time to Eat', 2324), (9, 'Policy-Test-01', 1940), (10, 'Policy-Test-02', 1940), (11, 'Policy-Test-04', 1941), (12, 'Time to Eat 2', 1941); +INSERT INTO books(id, title, publisher_id) + VALUES + (1, 'Awesome book', 1234), + (2, 'Also Awesome book', 1234), + (3, 'Great wall of china explained', 2345), + (4, 'US history in a nutshell', 2345), + (5, 'Chernobyl Diaries', 2323), + (6, 'The Palace Door', 2324), + (7, 'The Groovy Bar', 2324), + (8, 'Time to Eat', 2324), + (9, 'Policy-Test-01', 1940), + (10, 'Policy-Test-02', 1940), + (11, 'Policy-Test-04', 1941), + (12, 'Time to Eat 2', 1941), + (13, 'Before Sunrise', 1234), + (14, 'Before Sunset', 1234); INSERT INTO book_website_placements(book_id, price) VALUES (1, 100), (2, 50), (3, 23), (5, 33); INSERT INTO website_users(id, username) VALUES (1, 'George'), (2, NULL), (3, ''), (4, 'book_lover_95'), (5, 'null'); INSERT INTO book_author_link(book_id, author_id) VALUES (1, 123), (2, 124), (3, 123), (3, 124), (4, 123), (4, 124); diff --git a/src/Service/PostgreSqlBooks.sql b/src/Service/PostgreSqlBooks.sql index 8f122df19b..88363e9d01 100644 --- a/src/Service/PostgreSqlBooks.sql +++ b/src/Service/PostgreSqlBooks.sql @@ -225,7 +225,22 @@ ON DELETE CASCADE; INSERT INTO publishers(id, name) VALUES (1234, 'Big Company'), (2345, 'Small Town Publisher'), (2323, 'TBD Publishing One'), (2324, 'TBD Publishing Two Ltd'), (1940, 'Policy Publisher 01'), (1941, 'Policy Publisher 02'); INSERT INTO authors(id, name, birthdate) VALUES (123, 'Jelte', '2001-01-01'), (124, 'Aniruddh', '2002-02-02'), (125, 'Aniruddh', '2001-01-01'), (126, 'Aaron', '2001-01-01'); -INSERT INTO books(id, title, publisher_id) VALUES (1, 'Awesome book', 1234), (2, 'Also Awesome book', 1234), (3, 'Great wall of china explained', 2345), (4, 'US history in a nutshell', 2345), (5, 'Chernobyl Diaries', 2323), (6, 'The Palace Door', 2324), (7, 'The Groovy Bar', 2324), (8, 'Time to Eat', 2324), (9, 'Policy-Test-01', 1940), (10, 'Policy-Test-02', 1940), (11, 'Policy-Test-04', 1941), (12, 'Time to Eat 2', 1941); +INSERT INTO books(id, title, publisher_id) + VALUES + (1, 'Awesome book', 1234), + (2, 'Also Awesome book', 1234), + (3, 'Great wall of china explained', 2345), + (4, 'US history in a nutshell', 2345), + (5, 'Chernobyl Diaries', 2323), + (6, 'The Palace Door', 2324), + (7, 'The Groovy Bar', 2324), + (8, 'Time to Eat', 2324), + (9, 'Policy-Test-01', 1940), + (10, 'Policy-Test-02', 1940), + (11, 'Policy-Test-04', 1941), + (12, 'Time to Eat 2', 1941), + (13, 'Before Sunrise', 1234), + (14, 'Before Sunset', 1234); INSERT INTO book_website_placements(book_id, price) VALUES (1, 100), (2, 50), (3, 23), (5, 33); INSERT INTO website_users(id, username) VALUES (1, 'George'), (2, NULL), (3, ''), (4, 'book_lover_95'), (5, 'null'); INSERT INTO book_author_link(book_id, author_id) VALUES (1, 123), (2, 124), (3, 123), (3, 124), (4, 123), (4, 124); diff --git a/src/Service/Resolvers/IQueryBuilder.cs b/src/Service/Resolvers/IQueryBuilder.cs index d98e1b88b5..7cdb871162 100644 --- a/src/Service/Resolvers/IQueryBuilder.cs +++ b/src/Service/Resolvers/IQueryBuilder.cs @@ -52,6 +52,13 @@ public interface IQueryBuilder /// public string BuildForeignKeyInfoQuery(int numberOfParameters, bool developerMode, ILogger logger); + /// + /// Builds the query to obtain details about the result set for stored-procedure + /// + /// Name of stored-procedure + /// + public string BuildStoredProcedureResultDetailsQuery(string databaseObjectName); + /// /// Adds database specific quotes to string identifier /// diff --git a/src/Service/Resolvers/MsSqlQueryBuilder.cs b/src/Service/Resolvers/MsSqlQueryBuilder.cs index af2342d2d9..e11ae1f713 100644 --- a/src/Service/Resolvers/MsSqlQueryBuilder.cs +++ b/src/Service/Resolvers/MsSqlQueryBuilder.cs @@ -196,5 +196,17 @@ private static string BuildProcedureParameterList(Dictionary pro // If at least one parameter added, remove trailing comma and space, else return empty string return parameterList.Length > 0 ? parameterList[..^2] : parameterList; } + + /// + public string BuildStoredProcedureResultDetailsQuery(string databaseObjectName) + { + string query = "SELECT " + + "name as result_field_name, system_type_name, column_ordinal " + + "FROM " + + "sys.dm_exec_describe_first_result_set_for_object (" + + $"OBJECT_ID('{databaseObjectName}'), 0) " + + "WHERE is_hidden is not NULL AND is_hidden = 0"; + return query; + } } } diff --git a/src/Service/Resolvers/MySqlQueryBuilder.cs b/src/Service/Resolvers/MySqlQueryBuilder.cs index 95cb954301..4a76c078ed 100644 --- a/src/Service/Resolvers/MySqlQueryBuilder.cs +++ b/src/Service/Resolvers/MySqlQueryBuilder.cs @@ -328,5 +328,11 @@ private static string GetMySQLDefaultValue(ColumnDefinition column) return defaultValue; } + + /// + public string BuildStoredProcedureResultDetailsQuery(string databaseObjectName) + { + throw new NotImplementedException(); + } } } diff --git a/src/Service/Resolvers/PostgresQueryBuilder.cs b/src/Service/Resolvers/PostgresQueryBuilder.cs index 41b7d96146..15e843a172 100644 --- a/src/Service/Resolvers/PostgresQueryBuilder.cs +++ b/src/Service/Resolvers/PostgresQueryBuilder.cs @@ -217,5 +217,11 @@ private string MakeSelectColumns(SqlQueryStructure structure) return string.Join(", ", builtColumns); } + + /// + public string BuildStoredProcedureResultDetailsQuery(string databaseObjectName) + { + throw new NotImplementedException(); + } } } diff --git a/src/Service/Resolvers/SqlQueryEngine.cs b/src/Service/Resolvers/SqlQueryEngine.cs index 61d9286296..459187561c 100644 --- a/src/Service/Resolvers/SqlQueryEngine.cs +++ b/src/Service/Resolvers/SqlQueryEngine.cs @@ -6,6 +6,7 @@ using System.Text.Json.Nodes; using System.Threading.Tasks; using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Service.Configurations; using Azure.DataApiBuilder.Service.Models; using Azure.DataApiBuilder.Service.Services; @@ -15,6 +16,8 @@ using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using static Azure.DataApiBuilder.Service.Authorization.AuthorizationResolver; +using static Azure.DataApiBuilder.Service.GraphQLBuilder.GraphQLStoredProcedureBuilder; namespace Azure.DataApiBuilder.Service.Resolvers { @@ -87,33 +90,56 @@ await ExecuteAsync(structure), } /// - /// Executes the given IMiddlewareContext of the GraphQL and expecting a + /// Executes the given IMiddlewareContext of the GraphQL and expecting result of stored-procedure execution as /// list of Jsons and the relevant pagination metadata back. /// public async Task, IMetadata>> ExecuteListAsync(IMiddlewareContext context, IDictionary parameters) { - SqlQueryStructure structure = new( - context, - parameters, - _sqlMetadataProvider, - _authorizationResolver, - _runtimeConfigProvider, - _gQLFilterParser); - string queryString = _queryBuilder.Build(structure); - _logger.LogInformation(queryString); - List jsonListResult = - await _queryExecutor.ExecuteQueryAsync( - queryString, - structure.Parameters, - _queryExecutor.GetJsonResultAsync>); - - if (jsonListResult is null) + _sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(context.Field.Name.Value, out DatabaseObject? databaseObject); + if (databaseObject is not null && databaseObject.SourceType is SourceType.StoredProcedure) { - return new Tuple, IMetadata>(new List(), null); + SqlExecuteStructure sqlExecuteStructure = new( + context.FieldSelection.Name.Value, + _sqlMetadataProvider, + _authorizationResolver, + _gQLFilterParser, + parameters); + + // checking if role has read permission on the result + _authorizationResolver.EntityPermissionsMap.TryGetValue(context.Field.Name.Value, out EntityMetadata entityMetadata); + string role = context.ContextData[CLIENT_ROLE_HEADER].ToString(); + bool IsReadAllowed = entityMetadata.RoleToOperationMap[role].OperationToColumnMap.ContainsKey(Operation.Read); + + return new Tuple, IMetadata>( + FormatStoredProcedureResultAsJsonList(IsReadAllowed, await ExecuteAsync(sqlExecuteStructure)), + PaginationMetadata.MakeEmptyPaginationMetadata()); } else { - return new Tuple, IMetadata>(jsonListResult, structure.PaginationMetadata); + SqlQueryStructure structure = new( + context, + parameters, + _sqlMetadataProvider, + _authorizationResolver, + _runtimeConfigProvider, + _gQLFilterParser); + + string queryString = _queryBuilder.Build(structure); + _logger.LogInformation(queryString); + List jsonListResult = + await _queryExecutor.ExecuteQueryAsync( + queryString, + structure.Parameters, + _queryExecutor.GetJsonResultAsync>); + + if (jsonListResult is null) + { + return new Tuple, IMetadata>(new List(), null); + } + else + { + return new Tuple, IMetadata>(jsonListResult, structure.PaginationMetadata); + } } } diff --git a/src/Service/Services/GraphQLSchemaCreator.cs b/src/Service/Services/GraphQLSchemaCreator.cs index 8841141219..bed327e6ee 100644 --- a/src/Service/Services/GraphQLSchemaCreator.cs +++ b/src/Service/Services/GraphQLSchemaCreator.cs @@ -134,8 +134,7 @@ DatabaseType.postgresql or { // Skip creating the GraphQL object for the current entity due to configuration // explicitly excluding the entity from the GraphQL endpoint. - if (entity.ObjectType is SourceType.StoredProcedure - || entity.GraphQL is not null && entity.GraphQL is bool graphql && graphql == false) + if (entity.GraphQL is not null && entity.GraphQL is bool graphql && graphql == false) { continue; } @@ -147,6 +146,7 @@ DatabaseType.postgresql or IEnumerable rolesAllowedForEntity = _authorizationResolver.GetRolesForEntity(entityName); Dictionary> rolesAllowedForFields = new(); SourceDefinition sourceDefinition = _sqlMetadataProvider.GetSourceDefinition(entityName); + foreach (string column in sourceDefinition.Columns.Keys) { IEnumerable roles = _authorizationResolver.GetRolesForField(entityName, field: column, operation: Operation.Read); @@ -173,7 +173,11 @@ DatabaseType.postgresql or rolesAllowedForFields ); - InputTypeBuilder.GenerateInputTypesForObjectType(node, inputObjects); + if (databaseObject.SourceType is not SourceType.StoredProcedure) + { + InputTypeBuilder.GenerateInputTypesForObjectType(node, inputObjects); + } + objectTypes.Add(entityName, node); } } diff --git a/src/Service/Services/MetadataProviders/MsSqlMetadataProvider.cs b/src/Service/Services/MetadataProviders/MsSqlMetadataProvider.cs index 7360c0a9f5..715c2807fa 100644 --- a/src/Service/Services/MetadataProviders/MsSqlMetadataProvider.cs +++ b/src/Service/Services/MetadataProviders/MsSqlMetadataProvider.cs @@ -68,6 +68,7 @@ public override Type SqlToCLRType(string sqlType) return typeof(DateTimeOffset); case "char": case "varchar": + case "varchar(max)": case "text": case "nchar": case "nvarchar": diff --git a/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs b/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs index 17cb34035b..8827d8aba3 100644 --- a/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs +++ b/src/Service/Services/MetadataProviders/SqlMetadataProvider.cs @@ -7,6 +7,7 @@ using System.Net; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Tasks; using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Service.Configurations; @@ -149,16 +150,7 @@ public SourceDefinition GetSourceDefinition(string entityName) subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound); } - switch (databaseObject!.SourceType) - { - case SourceType.Table: - return ((DatabaseTable)databaseObject).TableDefinition; - case SourceType.View: - return ((DatabaseView)databaseObject).ViewDefinition; - default: - // For stored procedures - return null!; - } + return databaseObject.SourceDefinition; } /// @@ -707,6 +699,14 @@ await FillSchemaForStoredProcedureAsync( GetSchemaName(entityName), GetDatabaseObjectName(entityName), GetStoredProcedureDefinition(entityName)); + + if (GetDatabaseType() == DatabaseType.mssql) + { + await PopulateResultSetDefinitionsForStoredProcedureAsync( + GetSchemaName(entityName), + GetDatabaseObjectName(entityName), + GetStoredProcedureDefinition(entityName)); + } } else if (entitySourceType is SourceType.Table) { @@ -732,6 +732,41 @@ await PopulateSourceDefinitionAsync( await PopulateForeignKeyDefinitionAsync(); } + /// + /// Queries DB to get the result fields name and type to + /// populate the result set definition for entities specified as stored procedures + /// + private async Task PopulateResultSetDefinitionsForStoredProcedureAsync( + string schemaName, + string storedProcedureName, + SourceDefinition sourceDefinition) + { + StoredProcedureDefinition storedProcedureDefinition = (StoredProcedureDefinition)sourceDefinition; + string dbStoredProcedureName = $"{schemaName}.{storedProcedureName}"; + // Generate query to get result set details + // of the stored procedure. + string queryForResultSetDetails = SqlQueryBuilder.BuildStoredProcedureResultDetailsQuery( + dbStoredProcedureName); + + // Execute the query to get columns' details. + JsonArray? resultArray = await QueryExecutor.ExecuteQueryAsync( + sqltext: queryForResultSetDetails, + parameters: null!, + dataReaderHandler: QueryExecutor.GetJsonArrayAsync); + using JsonDocument sqlResult = JsonDocument.Parse(resultArray!.ToJsonString()); + + // Iterate through each row returned by the query which corresponds to + // one row in the result set. + foreach (JsonElement element in sqlResult.RootElement.EnumerateArray()) + { + string resultFieldName = element.GetProperty("result_field_name").ToString(); + Type resultFieldType = SqlToCLRType(element.GetProperty("system_type_name").ToString()); + + // Store the dictionary containing result set field with it's type as Columns + storedProcedureDefinition.Columns.TryAdd(resultFieldName, new(resultFieldType)); + } + } + /// /// Helper method to create params for the query. /// @@ -766,21 +801,17 @@ private void GenerateExposedToBackingColumnMapsForEntities() { foreach (string entityName in _entities.Keys) { - // Ensure we don't attempt for stored procedures, which have no - // SourceDefinition, Columns, Keys, etc. - if (_entities[entityName].ObjectType is not SourceType.StoredProcedure) + // InCase of StoredProcedures, result set definitions becomes the column definition. + Dictionary? mapping = GetMappingForEntity(entityName); + EntityBackingColumnsToExposedNames[entityName] = mapping is not null ? mapping : new(); + EntityExposedNamesToBackingColumnNames[entityName] = EntityBackingColumnsToExposedNames[entityName].ToDictionary(x => x.Value, x => x.Key); + SourceDefinition sourceDefinition = GetSourceDefinition(entityName); + foreach (string column in sourceDefinition.Columns.Keys) { - Dictionary? mapping = GetMappingForEntity(entityName); - EntityBackingColumnsToExposedNames[entityName] = mapping is not null ? mapping : new(); - EntityExposedNamesToBackingColumnNames[entityName] = EntityBackingColumnsToExposedNames[entityName].ToDictionary(x => x.Value, x => x.Key); - SourceDefinition sourceDefinition = GetSourceDefinition(entityName); - foreach (string column in sourceDefinition.Columns.Keys) + if (!EntityExposedNamesToBackingColumnNames[entityName].ContainsKey(column) && !EntityBackingColumnsToExposedNames[entityName].ContainsKey(column)) { - if (!EntityExposedNamesToBackingColumnNames[entityName].ContainsKey(column) && !EntityBackingColumnsToExposedNames[entityName].ContainsKey(column)) - { - EntityBackingColumnsToExposedNames[entityName].Add(column, column); - EntityExposedNamesToBackingColumnNames[entityName].Add(column, column); - } + EntityBackingColumnsToExposedNames[entityName].Add(column, column); + EntityExposedNamesToBackingColumnNames[entityName].Add(column, column); } } } diff --git a/src/Service/Services/RequestValidator.cs b/src/Service/Services/RequestValidator.cs index dab606cbe0..c842421e5b 100644 --- a/src/Service/Services/RequestValidator.cs +++ b/src/Service/Services/RequestValidator.cs @@ -155,7 +155,8 @@ public static void ValidateStoredProcedureRequestContext( if (extraFields.Count > 0) { throw new DataApiBuilderException( - message: $"Invalid request. Contained unexpected fields: {string.Join(", ", extraFields)}", + message: $"Invalid request. Contained unexpected fields: {string.Join(", ", extraFields)}" + + $" for entity: {spRequestCtx.EntityName}", statusCode: HttpStatusCode.BadRequest, subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); } @@ -164,7 +165,8 @@ public static void ValidateStoredProcedureRequestContext( if (missingFields.Count > 0) { throw new DataApiBuilderException( - message: $"Invalid request. Missing required procedure parameters: {string.Join(", ", missingFields)}", + message: $"Invalid request. Missing required procedure parameters: {string.Join(", ", missingFields)}" + + $" for entity: {spRequestCtx.EntityName}", statusCode: HttpStatusCode.BadRequest, subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest); } diff --git a/src/Service/Services/ResolverMiddleware.cs b/src/Service/Services/ResolverMiddleware.cs index 2b8b8b00cd..8f0e11eb3e 100644 --- a/src/Service/Services/ResolverMiddleware.cs +++ b/src/Service/Services/ResolverMiddleware.cs @@ -62,9 +62,20 @@ public async Task InvokeAsync(IMiddlewareContext context) { IDictionary parameters = GetParametersFromContext(context); - Tuple result = await _mutationEngine.ExecuteAsync(context, parameters); - context.Result = result.Item1; - SetNewMetadata(context, result.Item2); + // Only Stored-Procedure has ListType as returnType for Mutation + if (context.Selection.Type.IsListType()) + { + // Both Query and Mutation execute the same SQL statement for Stored Procedure. + Tuple, IMetadata> result = await _queryEngine.ExecuteListAsync(context, parameters); + context.Result = result.Item1; + SetNewMetadata(context, result.Item2); + } + else + { + Tuple result = await _mutationEngine.ExecuteAsync(context, parameters); + context.Result = result.Item1; + SetNewMetadata(context, result.Item2); + } } else if (context.Selection.Field.Coordinate.TypeName.Value == "Query") { diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index b084252883..5ea8f012f3 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -501,6 +501,8 @@ private async Task PerformOnConfigChangeAsync(IApplicationBuilder app) runtimeConfigValidator.ValidateRelationshipsInConfig(runtimeConfig, sqlMetadataProvider!); } + runtimeConfigValidator.ValidateStoredProceduresInConfig(runtimeConfig, sqlMetadataProvider!); + _logger.LogInformation($"Successfully completed runtime initialization."); return true; }