简体   繁体   English

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

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

I am trying to create an ASP.NET Core 3.1 application using Entity Framework Core and Hot Chocolate.我正在尝试使用 Entity Framework Core 和 Hot Chocolate 创建一个 ASP.NET Core 3.1 应用程序。 The application needs to support creating, querying, updating and deleting objects through GraphQL.应用程序需要通过 GraphQL 支持创建、查询、更新和删除对象。 Some fields are required to have values.有些字段需要有值。

Creating, Querying and Deleting objects is not a problem, however updating objects is more tricky.创建、查询和删除对象不是问题,但是更新对象比较棘手。 The issue that I am trying to resolve is that of partial updates.我要解决的问题是部分更新。

The following model object is used by Entity Framework to create the database table through code first.下面的model object是Entity Framework通过代码先创建数据库表的。

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; }
}

I can create an record in the database with a mutation defined something like this:我可以在数据库中创建一条记录,其突变定义如下:

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);
            });
    }
}

At the moment, the objects are small, but they will have far more fields before the project is finished.目前,对象很小,但在项目完成之前,它们将拥有更多的领域。 I need to leaverage the power of GraphQL to only send data for those fields that have changed, however if I use the same InputObjectType for updates, I encounter 2 problems.我需要保留 GraphQL 的强大功能,只为那些已更改的字段发送数据,但是如果我使用相同的 InputObjectType 进行更新,我会遇到 2 个问题。

  1. The update must include all "Required" fields.更新必须包括所有“必填”字段。
  2. The update tries to set all non-provided values to their default.此更新尝试将所有未提供的值设置为其默认值。

The avoid this issue I have looked at the Optional<> generic type provided by HotChocolate.为了避免这个问题,我查看了 HotChocolate 提供的Optional<>泛型类型。 This requires defining a new "Update" type like the following这需要定义一个新的“更新”类型,如下所示

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; }
}

Adding this to the mutation将此添加到突变中

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);
            });

The UpdateWarehouse method then needs to update only those fields that have been provided with a value.然后,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;
}

While this works, it does have a couple of major downsides.虽然这有效,但它确实有几个主要缺点。

  1. Because Enity Framework does not understand the Optional<> generic types, every model will require 2 classes因为 Enity Framework 不理解Optional<>泛型类型,所以每个 model 都需要 2 个类
  2. The Update method needs to have conditional code for every field to be updated This is obviously not ideal. Update 方法需要对每个要更新的字段都有条件代码,这显然是不理想的。

Entity Framework can be used along with the JsonPatchDocument<> generic class.实体框架可以与JsonPatchDocument<>通用 class 一起使用。 This allows partial updates to be applied to an entity without requiring custom code.这允许将部分更新应用于实体而无需自定义代码。 However I am struggling to find a way of combining this with the Hot Chocolate GraphQL implemention.但是,我正在努力寻找一种将其与 Hot Chocolate GraphQL 实现相结合的方法。

In order to make this work I am trying to create a custom InputObjectType that behaves as if the properties are defined using Optional<> and maps to a CLR type of JsonPatchDocument<> .为了完成这项工作,我正在尝试创建一个自定义 InputObjectType,其行为就像使用Optional<>定义属性并映射到JsonPatchDocument<>的 CLR 类型一样。 This would work by creating custom mappings for every property in the model class with the help of reflection.这将通过在反射的帮助下为 model class 中的每个属性创建自定义映射来工作。 I am finding however that some of the properties ( IsOptional ) that define the way the framework processes the request are internal to the Hot Chocolate framework and cannot be accessed from the overridable methods in the custom class.但是,我发现定义框架处理请求方式的某些属性 ( IsOptional ) 是 Hot Chocolate 框架的内部属性,并且无法从自定义 class 中的可覆盖方法访问。

I have also considered ways of我也考虑过方法

  • Mapping the Optional<> properties of the UpdateClass into a JsonPatchDocument<> object将 UpdateClass 的Optional<>属性映射到JsonPatchDocument<> object
  • Using code weaving to generate a class with Optional<> versions of every property使用代码编织生成一个 class ,每个属性的Optional<>版本
  • Overriding EF Code first to handle Optional<> properties首先覆盖 EF 代码以处理Optional<>属性

