Как создать частичное обновление 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 для обновлений, у меня возникают 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;
}
Хотя это работает, у него есть несколько серьезных недостатков.
- Поскольку Enity Framework не понимает
Optional<>
общие типы, каждая модель потребует 2 класса - Метод Update должен иметь условный код для каждого обновляемого поля. Это, очевидно, не идеально.
Entity Framework можно использовать вместе с JsonPatchDocument<>
общий класс. Это позволяет применять частичные обновления к сущности без необходимости настраивать код. Однако я изо всех сил пытаюсь найти способ объединить это с реализацией Hot Chocolate GraphQL.
Чтобы выполнить эту работу, я пытаюсь создать настраиваемый InputObjectType, который ведет себя так, как будто свойства определены с использованием Optional<>
и сопоставляется с типом CLR JsonPatchDocument<>
. Это будет работать путем создания настраиваемых сопоставлений для каждого свойства в классе модели с помощью отражения. Однако я обнаружил, что некоторые свойства (IsOptional
), которые определяют способ обработки запроса платформой, являются внутренними для инфраструктуры Hot Chocolate и не могут быть доступны из переопределяемых методов в настраиваемом классе.
Я также рассмотрел способы
- Составление карты
Optional<>
свойства UpdateClass вJsonPatchDocument<>
объект - Использование переплетения кода для создания класса с
Optional<>
версии каждой собственности - Переопределение кода EF в первую очередь для обработки
Optional<>
свойства
Я ищу любые идеи относительно того, как я могу реализовать это с помощью общего подхода и избежать необходимости писать 3 отдельных блока кода для каждого типа, которые необходимо синхронизировать друг с другом.
4 ответа
Полагаться на Optional<>, предоставленный HotChocolate, вероятно, не лучшая идея. Рассмотрим случай, когда у пользователя есть поле, которое всегда должно быть не нулевым (пароль, логин и т.д.). Используя Optional<> для исправления этого поля, вы будете вынуждены ослабить требования к его типу во входных данных метода обновления, разрешив нулевое значение. Конечно, вы могли бы проверить это позже на этапе выполнения, но ваш API становится менее строго типизированным — теперь недостаточно посмотреть на систему типов, чтобы понять, разрешено ли field = null в качестве значения для исправления или нет. Таким образом, если вы хотите использовать Optional<> без ухудшения информативности и согласованности API, вы можете сделать это, только если все поля всех методов исправления API не допускают null в качестве допустимого значения исправления. Однако в подавляющем большинстве случаев это неверно. Почти всегда,
mutation
{
updateUser(input: {
id: 1
phone: null
email: null
}) {
result
}
}
Например, в приведенном выше случае ваш API может позволить пользователю сбросить свой номер телефона до нуля (когда он потерял свой мобильный телефон), но запретить то же самое для электронной почты. Но, несмотря на эту разницу, для обоих полей будет использоваться обнуляемый тип. Это не лучший дизайн API.
По опыту работы с собственным API, можно сделать вывод, что использование для патчинга Optional<> вызывает путаницу в понимании API. Почти все свойства исправлений становятся обнуляемыми, даже если это не относится к объекту, который они исправляют. Однако стоит упомянуть, что проблема с Optional<> коренится не в реализации HotChocolate, а в спецификации graphql , которая определяет необязательные поля и поля, допускающие значение NULL, с очень похожей логикой:
Входные данные (например, аргументы полей) по умолчанию всегда необязательны. Однако требуется ненулевой тип ввода. Помимо того, что он не принимает значение null, он также не допускает пропусков. Для простоты типы, допускающие значение NULL, всегда необязательны , а типы, отличные от NULL, всегда требуются.
Вероятно, было бы лучше, если бы необязательные и пустые значения были полностью разделены. Например, спецификация может определять необязательное поле как просто поле, которое можно опустить (и ничего о том, может ли оно принимать значение 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 };
}
И все наши поля патча типа ввода выражаются через эти типы, например:
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; }
}
Как только значение исправления упаковано в объект setXXX, нам больше не нужно различать нули и необязательные значения. Является ли setXXX нулевым или не представлено, это означает одно и то же: для поля XXX нет патча.
Глядя на наш пример ввода типа, мы четко и без всяких послаблений системы типов понимаем следующее:
- setEmail может быть нулевым, setEmail.Value не может быть нулевым = необязательный ненулевой патч электронной почты. Т.е. ничего страшного, если поле setEmail пустое или не представлено - в этом случае наш бэкенд даже не будет пытаться обновить электронную почту пользователя. Но, когда setEmail не равен нулю, и мы пытаемся установить значение null в его значение - система типов graphql немедленно покажет нам ошибку, потому что поле «Значение» setEmail определено как не допускающее значение null.
- setSalary может быть нулевым, а его значение = необязательный патч заработной платы, допускающий значение NULL. Пользователь не обязан предоставлять патч за зарплату; даже если они предоставляют, оно может быть нулевым - например, нулевое значение может быть способом, которым пользователь скрывает свою реальную зарплату. Нулевая зарплата будет успешно сохранена в серверной базе данных.
- setPhone — та же логика, что и для setSalary.
Для стр. 3 стоит отметить, что нет логической разницы между SetNullableValueInput<string> и SetValueInput<string?>. Но технически для обнуляемого ссылочного типа T — параметра универсального SetValueInput<T> мы должны определить отдельный класс SetNullableValueInput<T> , потому что в противном случае отражение пропускает информацию о допустимости значения null этого универсального параметра. Т.е. используя SetValueInput<string?>, мы в итоге получаем необнуляемый (вместо обнуляемого) строковый тип Value, сгенерированный HotChocolate. Хотя для типов значений, допускающих значение NULL, такой проблемы нет - как SetValueInput<decimal>, так и SetValueInput<decimal?> будут генерировать правильную допустимость значений NULL для decimal Value (не обнуляемое в первом случае и обнуляемое во втором) и, таким образом,
Продолжая наш пример, у нас могут быть другие сценарии для нашей сущности «Пользователь» с некоторыми отличиями в логике исправления. Учитывать:
public class CreateUserInput
{
public SetValueInput<string>? setEmail { get; set; }
public SetValueInput<decimal?> setSalary { get; set; }
public SetValueInput<string> setPhone { get; set; }
}
Здесь для создания пользовательского конвейера у нас есть:
- setEmail можно пропустить — в этом случае наш бэкэнд, например, может назначить адрес электронной почты по умолчанию «{Guid.NewGuid()}@ourdomain.example.com», но если пользователь решит установить свой собственный адрес электронной почты, они обязаны установить некоторое ненулевое значение.
- setSalary не равен нулю - при создании учетной записи пользователь должен сказать несколько слов о своей зарплате. Однако они могли намеренно скрыть зарплату, установив в поле значения объекта исправления значение null. В нашем API мы используем необнуляемые поля SetValueInput в сценариях создания, когда у нас нет для них очевидных значений по умолчанию. Например, в данном случае мы могли бы разрешить setSalary patch принимать значение NULL. Затем, если объект исправления имеет значение null, установите значение по умолчанию, например null или ноль, для нашей базы данных. Но так как мы не распознаем нуль или ноль как очевидное значение по умолчанию (по крайней мере, ради примера), мы требуем явного заполнения поля setSalary.
- setPhone — у нас нет очевидного значения по умолчанию (как в случае с электронной почтой), и мы не разрешаем устанавливать значение null, поэтому необнуляемый патч с необнуляемым значением — очевидное решение.
И последний пункт об использовании автоматического патчинга сущностей — мы так не делаем, предпочитая «ручные» обновления:
if (input.setEmail != null)
user.Email = input.setEmail.Value;
Но решения с отражением, предложенные в других ответах этой темы, могут быть легко реализованы и для модели SetInputValue.
Я столкнулся с той же проблемой с 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;
}
Надеюсь это поможет. Пожалуйста, отметьте это как ответ, если это так.
Вы можете использовать Automapper или Mapster, чтобы игнорировать нулевые значения. Поэтому, если у вас есть нулевые значения в вашей модели, она не заменит существующие значения.
Здесь я использую Mapster.
public class MapsterConfig
{
public static void Config()
{
TypeAdapterConfig<WarehouseUpdate , Warehouse>
.ForType()
.IgnoreNullValues(true);
}
}
Добавьте к этому в ваше MiddleWare
MapsterConfig.Config();
Это решение, с которым я столкнулся. Он также использует отражение, но я думаю, что для оптимизации этого можно использовать 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 });
}
}
}
}
}
}