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

Alb*_*ban 8 c# json-patch entity-framework-core graphql hotchocolate

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

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

实体框架使用以下模型对象首先通过代码创建数据库表。

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; }
}
Run Code Online (Sandbox Code Playgroud)

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

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);
            });
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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

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

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; }
}
Run Code Online (Sandbox Code Playgroud)

将其添加到突变中

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);
            });
Run Code Online (Sandbox Code Playgroud)

然后,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;
}
Run Code Online (Sandbox Code Playgroud)

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

  1. 由于实体框架不理解Optional<>泛型类型,因此每个模型都需要 2 个类
  2. Update 方法需要为每个要更新的字段提供条件代码,这显然不理想。

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

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

我也考虑过方法

  • Optional<>将UpdateClass 的属性映射到JsonPatchDocument<>对象中
  • 使用代码编织生成具有Optional<>每个属性版本的类
  • 首先重写 EF 代码来处理Optional<>属性

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

ade*_*nko 5

依赖 HotChocolate 提供的Optional<>可能不是最好的主意。考虑这样一种情况:用户有一个始终不为空的字段(密码、登录名等)。使用Optional<>来修补该字段,您将被迫放宽更新方法输入中的类型要求,允许空值。当然,您可以稍后在执行阶段验证这一点,但是您的 API 的类型变得不那么强 - 现在仅查看类型系统来了解是否允许 field = null 作为修补值是不够的。因此,如果您想使用Optional<>而不降低API的自描述性和一致性,只有当API的所有补丁方法的所有字段都不允许将null作为有效补丁值时,您才可以这样做。然而,在绝大多数情况下,这是错误的。几乎总是,您的 API 中会出现这样的情况:您需要允许用户将某些字段重置为 null。

mutation
{ 
 updateUser(input: {
  id: 1 
  phone: null
  email: null
 }) {
  result
 }
}
Run Code Online (Sandbox Code Playgroud)

例如,在上述情况下,您的 API 可以允许用户将其电话号码重置为空(当他们丢失手机时),但不允许对电子邮件进行相同的操作。但是,尽管存在这种差异,这两个字段都将使用可空类型。这不是 API 的最佳设计。

根据我们自己的API的经验,我们可以得出结论,使用Optional<>进行修补会导致对API的理解混乱。几乎所有补丁属性都可以为空,即使它们补丁的对象并非如此。不过,值得一提的是,Optional<> 的问题并非源于 HotChocolate 实现,而是源于 graphql 规范该规范使用非常接近的逻辑定义了可选字段和可为空字段:

默认情况下,输入(例如字段参数)始终是可选的。但是,需要非空输入类型。除了不接受 null 值之外,它也不接受省略。为了简单起见,可空类型始终是可选的非空类型始终是必需的

如果可选值和空值完全分开,可能会更好。例如,规范可以将可选字段定义为可以省略的字段(而不考虑它是否可为空),反之亦然。这将允许在[可选,非可选][可空,不可空]之间进行“交叉连接” 。这样,我们就可以得到所有可能的组合,并且任何一种都可以有实际用途。例如,某些字段可能是可选的,但如果设置它们,则必须遵守它们的非空性。那将是可选的不可为空的字段。不幸的是,规范不允许我们立即获得该功能,但使用自己的解决方案很容易实现该功能。

在我们的生产就绪 API 中,由数十个突变组成,我们没有依赖Optional<>,而是定义了两种补丁类型:

public class SetValueInput<TValue>
{
    public TValue Value { get; set; }
}

public class SetNullableValueInput<T> where T : notnull
{
    public T? Value { get; set; }

    public static implicit operator SetValueInput<T?>?(SetNullableValueInput<T>? value) => value == null ? null : new() { Value = value.Value };
}
Run Code Online (Sandbox Code Playgroud)

我们所有的输入类型补丁字段都是通过该类型来表达的,例如:

