如何使用 HotChocolate 和 EFCore 创建 GraphQL 部分更新

How can I create a GraphQL partial update with HotChocolate and EFCore

我正在尝试使用 Entity Framework Core 和 Hot Chocolate 创建一个 ASP.NET Core 3.1 应用程序。 应用程序需要支持通过 GraphQL 创建、查询、更新和删除对象。 某些字段需要有值。

创建、查询和删除对象不是问题,但是更新对象就比较棘手了。 我要解决的问题是部分更新。

Entity Framework 使用以下模型对象首先通过代码创建数据库table。

public class Warehouse
{
    [Key]
    public int Id { get; set; }

    [Required]
    public string Code { get; set; }
    public string CompanyName { get; set; }
    [Required]
    public string WarehouseName { get; set; }
    public string Telephone { get; set; }
    public string VATNumber { get; set; }
}

我可以在数据库中创建一条记录,其中的突变定义如下:

public class WarehouseMutation : ObjectType
{
    protected override void Configure(IObjectTypeDescriptor descriptor)
    {
        descriptor.Field("create")
            .Argument("input", a => a.Type<InputObjectType<Warehouse>>())
            .Type<ObjectType<Warehouse>>()
            .Resolver(async context =>
            {
                var input = context.Argument<Warehouse>("input");
                var provider = context.Service<IWarehouseStore>();

                return await provider.CreateWarehouse(input);
            });
    }
}

目前,对象很小,但在项目完成之前它们将拥有更多的领域。我需要利用 GraphQL 的强大功能来只为那些已更改的字段发送数据,但是如果我使用相同的 InputObjectType 进行更新,我会遇到 2 个问题。

  1. 更新必须包含所有 "Required" 个字段。
  2. 更新尝试将所有未提供的值设置为其默认值。

为了避免这个问题,我查看了 HotChocolate 提供的 Optional<> 通用类型。 这需要定义一个新的 "Update" 类型,如下所示

public class WarehouseUpdate
{
    public int Id { get; set; } // Must always be specified
    public Optional<string> Code { get; set; }
    public Optional<string> CompanyName { get; set; }
    public Optional<string> WarehouseName { get; set; }
    public Optional<string> Telephone { get; set; }
    public Optional<string> VATNumber { get; set; }
}

将此添加到突变中

descriptor.Field("update")
            .Argument("input", a => a.Type<InputObjectType<WarehouseUpdate>>())
            .Type<ObjectType<Warehouse>>()
            .Resolver(async context =>
            {
                var input = context.Argument<WarehouseUpdate>("input");
                var provider = context.Service<IWarehouseStore>();

                return await provider.UpdateWarehouse(input);
            });

然后 UpdateWarehouse 方法只需要更新那些已提供值的字段。

public async Task<Warehouse> UpdateWarehouse(WarehouseUpdate input)
{
    var item = await _context.Warehouses.FindAsync(input.Id);
    if (item == null)
        throw new KeyNotFoundException("No item exists with specified key");

    if (input.Code.HasValue)
        item.Code = input.Code;
    if (input.WarehouseName.HasValue)
        item.WarehouseName = input.WarehouseName;
    if (input.CompanyName.HasValue)
        item.CompanyName = input.CompanyName;
    if (input.Telephone.HasValue)
        item.Telephone = input.Telephone;
    if (input.VATNumber.HasValue)
        item.VATNumber = input.VATNumber;

    await _context.SaveChangesAsync();

    return item;
}

虽然这有效,但它确实有几个主要缺点。

  1. 因为 Enity Framework 不理解 Optional<> 泛型,每个模型都需要 2 classes
  2. Update 方法需要为每个要更新的 字段提供条件代码 这显然不理想。

Entity Framework 可以与 JsonPatchDocument<> 泛型 class 一起使用。这允许将部分更新应用于实体而无需自定义代码。 然而,我正在努力寻找一种将其与 Hot Chocolate GraphQL 实现相结合的方法。

为了完成这项工作,我正在尝试创建一个自定义 InputObjectType,其行为就像使用 Optional<> 定义属性并映射到 JsonPatchDocument<> 的 CLR 类型一样。这可以通过在反射的帮助下为模型 class 中的每个 属性 创建自定义映射来实现。然而,我发现一些定义框架处理请求方式的属性 (IsOptional) 是 Hot Chocolate 框架的内部属性,无法从自定义 class 中的可覆盖方法访问。

我也考虑过

我正在寻找关于如何使用通用方法实现它并避免需要为每种类型编写 3 个单独的代码块的任何想法 - 它们需要彼此保持同步。

您可以使用Automapper 或Mapster 来忽略空值。因此,如果您的模型中有空值,它不会替换现有值。

在这里,我使用的是 Mapster。

public class MapsterConfig
{
    public static void Config()
    {
        TypeAdapterConfig<WarehouseUpdate , Warehouse>
               .ForType()
               .IgnoreNullValues(true);
     }
}

将此添加到您的中间件

MapsterConfig.Config();

我 运行 遇到了与 Hot Chocolate 相同的问题,并且有巨大的 tables(其中一个有 129 列)映射到对象。为每个 table 的每个可选 属性 编写 if 检查会太痛苦,因此,在下面编写了一个通用的辅助方法以使其更容易:

/// <summary>
/// Checks which of the optional properties were passed and only sets those on the db Entity. Also, handles the case where explicit null
/// value was passed in an optional/normal property and such property would be set to the default value of the property's type on the db entity
/// Recommendation: Validate the dbEntityObject afterwards before saving to db
/// </summary>
/// <param name="inputTypeObject">The input object received in the mutation which has Optional properties as well as normal properties</param>
/// <param name="dbEntityObject">The database entity object to update</param>
public void PartialUpdateDbEntityFromGraphQLInputType(object inputTypeObject, object dbEntityObject)
{
    var inputObjectProperties = inputTypeObject.GetType().GetProperties();
    var dbEntityPropertiesMap = dbEntityObject.GetType().GetProperties().ToDictionary(x => x.Name);
    foreach (var inputObjectProperty in inputObjectProperties)
    {
        //For Optional Properties
        if (inputObjectProperty.PropertyType.Name == "Optional`1")
        {
            dynamic hasValue = inputObjectProperty.PropertyType.GetProperty("HasValue").GetValue(inputObjectProperty.GetValue(inputTypeObject));
            if (hasValue == true)
            {
                var value = inputObjectProperty.PropertyType.GetProperty("Value").GetValue(inputObjectProperty.GetValue(inputTypeObject));
                //If the field was passed as null deliberately to set null in the column, setting it to the default value of the db type in this case.
                if (value == null)
                {
                    dbEntityPropertiesMap[inputObjectProperty.Name].SetValue(dbEntityObject, default);
                }
                else
                {
                    dbEntityPropertiesMap[inputObjectProperty.Name].SetValue(dbEntityObject, value);
                }
            }
        }
        //For normal required Properties
        else
        {
            var value = inputObjectProperty.GetValue(inputTypeObject);
            //If the field was passed as null deliberately to set null in the column, setting it to the default value of the db type in this case.
            if (value == null)
            {
                dbEntityPropertiesMap[inputObjectProperty.Name].SetValue(dbEntityObject, default);
            }
            else
            {
                dbEntityPropertiesMap[inputObjectProperty.Name].SetValue(dbEntityObject, value);
            }
        }
    }
}

然后,在您的示例中,只需像下面这样调用它并将其重新用于所有其他实体更新突变:

public async Task<Warehouse> UpdateWarehouse(WarehouseUpdate input)
{
    var item = await _context.Warehouses.FindAsync(input.Id);
    if (item == null)
        throw new KeyNotFoundException("No item exists with specified key");

    PartialUpdateDbEntityFromGraphQLInputType(input, item);

    await _context.SaveChangesAsync();

    return item;
}

希望这对您有所帮助。如果是,请标记为答案。

这是我最终得到的解决方案。 它还使用反射,但我认为可以使用一些 JIT 编译来优化它。

public void ApplyTo(TModel objectToApplyTo)
{
    var targetProperties = typeof(TModel).GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public).ToDictionary(p => p.Name);
    var updateProperties = GetType().GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public);

    // OK this is going to use reflection - bad boy - but lets see if we can get it to work
    // TODO: Sub types
    foreach (var prop in updateProperties)
    {
        Type? propertyType = prop?.PropertyType;
        if (propertyType is { }
            && propertyType.IsGenericType
            && propertyType.GetGenericTypeDefinition() == typeof(Optional<>))
        {
            var hasValueProp = propertyType.GetProperty("HasValue");
            var valueProp = propertyType.GetProperty("Value");
            var value = prop?.GetValue(this);
            if (valueProp !=null && (bool)(hasValueProp?.GetValue(value) ?? false))
            {
                if (targetProperties.ContainsKey(prop?.Name ?? string.Empty))
                {
                    var targetProperty = targetProperties[prop.Name];
                    if (targetProperty.PropertyType.IsValueType || targetProperty.PropertyType == typeof(string) ||
                            targetProperty.PropertyType.IsArray || (targetProperty.PropertyType.IsGenericType && targetProperty.PropertyType.GetGenericTypeDefinition() == typeof(IList<>)))
                        targetProperty.SetValue(objectToApplyTo, valueProp?.GetValue(value));
                    else
                    {
                        var targetValue = targetProperty.GetValue(objectToApplyTo);
                        if (targetValue == null)
                        {
                            targetValue = Activator.CreateInstance(targetProperty.PropertyType);
                            targetProperty.SetValue(objectToApplyTo, targetValue);
                        }

                        var innerType = propertyType.GetGenericArguments().First();
                        var mi = innerType.GetMethod(nameof(ApplyTo));
                        mi?.Invoke(valueProp?.GetValue(value), new[] { targetValue });
                    }
                }
            }
        }
    }
}