Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cosmos DB: Adds Patch Support #2161

Merged
merged 38 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
150f42b
wip
sourabh1007 Apr 11, 2024
7bf06bd
remove unused file
sourabh1007 Apr 12, 2024
abc2e62
refcators code
sourabh1007 Apr 12, 2024
d2c014b
implement patch func
sourabh1007 Apr 12, 2024
41701e9
fix patch
sourabh1007 Apr 12, 2024
51dd0b1
updated test
sourabh1007 Apr 14, 2024
c7f830b
wip
sourabh1007 Apr 15, 2024
cc06d5f
refactor code
sourabh1007 Apr 15, 2024
482e196
rename tets
sourabh1007 Apr 15, 2024
9b5b7b0
fix test
sourabh1007 Apr 15, 2024
434f4cf
fix test
sourabh1007 Apr 15, 2024
1d1b3a7
updates test
sourabh1007 Apr 16, 2024
fc450b5
fix format
sourabh1007 Apr 16, 2024
31b6d97
config update
sourabh1007 Apr 16, 2024
85eb02a
verified txt
sourabh1007 Apr 16, 2024
b406a1c
add cosmos db check
sourabh1007 Apr 16, 2024
3da78e9
format fix
sourabh1007 Apr 16, 2024
a707128
cli fixes
sourabh1007 Apr 17, 2024
529999d
used datasource instead of flag
sourabh1007 Apr 17, 2024
0da866a
updated more file
sourabh1007 Apr 17, 2024
d0893d6
refactor code
sourabh1007 Apr 17, 2024
aa022b7
fix format
sourabh1007 Apr 17, 2024
6ecb8af
removed patch as separate permission
sourabh1007 Apr 23, 2024
78ef01b
clean up
sourabh1007 Apr 23, 2024
0ec7142
refactor code
sourabh1007 Apr 23, 2024
553f95f
add doc
sourabh1007 Apr 23, 2024
8233fdd
review comments
sourabh1007 Apr 26, 2024
0a89888
change error message
sourabh1007 Apr 29, 2024
16db369
fix assertion
sourabh1007 Apr 29, 2024
b4e1a6e
made changes to honor update access insteadof adding patch access dyn…
sourabh1007 Apr 29, 2024
0d7a9b7
revert some changes
sourabh1007 Apr 29, 2024
2d3cb47
revert few more files
sourabh1007 Apr 29, 2024
d85c486
fixed tests
sourabh1007 Apr 30, 2024
4d7c8c9
fix formatting
sourabh1007 Apr 30, 2024
3d5bf95
Update src/Core/Services/TypeHelper.cs
sourabh1007 Apr 30, 2024
67c0e00
fix test
sourabh1007 Apr 30, 2024
9b515ab
Merge branch 'main' into users/sourabhjain/addpatchsupport
sourabh1007 Apr 30, 2024
1d2f536
Merge branch 'main' into users/sourabhjain/addpatchsupport
sourabh1007 Apr 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Config/ObjectModel/EntityActionOperation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public enum EntityActionOperation
Delete, Read,

// cosmosdb_nosql operations
Upsert, Create,
Upsert, Create, Patch,
sourabh1007 marked this conversation as resolved.
Show resolved Hide resolved

// Sql operations
Insert, Update, UpdateGraphQL,
Expand Down
9 changes: 6 additions & 3 deletions src/Config/ObjectModel/RuntimeEntities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using Azure.DataApiBuilder.Config.Converters;
using Azure.DataApiBuilder.Service.Exceptions;
using Humanizer;

namespace Azure.DataApiBuilder.Config.ObjectModel;
Expand All @@ -22,7 +23,7 @@ public record RuntimeEntities : IEnumerable<KeyValuePair<string, Entity>>

