Skip to content

Commit 54308cc

Browse files
ayush3797severussundarseantleonard
authored
Multiple-create: Request Validation (#2057)
## Why make this change? With support for multiple-create, we introduce additional flexibility in the GraphQL schema where we make the referencing fields in an entity optional because their value can be derived from insertions in the referenced entity. So to say, _the values of referencing fields in a referencing entity can have 3 sources of truth_. The value for a referencing field can be derived via: 1. User input data 2. Insertion in the parent referenced entity (if current entity is a referencing entity for a relationship with its parent entity) 3. Insertion in the child referenced entity (if the current entity is a referencing entity for a relationship with its child entity) **_They can all exist at the same time, or none of them can. Presence of more than one sources of truth leads to possible conflicting values for the referencing column. Thus, at a time, we want exactly one of them to be present - so that we exactly one source of truth_**. This needs us to do request validation during request execution, so that only valid GQL mutations reach the query generation/query execution stage. This PR adds the logic for the same. ## What is this change? - Added class `MultipleMutationInputValidator.cs` to perform the validations for a multiple-create mutation. - Added class `MultipleMutationEntityInputValidationContext` to store relevant information while performing validations on a particular entity. - A call to the method `MultipleMutationInputValidator.ValidateGraphQLValueNode()` will be made _just after Authorization_ - request validation only occurs when the user is authorized to execute the mutation. - The method `MultipleMutationInputValidator.ValidateGraphQLValueNode()` makes recursive call to itself to parse out the entire create mutation request body based on an entity or relationship basis. Meaning, this method will be called on the top-level entity, followed by all the relationships for that entity, followed by all the relationships for the entity's relationships and so on.... - The actual validation for the input data for an entity (top-level entity or a nested entity) which includes column fields and relationship fields is done by the method `GraphQLRequestValidator.ValidateObjectFieldNodes()` which is called from the method `ValidateGraphQLValueNode()`. - The method `ValidateObjectFieldNodes()` contains logic to ensure that when the current entity acts as the referencing entity for the relationships with its target entity, then the current entity should not specify a value for the referencing fields - as the values for the referencing fields will be derived from the insertion in the referenced target entity. Note: The determination of referencing entity is done via a call to `MultipleCreateOrderHelper.GetReferencingEntityName()` method added via: #2056. - The method `ValidateObjectFieldNodes()` makes calls to 1. `ValidateAbsenceOfReferencingColumnsInTargetEntity()`: To validate that the current entity does not specify value for referencing columns to its parent entity (when parent entity is the referenced entity). 2. `ProcessRelationshipField()`: To process a relationship field and does several things. Please refer to method summary. 3. `ValidateAbsenceOfRepeatedReferencingColumn()`: This method is called for all the relationships with the current entity which are included in the request body. This ensures that referencing columns in the referencing entity (whichever entity, either source or target turns out as the referencing entity) does not hold references to multiple columns in the referenced entity - to avoid multiple sources of truth for the referencing column's value. (Issue: #2019) 4. `ValidatePresenceOfRequiredColumnsForInsertion()`: To ensure that we have one source of truth for values for referencing fields. 5. `ValidateRelationshipFields()`: Recursively perform the same validations for all the relationships with the current entity included in the mutation request body. ` ## How was this tested? - [x] Integration Tests - Added tests to `MultipleMutationIntegrationTests.cs` to validate: 1. Throwing exception for absence of source of truth for referencing column for create one mutations 2. Throwing exception for absence of source of truth for referencing column for create multiple mutations. 3. Throwing exception for presence of multiple sources of truth for referencing column for create one mutations. 4. Throwing exception for presence of multiple sources of truth for referencing column for create one mutations. ## Sample Request(s) 1. Request: ![image](https://github.com/Azure/data-api-builder/assets/34566234/b6ef03bf-016d-4a45-9adf-dbe422683f73) Response: ![image](https://github.com/Azure/data-api-builder/assets/34566234/cbec3e22-3a9e-4bfd-abc8-0fb4fd45cfdd) 2. Request: ![image](https://github.com/Azure/data-api-builder/assets/34566234/6c58f6b9-54b8-42b3-8945-39272023c2bf) Response: ![image](https://github.com/Azure/data-api-builder/assets/34566234/b0d5f689-d029-4ba9-9aeb-40fca7eb1b85) 3. Request and response: ![image](https://github.com/Azure/data-api-builder/assets/34566234/c39ad294-412c-45d9-9fdd-5efb69908836) --------- Co-authored-by: Shyam Sundar J <[email protected]> Co-authored-by: Sean Leonard <[email protected]>
1 parent a352f71 commit 54308cc

File tree

15 files changed

+1400
-8
lines changed

15 files changed

+1400
-8
lines changed

config-generators/mssql-commands.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
init --config "dab-config.MsSql.json" --database-type mssql --set-session-context true --connection-string "Server=tcp:127.0.0.1,1433;Persist Security Info=False;User ID=sa;Password=REPLACEME;MultipleActiveResultSets=False;Connection Timeout=5;" --host-mode Development --cors-origin "http://localhost:5000"
1+
init --config "dab-config.MsSql.json" --database-type mssql --set-session-context true --connection-string "Server=tcp:127.0.0.1,1433;Persist Security Info=False;User ID=sa;Password=REPLACEME;MultipleActiveResultSets=False;Connection Timeout=5;" --host-mode Development --cors-origin "http://localhost:5000" --graphql.multiple-create.enabled true
22
add Publisher --config "dab-config.MsSql.json" --source publishers --permissions "anonymous:read"
33
add Stock --config "dab-config.MsSql.json" --source stocks --permissions "anonymous:create,read,update,delete"
44
add Book --config "dab-config.MsSql.json" --source books --permissions "anonymous:create,read,update,delete" --graphql "book:books"
@@ -33,6 +33,8 @@ add User_NonAutogenRelationshipColumn --config "dab-config.MsSql.json" --source
3333
add UserProfile --config "dab-config.MsSql.json" --source "user_profiles" --permissions "anonymous:*" --rest true --graphql true
3434
add User_AutogenRelationshipColumn --config "dab-config.MsSql.json" --source "users" --permissions "anonymous:*" --rest true --graphql true
3535
add User_AutogenToNonAutogenRelationshipColumn --config "dab-config.MsSql.json" --source "users" --permissions "anonymous:*" --rest true --graphql true
36+
add User_RepeatedReferencingColumnToOneEntity --config "dab-config.MsSql.json" --source "users" --permissions "anonymous:*" --rest true --graphql true
37+
add UserProfile_RepeatedReferencingColumnToTwoEntities --config "dab-config.MsSql.json" --source "user_profiles" --permissions "anonymous:*" --rest true --graphql true
3638
add GetBooks --config "dab-config.MsSql.json" --source "get_books" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql true
3739
add GetBook --config "dab-config.MsSql.json" --source "get_book_by_id" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql false
3840
add GetPublisher --config "dab-config.MsSql.json" --source "get_publisher_by_id" --source.type "stored-procedure" --permissions "anonymous:execute" --rest true --graphql true --graphql.operation "query"
@@ -145,6 +147,9 @@ update ArtOfWar --config "dab-config.MsSql.json" --permissions "authenticated:*"
145147
update User_NonAutogenRelationshipColumn --config "dab-config.MsSql.json" --relationship UserProfile_NonAutogenRelationshipColumn --target.entity UserProfile --cardinality one --relationship.fields "username:username"
146148
update User_AutogenRelationshipColumn --config "dab-config.MsSql.json" --relationship UserProfile_AutogenRelationshipColumn --target.entity UserProfile --cardinality one --relationship.fields "userid:profileid"
147149
update User_AutogenToNonAutogenRelationshipColumn --config "dab-config.MsSql.json" --relationship UserProfile_AutogenToNonAutogenRelationshipColumn --target.entity UserProfile --cardinality one --relationship.fields "userid,username:userid,username"
150+
update User_RepeatedReferencingColumnToOneEntity --config "dab-config.MsSql.json" --relationship UserProfile --target.entity UserProfile --cardinality one --relationship.fields "username,username:profilepictureurl,username"
151+
update UserProfile_RepeatedReferencingColumnToTwoEntities --config "dab-config.MsSql.json" --relationship book --target.entity Book --cardinality one --relationship.fields "userid:id"
152+
update UserProfile_RepeatedReferencingColumnToTwoEntities --config "dab-config.MsSql.json" --relationship publisher --target.entity Publisher --cardinality one --relationship.fields "userid:id"
148153
update GetBook --config "dab-config.MsSql.json" --permissions "authenticated:execute" --rest.methods "Get"
149154
update GetPublisher --config "dab-config.MsSql.json" --permissions "authenticated:execute"
150155
update GetBooks --config "dab-config.MsSql.json" --permissions "authenticated:execute" --graphql.operation "Query" --rest.methods "Get"

src/Config/DataApiBuilderException.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ public enum SubStatusCodes
3131
/// </summary>
3232
EntityNotFound,
3333
/// <summary>
34+
/// The relationship for a pair of source/target entities does not exist.
35+
/// </summary>
36+
RelationshipNotFound,
37+
/// <summary>
3438
/// Request failed authentication. i.e. No/Invalid JWT token
3539
/// </summary>
3640
AuthenticationChallenge,
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Azure.DataApiBuilder.Core.Models
5+
{
6+
/// <summary>
7+
/// Class to represent input for an entity in a multiple-create request.
8+
/// </summary>
9+
public class MultipleMutationEntityInputValidationContext
10+
{
11+
/// <summary>
12+
/// Current entity name.
13+
/// </summary>
14+
public string EntityName { get; }
15+
16+
/// <summary>
17+
/// Parent entity name. For the topmost entity, this will be set as an empty string.
18+
/// </summary>
19+
public string ParentEntityName { get; }
20+
21+
/// <summary>
22+
/// Set of columns in the current entity whose values are derived from insertion in the parent entity
23+
/// (i.e. parent entity would have been the referenced entity).
24+
/// For the topmost entity, this will be an empty set.</param>
25+
/// </summary>
26+
public HashSet<string> ColumnsDerivedFromParentEntity { get; }
27+
28+
/// <summary>
29+
/// Set of columns in the current entity whose values are to be derived from insertion in the entity or its subsequent
30+
/// referenced entities and returned to the parent entity so as to provide values for the corresponding referencing fields
31+
/// (i.e. parent entity would have been the referencing entity)
32+
/// For the topmost entity, this will be an empty set.
33+
/// </summary>
34+
public HashSet<string> ColumnsToBeDerivedFromEntity { get; }
35+
36+
public MultipleMutationEntityInputValidationContext(
37+
string entityName,
38+
string parentEntityName,
39+
HashSet<string> columnsDerivedFromParentEntity,
40+
HashSet<string> columnsToBeDerivedFromEntity)
41+
{
42+
EntityName = entityName;
43+
ParentEntityName = parentEntityName;
44+
ColumnsDerivedFromParentEntity = columnsDerivedFromParentEntity;
45+
ColumnsToBeDerivedFromEntity = columnsToBeDerivedFromEntity;
46+
}
47+
}
48+
}

src/Core/Resolvers/SqlMutationEngine.cs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,25 @@ public SqlMutationEngine(
9696
// If authorization fails, an exception will be thrown and request execution halts.
9797
AuthorizeMutation(context, parameters, entityName, mutationOperation);
9898

99+
// Multiple create mutation request is validated to ensure that the request is valid semantically.
100+
string inputArgumentName = IsPointMutation(context) ? MutationBuilder.ITEM_INPUT_ARGUMENT_NAME : MutationBuilder.ARRAY_INPUT_ARGUMENT_NAME;
101+
if (parameters.TryGetValue(inputArgumentName, out object? param) && mutationOperation is EntityActionOperation.Create)
102+
{
103+
IInputField schemaForArgument = context.Selection.Field.Arguments[inputArgumentName];
104+
MultipleMutationEntityInputValidationContext multipleMutationEntityInputValidationContext = new(
105+
entityName: entityName,
106+
parentEntityName: string.Empty,
107+
columnsDerivedFromParentEntity: new(),
108+
columnsToBeDerivedFromEntity: new());
109+
MultipleMutationInputValidator multipleMutationInputValidator = new(sqlMetadataProviderFactory: _sqlMetadataProviderFactory, runtimeConfigProvider: _runtimeConfigProvider);
110+
multipleMutationInputValidator.ValidateGraphQLValueNode(
111+
schema: schemaForArgument,
112+
context: context,
113+
parameters: param,
114+
nestingLevel: 0,
115+
multipleMutationEntityInputValidationContext: multipleMutationEntityInputValidationContext);
116+
}
117+
99118
// The presence of READ permission is checked in the current role (with which the request is executed) as well as Anonymous role. This is because, for GraphQL requests,
100119
// READ permission is inherited by other roles from Anonymous role when present.
101120
bool isReadPermissionConfigured = _authorizationResolver.AreRoleAndOperationDefinedForEntity(entityName, roleName, EntityActionOperation.Read)
@@ -1225,7 +1244,7 @@ private void AuthorizeEntityAndFieldsForMutation(
12251244
/// title: "book #1",
12261245
/// reviews: [{ content: "Good book." }, { content: "Great book." }],
12271246
/// publishers: { name: "Macmillan publishers" },
1228-
/// authors: [{ birthdate: "1997-09-03", name: "Red house authors", author_name: "Dan Brown" }]
1247+
/// authors: [{ birthdate: "1997-09-03", name: "Red house authors", royal_percentage: 4.6 }]
12291248
/// })
12301249
/// {
12311250
/// id
@@ -1236,13 +1255,13 @@ private void AuthorizeEntityAndFieldsForMutation(
12361255
/// title: "book #1",
12371256
/// reviews: [{ content: "Good book." }, { content: "Great book." }],
12381257
/// publishers: { name: "Macmillan publishers" },
1239-
/// authors: [{ birthdate: "1997-09-03", name: "Red house authors", author_name: "Dan Brown" }]
1258+
/// authors: [{ birthdate: "1997-09-03", name: "Red house authors", royal_percentage: 4.9 }]
12401259
/// },
12411260
/// {
12421261
/// title: "book #2",
12431262
/// reviews: [{ content: "Awesome book." }, { content: "Average book." }],
12441263
/// publishers: { name: "Pearson Education" },
1245-
/// authors: [{ birthdate: "1990-11-04", name: "Penguin Random House", author_name: "William Shakespeare" }]
1264+
/// authors: [{ birthdate: "1990-11-04", name: "Penguin Random House", royal_percentage: 8.2 }]
12461265
/// }])
12471266
/// {
12481267
/// items{

src/Core/Services/MetadataProviders/ISqlMetadataProvider.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,5 +208,30 @@ public DatabaseObject GetDatabaseObjectForGraphQLType(string graphqlType)
208208
void InitializeAsync(
209209
Dictionary<string, DatabaseObject> entityToDatabaseObject,
210210
Dictionary<string, string> graphQLStoredProcedureExposedNameToEntityNameMap);
211+
212+
/// <summary>
213+
/// Helper method to get the Foreign Key definition in the object definition of the source entity which relates it
214+
/// with the target entity. In the Foreign key definition, the table backing the referencing entity acts as the referencing table
215+
/// and the table backing the referenced entity acts as the referenced table.
216+
/// </summary>
217+
/// <param name="sourceEntityName">Source entity name.</param>
218+
/// <param name="targetEntityName">Target entity name.</param>
219+
/// <param name="referencedEntityName">Referenced entity name.</param>
220+
/// <param name="referencingEntityName">Referencing entity name.</param>
221+
/// <param name="foreignKeyDefinition">Stores the required foreign key definition from the referencing to referenced entity.</param>
222+
/// <returns>true when the foreign key definition is successfully determined.</returns>
223+
/// <example>
224+
/// For a 1:N relationship between Publisher: Book entity defined in Publisher entity's config:
225+
/// sourceEntityName: Publisher (The entity in whose config the relationship is defined)
226+
/// targetEntityName: Book (The target.entity in the relationship config)
227+
/// referencingEntityName: Book (Entity holding foreign key reference)
228+
/// referencedEntityName: Publisher (Entity being referenced by foreign key).
229+
/// </example>
230+
public bool TryGetFKDefinition(
231+
string sourceEntityName,
232+
string targetEntityName,
233+
string referencingEntityName,
234+
string referencedEntityName,
235+
[NotNullWhen(true)] out ForeignKeyDefinition? foreignKeyDefinition) => throw new NotImplementedException();
211236
}
212237
}

src/Core/Services/MetadataProviders/SqlMetadataProvider.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1911,6 +1911,38 @@ public bool IsDevelopmentMode()
19111911
{
19121912
return _runtimeConfigProvider.GetConfig().IsDevelopmentMode();
19131913
}
1914+
1915+
/// <inheritdoc/>
1916+
public bool TryGetFKDefinition(
1917+
string sourceEntityName,
1918+
string targetEntityName,
1919+
string referencingEntityName,
1920+
string referencedEntityName,
1921+
[NotNullWhen(true)] out ForeignKeyDefinition? foreignKeyDefinition)
1922+
{
1923+
if (GetEntityNamesAndDbObjects().TryGetValue(sourceEntityName, out DatabaseObject? sourceDbObject) &&
1924+
GetEntityNamesAndDbObjects().TryGetValue(referencingEntityName, out DatabaseObject? referencingDbObject) &&
1925+
GetEntityNamesAndDbObjects().TryGetValue(referencedEntityName, out DatabaseObject? referencedDbObject))
1926+
{
1927+
DatabaseTable referencingDbTable = (DatabaseTable)referencingDbObject;
1928+
DatabaseTable referencedDbTable = (DatabaseTable)referencedDbObject;
1929+
SourceDefinition sourceDefinition = sourceDbObject.SourceDefinition;
1930+
RelationShipPair referencingReferencedPair = new(referencingDbTable, referencedDbTable);
1931+
List<ForeignKeyDefinition> fKDefinitions = sourceDefinition.SourceEntityRelationshipMap[sourceEntityName].TargetEntityToFkDefinitionMap[targetEntityName];
1932+
1933+
// At this point, DAB guarantees that a valid foreign key definition exists between the the referencing entity
1934+
// and the referenced entity. That's because DAB validates that all foreign key metadata
1935+
// was inferred for each relationship during startup.
1936+
foreignKeyDefinition = fKDefinitions.FirstOrDefault(
1937+
fk => fk.Pair.Equals(referencingReferencedPair) &&
1938+
fk.ReferencingColumns.Count > 0
1939+
&& fk.ReferencedColumns.Count > 0)!;
1940+
return true;
1941+
}
1942+
1943+
foreignKeyDefinition = null;
1944+
return false;
1945+
}
19141946
}
19151947
}
19161948

0 commit comments

Comments
 (0)