I am looking for any ideas as to how I can implement this using a generic approach and avoid needing to write 3 separate code blocks for each type - which need to be kept in sync with each other.我正在寻找有关如何使用通用方法来实现这一点的任何想法,并避免需要为每种类型编写 3 个单独的代码块——这些代码块需要彼此保持同步。

I ran into the same problem with Hot Chocolate and have huge tables (one of them has 129 columns) mapped to the objects.我在使用 Hot Chocolate 时遇到了同样的问题,并且将巨大的表(其中一个有 129 列)映射到对象。 Writing if checks for each optional property of each table would be too much pain so, have written a generic helper method below to make it easier:为每个表的每个可选属性编写 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);
            }
        }
    }
}

Then, in your example just call it like below and reuse it for all other entity update mutations:然后,在您的示例中,只需像下面这样调用它,并将其重用于所有其他实体更新突变:

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;
}

Hope this helps.希望这可以帮助。 Please mark it as answer if it does.如果有,请将其标记为答案。

You can use Automapper or Mapster to ignore null values.您可以使用 Automapper 或 Mapster 忽略 null 值。 So if you have null values in your model, it will not replace the existing values.因此,如果 model 中有 null 值,它不会替换现有值。

Here, I'm using Mapster.在这里,我正在使用 Mapster。

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

Add to this to your MiddleWare将此添加到您的中间件

MapsterConfig.Config();

This is the solution I ended up with.这是我最终得到的解决方案。 It also uses reflection, but I think it may be possible to use some JIT compilation to optimise this.它也使用反射,但我认为可以使用一些 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 });
                    }
                }
            }
        }
    }
}

Relying on Optional<> provided by HotChocolate is probably not the best idea.依赖 HotChocolate 提供的 Optional<> 可能不是最好的主意。 Consider a case when a user has a field that is always supposed to be not null (password, login, etc.).考虑这样一种情况:用户的字段始终不应该是 null(密码、登录名等)。 Using Optional<> to patch that field, you will be forced to relax its type requirements in your update method input, allowing a null value.使用 Optional<> 修补该字段,您将被迫在更新方法输入中放宽其类型要求,允许 null 值。 Of course, you could verify that later on in the execution stage, but your API becomes less strongly typed - now it's not enough to look at the type system to understand if field = null is allowed as a value for patching or not.当然,您可以稍后在执行阶段验证这一点,但是您的 API 的类型变得不那么强 - 现在仅查看类型系统以了解 field = null 是否允许作为修补值是不够的。 So, if you want to use Optional<> without degrading API self-descriptiveness and consistency, you can do that only if all fields of all patch methods of the API don't allow null as a valid patch value.因此,如果您想在不降低 API 自描述性和一致性的情况下使用 Optional<>,则只有在 API 的所有补丁方法的所有字段都不允许 Z37A6259CC0C1DAE299A786Z9 为有效补丁值 649 时,才可以这样做。 However, that's false in the vast majority of cases.然而,在绝大多数情况下这是错误的。 Almost always, there's a situation in your API when you need to allow a user to reset some field to null.当您需要允许用户将某些字段重置为 null 时,几乎总是在您的 API 中出现这种情况。

mutation
{ 
 updateUser(input: {
  id: 1 
  phone: null
  email: null
 }) {
  result
 }
}

For example, in the above case, your API can allow the user to reset their phone number to null (when they have lost their mobile phone) but disallow the same for the email.例如,在上述情况下,您的 API 可以允许用户将他们的电话号码重置为 null(当他们丢失手机时),但不允许 email 这样做。 But, despite that difference, for both fields the nullable type will be used.但是,尽管存在这种差异,但对于这两个字段,都将使用可为空的类型。 That's not the best design of the API.这不是 API 的最佳设计。

