Backend/e-suite.API.Common/e-suite.API.Common/Patch.cs

165 lines
6.2 KiB
C#

using e_suite.API.Common.exceptions;
using e_suite.Database.Core;
using e_suite.Database.Core.Extensions;
using e_suite.Database.Core.Models;
using eSuite.Core.Miscellaneous;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations.Schema;
using System.Reflection;
namespace e_suite.API.Common;
public class Patch<T> : IPatch<T>
{
private readonly IEsuiteDatabaseDbContext _dbContext;
private readonly Type _targetType;
public T Value { get; }
public Patch(T value, IEsuiteDatabaseDbContext dbContext)
{
Value = value;
_dbContext = dbContext;
var patchesAttr = typeof(T).GetCustomAttribute<PatchesAttribute>();
if (patchesAttr == null)
throw new InvalidDataException(
$"Patch DTO '{typeof(T).Name}' is missing required [Patches] attribute.");
_targetType = patchesAttr.TargetType;
}
public async Task ApplyTo(object target)
{
if (target == null)
throw new ArgumentNullException(nameof(target));
if (!_targetType.IsAssignableFrom(target.GetType()))
{
throw new InvalidDataException(
$"Patch DTO '{typeof(T).Name}' is defined to patch '{_targetType.Name}', " +
$"but was applied to instance of '{target.GetType().Name}'.");
}
var targetType = target.GetType();
var targetProps = targetType
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.ToDictionary(p => p.Name);
var patchProps = typeof(T)
.GetProperties(BindingFlags.Public | BindingFlags.Instance);
var mappedProps = patchProps
.Where(p => p.GetCustomAttribute<PatchMapAttribute>() != null)
.ToList();
if (mappedProps.Count == 0)
{
throw new InvalidDataException(
$"Patch DTO '{typeof(T).Name}' does not define any properties with PatchMapAttribute.");
}
foreach (var patchProp in patchProps)
{
// Only process properties with PatchMap
var mapAttr = patchProp.GetCustomAttribute<PatchMapAttribute>();
if (mapAttr == null)
continue;
var targetPropName = mapAttr.TargetPropertyName;
if (!targetProps.TryGetValue(targetPropName, out var targetProp))
throw new InvalidDataException(
$"PatchMap refers to unknown property '{targetPropName}' on '{targetType.Name}'");
if (!targetProp.CanWrite)
throw new InvalidDataException(
$"Target property '{targetPropName}' is not writable");
var patchValue = patchProp.GetValue(Value);
// Null means "not included in the patch"
if (patchValue == null)
continue;
bool isGeneralIdRef = typeof(IGeneralIdRef).IsAssignableFrom(patchProp.PropertyType);
// Disallow nested objects (except string and IGeneralIdRef)
if (patchProp.PropertyType.IsClass &&
patchProp.PropertyType != typeof(string) &&
!isGeneralIdRef)
{
throw new InvalidDataException(
$"Nested objects are not allowed in PATCH DTOs. Property: {patchProp.Name}");
}
// Handle navigation properties via IGeneralIdRef
if (isGeneralIdRef)
{
// Ensure the target navigation type implements IGeneralId
if (!typeof(IGeneralId).IsAssignableFrom(targetProp.PropertyType))
{
throw new InvalidDataException(
$"PatchMap for '{patchProp.Name}' refers to navigation property '{targetPropName}', " +
$"but '{targetProp.PropertyType.Name}' does not implement IGeneralId. " +
$"Navigation properties patched via IGeneralIdRef must implement IGeneralId.");
}
var refValue = (IGeneralIdRef)patchValue;
var targetEntityType = targetProp.PropertyType;
// Get DbSet<TEntity>
var setMethod = typeof(DbContext)
.GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
.MakeGenericMethod(targetEntityType);
var dbSet = setMethod.Invoke(_dbContext, null);
// Call FindByGeneralIdRefAsync<TEntity>
var findMethod = typeof(QueryableExtensions)
.GetMethod(nameof(QueryableExtensions.FindByGeneralIdRefAsync))!
.MakeGenericMethod(targetEntityType);
var task = (Task)findMethod.Invoke(
null,
[dbSet, refValue, CancellationToken.None]
)!;
await task.ConfigureAwait(false);
var resultProperty = task.GetType().GetProperty("Result");
var resolvedEntity = resultProperty?.GetValue(task);
if (resolvedEntity == null)
throw new NotFoundException(
$"Unable to resolve reference for '{patchProp.Name}'");
// Assign navigation property
targetProp.SetValue(target, resolvedEntity);
// Look for ForeignKeyAttribute on the navigation property
var fkAttr = targetProp.GetCustomAttribute<ForeignKeyAttribute>();
if (fkAttr != null)
{
var fkName = fkAttr.Name;
if (targetProps.TryGetValue(fkName, out var fkProp))
{
var idValue = resolvedEntity
.GetType()
.GetProperty("Id")
?.GetValue(resolvedEntity);
fkProp.SetValue(target, idValue);
}
}
continue;
}
// Default scalar assignment
targetProp.SetValue(target, patchValue);
}
}
}