Last active
March 7, 2020 21:28
-
-
Save btshft/136e8cabaefa6d5f0bf5ceaa7c9ecba1 to your computer and use it in GitHub Desktop.
Modification / EntityPatch
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// <summary> | |
/// Контейнер содержащий модификацию для объекта. | |
/// </summary> | |
/// <typeparam name="T">Тип объекта.</typeparam> | |
public class Modification<T> where T : class | |
{ | |
/// <summary> | |
/// Модификации объекта. | |
/// </summary> | |
public ICollection<Change> Changes { get; set; } = new List<Change>(); | |
/// <summary> | |
/// Создает сущность патча, где присутствуют только измененные свойства. | |
/// </summary> | |
public dynamic CreatePatch() | |
{ | |
var patch = new ExpandoObject(); | |
if (Changes == null) | |
return patch; | |
foreach (var change in Changes) | |
{ | |
var propertyChain = change.Path.Split('.', StringSplitOptions.RemoveEmptyEntries).ToArray(); | |
var container = (IDictionary<string, object>) patch; | |
for (var i = 0; i < propertyChain.Length; i++) | |
{ | |
var isLastLeaf = i == propertyChain.Length - 1; | |
var propertyName = propertyChain[i]; | |
if (isLastLeaf) | |
{ | |
container.Add(propertyName, change.Value); | |
} | |
else | |
{ | |
var nestedProperty = new ExpandoObject(); | |
container.Add(propertyName, nestedProperty); | |
container = nestedProperty; | |
} | |
} | |
} | |
return patch; | |
} | |
/// <summary> | |
/// Применяет модификацию к объекту. | |
/// </summary> | |
public T ApplyTo(T source) | |
{ | |
static (PropertyInfo property, object container) ConstructProperty(string propertyPath, T propertyContainer, object valueToSet) | |
{ | |
if (string.IsNullOrEmpty(propertyPath)) | |
throw new ArgumentNullException(nameof(propertyPath)); | |
var property = default(PropertyInfo); | |
var reflectedType = typeof(T); | |
var container = (object) propertyContainer; | |
var propertyChain = propertyPath.Split('.', StringSplitOptions.RemoveEmptyEntries).ToArray(); | |
for (var i = 0; i < propertyChain.Length; i++) | |
{ | |
var isLastLeaf = i == propertyChain.Length - 1; | |
var propertyName = propertyChain[i]; | |
property = reflectedType.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public); | |
if (property == null) | |
throw new InvalidOperationException($"Не найдено свойство '{propertyName}' в типе '{reflectedType}' при обработке модификации по пути '{propertyPath}'"); | |
if (isLastLeaf) | |
{ | |
if (!property.CanWrite || property.GetSetMethod(nonPublic: false) == null) | |
throw new InvalidOperationException($"Свойство '{propertyName}' в типе '{reflectedType}' не поддерживает запись. Путь: '{propertyPath}'"); | |
} | |
else | |
{ | |
var propertyValue = property.GetValue(container); | |
// Не инициализируем свойство если значение для установки - null. | |
if (propertyValue == null && valueToSet != null) | |
{ | |
if (property.PropertyType.IsAbstract) | |
throw new InvalidOperationException($"Свойство '{propertyName}' в типе '{reflectedType}' не инициализировано и не может быть изменено. Путь: '{propertyPath}'"); | |
try | |
{ | |
var emptyContainer = FormatterServices.GetUninitializedObject(property.PropertyType); | |
property.SetValue(container, emptyContainer); | |
propertyValue = property.GetValue(container); | |
} catch(Exception e) | |
{ | |
throw new InvalidOperationException($"Свойство '{propertyName}' в типе '{reflectedType}' не инициализировано и не может быть изменено. Путь: '{propertyPath}'", e); | |
} | |
} | |
container = propertyValue; | |
reflectedType = property.PropertyType; | |
} | |
} | |
return (property, container); | |
} | |
if (source == null) | |
throw new ArgumentNullException(nameof(source)); | |
if (Changes == null) | |
return source; | |
foreach (var change in Changes) | |
{ | |
if (string.IsNullOrEmpty(change.Path)) | |
throw new InvalidOperationException("Не задан путь к свойству"); | |
var (property, container) = ConstructProperty(change.Path, source, change.Value); | |
if (change.Value == null) | |
{ | |
var defaultValue = property.PropertyType.IsValueType | |
? Activator.CreateInstance(property.PropertyType) | |
: null; | |
property.SetValue(container, defaultValue); | |
} | |
else | |
{ | |
var changeType = change.Value.GetType(); | |
if (property.PropertyType != changeType && !property.PropertyType.IsInstanceOfType(changeType)) | |
throw new InvalidOperationException($"Свойство '{change.Path}' в типе '{typeof(T).Name}' не поддерживает установку значения типа '{changeType}'"); | |
var value = changeType == property.PropertyType | |
? change.Value | |
: Convert.ChangeType(change.Value, property.PropertyType); | |
property.SetValue(container, value); | |
} | |
} | |
return source; | |
} | |
/// <summary> | |
/// Создает экземпляр билдера. | |
/// </summary> | |
public static Builder CreateBuilder() | |
{ | |
return new Builder(); | |
} | |
/// <summary> | |
/// Изменение свойства. | |
/// </summary> | |
public class Change | |
{ | |
/// <summary> | |
/// Путь к свойству через точку. | |
/// Например: Customer.Name или Customer.Address.Street | |
/// </summary> | |
public string Path { get; set; } | |
/// <summary> | |
/// Значение для установки в свойство. | |
/// </summary> | |
public object Value { get; set; } | |
} | |
/// <summary> | |
/// Билдер модификации. | |
/// </summary> | |
public class Builder | |
{ | |
private readonly List<Change> _changes; | |
/// <summary> | |
/// Инициализирует экземпляр <see cref="Builder"/>. | |
/// </summary> | |
internal Builder() | |
{ | |
_changes = new List<Change>(); | |
} | |
/// <summary> | |
/// Добавляет модификацию для свойства. | |
/// </summary> | |
/// <typeparam name="TProperty">Тип свойства.</typeparam> | |
/// <param name="propertySelector">Селектор свойства.</param> | |
/// <param name="value">Новое значение.</param> | |
/// <returns>Билдер.</returns> | |
public Builder Assign<TProperty>(Expression<Func<T, TProperty>> propertySelector, TProperty value) | |
{ | |
_changes.Add(new Change | |
{ | |
Path = string.Join('.', GetPropertyChain(propertySelector)), | |
Value = value | |
}); | |
return this; | |
} | |
/// <summary> | |
/// Создает модификацию объекта. | |
/// </summary> | |
public Modification<T> Build() => new Modification<T> { Changes = _changes }; | |
internal static IReadOnlyCollection<string> GetPropertyChain<TProperty>( | |
Expression<Func<T, TProperty>> expression) | |
{ | |
static MemberExpression ExtractMember(Expression source) | |
{ | |
if (source is UnaryExpression unary) | |
return unary.Operand as MemberExpression; | |
return source as MemberExpression; | |
} | |
var chain = new Stack<string>(); | |
var member = ExtractMember(expression.Body); | |
while (member != null) | |
{ | |
chain.Push(member.Member.Name); | |
member = ExtractMember(member.Expression); | |
} | |
return chain; | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// <summary> | |
/// Тесты для модификации <see cref="Modification{T}"/>. | |
/// </summary> | |
public class ModificationTests | |
{ | |
[Fact] | |
public void FlatPath_MultipleProperties_Should_Change_Object() | |
{ | |
// Arrange | |
var customerId = Guid.NewGuid(); | |
var customer = new Customer | |
{ | |
Id = customerId, | |
Name = "John", | |
LastName = "King" | |
}; | |
var modification = Modification<Customer>.CreateBuilder() | |
.Assign(p => p.LastName, "Doe") | |
.Assign(p => p.Name, "Jake") | |
.Build(); | |
// Act | |
var modifiedObject = modification.ApplyTo(customer); | |
// Assert | |
modifiedObject.LastName.ShouldBe("Doe"); | |
modifiedObject.Name.ShouldBe("Jake"); | |
modifiedObject.Id.ShouldBe(customerId); | |
modifiedObject.ShouldBe(customer); | |
} | |
[Fact] | |
public void NestedPath_InitializedNestedObject_Should_Change_Object() | |
{ | |
// Arrange | |
var customer = new Customer | |
{ | |
Address = new Customer.AddressInfo | |
{ | |
Index = 123, | |
Street = "Default" | |
} | |
}; | |
var modification = Modification<Customer>.CreateBuilder() | |
.Assign(p => p.Address.Street, "Custom") | |
.Assign(p => p.Address.Index, 555) | |
.Build(); | |
// Act | |
var modifiedObject = modification.ApplyTo(customer); | |
// Assert | |
modifiedObject.Address.Index.ShouldBe(555); | |
modifiedObject.Address.Street.ShouldBe("Custom"); | |
} | |
[Fact] | |
public void NestedPath_UninitializedNestedObject_Should_Change_Object() | |
{ | |
// Arrange | |
var customer = new Customer { }; | |
var modification = Modification<Customer>.CreateBuilder() | |
.Assign(p => p.Address.Index, 555) | |
.Build(); | |
// Act | |
var modifiedObject = modification.ApplyTo(customer); | |
// Assert | |
modifiedObject.Address.Index.ShouldBe(555); | |
modifiedObject.Address.Street.ShouldBe(null); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment