Как создать частичное обновление GraphQL с помощью HotChocolate и EFCore

Я пытаюсь создать приложение ASP.NET Core 3.1 с использованием Entity Framework Core и Hot Chocolate. Приложение должно поддерживать создание, запрос, обновление и удаление объектов через GraphQL. Некоторые поля должны иметь значения.

Создание, запрос и удаление объектов не проблема, однако обновление объектов сложнее. Проблема, которую я пытаюсь решить, связана с частичными обновлениями.

Следующий объект модели используется 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; }
}

Я могу создать запись в базе данных с определением мутации примерно так:

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 для обновлений, я сталкиваюсь с двумя проблемами.

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

Добавляя это к мутации

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 класса.
  2. Метод Update должен иметь условный код для обновления каждого поля. Это, очевидно, не идеально.

Entity Framework можно использовать вместе с универсальным классом JsonPatchDocument<>. Это позволяет применять частичные обновления к сущности без необходимости настраивать код. Однако я изо всех сил пытаюсь найти способ объединить это с реализацией Hot Chocolate GraphQL.

Чтобы выполнить эту работу, я пытаюсь создать собственный InputObjectType, который ведет себя так, как будто свойства определены с помощью Optional<> и сопоставляются с типом CLR JsonPatchDocument<>. Это будет работать путем создания настраиваемых сопоставлений для каждого свойства в классе модели с помощью отражения. Однако я обнаружил, что некоторые свойства (IsOptional), которые определяют способ обработки запроса платформой, являются внутренними по отношению к платформе Hot Chocolate, и к ним нельзя получить доступ из переопределяемых методов в пользовательском классе.

Я также рассмотрел способы

  • Сопоставление Optional<> свойств UpdateClass с объектом JsonPatchDocument<>
  • Использование переплетения кода для создания класса с Optional<> версиями каждого свойства
  • Переопределение кода EF в первую очередь для обработки Optional<> свойств

Я ищу любые идеи относительно того, как я могу реализовать это, используя общий подход и избежать необходимости писать 3 отдельных блока кода для каждого типа, которые необходимо синхронизировать друг с другом.


person Alban    schedule 06.04.2020    source источник
comment
Мы решили это с помощью AutoMapper в graphql-dotnet, где вы можете получить аргумент в виде словаря, а затем сопоставить его с сущностью. Таким образом, непереданные свойства не будут в словаре и, следовательно, не будут сопоставлены, а для свойств, переданных со значением null, будет установлено значение null. Однако я не могу понять, как получить аргументы из IResolverContext в hotchoclate в качестве словаря.   -  person furier    schedule 24.08.2020


Ответы (3)


Я столкнулся с той же проблемой с Hot Chocolate, и у меня были огромные таблицы (в одной из них 129 столбцов), сопоставленные с объектами. Написание проверок каждого необязательного свойства каждой таблицы было бы слишком сложной задачей, поэтому, чтобы упростить задачу, мы написали общий вспомогательный метод ниже:

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

Надеюсь это поможет. Пожалуйста, отметьте это как ответ, если это так.

person Saad    schedule 09.09.2020
comment
Я получил что-то очень похожее, но более строго типизированное. См. ниже. - person Alban; 03.02.2021

Вы можете использовать Automapper или Mapster, чтобы игнорировать нулевые значения. Поэтому, если у вас есть нулевые значения в вашей модели, она не заменит существующие значения.

Здесь я использую Mapster.

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

Добавьте к этому в ваше MiddleWare

MapsterConfig.Config();
person BossJake    schedule 29.07.2020
comment
К сожалению, этот подход не решает основную проблему. Одно из требований состоит в том, что некоторые поля могут не иметь значений ‹null› при создании, но при обновлении полей они могут быть опущены. Кроме того, некоторые поля должны иметь возможность явно заполняться с помощью ‹null›. Пока что решение, над которым я работал, пока Hot Chocolate не поддержит это должным образом, представляет собой настраиваемый универсальный InputType ‹› в сочетании с TypeBuilder для создания класса с типизированными свойствами Optional ‹›, отражающими базовый тип. - person Alban; 30.07.2020

Это решение, к которому я пришел. Он также использует отражение, но я думаю, что для его оптимизации можно использовать некоторую 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 });
                    }
                }
            }
        }
    }
}
person Alban    schedule 03.02.2021