public class UpdateUserInput
  {
        int Id { get; set; }
        
        public SetValueInput<string>? setEmail { get; set; }

        public SetValueInput<decimal?>? setSalary { get; set; }

        public SetNullableValueInput<string>? setPhone { get; set; }
  }
Run Code Online (Sandbox Code Playgroud)

一旦 patch 值被打包到 setXXX 对象中,我们就不再需要区分 null 和可选值。无论setXXX是否为null,其含义都是一样的:XXX字段没有补丁。

看看我们的示例输入类型,我们清楚地且没有任何类型系统放松地理解以下内容:

  1. setEmail 可以为 null,setEmail.Value 不能为 null = 可选的不可为空的电子邮件补丁。也就是说,如果 setEmail 字段为空或不存在也没关系 - 在这种情况下,我们的后端甚至不会尝试更新用户的电子邮件。但是,当 setEmail 不为 null 并且我们尝试将其值设置为 null 时 - graphql 类型系统将立即向我们显示错误,因为 setEmail 的字段“Value”被定义为不可为 null。
  2. setSalary 可以为 null,并且其值 = 工资的可选可空补丁。用户没有义务提供补丁来获取工资;即使他们提供,它也可以为空 - 例如,空值可能是用户隐藏其实际工资的方式。空工资将成功保存到后台数据库。
  3. setPhone - 与 setSalary 的逻辑相同。

对于 p。3 值得一提的是,SetNullableValueInput<string> 和 SetValueInput<string?> 之间没有逻辑差异。但是,从技术上讲,对于可空引用类型 T(SetValueInput<T> 泛型参数),我们必须定义一个单独的类SetNullableValueInput<T>,因为否则反射会错过有关该泛型参数可空性的信息。即使用 SetValueInput<string?> 我们最终得到由 HotChocolate 生成的不可为空(而不是可为空)字符串类型的值。尽管可空值类型不存在这样的问题 - SetValueInput<decimal> 和 SetValueInput<decimal?> 都将生成十进制值的正确可空性(在第一种情况下不可为空,在第二种情况下可空),因此可以使用安全。

继续我们的示例,我们可以在实体“用户”上有其他场景,但补丁逻辑存在一些差异。考虑:

public class CreateUserInput
  {            
        public SetValueInput<string>? setEmail { get; set; }

        public SetValueInput<decimal?> setSalary { get; set; }

        public SetValueInput<string> setPhone { get; set; }
  }
Run Code Online (Sandbox Code Playgroud)

在这里,对于创建用户管道,我们有:

  1. setEmail 可以被忽略 - 在这种情况下,例如,我们的后端可以分配默认电子邮件“{Guid.NewGuid()}@ourdomain.example.com”,但如果用户决定设置自己的电子邮件,他们有义务设置一些不可为空的值。
  2. setSalary 不为空 - 在创建帐户时,用户应该说出一些有关他的工资的信息。但是,他们可以通过将补丁对象的 value 字段设置为 null 来故意隐藏工资。在我们的 API 中,当我们没有明显的默认值时,我们在创建场景中使用不可为空的 SetValueInput 字段。例如,在当前情况下,我们可以允许 setSalary patch 可为空。然后,如果补丁对象为空,则为我们的数据库设置一些默认值,例如空或零。但由于我们不认为 null 或零是明显的默认值(至少为了示例),我们需要显式填充 setSalary 字段。
  3. setPhone - 我们既没有明显的默认值(如电子邮件),也不允许设置 null,因此具有不可为空值的不可为空补丁是一个明显的决定。

关于使用实体自动修补的最后一点 - 我们不这样做,更喜欢“手动”更新:

if (input.setEmail != null)
   user.Email = input.setEmail.Value;    
Run Code Online (Sandbox Code Playgroud)

但是,在该线程的其他答案中提出的具有反射的解决方案也可以轻松地针对 SetInputValue 模型实现。