/// <summary>
/// Creates a new instance of the <see cref="RuntimeEntities"/> class using a collection of entities.
///
///
/// The constructor will apply default values for the entities for GraphQL and REST.
/// </summary>
/// <param name="entities">The collection of entities to map to RuntimeEntities.</param>
Expand Down Expand Up @@ -67,7 +68,9 @@ public bool ContainsKey(string key)
}
else
{
throw new ApplicationException($"The entity '{key}' was not found in the dab-config json");
sourabh1007 marked this conversation as resolved.
Show resolved Hide resolved
throw new DataApiBuilderException(message: $"The entity '{key}' was not found in the runtime config.",
statusCode: System.Net.HttpStatusCode.ServiceUnavailable,
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError);
}
}
}
Expand Down Expand Up @@ -148,7 +151,7 @@ private static Entity ProcessRestDefaults(Entity nameCorrectedEntity)
else if (nameCorrectedEntity.Source.Type is EntitySourceType.StoredProcedure && (nameCorrectedEntity.Rest.Methods is null || nameCorrectedEntity.Rest.Methods.Length == 0))
{
// REST Method field is relevant only for stored procedures. For an entity backed by a table/view, all HTTP verbs are enabled by design
// unless configured otherwise through the config file. An entity backed by a stored procedure also supports all HTTP verbs but only POST is
// unless configured otherwise through the config file. An entity backed by a stored procedure also supports all HTTP verbs but only POST is
// enabled by default unless otherwise specified.
// When the Methods property is configured in the config file, the parser correctly parses and populates the methods configured.
// However, when absent in the config file, REST methods that are enabled by default needs to be populated.
Expand Down
12 changes: 8 additions & 4 deletions src/Core/Configurations/RuntimeConfigValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public void ValidateConfigProperties()

if (runtimeConfig.IsGraphQLEnabled)
{
ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(runtimeConfig.Entities);
ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(runtimeConfig.DataSource.DatabaseType, runtimeConfig.Entities);
}
}
}
Expand Down Expand Up @@ -318,10 +318,11 @@ private void LogConfigValidationExceptions()
/// create mutation name: createBook
/// update mutation name: updateBook
/// delete mutation name: deleteBook
/// patch mutation name: patchBook
/// </summary>
/// <param name="entityCollection">Entity definitions</param>
/// <exception cref="DataApiBuilderException"></exception>
public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(RuntimeEntities entityCollection)
public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType databaseType, RuntimeEntities entityCollection)
{
HashSet<string> graphQLOperationNames = new();

Expand All @@ -345,7 +346,8 @@ public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(RuntimeEntit
}
else
{
// For entities (table/view) that have graphQL exposed, two queries and three mutations would be generated.
// For entities (table/view) that have graphQL exposed, two queries, three mutations for Relational databases (e.g. MYSQL, MSSQL etc.)
// and four mutations for CosmosDb_NoSQL would be generated.
// Primary Key Query: For fetching an item using its primary key.
// List Query: To fetch a paginated list of items.
// Query names for both these queries are determined.
Expand All @@ -356,12 +358,14 @@ public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(RuntimeEntit
string createMutationName = $"create{GraphQLNaming.GetDefinedSingularName(entityName, entity)}";
string updateMutationName = $"update{GraphQLNaming.GetDefinedSingularName(entityName, entity)}";
string deleteMutationName = $"delete{GraphQLNaming.GetDefinedSingularName(entityName, entity)}";
string patchMutationName = $"patch{GraphQLNaming.GetDefinedSingularName(entityName, entity)}";

if (!graphQLOperationNames.Add(pkQueryName)
|| !graphQLOperationNames.Add(listQueryName)
|| !graphQLOperationNames.Add(createMutationName)
|| !graphQLOperationNames.Add(updateMutationName)
|| !graphQLOperationNames.Add(deleteMutationName))
|| !graphQLOperationNames.Add(deleteMutationName)
|| ((databaseType is DatabaseType.CosmosDB_NoSQL) && !graphQLOperationNames.Add(patchMutationName)))
{
containsDuplicateOperationNames = true;
}
Expand Down
196 changes: 178 additions & 18 deletions src/Core/Resolvers/CosmosMutationEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ namespace Azure.DataApiBuilder.Core.Resolvers
{
public class CosmosMutationEngine : IMutationEngine
{
private const int PATCH_OPERATIONS_LIMIT = 10;

private readonly CosmosClientProvider _clientProvider;
private readonly IMetadataProviderFactory _metadataProviderFactory;
private readonly IAuthorizationResolver _authorizationResolver;
Expand Down Expand Up @@ -65,13 +67,23 @@ private async Task<JObject> ExecuteAsync(IMiddlewareContext context, IDictionary
string entityName = metadataProvider.GetEntityName(graphQLType);
AuthorizeMutation(context, queryArgs, entityName, resolver.OperationType);

ItemResponse<JObject>? response = resolver.OperationType switch
JObject result;
if (resolver.OperationType == EntityActionOperation.Patch)
sourabh1007 marked this conversation as resolved.
Show resolved Hide resolved
{
EntityActionOperation.UpdateGraphQL => await HandleUpdateAsync(queryArgs, container),
EntityActionOperation.Create => await HandleCreateAsync(queryArgs, container),
EntityActionOperation.Delete => await HandleDeleteAsync(queryArgs, container),
_ => throw new NotSupportedException($"unsupported operation type: {resolver.OperationType}")
};
result = await HandlePatchAsync(queryArgs, container);
sourabh1007 marked this conversation as resolved.
Show resolved Hide resolved
}
else
{
ItemResponse<JObject>? response = resolver.OperationType switch
{
EntityActionOperation.UpdateGraphQL => await HandleUpdateAsync(queryArgs, container),
EntityActionOperation.Create => await HandleCreateAsync(queryArgs, container),
EntityActionOperation.Delete => await HandleDeleteAsync(queryArgs, container),
_ => throw new NotSupportedException($"unsupported operation type: {resolver.OperationType}")
};

result = response.Resource;
}

string roleName = AuthorizationResolver.GetRoleOfGraphQLRequest(context);

Expand All @@ -88,7 +100,7 @@ private async Task<JObject> ExecuteAsync(IMiddlewareContext context, IDictionary
subStatusCode: DataApiBuilderException.SubStatusCodes.AuthorizationCheckFailed);
}

return response.Resource;
return result;
}

/// <inheritdoc/>
Expand All @@ -112,16 +124,27 @@ private async Task<JObject> ExecuteAsync(IMiddlewareContext context, IDictionary
bool isAuthorized = mutationOperation switch
{
EntityActionOperation.UpdateGraphQL =>
_authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: clientRole, operation: EntityActionOperation.Update, inputArgumentKeys),
_authorizationResolver.AreColumnsAllowedForOperation(entityName,
roleName: clientRole,
operation: EntityActionOperation.Update,
columns: inputArgumentKeys),
EntityActionOperation.Patch =>
_authorizationResolver.AreColumnsAllowedForOperation(entityName,
roleName: clientRole,
operation: EntityActionOperation.Update,
columns: inputArgumentKeys),
EntityActionOperation.Create =>
_authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: clientRole, operation: mutationOperation, inputArgumentKeys),
_authorizationResolver.AreColumnsAllowedForOperation(entityName,
roleName: clientRole,
operation: mutationOperation,
columns: inputArgumentKeys),
EntityActionOperation.Delete => true,// Field level authorization is not supported for delete mutations. A requestor must be authorized
// to perform the delete operation on the entity to reach this point.
_ => throw new DataApiBuilderException(
message: "Invalid operation for GraphQL Mutation, must be Create, UpdateGraphQL, or Delete",
statusCode: HttpStatusCode.BadRequest,
subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest
),
message: "Invalid operation for GraphQL Mutation, must be Create, UpdateGraphQL, or Delete",
statusCode: HttpStatusCode.BadRequest,
subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest
),
};
if (!isAuthorized)
{
Expand Down Expand Up @@ -234,6 +257,114 @@ private static async Task<ItemResponse<JObject>> HandleUpdateAsync(IDictionary<s
}
}

