четверг, 28 мая 2009 г.

Влюбляемся в Entity Framework: Шаг пятый: Выполнение запросов и маппинг

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

За выполнение запросов в EF отвечает метод Execute уже знакомого нам класса ObjectQuery. В качестве аргумента метод Execute принимает параметр перечисления MergeOption. MergeOption определяет поведение кэша:
  • AppendOnly: Добавлять в кэш только новые записи. Существующие (в кэше) сущности не обновлять;
  • OverwriteChanges: Заменять текущие (current) значения существующих сущностей полученными значениями;
  • PreserveChanges: Заменять исходные (original) значения существующих сущностей полученными значениями. При этом текущие значения не изменяются, следовательно все изменения, внесенные до сих пор, остаются в силе;
  • NoTracking: Полученные значения не записываются в кэш
Трактовка значений MergeOption, данная выше, - перевод соответствующего раздела MSDN. Не считаю это описание интуитивно понятным, поэтому предлагаю разобраться с этим вопросом детальнее.

Рассмотрим небольшое тестовое приложение, которое демонстрирует, как значение MergeOption влияет на результат запроса:

using (FirstModel ctx = new FirstModel())
{
Person person = ctx.Persons.Where(p => p.PersonId == 1).First();

//После выполнения предыдущей строчки измените значение какого-нибудь свойства данной записи в базе вручную

ObjectQuery<person> personQuery = (ObjectQuery<person>)ctx.Persons.Where(p => p.PersonId == 1);

//Раскоментируйте поочередно нижележащие строчки и обратите внимание на возвращаемый результат
//Person person1 = personQuery.Execute(MergeOption.AppendOnly).First();
//person1.Person person2 = personQuery.Execute(MergeOption.OverwriteChanges).First();
//Person person3 = personQuery.Execute(MergeOption.PreserveChanges).First();
//Person person4 = personQuery.Execute(MergeOption.NoTracking).First();
}
Итак, после выполнения первой строчки у нас есть запись в кэше. Затем мы вручную меняем значение в базе и выполняем последующие запросы в четыре захода (для чистоты эксперимента).
  1. Полученное из базы значение игнорируется - возвращается значение из кэша;
  2. Текущие значения кэша подменяются значениями, полученными из базы - person2 содержит актуальные значения.
  3. Самый интересный случай. Как было описано выше, PreserveChanges лишь подменяет исходные значения, и не оказывает никакого влияния на текущие значения. Однако после выполнения теста в person3 будет содержаться актуальная информация из базы. Это связано с тем, что так как мы не производили никаких манипуляций с person, его текущие свойства не заданы. После выполнения запроса с PreserveChanges текущие значения останутся не заданными, поэтому в результате запроса будут возвращены исходные значения (которые к этому моменту уже были подменены на актуальные данные из базы). Если дополнить считывание person модификацией его свойств (например, если Вы планирует модифицировать в базе фамилию, достаточно дописать person.Surname = "AnySurname") - в person3 будут помещены текущие значения из кэша (в данном случае Surname будет равен AnySurname), а PreserveChanges, как ему и полагается, подменит лишь исходные значения.
  4. В случае NoTracking данные в кэш не заносятся и из кэша не считываются: person4 всегда будет содержать актуальные данные из базы.
Помимо явного вызова метода Execute, в EF есть ряд методов, делающих соответствующий вызов неявно (при этом в качестве MergeOption используется значение AppendOnly). Так, выполнение запроса происходит при вызове метода ObjectQuery.GetEnumenator, который (опять же неявно) происходит при использовании конструкции foreach, а также при вызове некоторых методов-расширений (например, ToList, ToArray и др.). Выполнение запроса произойдет и при вызове ряда других (не связанных с GetEnumenator) методов-расширений, таких как First/FirstOrDefault, Last/LastOfDefault и др.

Маппинг
Вот мы и добрались до самой "вкусной" возможности любой ORM-системы - до маппинга. К счастью, на эту тему уже написано достаточно статей, и повторяться я не буду. Я в очередной раз сошлюсь на статьи Сергея Розовика (раз, два), а опишу лишь маппинг Table splitting, которого в указанных статьях нет.

Table splitting
Представьте, что у Вас есть таблица, которая помимо относительно легких атрибутов содержит и тяжелые, вроде varbinary(max) или filestream (на примере SQL Server), причем в большинстве случаев требуются лишь легкие атрибуты:
  • Если мы храним в varchar(max) фотографию товара в высоком разрешении, то она понадобится лишь в тех редких случаях, когда пользователь интернет-магазина кликнет "посмотреть в полном размере";
  • Если мы храним в filestream содержимое файла библиотеки, то значение этого атрибута понадобится лишь при скачивании файла; во всех остальных случаях необходимы лишь описывающие файл атрибуты: имя, размер, дата загрузки и т. д.;
  • и т. д.
К нам на помощь приходит маппинг Table Splitting, который позволяет разделить работу с одной таблицей на несколько сущностей.

Добавим в таблицу Persons нашей базы данных (бэкап которой можно скачать в прикрепленном ко второй части файле) поле Photo типа varbinary(max). Для того чтобы не скачивать с сервера полуторамегабайтный jpeg каждый раз, когда нам нужна информация о пользователе, реализуем Table Splitting:
  1. В дизайнере скопируем и вставим сущность Person
  2. Переименуем полученную сущность в PersonPhoto (а ее Entity Set - в PersonPhotos) и в Mapping Details зададим Maps To Persons.
  3. Удалим из сущности Person свойство Photo, а из сущности PersonPhoto - свойства Name и Surname
  4. Добавим связь 1:1 между Person и PersonPhoto (правой кнопкой в окне дизайнера -> Add -> Association); затем выделим связь -> Mapping Details -> выберем Maps to Persons)
Сверим модель:
, Mapping Details сущности PersonPhoto:
и маппинг созданной связи:
Если сейчас попробовать сбилдить проект, в результате получим ошибку "Each of the following columns in table Persons is mapped to multiple conceptual side properties: Persons.PersonId is mapped to".

Для того чтобы избавиться от ошибки, необходимо создать ReferentialConstraint, описывающий связь между PersonId сущностей Person и PersonPhoto. К сожалению, дизайнер в EF v. 1 этой возможности не поддерживает, поэтому закроем дизайнер -> щелкнем правой кнопкой по FirstModel.edmx в Solution Explorer -> Open With... -> XML Editor... -> в разделе CSDL найдем определение ассоциации PersonPersonPhoto:

<Association Name="PersonPersonPhoto">
<End Type="FirstModelModel.Person" Role="Person" Multiplicity="1" />
<End Type="FirstModelModel.PersonPhoto" Role="PersonPhoto" Multiplicity="1" />
</Association>
и добавим в него ReferentialConstraint:

<Association Name="PersonPersonPhoto">
<End Type="FirstModelModel.Person" Role="Person" Multiplicity="1" />
<End Type="FirstModelModel.PersonPhoto" Role="PersonPhoto" Multiplicity="1" />
<ReferentialConstraint>
<Principal Role="Person">
<PropertyRef Name="PersonId" />
</Principal>
<Dependent Role="PersonPhoto">
<PropertyRef Name="PersonId" />
</Dependent>
</ReferentialConstraint>
</Association>
Вот теперь нас поджидает successful build.

Воспользуемся созданным маппингом и добавим запись в таблицу:

using (FirstModel ctx = new FirstModel())
{
Person person = new Person();
person.Name = "X";
person.Surname = "Y";

PersonPhoto personPhoto = new PersonPhoto();
personPhoto.Photo = new byte[] { 1, 2, 3, 4, 5 };

person.PersonPhoto = personPhoto;

ctx.AddToPersons(person);

ctx.SaveChanges();
}
При этом будет сгенерирован один INSERT-запрос, в котором задаются все поля (в том числе и Photo), а это значит, что маппинг работает. Правда, есть один нюанс. Так, если при создании сущности необходимо оставить свойству Photo значение NULL, нельзя просто не задавать его, как это обычно делается со скалярынми свойствами. Загвоздка в том, что таблицы Person и PersonPhoto связаны 1:1, а связать их 1:0..1 невозможно, ибо связывается первичный ключ одной и той же таблицы. Поэтому необходимо явно присвоить свойству Photo экземпляр класса PersonPhoto:

using (FirstModel ctx = new FirstModel())
{
Person person = new Person();
person.Name = "X";
person.Surname = "Y";

person.PersonPhoto = new PersonPhoto();

ctx.AddToPersons(person);

ctx.SaveChanges();
}
Считывать значения PersonPhoto можно следующими способами:
  • можно считать непосредственно PersonPhoto (для него создан свой EntitySet), отобрав записи по PersonId;
  • можно считать Person, а затем при помощи defered-loading подгрузить навигационное свойство PersonPhoto;
  • а можно, считывая Person, загрузить навигационное свойство PersonPhoto при помощи eager-loading:

using (FirstModel ctx = new FirstModel())
{
List<Person> persons = ctx.Persons.Include("PersonPhoto").ToList();

...
}
Сгенерированный в этом случае запрос радует глаз: вся загрузка сводится к одному простенькому SELECT'у (точно такому, какой бы Вы написали вручную).

Заключение
В мои планы входило описание еще одного редко встречающегося типа маппинга - Table per concrete type (TPC). Однако столкнувшись с рядом ограничений, а также с несовместимостью Table splitting и TPC, отказался от этой идеи. Надеюсь, в следующей версии реализация TPC будет улучшена.

На этом посте данный цикл статей будет временно заморожен. Хотя некоторое время спустя я, наверное, к нему вернусь. В ближайшее же время Вас ждет новый цикл статей, который (уж не знаю, к счастью или к сожалению) опять-таки будет посвящен Entity Framework. До скорых встреч!

9 комментариев:

Анонимный комментирует...

Можете подсказать?
У меня есть 2 сущности, скажем Документ и ДокументТабличнаяЧасть
В сущности Документ есть ключевое поле ДокументID, а в сущности ДокументТабличнаяЧасть ДокументID и ТоварID. Как их правильно связать?
1 Документ по идее может иметь 0...1 Строк в табличной части

Idsa комментирует...

Нужно просто связать при помощи FK атрибуты ДокументID двух таблиц. Никаких хитростей.

А вообще, с такими вопросами лучше обращайтесь на форумы, например, на Винград или RSDN.

Анонимный комментирует...

Спасибо за ссылки, но хетелось спросить именно у Вас!

Нельзя ли связывать таблицы без создания FK в БД?

Спасибо!

Idsa комментирует...

>>Спасибо за ссылки, но хетелось >>спросить именно у Вас!

На Винграде я модерирую разделы Базы данных под .NET и LINQ, так что подобные вопросы регулярно просматриваю. Прелесть форума в том, что там Вам смогут помочь оперативнее и шире.

>>Нельзя ли связывать таблицы без >>создания FK в БД?

Связать можно, щелкнув правой кнопкой в дизайнере -> Add -> Association. Но вот только зачем? Например, без FK удалять дочерние записи можно лишь при помощи тригера (а не простым выставлением Delete action = Cascade). Что мешает создать FK?

Анонимный комментирует...

как можно после этого влюбиться в EF? все через одно место :(

Idsa комментирует...

Аноним, нюансы со сложными маппингами есть во всех ORM (по крайней мере среди тех, которые мне приходилось использовать). Если Вам в целом не нравится EF, - есть масса альтернатив, не мучайте себя :)

build_your_web комментирует...

за defered-loading спасибо, не знал.

да и вобще за весь цикл спасибо, с большим интересом все прочитал разом.

build_your_web комментирует...

Про "зачем связывание без FK"
- у меня как раз такой случай.

Нужно расширить основной модуль дополнительным так, чтобы не влиять на работу первого.
Т.е. FK я создавать уже не могу.

Анонимный комментирует...

Спасибо за отличную серию статей! А можешь перезалить бэкап БД? Спасибо! :)