Search code examples
c#graphqlentity-framework-corejson-patchhotchocolate

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. The application needs to support creating, querying, updating and deleting objects through 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.

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.

  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. 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.

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
  2. The Update method needs to have conditional code for every field to be updated This is obviously not ideal.

Entity Framework can be used along with the JsonPatchDocument<> generic 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.

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<>. This would work by creating custom mappings for every property in the model class with the help of reflection. 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.

I have also considered ways of

  • Mapping the Optional<> properties of the UpdateClass into a JsonPatchDocument<> object
  • Using code weaving to generate a class with Optional<> versions of every property
  • Overriding EF Code first to handle Optional<> properties

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.


Solution

  • Relying on Optional<> provided by HotChocolate is probably not the best idea. Consider a case when a user has a field that is always supposed to be not null (password, login, etc.). Using Optional<> to patch that field, you will be forced to relax its type requirements in your update method input, allowing a null value. 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. 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. 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.

    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. But, despite that difference, for both fields the nullable type will be used. That's not the best design of the API.

    According to the experience with our own API, we can conclude that using Optional<> for patching causes a mess in understanding the API. Almost all patch properties become nullable, even if that's not the case for the object they patch. 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:

    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. 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]. 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:

    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. Whether setXXX is null or not presented, it means the same: there's no patch for the field 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. I.e. 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. 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.
    2. setSalary can be null as well as its value = optional nullable patch of salary. 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. The null salary will be successfully saved to the backend database.
    3. setPhone - the same logic as for setSalary.

    For p. 3 it's worthwhile to mention that there's no logical difference between SetNullableValueInput<string> and 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. I.e. using SetValueInput<string?> we end up getting a non-nullable (instead of nullable) string type of Value generated by 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.

    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.
    2. setSalary is not null - on creating the account, the user is supposed to say some words about his salary. However, they could intentionally hide the salary by setting the value field of the patch object to null. In our API we use non-nullable SetValueInput fields on the creation scenarios when we don't have the obvious defaults for them. For example, in the current case, we could allow setSalary patch to be nullable. Then, if the patch object is null, set some default value like null or zero to our database. 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.
    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.

    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.