/// <summary>
/// Refer to https://learn.microsoft.com/azure/cosmos-db/partial-document-update for more details on patch operations
/// </summary>
/// <exception cref="InvalidDataException"></exception>
/// <exception cref="DataApiBuilderException"></exception>
private static async Task<JObject> HandlePatchAsync(IDictionary<string, object?> queryArgs, Container container)
{
string? partitionKey = null;
string? id = null;

if (queryArgs.TryGetValue(QueryBuilder.ID_FIELD_NAME, out object? idObj))
{
id = idObj?.ToString();
}

if (string.IsNullOrEmpty(id))
{
throw new InvalidDataException("id field is mandatory");
}

if (queryArgs.TryGetValue(QueryBuilder.PARTITION_KEY_FIELD_NAME, out object? partitionKeyObj))
{
partitionKey = partitionKeyObj?.ToString();
}

if (string.IsNullOrEmpty(partitionKey))
{
throw new InvalidDataException("Partition Key field is mandatory");
}

object? item = queryArgs[MutationBuilder.ITEM_INPUT_ARGUMENT_NAME];

JObject? input;
// Variables were provided to the mutation
if (item is Dictionary<string, object?>)
{
input = (JObject?)ParseVariableInputItem(item);
}
else
{
// An inline argument was set
input = (JObject?)ParseInlineInputItem(item);
}

if (input is null)
{
throw new InvalidDataException("Input Item field is invalid");
}

// This would contain the patch operations to be applied on the document
List<PatchOperation> patchOperations = new();
GeneratePatchOperations(input, "", patchOperations);

if (patchOperations.Count <= 10)
{
return (await container.PatchItemAsync<JObject>(id, new PartitionKey(partitionKey), patchOperations)).Resource;
}

// maximum 10 patch operations can be applied in a single patch request,
// Hence dividing into multiple patch request, if it is requested for more than 10 item at a time.
TransactionalBatch batch = container.CreateTransactionalBatch(new PartitionKey(partitionKey));
int numberOfBatches = -1;
for (int counter = 0; counter < patchOperations.Count; counter += PATCH_OPERATIONS_LIMIT)
{
// Get next 10 patch operations from the list
List<PatchOperation> chunk = patchOperations.GetRange(counter, Math.Min(10, patchOperations.Count - counter));
batch = batch.PatchItem(id, chunk);
numberOfBatches++;
}

TransactionalBatchResponse response = await batch.ExecuteAsync();
if (!response.IsSuccessStatusCode)
{
throw new DataApiBuilderException(
message: "Failed to patch the item",
statusCode: HttpStatusCode.InternalServerError,
subStatusCode: DataApiBuilderException.SubStatusCodes.DatabaseOperationFailed);
}

return response.GetOperationResultAtIndex<JObject>(numberOfBatches).Resource;
sourabh1007 marked this conversation as resolved.
Show resolved Hide resolved
}