According to the experience with our own API, we can conclude that using Optional<> for patching causes a mess in understanding the API.根据我们自己的 API 的经验,我们可以得出结论,使用 Optional<> 进行修补会导致对 API 的理解混乱。 Almost all patch properties become nullable, even if that's not the case for the object they patch.几乎所有补丁属性都可以为空,即使他们补丁的 object 不是这种情况。 It's worthwhile to mention, though, that issue with Optional<> is rooted not in the HotChocolate implementation but in the graphql spec , which defines optional and nullable fields with the very close logic:不过,值得一提的是, Optional<> 的问题不是源于 HotChocolate 实现,而是源于 graphql规范,该规范使用非常接近的逻辑定义了可选和可为空的字段:

Inputs (such as field arguments), are always optional by default.默认情况下,输入(例如字段参数)始终是可选的。 However a non-null input type is required.但是,需要非空输入类型。 In addition to not accepting the value null, it also does not accept omission.除了不接受 null 的值外,也不接受遗漏。 For the sake of simplicity nullable types are always optional and non-null types are always required .为简单起见,可空类型始终是可选的,非空类型始终是必需的

Probably it would be better if optionals and nulls were completely separated.如果可选项和空值完全分开,可能会更好。 For example, the spec could define an optional field as just the field that can be omitted (and nothing about whether it's nullable or not) and vice versa.例如,规范可以将一个可选字段定义为可以省略的字段(而不管它是否可以为空),反之亦然。 That would allow making "cross-join" between [optional, non-optional] and [nullable, non-nullable] .这将允许在[optional, non-optional][nullable, non-nullable]之间进行“交叉连接”。 In that way, we could get all possible combinations, and any one could have a practical use.这样,我们就可以得到所有可能的组合,任何一种组合都可以有实际用途。 For example, some fields could be optional, but if you set them, you must conform to their non-nullability.例如,某些字段可能是可选的,但如果设置它们,则必须符合它们的不可为空性。 That would be optional non-nullable fields.那将是可选的不可为空的字段。 Unfortunately, the spec doesn't allow us to get that functionality out-of-the-box, but it's quite easy to achieve that with the own solution.不幸的是,规范不允许我们开箱即用地获得该功能,但使用自己的解决方案很容易实现。

In our production-ready API, consisting of dozens of mutations, instead of relying on Optional<>, we have just defined two patch types:在我们生产就绪的 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 };
}

And all our input type patch fields are expressed through that types, for instance:我们所有的输入类型补丁字段都是通过这些类型表示的,例如:

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; }
  }

Once the patch value is packed into setXXX object, we no longer need to distinguish nulls and optionals.一旦补丁值被打包到 setXXX object 中,我们就不再需要区分空值和可选项了。 Whether setXXX is null or not presented, it means the same: there's no patch for the field XXX. setXXX 是否为 null 的意思是一样的:字段 XXX 没有补丁。

Looking at our example input type, we clearly and without any type system relaxations, understand the following:查看我们的示例输入类型,我们清楚且没有任何类型系统松弛,了解以下内容:

  1. setEmail can be null, setEmail.Value cannot be null = optional non-nullable patch of email. setEmail 可以是 null,setEmail.Value 不能是 null = email 的可选不可空补丁。 Ie it is okay if the field setEmail is null or not presented - in that case our backend will not even try to update the user's email.即,如果字段 setEmail 是 null 或未显示,那也没关系 - 在这种情况下,我们的后端甚至不会尝试更新用户的 email。 But, when setEmail is not null and we try to set null to its value - the graphql type system will immediately show us the error because the field "Value" of setEmail is defined as not nullable.但是,当 setEmail 不是 null 并且我们尝试将 null 设置为其值时 - graphql 类型系统将立即向我们显示错误,因为 setE 的字段“Value”被定义为不可空。
  2. setSalary can be null as well as its value = optional nullable patch of salary. setSalary 可以是 null 以及它的值 = 可选的可为空的工资补丁。 A user is not obliged to provide the patch for salary;用户没有义务提供工资补丁; even if they provide, it can be null - for example, the null value might be the way the user hides his actual salary.即使他们提供,也可以是 null - 例如,null 值可能是用户隐藏其实际工资的方式。 The null salary will be successfully saved to the backend database. null 工资将成功保存到后端数据库。
  3. setPhone - the same logic as for setSalary. setPhone - 与 setSalary 的逻辑相同。

