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;
}