Skip to content

Commit

Permalink
Cosmos DB: Adds Patch Support (#2161)
Browse files Browse the repository at this point in the history
## Why make this change?
Today, in cosmos DB, we have ability to `update` the item which is
actually `replace` the item. Adding new operation i.e. `patch` where
customer would have ability to "patch" an item.
`PATCH` would be available only for Cosmos DB.

## What is this change?
Before going further, it is highly recommended to go through below docs:
1. How patch works in Cosmos DB
https://learn.microsoft.com/en-us/azure/cosmos-db/partial-document-update#supported-operations
2. Limitations:
https://learn.microsoft.com/en-us/azure/cosmos-db/partial-document-update#supported-modes
3. How it works with SDK:
https://learn.microsoft.com/en-us/azure/cosmos-db/partial-document-update-getting-started?tabs=dotnet#prerequisites

To Summarize, here is a simple example of patch:
```
List<PatchOperation> operations = new ()
{
    PatchOperation.Add("/color", "silver"),
    PatchOperation.Remove("/used"),
    PatchOperation.Increment("/price", 50.00),
    PatchOperation.Add("/tags/-", "featured-bikes")
};

ItemResponse<Product> response = await container.PatchItemAsync<Product>(
    id: "e379aea5-63f5-4623-9a9b-4cd9b33b91d5",
    partitionKey: new PartitionKey("road-bikes"),
    patchOperations: operations
);
```
You need to generate `PatchOpertation` which need below information:
a) Operation type i.e. Set, Add, Remove etc
b) Attribute Path where operation needs to be applied i.e "/color",
"/used" in above example
c) New value

**What DAB supports?**
1. We decided to support only `Set` operation, as it solves the purpose
to update an item. It means, you cannot perform any specific operations
like remove, move etc.
2. There is no special handling for an array.
3. If the target path specifies an element that doesn't exist, it's
added.
4. If the target path specifies an element that already exists, its
value is replaced.

**Changes as part of this PR:**
1. Generate `patch` operation for given entities

![image](https://github.com/Azure/data-api-builder/assets/6362382/4501b73b-1b09-4a82-bc3b-dae9352ca2e7)
5. Generate `PatchPlanetInput` without _id_ field as using patch
operation you can not update an `Id`

![image](https://github.com/Azure/data-api-builder/assets/6362382/400b19ad-779d-4282-9b70-d73793dc4fa5)
6. Implement patch operation 
a) It translates the passed item into "patchoperation" by traversing the
item.
b) Checks if number of patch operations are less than 10 or more than 10
(as cosmsodb supports at max 10 patch operations at a time)
c) If it is less than or equal to 10, it fires patch call with patch
operations
d) If it is greater than 10, then it creates a transaction batch of
patch call, with 10 patch operations in each patch call. (_RU exhaustive
but functionally it works_)

**Pictorial Overview of the implementation**
```mermaid
flowchart TD
    User[fa:fa-user User]-->| patch operation |DAB
    subgraph DAB[DAB]
        Authorization[Authorization]-->Patch
        subgraph Patch[Patch Operation]
        PatchOperation[Generate Patch 'Set' Operations with passed item] -->CheckCount{Number of Patch Operation > 10}
        CheckCount --> |No| SDKOperation[SDK Patch call]
        CheckCount --> |Yes| TransactionBatch[Create a batch of patch calls with max 10 patch operations ]-->SDKBatchOperation[SDK ExecuteBatch call]
        end
    end
```

## How was this tested?

- [ ] Integration Tests
- [ ] Unit Tests

## Sample Request(s)

- Example REST and/or GraphQL request to demonstrate modifications
- Example of CLI usage to demonstrate modifications
  • Loading branch information
sourabh1007 committed Apr 30, 2024
1 parent d2aa5aa commit e076d87
Show file tree
Hide file tree
Showing 12 changed files with 738 additions and 109 deletions.
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,

// 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");
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)
{
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);
}
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;
}

/// <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

0 comments on commit e076d87

Please sign in to comment.