For p.对于 p。 3 it's worthwhile to mention that there's no logical difference between SetNullableValueInput<string> and SetValueInput<string?>. 3 值得一提的是,SetNullableValueInput<string> 和 SetValueInput<string?> 之间没有逻辑上的区别。 But, technically, for nullable reference type T - the parameter of SetValueInput<T> generic, we have to define a separate class SetNullableValueInput<T> because, otherwise, the reflection misses the information about the nullability of that generic parameter.但是,从技术上讲,对于可为空的引用类型 T - SetValueInput<T> 泛型的参数,我们必须定义一个单独的 class SetNullableValueInput<T>因为,否则,反射会丢失有关该泛型参数的可空性的信息。 Ie using SetValueInput<string?> we end up getting a non-nullable (instead of nullable) string type of Value generated by HotChocolate.即使用 SetValueInput<string?> 我们最终得到由 HotChocolate 生成的不可空(而不是可空)字符串类型的值。 Though there's no such problem for nullable value types - both SetValueInput<decimal> and SetValueInput<decimal?> will generate the correct nullability of decimal Value (non-nullable in the first case and nullable in the second) and, thus, can be used safely.虽然对于可空值类型没有这样的问题 - SetValueInput<decimal> 和 SetValueInput<decimal?> 都会生成正确的小数值可空性(在第一种情况下不可为空,在第二种情况下可以为空),因此可以使用安全。

Continuing our example, we could have other scenarios over our entity "User" with some differences in the patch logic.继续我们的示例,我们可以在实体“用户”上使用其他场景,但补丁逻辑存在一些差异。 Consider:考虑:

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

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

        public SetValueInput<string> setPhone { get; set; }
  }

Here, for the create user pipeline, we have:在这里,对于创建用户管道,我们有:

  1. setEmail is allowed to be missed - in that case, our backend, for example, could assign the default email "{Guid.NewGuid()}@ourdomain.example.com", but if the user decides to set their own email, they are obliged to set some non-nullable value.允许错过 setEmail - 在这种情况下,例如,我们的后端可以分配默认的 email "{Guid.NewGuid()}@ourdomain.example.com",但如果用户决定设置自己的 email,他们必须设置一些不可为空的值。
  2. setSalary is not null - on creating the account, the user is supposed to say some words about his salary. setSalary 不是 null - 在创建帐户时,用户应该说一些关于他的薪水的话。 However, they could intentionally hide the salary by setting the value field of the patch object to null.但是,他们可以通过将补丁 object 的值字段设置为 null 来故意隐藏薪水。 In our API we use non-nullable SetValueInput fields on the creation scenarios when we don't have the obvious defaults for them.在我们的 API 中,当我们没有明显的默认值时,我们在创建场景中使用不可为空的 SetValueInput 字段。 For example, in the current case, we could allow setSalary patch to be nullable.例如,在当前情况下,我们可以允许 setSalary 补丁可以为空。 Then, if the patch object is null, set some default value like null or zero to our database.然后,如果补丁 object 是 null,则为我们的数据库设置一些默认值,例如 null 或零。 But since we don't recognize null or zero as an obvious default (at least for the sake of the example), we require to fill the setSalary field explicitly.但是由于我们不承认 null 或零作为明显的默认值(至少对于示例而言),我们需要明确填写 setSalary 字段。
  3. setPhone - neither we have an obvious default (like with the email) nor we allow to set null, so non-nullable patch with non-nullable value is an obvious decision. setPhone - 我们既没有明显的默认值(如电子邮件),也不允许设置 null,因此具有不可空值的不可空补丁是一个明显的决定。

And the last point about using the automatic patching of the entities - we don't do so, preferring "manual" updates:最后一点关于使用实体的自动修补 - 我们不这样做,更喜欢“手动”更新:

if (input.setEmail != null)
   user.Email = input.setEmail.Value;    

But the solutions with the reflection proposed in other answers of this thread could be easily implemented for the SetInputValue model as well.但是对于 SetInputValue model 也可以轻松地实现在该线程的其他答案中提出的反射解决方案。

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM