EF Core: Ошибка со связанными данными, разница Include и LINQ expression
Entity Framework Core, .NET Core 3.1, PostgresPro
Есть примерно такая модель EF:
public class Location
{
[Key]
public int Id { get; set; }
[Required]
public string Name { get; set; }
public string Desc { get; set; }
public int? ParentLocId { get; set; }
public virtual Location ParentLoc { get; set; }
public virtual ICollection<Location> Children { get; set; }
}
Есть метод API, который должен возвращать Location по id.
[HttpGet]
public JsonResult Get(int id)
{
var data = (from loc in _db.Locations
where loc.Id == id
select new
{
Id = loc.Id,
Name = loc.Name,
Desc = loc.Desc,
ParentLoc = loc.ParentLoc.Name,
ParentLocId = loc.ParentLocId,
Children = loc.Children
}).FirstOrDefault();
return new JsonResult(data, _jsonOptions);
}
При запросе возвращается корректный JSON:
{"Id":1,"Name":"Loc1","Desc":"Desc1","ParentLoc":null,"ParentLocId":null,"Children":
[{"Id":2,"Name":"Loc2","Desc":"Desc2","ParentLocId":1,"ParentLoc":null,"Children":null},
{"Id":3,"Name":"Loc3","Desc":"Desc3","ParentLocId":1,"ParentLoc":null,"Children":null}]}
Однако мне показалось, что использование выражений LINQ может здесь быть и не нужно, и достаточно обойтись Include-ами. Я переписал метод на такой:
[HttpGet]
public JsonResult Get(int id)
{
var data = _db.Locations
.Include(loc => loc.ParentLoc).Include(loc => loc.Children)
.FirstOrDefault(loc => loc.Id == id);
return data;
}
В результате, при попытке сериализации в JSON, я стал получать подобную ошибку:
System.Text.Json.JsonException: "A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 64. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles."
Насколько я понимаю, Include() должен включить в запрос загрузку связанных данных (по указанному полю). А ошибка, насколько я понимаю, возникает, поскольку у меня в модели некоторые поля ссылаются на объекты той же модели (self-related) - возникает потенциальная возможность замкнутого цикла (ParentLoc ссылается на "родительскую" Location, у которой есть список Children "дочерних" Location).
Но Include(), если я правильно понимаю, не "заставляет" загруженные связанные объекты, в свою очередь, загружать связанные уже с ними объекты (для этого, ведь, и нужен ThenInclude()?). А если так - то я не вполне понимаю, в связи чем возникает ошибка?
Ну то есть, как я понимаю, это не ошибка как таковая (фактически), а предотвращение возможной ошибки со стороны сериализатора (т.е. можно настроить JsonSerializerOptions и объект успешно будет сериализован?). Или в каком-то месте действительно происходит зацикливание загрузки связанных данных? Почему оно не происходит при LINQ-запросе, хотя он так же загружает связанные данные для запрошенного объекта?
(При выбрасывании исключения я проверял объект data: данные в результате запроса загружены корректно, основной объект Location и два других объекта Location в списке Children, всё как должно быть - так же, как из через выражение LINQ).
В итоге, у меня несколько вопросов:
1
Почему указанный JsonException возникает при использовании Include(), но не возникает при использовании select new в LINQ? И правильно ли я описал ситуацию выше, или я чего-то концептуально не понимаю?
Как, в итоге, мне правильно использовать Include-ы для текущей задачи и что я упускаю, почему поведение разное при Include и через LINQ?
2
При использовании LINQ, как я понимаю, связанные данные загружаются жадно автоматически (в результате запроса в Children находятся полные объекты дочерних Location, со всеми их полями (кроме, в свою очередь, связанных - они не загружаются: ParentLoc, например, у Loc2 в JSON-представлении - null, да и в Children у Loc2 должна быть ещё Loc4, она тоже не загружается запросом).
Однако можно ли как-то управлять полями, которые будут получены при запросе от связанных объектов? То есть указать, чтобы для объектов Location в Children, например, загружались только Id и Name поля? (Я понимаю, что можно загрузить данные и затем уже преобразовать к нужному виду, чтобы метод возвращал то, что я хочу - но мне интересно, можно ли на LINQ сконструировать именно такой запрос (или, даже если это возможно, стоит ли подобным образом переусложнять запрос и код?)).
Ответы (2 шт):
Если верить первой ссылке в гугле по запросу "selectnew", то
LINQ возвращает коллекцию анонимных объектов в любом случае. select new давайте определим макет этого объекта и какие свойства/имена свойств включены в этот анонимный объект.
Вы также можете использовать select new ClassName { } для возврата списка экземпляров определенного вами класса сущностей.
Соответственно, select new возвращает анонимный тип, определяемый пользователем, а инклюды пытаются разрешить зависимости между объектами, натыкаются на циклические ссылки и вылетают, не зная, как составить корректный SQL запрос к БД.
В первом варианте возвращается динамически созданный объект анонимного типа, создаваемый через select new {}
Во втором случае возвращается объект класса Location, может уже самостоятельно установить ссылки у Children'ов и у ParentLoc'ов на связанные объекты.
Что лично мне кажется мне более правильным.
Чтобы удостовериться - поставьте точки останова перед return'нами и проверьте связи на ParentLoc.
Ошибки со стороны извлечения данных из БД тут нет, тут уже сериализатору нужно сообщить что цикличные ссылки - это ОК, и сообщить как их обработать.
ThenInclude() нужен для другого, чтобы тянуть связанные данные за другими связанными данными, например: БД.Табл1.Include(Ссылка1).ThenInclude(Ссылка2ЗаСсылкой1)
У вас же вообще всё ограничивается одной таблицей и одной ссылкой, просто EFCore добавил обратную ссылку (скорее всего, я вашу выдачу не вижу)
(т.е. можно настроить JsonSerializerOptions и объект успешно будет сериализован?)
Нужно, причем вы можете выбрать варианты:
Пример:
#r ".nuget/packages/newtonsoft.json/13.0.1/lib/netstandard2.0/Newtonsoft.Json.dll"
using Newtonsoft.Json;
class A
{
public int val = 1;
public B b1;
public B b2;
}
class B
{
public int val = 2;
public A parent;
}
A a = new A();
B b = new B();
b.parent = a;
a.b1 = b;
a.b2 = b;
Вариант 1: Отрезать циклические ссылки. Но тогда при обнаружении циклической ссылки в ветке сериализации на месте повторно найдеррного объекта вы получите null, а поле, содержащее null может даже не попасть в сериализацию, т.е. свойства ParentLoc у объектов коллекции Children вы даже не увидите в json-строке. А если в вашем объекте у вас есть 2 ссылки на один и тот-же объект - вы получите дубликат при сериализации, например бы в вашей коллекции ICollection Children были ссылки (2+) на один и тот-же Location - то при десериализации вы бы получили уже 2+ независимых объекта Location в коллекции Children по разным ссылкам. В Newtonsoft.Json это делается так:
JsonConvert.SerializeObject(a, Formatting.Indented,
new JsonSerializerSettings()
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
})
//вывод:
{
val: 1,
b1: {
val: 2
},
b2: {
val: 2
}
}
Вариант 2: Настроить сериализацию на сериализацию ссылок. Тогда к вашим объектам добавятся поля, обозначающую условную ссылку на данный объект (будет заведёны итератор, обозначающий ссылки), а к объектам, содержащим циклические ссылки, свойство/поле, содержащее ссылку, будет заменено на объект обозначающее ссылку на объект, который присутствует в json-строке, если кратко: $ref -> $id
В Newtonsoft.Json это делается так:
JsonConvert.SerializeObject(a, Formatting.Indented,
new JsonSerializerSettings()
{
ReferenceLoopHandling = ReferenceLoopHandling.Serialize,
PreserveReferencesHandling = PreserveReferencesHandling.Objects
})
//вывод:
{
$id: 1,
val: 1,
b1: {
$id: 2,
val: 2,
parent: {
$ref: 1
}
},
b2: {
$ref: 2
}
}
В System.Text.Json должны быть подобные механизмы, возможно они работают абсолютно идентично, только имеют другие имена, ознакомьтесь с JsonSerializerOptions https://docs.microsoft.com/ru-ru/dotnet/api/system.text.json.jsonserializeroptions?view=netcore-3.1