Skip to content

Commit e076d87

Browse files
authored
Cosmos DB: Adds Patch Support (#2161)
## 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
1 parent d2aa5aa commit e076d87

File tree

12 files changed

+738
-109
lines changed

12 files changed

+738
-109
lines changed

src/Config/ObjectModel/EntityActionOperation.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public enum EntityActionOperation
1919
Delete, Read,
2020

2121
// cosmosdb_nosql operations
22-
Upsert, Create,
22+
Upsert, Create, Patch,
2323

2424
// Sql operations
2525
Insert, Update, UpdateGraphQL,

src/Config/ObjectModel/RuntimeEntities.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Diagnostics.CodeAnalysis;
66
using System.Text.Json.Serialization;
77
using Azure.DataApiBuilder.Config.Converters;
8+
using Azure.DataApiBuilder.Service.Exceptions;
89
using Humanizer;
910

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

2324
/// <summary>
2425
/// Creates a new instance of the <see cref="RuntimeEntities"/> class using a collection of entities.
25-
///
26+
///
2627
/// The constructor will apply default values for the entities for GraphQL and REST.
2728
/// </summary>
2829
/// <param name="entities">The collection of entities to map to RuntimeEntities.</param>
@@ -67,7 +68,9 @@ public Entity this[string key]
6768
}
6869
else
6970
{
70-
throw new ApplicationException($"The entity '{key}' was not found in the dab-config json");
71+
throw new DataApiBuilderException(message: $"The entity '{key}' was not found in the runtime config.",
72+
statusCode: System.Net.HttpStatusCode.ServiceUnavailable,
73+
subStatusCode: DataApiBuilderException.SubStatusCodes.ConfigValidationError);
7174
}
7275
}
7376
}
@@ -148,7 +151,7 @@ private static Entity ProcessRestDefaults(Entity nameCorrectedEntity)
148151
else if (nameCorrectedEntity.Source.Type is EntitySourceType.StoredProcedure && (nameCorrectedEntity.Rest.Methods is null || nameCorrectedEntity.Rest.Methods.Length == 0))
149152
{
150153
// REST Method field is relevant only for stored procedures. For an entity backed by a table/view, all HTTP verbs are enabled by design
151-
// unless configured otherwise through the config file. An entity backed by a stored procedure also supports all HTTP verbs but only POST is
154+
// unless configured otherwise through the config file. An entity backed by a stored procedure also supports all HTTP verbs but only POST is
152155
// enabled by default unless otherwise specified.
153156
// When the Methods property is configured in the config file, the parser correctly parses and populates the methods configured.
154157
// However, when absent in the config file, REST methods that are enabled by default needs to be populated.

src/Core/Configurations/RuntimeConfigValidator.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public void ValidateConfigProperties()
9191

9292
if (runtimeConfig.IsGraphQLEnabled)
9393
{
94-
ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(runtimeConfig.Entities);
94+
ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(runtimeConfig.DataSource.DatabaseType, runtimeConfig.Entities);
9595
}
9696
}
9797
}
@@ -318,10 +318,11 @@ public void ValidateDatabaseType(
318318
/// create mutation name: createBook
319319
/// update mutation name: updateBook
320320
/// delete mutation name: deleteBook
321+
/// patch mutation name: patchBook
321322
/// </summary>
322323
/// <param name="entityCollection">Entity definitions</param>
323324
/// <exception cref="DataApiBuilderException"></exception>
324-
public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(RuntimeEntities entityCollection)
325+
public void ValidateEntitiesDoNotGenerateDuplicateQueriesOrMutation(DatabaseType databaseType, RuntimeEntities entityCollection)
325326
{
326327
HashSet<string> graphQLOperationNames = new();
327328

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

360363
if (!graphQLOperationNames.Add(pkQueryName)
361364
|| !graphQLOperationNames.Add(listQueryName)
362365
|| !graphQLOperationNames.Add(createMutationName)
363366
|| !graphQLOperationNames.Add(updateMutationName)
364-
|| !graphQLOperationNames.Add(deleteMutationName))
367+
|| !graphQLOperationNames.Add(deleteMutationName)
368+
|| ((databaseType is DatabaseType.CosmosDB_NoSQL) && !graphQLOperationNames.Add(patchMutationName)))
365369
{
366370
containsDuplicateOperationNames = true;
367371
}

src/Core/Resolvers/CosmosMutationEngine.cs

Lines changed: 178 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ namespace Azure.DataApiBuilder.Core.Resolvers
2222
{
2323
public class CosmosMutationEngine : IMutationEngine
2424
{
25+
private const int PATCH_OPERATIONS_LIMIT = 10;
26+
2527
private readonly CosmosClientProvider _clientProvider;
2628
private readonly IMetadataProviderFactory _metadataProviderFactory;
2729
private readonly IAuthorizationResolver _authorizationResolver;
@@ -65,13 +67,23 @@ private async Task<JObject> ExecuteAsync(IMiddlewareContext context, IDictionary
6567
string entityName = metadataProvider.GetEntityName(graphQLType);
6668
AuthorizeMutation(context, queryArgs, entityName, resolver.OperationType);
6769

68-
ItemResponse<JObject>? response = resolver.OperationType switch
70+
JObject result;
71+
if (resolver.OperationType == EntityActionOperation.Patch)
6972
{
70-
EntityActionOperation.UpdateGraphQL => await HandleUpdateAsync(queryArgs, container),
71-
EntityActionOperation.Create => await HandleCreateAsync(queryArgs, container),
72-
EntityActionOperation.Delete => await HandleDeleteAsync(queryArgs, container),
73-
_ => throw new NotSupportedException($"unsupported operation type: {resolver.OperationType}")
74-
};
73+
result = await HandlePatchAsync(queryArgs, container);
74+
}
75+
else
76+
{
77+
ItemResponse<JObject>? response = resolver.OperationType switch
78+
{
79+
EntityActionOperation.UpdateGraphQL => await HandleUpdateAsync(queryArgs, container),
80+
EntityActionOperation.Create => await HandleCreateAsync(queryArgs, container),
81+
EntityActionOperation.Delete => await HandleDeleteAsync(queryArgs, container),
82+
_ => throw new NotSupportedException($"unsupported operation type: {resolver.OperationType}")
83+
};
84+
85+
result = response.Resource;
86+
}
7587

7688
string roleName = AuthorizationResolver.GetRoleOfGraphQLRequest(context);
7789

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

91-
return response.Resource;
103+
return result;
92104
}
93105

94106
/// <inheritdoc/>
@@ -112,16 +124,27 @@ public void AuthorizeMutation(
112124
bool isAuthorized = mutationOperation switch
113125
{
114126
EntityActionOperation.UpdateGraphQL =>
115-
_authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: clientRole, operation: EntityActionOperation.Update, inputArgumentKeys),
127+
_authorizationResolver.AreColumnsAllowedForOperation(entityName,
128+
roleName: clientRole,
129+
operation: EntityActionOperation.Update,
130+
columns: inputArgumentKeys),
131+
EntityActionOperation.Patch =>
132+
_authorizationResolver.AreColumnsAllowedForOperation(entityName,
133+
roleName: clientRole,
134+
operation: EntityActionOperation.Update,
135+
columns: inputArgumentKeys),
116136
EntityActionOperation.Create =>
117-
_authorizationResolver.AreColumnsAllowedForOperation(entityName, roleName: clientRole, operation: mutationOperation, inputArgumentKeys),
137+
_authorizationResolver.AreColumnsAllowedForOperation(entityName,
138+
roleName: clientRole,
139+
operation: mutationOperation,
140+
columns: inputArgumentKeys),
118141
EntityActionOperation.Delete => true,// Field level authorization is not supported for delete mutations. A requestor must be authorized
119142
// to perform the delete operation on the entity to reach this point.
120143
_ => throw new DataApiBuilderException(
121-
message: "Invalid operation for GraphQL Mutation, must be Create, UpdateGraphQL, or Delete",
122-
statusCode: HttpStatusCode.BadRequest,
123-
subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest
124-
),
144+
message: "Invalid operation for GraphQL Mutation, must be Create, UpdateGraphQL, or Delete",
145+
statusCode: HttpStatusCode.BadRequest,
146+
subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest
147+
),
125148
};
126149
if (!isAuthorized)
127150
{
@@ -234,6 +257,114 @@ private static async Task<ItemResponse<JObject>> HandleUpdateAsync(IDictionary<s
234257
}
235258
}
236259

260+
/// <summary>
261+
/// Refer to https://learn.microsoft.com/azure/cosmos-db/partial-document-update for more details on patch operations
262+
/// </summary>
263+
/// <exception cref="InvalidDataException"></exception>
264+
/// <exception cref="DataApiBuilderException"></exception>
265+
private static async Task<JObject> HandlePatchAsync(IDictionary<string, object?> queryArgs, Container container)
266+
{
267+
string? partitionKey = null;
268+
string? id = null;
269+
270+
if (queryArgs.TryGetValue(QueryBuilder.ID_FIELD_NAME, out object? idObj))
271+
{
272+
id = idObj?.ToString();
273+
}
274+
275+
if (string.IsNullOrEmpty(id))
276+
{
277+
throw new InvalidDataException("id field is mandatory");
278+
}
279+
280+
if (queryArgs.TryGetValue(QueryBuilder.PARTITION_KEY_FIELD_NAME, out object? partitionKeyObj))
281+
{
282+
partitionKey = partitionKeyObj?.ToString();
283+
}
284+
285+
if (string.IsNullOrEmpty(partitionKey))
286+
{
287+
throw new InvalidDataException("Partition Key field is mandatory");
288+
}
289+
290+
object? item = queryArgs[MutationBuilder.ITEM_INPUT_ARGUMENT_NAME];
291+
292+
JObject? input;
293+
// Variables were provided to the mutation
294+
if (item is Dictionary<string, object?>)
295+
{
296+
input = (JObject?)ParseVariableInputItem(item);
297+
}
298+
else
299+
{
300+
// An inline argument was set
301+
input = (JObject?)ParseInlineInputItem(item);
302+
}
303+
304+
if (input is null)
305+
{
306+
throw new InvalidDataException("Input Item field is invalid");
307+
}
308+
309+
// This would contain the patch operations to be applied on the document
310+
List<PatchOperation> patchOperations = new();
311+
GeneratePatchOperations(input, "", patchOperations);
312+
313+
if (patchOperations.Count <= 10)
314+
{
315+
return (await container.PatchItemAsync<JObject>(id, new PartitionKey(partitionKey), patchOperations)).Resource;
316+
}
317+
318+
// maximum 10 patch operations can be applied in a single patch request,
319+
// Hence dividing into multiple patch request, if it is requested for more than 10 item at a time.
320+
TransactionalBatch batch = container.CreateTransactionalBatch(new PartitionKey(partitionKey));
321+
int numberOfBatches = -1;
322+
for (int counter = 0; counter < patchOperations.Count; counter += PATCH_OPERATIONS_LIMIT)
323+
{
324+
// Get next 10 patch operations from the list
325+
List<PatchOperation> chunk = patchOperations.GetRange(counter, Math.Min(10, patchOperations.Count - counter));
326+
batch = batch.PatchItem(id, chunk);
327+
numberOfBatches++;
328+
}
329+
330+
TransactionalBatchResponse response = await batch.ExecuteAsync();
331+
if (!response.IsSuccessStatusCode)
332+
{
333+
throw new DataApiBuilderException(
334+
message: "Failed to patch the item",
335+
statusCode: HttpStatusCode.InternalServerError,
336+
subStatusCode: DataApiBuilderException.SubStatusCodes.DatabaseOperationFailed);
337+
}
338+
339+
return response.GetOperationResultAtIndex<JObject>(numberOfBatches).Resource;
340+
}
341+
342+
/// <summary>
343+
/// This method generates the patch operations for the input JObject by traversing the JObject recursively.
344+
/// e.g. if the input JObject is { "a": { "b": "c" } },
345+
/// the generated patch operation would be "set /a/b c"
346+
/// </summary>
347+
/// <param name="jObject">JObject needs to be traversed</param>
348+
/// <param name="currentPath">Current Position of the json token</param>
349+
/// <param name="patchOperations">Generated Patch Operation</param>
350+
private static void GeneratePatchOperations(JObject jObject, string currentPath, List<PatchOperation> patchOperations)
351+
{
352+
foreach (JProperty property in jObject.Properties())
353+
{
354+
string newPath = currentPath + "/" + property.Name;
355+
356+
if (property.Value.Type != JTokenType.Array && property.Value.Type == JTokenType.Object)
357+
{
358+
// Skip generating JPaths for array-type properties
359+
GeneratePatchOperations((JObject)property.Value, newPath, patchOperations);
360+
}
361+
else
362+
{
363+
patchOperations.Add(PatchOperation.Set(newPath, property.Value));
364+
}
365+
}
366+
}
367+
237368
/// <summary>
238369
/// The method is for parsing the mutation input object with nested inner objects when input is passed in as variables.
239370
/// </summary>
@@ -270,15 +401,37 @@ private static async Task<ItemResponse<JObject>> HandleUpdateAsync(IDictionary<s
270401

271402
if (item is ObjectFieldNode node)
272403
{
273-
createInput.Add(new JProperty(node.Name.Value, ParseInlineInputItem(node.Value.Value)));
404+
if (TypeHelper.IsPrimitiveType(node.Value.Kind))
405+
{
406+
createInput
407+
.Add(new JProperty(
408+
node.Name.Value,
409+
ParseInlineInputItem(TypeHelper.GetValue(node.Value))));
410+
}
411+
else
412+
{
413+
createInput.Add(new JProperty(node.Name.Value, ParseInlineInputItem(node.Value.Value)));
414+
415+
}
416+
274417
return createInput;
275418
}
276419

277420
if (item is List<ObjectFieldNode> nodeList)
278421
{
279422
foreach (ObjectFieldNode subfield in nodeList)
280423
{
281-
createInput.Add(new JProperty(subfield.Name.Value, ParseInlineInputItem(subfield.Value.Value)));
424+
if (TypeHelper.IsPrimitiveType(subfield.Value.Kind))
425+
{
426+
createInput
427+
.Add(new JProperty(
428+
subfield.Name.Value,
429+
ParseInlineInputItem(TypeHelper.GetValue(subfield.Value))));
430+
}
431+
else
432+
{
433+
createInput.Add(new JProperty(subfield.Name.Value, ParseInlineInputItem(subfield.Value.Value)));
434+
}
282435
}
283436

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

292445
foreach (IValueNode subfield in nodeArray)
293446
{
294-
jarrayObj.Add(ParseInlineInputItem(subfield.Value));
447+
if (TypeHelper.IsPrimitiveType(subfield.Kind))
448+
{
449+
jArrayObj.Add(ParseInlineInputItem(TypeHelper.GetValue(subfield)));
450+
}
451+
else
452+
{
453+
jArrayObj.Add(ParseInlineInputItem(subfield.Value));
454+
}
295455
}
296456

297-
return jarrayObj;
457+
return jArrayObj;
298458
}
299459

300460
return item;

src/Core/Resolvers/Sql Query Structures/SqlUpdateQueryStructure.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,10 @@ public SqlUpdateStructure(
122122
Predicates.Add(CreatePredicateForParam(new KeyValuePair<string, object?>(pkBackingColumn, param.Value)));
123123
}
124124
else // Unpack the input argument type as columns to update
125-
if (param.Key == UpdateMutationBuilder.INPUT_ARGUMENT_NAME)
125+
if (param.Key == UpdateAndPatchMutationBuilder.INPUT_ARGUMENT_NAME)
126126
{
127127
IDictionary<string, object?> updateFields =
128-
GQLMutArgumentToDictParams(context, UpdateMutationBuilder.INPUT_ARGUMENT_NAME, mutationParams);
128+
GQLMutArgumentToDictParams(context, UpdateAndPatchMutationBuilder.INPUT_ARGUMENT_NAME, mutationParams);
129129

130130
foreach (KeyValuePair<string, object?> field in updateFields)
131131
{

0 commit comments

Comments
 (0)