From c2221363744c274a6eeb8de605a5b4c7c347fbe2 Mon Sep 17 00:00:00 2001 From: Mateusz Kumpf Date: Mon, 1 Jul 2024 15:15:52 +0200 Subject: [PATCH] Add full path support for the patch operations (#442) * Add full path support for the patch * Apply suggestions * Add backward compatibility --- .../Builders/PatchOperationBuilder.cs | 32 +++++++--- .../Extensions/ExpressionExtensions.cs | 64 ++++++++++++++++--- .../Internals/InternalPatchOperation.cs | 5 +- .../Repositories/InMemoryRepository.Update.cs | 20 +++++- .../InMemoryRepositoryTests.cs | 30 +++++++++ 5 files changed, 129 insertions(+), 22 deletions(-) diff --git a/src/Microsoft.Azure.CosmosRepository/Builders/PatchOperationBuilder.cs b/src/Microsoft.Azure.CosmosRepository/Builders/PatchOperationBuilder.cs index b5bb4af3e..f9d39e6de 100644 --- a/src/Microsoft.Azure.CosmosRepository/Builders/PatchOperationBuilder.cs +++ b/src/Microsoft.Azure.CosmosRepository/Builders/PatchOperationBuilder.cs @@ -22,20 +22,34 @@ public PatchOperationBuilder(CosmosPropertyNamingPolicy? cosmosPropertyNamingPol public IPatchOperationBuilder Replace(Expression> expression, TValue? value) { - PropertyInfo property = expression.GetPropertyInfo(); - var propertyToReplace = GetPropertyToReplace(property); - _rawPatchOperations.Add(new InternalPatchOperation(property, value, PatchOperationType.Replace)); + IReadOnlyList propertyInfos = expression.GetPropertyInfos(); + var propertyToReplace = GetPropertyToReplace(propertyInfos); + + _rawPatchOperations.Add(new InternalPatchOperation(propertyInfos, value, PatchOperationType.Replace)); _patchOperations.Add(PatchOperation.Replace($"/{propertyToReplace}", value)); + return this; } - private string GetPropertyToReplace(MemberInfo propertyInfo) + private string GetPropertyToReplace(MemberInfo propertyInfo) => + GetPropertyToReplace([propertyInfo]); + + private string GetPropertyToReplace(IEnumerable propertyInfos) { - JsonPropertyAttribute[] attributes = - propertyInfo.GetCustomAttributes(true).ToArray(); + List propertiesNames = []; + + foreach (PropertyInfo propertyInfo in propertyInfos) + { + JsonPropertyAttribute[] attributes = + propertyInfo.GetCustomAttributes(true).ToArray(); + + var propertyName = attributes.Length is 0 + ? _namingStrategy.GetPropertyName(propertyInfo.Name, false) + : attributes[0].PropertyName; + + propertiesNames.Add(propertyName); + } - return attributes.Length is 0 - ? _namingStrategy.GetPropertyName(propertyInfo.Name, false) - : attributes[0].PropertyName; + return string.Join("/", propertiesNames); } } \ No newline at end of file diff --git a/src/Microsoft.Azure.CosmosRepository/Extensions/ExpressionExtensions.cs b/src/Microsoft.Azure.CosmosRepository/Extensions/ExpressionExtensions.cs index a51d85fda..efda1af6f 100644 --- a/src/Microsoft.Azure.CosmosRepository/Extensions/ExpressionExtensions.cs +++ b/src/Microsoft.Azure.CosmosRepository/Extensions/ExpressionExtensions.cs @@ -39,21 +39,67 @@ internal static Expression> Or( internal static PropertyInfo GetPropertyInfo(this Expression> propertyLambda) { - Type type = typeof(TSource); + var propertyInfos = GetPropertyInfosInternal(propertyLambda); - if (propertyLambda.Body is not MemberExpression member) + PropertyInfo propInfo = propertyInfos[propertyInfos.Count - 1]; + + ThrowArgumentExceptionIfPropertyIsNotFromSourceType(propertyLambda, propInfo); + + return propInfo; + } + + internal static IReadOnlyList GetPropertyInfos(this Expression> propertyLambda) + { + List propertyInfos = GetPropertyInfosInternal(propertyLambda); + + PropertyInfo propInfo = propertyInfos[0]; + + ThrowArgumentExceptionIfPropertyIsNotFromSourceType(propertyLambda, propInfo); + + return propertyInfos; + } + + private static List GetPropertyInfosInternal(Expression> propertyLambda) + { + List propertyInfos = []; + + MemberExpression? member = propertyLambda.Body as MemberExpression; + + if (member == null) + { throw new ArgumentException($"Expression '{propertyLambda}' refers to a method, not a property."); + } + + while (member != null) + { + if (member.Member is not PropertyInfo propertyInfo) + { + throw new ArgumentException($"Expression '{propertyLambda}' refers to a field, not a property."); + } - if (member.Member is not PropertyInfo propInfo) - throw new ArgumentException($"Expression '{propertyLambda}' refers to a field, not a property."); + propertyInfos.Add(propertyInfo); + + member = member.Expression as MemberExpression; + } + + propertyInfos.Reverse(); // The properties are added from the leaf to the root, so we reverse to get them in the correct order. + + return propertyInfos; + } + + private static void ThrowArgumentExceptionIfPropertyIsNotFromSourceType( + Expression> propertyLambda, + PropertyInfo propertyInfo) + { + var type = typeof(TSource); #pragma warning disable IDE0046 // Convert to conditional expression - if (propInfo.ReflectedType != null && - type != propInfo.ReflectedType && - !type.IsSubclassOf(propInfo.ReflectedType)) + if (propertyInfo.ReflectedType != null && + type != propertyInfo.ReflectedType && + !type.IsSubclassOf(propertyInfo.ReflectedType)) + { throw new ArgumentException($"Expression '{propertyLambda}' refers to a property that is not from type {type}."); + } #pragma warning restore IDE0046 // Convert to conditional expression - - return propInfo; } } \ No newline at end of file diff --git a/src/Microsoft.Azure.CosmosRepository/Internals/InternalPatchOperation.cs b/src/Microsoft.Azure.CosmosRepository/Internals/InternalPatchOperation.cs index a4a53606b..e725ca085 100644 --- a/src/Microsoft.Azure.CosmosRepository/Internals/InternalPatchOperation.cs +++ b/src/Microsoft.Azure.CosmosRepository/Internals/InternalPatchOperation.cs @@ -3,10 +3,11 @@ namespace Microsoft.Azure.CosmosRepository.Internals; -internal class InternalPatchOperation(PropertyInfo propertyInfo, object? newValue, PatchOperationType type) +internal class InternalPatchOperation(IReadOnlyList propertyInfos, object? newValue, PatchOperationType type) { public PatchOperationType Type { get; } = type; - public PropertyInfo PropertyInfo { get; } = propertyInfo; + + public IReadOnlyList PropertyInfos { get; } = propertyInfos; public object? NewValue { get; } = newValue; } \ No newline at end of file diff --git a/src/Microsoft.Azure.CosmosRepository/Repositories/InMemoryRepository.Update.cs b/src/Microsoft.Azure.CosmosRepository/Repositories/InMemoryRepository.Update.cs index 4fd45f65d..ff9721230 100644 --- a/src/Microsoft.Azure.CosmosRepository/Repositories/InMemoryRepository.Update.cs +++ b/src/Microsoft.Azure.CosmosRepository/Repositories/InMemoryRepository.Update.cs @@ -107,8 +107,24 @@ public async ValueTask UpdateAsync(string id, foreach (InternalPatchOperation internalPatchOperation in patchOperationBuilder._rawPatchOperations.Where(ipo => ipo.Type is PatchOperationType.Replace)) { - PropertyInfo property = item!.GetType().GetProperty(internalPatchOperation.PropertyInfo.Name)!; - property.SetValue(item, internalPatchOperation.NewValue); + IReadOnlyList propertyInfos = internalPatchOperation.PropertyInfos; + object? currentObject = item; + + if (propertyInfos.Count is 0) + { + continue; + } + + for (var i = 0; i < propertyInfos.Count; i++) + { + if (i == propertyInfos.Count - 1) + { + propertyInfos[i].SetValue(currentObject, internalPatchOperation.NewValue); + break; + } + + currentObject = propertyInfos[i].GetValue(currentObject); + } } ConcurrentDictionary items = InMemoryStorage.GetDictionary(); diff --git a/tests/Microsoft.Azure.CosmosRepositoryTests/InMemoryRepositoryTests.cs b/tests/Microsoft.Azure.CosmosRepositoryTests/InMemoryRepositoryTests.cs index 9089c2fb0..27c6c7f0c 100644 --- a/tests/Microsoft.Azure.CosmosRepositoryTests/InMemoryRepositoryTests.cs +++ b/tests/Microsoft.Azure.CosmosRepositoryTests/InMemoryRepositoryTests.cs @@ -989,6 +989,36 @@ await _rootObjectRepository.UpdateAsync(root.Id, builder => Assert.Equal(2, deserialisedItem.NestedObject.Property2); } + [Fact] + public async Task UpdateAsync_PropertiesInNestedObjectToPatch_UpdatesValues() + { + //Arrange + RootObject root = new() + { + Id = Guid.NewGuid().ToString(), + NestedObject = new NestedObject + { + Property1 = "prop1", + Property2 = 55 + } + }; + + InMemoryStorage.GetDictionary().TryAddAsJson(root.Id, root); + + //Act + await _rootObjectRepository.UpdateAsync( + root.Id, + builder => + builder.Replace(x => x.NestedObject.Property1, "prop2") + .Replace(x => x.NestedObject.Property2, 2)); + + //Assert + RootObject deserialisedItem = + _rootObjectRepository.DeserializeItem(InMemoryStorage.GetDictionary().First().Value); + Assert.Equal("prop2", deserialisedItem.NestedObject.Property1); + Assert.Equal(2, deserialisedItem.NestedObject.Property2); + } + [Fact] public async Task PageAsync_PredicateThatDoesNotMatch_ReturnsEmptyList() {