/// <summary>
/// This method generates the patch operations for the input JObject by traversing the JObject recursively.
/// e.g. if the input JObject is { "a": { "b": "c" } },
/// the generated patch operation would be "set /a/b c"
/// </summary>
/// <param name="jObject">JObject needs to be traversed</param>
/// <param name="currentPath">Current Position of the json token</param>
/// <param name="patchOperations">Generated Patch Operation</param>
private static void GeneratePatchOperations(JObject jObject, string currentPath, List<PatchOperation> patchOperations)
{
foreach (JProperty property in jObject.Properties())
{
string newPath = currentPath + "/" + property.Name;

if (property.Value.Type != JTokenType.Array && property.Value.Type == JTokenType.Object)
{
// Skip generating JPaths for array-type properties
GeneratePatchOperations((JObject)property.Value, newPath, patchOperations);
}
else
{
patchOperations.Add(PatchOperation.Set(newPath, property.Value));
}
}
}

/// <summary>
/// The method is for parsing the mutation input object with nested inner objects when input is passed in as variables.
/// </summary>
Expand Down Expand Up @@ -270,15 +401,37 @@ private static async Task<ItemResponse<JObject>> HandleUpdateAsync(IDictionary<s

if (item is ObjectFieldNode node)
{
createInput.Add(new JProperty(node.Name.Value, ParseInlineInputItem(node.Value.Value)));
if (TypeHelper.IsPrimitiveType(node.Value.Kind))
{
createInput
.Add(new JProperty(
node.Name.Value,
ParseInlineInputItem(TypeHelper.GetValue(node.Value))));
}
else
{
createInput.Add(new JProperty(node.Name.Value, ParseInlineInputItem(node.Value.Value)));

}

return createInput;
}

if (item is List<ObjectFieldNode> nodeList)
{
foreach (ObjectFieldNode subfield in nodeList)
{
createInput.Add(new JProperty(subfield.Name.Value, ParseInlineInputItem(subfield.Value.Value)));
if (TypeHelper.IsPrimitiveType(subfield.Value.Kind))
{
createInput
.Add(new JProperty(
subfield.Name.Value,
ParseInlineInputItem(TypeHelper.GetValue(subfield.Value))));
}
else
{
createInput.Add(new JProperty(subfield.Name.Value, ParseInlineInputItem(subfield.Value.Value)));
}
}

return createInput;
Expand All @@ -287,14 +440,21 @@ private static async Task<ItemResponse<JObject>> HandleUpdateAsync(IDictionary<s
// For nested array objects
if (item is List<IValueNode> nodeArray)
{
JArray jarrayObj = new();
JArray jArrayObj = new();

foreach (IValueNode subfield in nodeArray)
{
jarrayObj.Add(ParseInlineInputItem(subfield.Value));
if (TypeHelper.IsPrimitiveType(subfield.Kind))
{
jArrayObj.Add(ParseInlineInputItem(TypeHelper.GetValue(subfield)));
}
else
{
jArrayObj.Add(ParseInlineInputItem(subfield.Value));
}
}

return jarrayObj;
return jArrayObj;
}

return item;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,10 @@ public class SqlUpdateStructure : BaseSqlQueryStructure
Predicates.Add(CreatePredicateForParam(new KeyValuePair<string, object?>(pkBackingColumn, param.Value)));
}
else // Unpack the input argument type as columns to update
if (param.Key == UpdateMutationBuilder.INPUT_ARGUMENT_NAME)
if (param.Key == UpdateAndPatchMutationBuilder.INPUT_ARGUMENT_NAME)
{
IDictionary<string, object?> updateFields =
GQLMutArgumentToDictParams(context, UpdateMutationBuilder.INPUT_ARGUMENT_NAME, mutationParams);
GQLMutArgumentToDictParams(context, UpdateAndPatchMutationBuilder.INPUT_ARGUMENT_NAME, mutationParams);

foreach (KeyValuePair<string, object?> field in updateFields)
{
Expand Down