вторник, января 29, 2008

Entity Framework - mapping (часть 1)

В прошлых статьях я рассказывал про EDM и теперь приступаю к наиболее интересной ее части Mapping Specification. С того момента, как я взялся за описание этой темы, количество материала непрерывно увеличивалось, и сегодня я понял, что в один пост это все не влезет. Возможности маппинга в EF обширны. Я буду рассматривать их от простых к более сложным на конкретных примерах. Сегодня мы рассмотрим мапинг класса на несколько таблиц, маппинг ассоциаций и варианты маппинга при наследовании классов.

Один класс - одна таблица


Не рассматриваю данный вариант, во-первых, в виду его очевидности, во-вторых, он является подмножеством варианта «один класс – несколько таблиц», который мы рассмотрим более подробно.

Один класс - несколько таблиц


В маппинг EDM сущности (здесь и далее сущность и класс - это синонимы) изначально заложена такая возможность. Рассмотрим БД, в которой две таблицы БД Contact и Person:

В таблице Contact хранятся контактные данные людей. Для некоторых из них хранится дополнительная информация в таблице Person. Таблицы связаны отношением один к одному по первичным ключам. Наша задача, получить EDM сущность Person, данные которой хранятся в этих двух таблицах.
Для начала создадим EDM по данной БД при помощи визарда VS2008.

Согласитесь, получилось не совсем то, что надо, вернее, совсем не то. Нам не нужна ссылка на Contact из Person. Нам надо, чтобы все поля Contact были в Person. Поэтому, удаляем Contact из модели, а в Person добавляем Scalar Properties: FirstName, LastName, Email, Phone. Теперь переходим к маппингу, кликнув в контекстном меню дизайнера команду “Mapping details”, и выделив Person на диаграмме:

То же самое, но в Xml выглядит вот так:


<EntitySetMapping Name="Person">
<EntityTypeMapping TypeName="IsTypeOf(test1Model.Person)">
<MappingFragment StoreEntitySet="Person">
<ScalarProperty Name="PersonId" ColumnName="PersonId" />
<ScalarProperty Name="BirthDate" ColumnName="BirthDate" />
<ScalarProperty Name="Address" ColumnName="Address" />
</MappingFragment>
</EntityTypeMapping>
</EntitySetMapping>


“Maps to Person” в дизайнере соответствует Mapping Fragment в Xml описании EDM. Мапппинг любой сущности (EntityType) может состоять из нескольких фрагментов. Каждый фрагмент – это одна таблица в БД. В данный момент у нас выполнен маппинг только свойств, соответствующих таблице Person. Чтобы выполнить маппинг свойств добавленных в ручную жмем в дизайнере <add> и выбираем таблицу Contact. Дизайнер добавляет новый фрагмент и автоматически устанавливает соответствие между колонками таблицы и свойствами сущности, ориентируясь при этом на имена и типы данных:

Дизайнер все сделал замечательно, но у нас осталась без соответствия колонка Contact.ContactId. Устанавливаем ему в соответствие свойство PersonId, которое является ключом сущности Person. Все, теперь у нас данные сущности Person будут храниться в таблицах Person и Contact. Если вы что-то сделали не так, и вам надо удалить фрагмент маппинг свойства или фрагмент маппинга (таблицу) не ломайте кнопку Del на клавиатуре, не поможет. Для удаления элемента в дизайнере маппинга нужно в его выпадающем списке выбрать опцию <delete>. В первый раз я потратил на это пять минут :)

Итак, для создания описанного типа маппинга «один класс - несколько таблиц», при котором данные экземпляра сущности «размазаны» по нескольким таблицам, необходимым условием является наличие первичных ключей во всех участвующих таблицах, и наличие отношения «один к одному» между ними (причем наличие в БД явно прописанных foreign keys не обязательно). Если теперь выполнить объектный запрос к EntitySet Person, то SQL трассировщик покажет нам следующий SQL запрос сформированный EF:


SELECT
[Extent1].[PersonId] AS [PersonId],
[Extent1].[BirthDate] AS [BirthDate],
[Extent1].[Address] AS [Address],
[Extent2].[FirstName] AS [FirstName],
[Extent2].[LastName] AS [LastName],
[Extent2].[Email] AS [Email],
[Extent2].[Phone] AS [Phone]
FROM [dbo].[Person] AS [Extent1]
INNER JOIN [dbo].[Contact] AS [Extent2] ON [Extent1].[PersonId] = [Extent2].[ContactId]


Обратите внимание на INNER JOIN. Он означает, что в выборку могут попасть не все записи таблицы Contact. Если вы помните, то в БД таблица Person содержит foreign key constraint на таблицу Contact. Это означает, что любой записи Person соответствует запись Contact. Однако обратное утверждение не верно, и в таблице Contact могут быть записи, для которых нет соответствия в таблице Person. Объектным запросом к EntitySet Person мы эти записи выбрать не сможем. Имейте это в виду.

Наследование, или одна таблица - несколько классов (TPH)


TPH – это Table per Hierarchy. Таким термином в EF окрестили вариант маппинга, когда данные нескольких сущностей хранятся в одной таблице БД, а сущности (или классы) представляют собой иерархию, связанную отношением наследования. Звучит довольно сложно, но выглядит все очень просто. Рассмотрим пример, в котором снова используем многострадальную таблицу Person.


Теперь мы собираемся хранить в таблице Person данные о студентах и инструкторах. Id является первичным ключом таблицы, колонки FirstName, LastName, Email - общие для студентов и инструкторов. Для студентов также храним дату зачисления - EnrollmentDate, а для инструкторов дату приема HireDate, размер зарплаты Salary и ссылку на подразделение в котором числится инструктор - DepartmentId. Я специально не стал делать foreign key constraint на поле DepartmentId, чтобы показать, как EF обходится без них при создании ассоциаций сущностей. И, наконец, в колонке PersonKind мы будем хранить 0 для студентов и 1 для инструкторов. Этой колонке предстоит сыграть большую роль в построении TPH маппинга.

По представленной базе нам надо построить вот такую модель:



Для этого сначала воспользуемся визардом. Затем уберем в сущности Person лишние поля и переименуем ее в PersonBase. Это будет базовый класс (сущность) для нашей иерархии. Далее мы создаем две сущности унаследованные от PersonBase: Student и Instructor. Для этого в диалоге “New Entity” мы выбираем PersonBase в списке “Base Type”. Добавляем свойства в новых сущностях, в Student - EnrollmentDate, а в Instructor - HireDate и Salary. Теперь самое интересное - маппинг. Для сущности Instructor добавляем маппинг на таблицу Person:


Нужные столбцы замаплены автоматом. Как мы помним, данные об инструкторах у нас содержат строки таблицы, в которых PersonKind = 1. Этот столбец в терминах EF называется столбцом дискриминатором (discriminator column), а для маппинга мы задаем условие (condition). Условие накладывается именно на столбец дискриминатор. Для сущности Instructor мы устанавливаем условие PersonKind = 1, а для Student соответственно PersonKind = 0. Если мы попробуем сейчас скомпилировать всою модель, то получим ошибку примерно такого содержания:

Error 3023: Problem in Mapping Fragment(s) starting at line(s) (122, 130, 136, 137): Column Person.PersonKind has no default value and is not nullable. A default value is required to store some of the EDM states.

EF говорит, что ему не понятно, что же делать с PersonKind для сущности PersonBase. Оно не имеет маппинга и значения по умолчанию, а в БД описано как NOT NULLABLE. В предыдущих CTP для разрешения этой проблемы приходилось вводить условие в маппинге базового класса. Но в нашей стрктуре не придусмотрена возможность хранения каких-то персон, кроме студентов и инструкторов. Студентам предназначено значение PersonKind=0 а инструкторам PersonKind=1. В beta3 эта проблема решена очень красиво. В свойствах сущности PersonBase мы можем указать специальное свойство «Inheritance Modifier» - abstract. Оно указывет на то, что класс PersonBase будет объявлен как абстрактный, а это именно то, что нам нужно.

Заметьте, что столбец PersonKind нигде не замаплен в виде свойства. Тем не менее, его значения будут заданы автоматически при сохранении экземпляров Student и Instructor. В этом смысле Condition можно рассматривать как разновидность маппинга.

Теперь рассмотрим, какие возможности существуют для задания условий. Прежде всего, мы можем задавать условия по нескольким столбцам. Для сравнения может быть использован оператор «=» или оператор «is null» или оператор «is not null». Если мы используем оператор «=», то для сравнения используется константа. Никаких «больше» или «меньше», никаких диапазонов выражений и т.д. Эти ограничения следует учитывать при дизайне модели. В дизайнере маппинга в EF beta3 присутствует баг связанный, с заданием условий. При задании условия PersonKind = 1 значение «1» записывается в edmx файл в каком то закодированном значении. Если при компиляции модели вы получаете сообщение об ошибке, подобное этому:

Error 2016: The value specified for the condition is not compatible with the type of the Member.

то вам надо открыть edmx файл в XML редакторе и исправить значение атрибута Value элемента Condition(<Condition ColumnName="PersonKind" Value="_x0031_" >)
Вместо “_x0031_” должно быть “1”. После этого сохраняем файл и вновь открываем его в дизайнере.

Теперь несколько слов о реализации отношений наследования. Сущности наследники не имеют своих EntitySet и принадлежат EntitySet базовой сущности. Это означает, что в классе ObjectContext нашей модели не будет свойств коллекций, соответствующих Student и Instructor. Как же тогда нам делать выборки судентов и инструкторов? Для этих целей существует метод ObjectQuery.OfType(). Используется он следующим образом (выбираем всех инструкторов):


using (TPH context = new TPH())
{
ObjectQuery<Instructor> q = context.Persons.OfType<Instructor>();
foreach (Instructor i in q)
{
if (!i.DepartmentReference.IsLoaded)
i.DepartmentReference.Load();
Console.WriteLine("Instructor: {0} {1} dep. {2}", i.FirstName, i.Lastname, i.Department.Title);
}
}



Ассоциации



В нашей модели есть еще и ассоциация между Instructor и Department, которую мы создали руками, поскольку соответствующего constraint в БД не было. Рассмотрим создание ассоциации более подробно. Для создания ассоциации используем команду контекстного меню дизанера «Add association».



В появившемся диалоге указываем сущности (Entity) между, которыми устанавливается ассоциация, мощность отношения (Multiplicity), и навигационные сойства (Navigation Property), которые будут добавлены в сущности. Тут главное, не запутаться в концах ассоциации :). В этом плане очень помогает информационное поле в нижней части окна, в котором ассоциация описывается словами. Внимательно читайте эти описания и сопоставляйте их с тем, чего вы хотите добиться. Изменяйте параметры концов ассоциации, пока не добъетесь нужного результата. В нашем случае необходимо, чтобы Instrictor имел ссылку на 0 или 1 экземпляр Department, а Department ссылался на * (несколько) экземпляров Instructor посредством специального свойства - коллекции. Заметьте, что в зависимости от мощности отношения на конце ассоциации, соответствующее навигационное свойство будет представлять собой ссылку либо коллекцию. Для большей семантической ясности я рекомендую использовать множественное число для имен свойств – коллекций (Department.Instructors), хотя дизайнер по умолчанию дает имена в единственном числе всем navigation properties.

Итак, ассоциация создана, но это еще не все. Для ассоциаций, как и для сущностей в EF необходимо указывать маппинг. Поскольку в ассоциации участвуют две таблицы, в нашем случае это Department и Person, то какую из них выбирать для марппинга? Ответ очевиден, ту, в которой размещено поле внешнего ключа. В нашем случае это таблица Person и поле DepartmentId. А сам маппинг ассоциации выглядит так:



С ассоциациями EF связанна одна неприятная особенность. Как вы заметили, навигационные свойства создаются для обеих сущностей, участвующих в ассоциации. Причем способа отменить создание навигационного свойства на одном из концов ассоциации не существует (во всяком случае я его не обнаружил). Однако многие ассоциации имеют однонаправленный характер. Типичный пример, справочник валют. В большой системе десятки сущностей могут иметь ссылки на справочник валют, некоторые и не одну. Причем самомой сущности «справочник валют» вовсе не интересно, кто там на нее ссылается. В EF ваш справочник валют ощетинится десятками свойств коллекций, вовсе ему не нужных.

Наследование, или таблица для каждого класса (TPT)



Рассмотрим вариант реализации наследования, в котором каждому классу соответствует отдельная таблица в БД. В терминах EF данный вариант называется Table per Type (TPT).

Вернемся к схеме БД, которую мы использовали в самом начале статьи. В ней были две таблицы Contact и Person, связанные отношением один к одному. Кроме того PK таблицы Person одновременно является FK на таблицу Contact, поскольку предпологается, что в таблице Person будут храниться расширенные данные для некоторых контактов.

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

Обратимся опять к модели, которую создал визард по схеме БД.


Для реализации задуманного нам необходимо удалить ассоциацию FK_Person_Contact и вместо нее установить между двумя сущностями отношение наследования. Для этого надо выполнить команду контекстного меню «Add Inheritance…» и в появившемся диалоге указать родительскую и дочернюю сущность. После того как отношение наследования установлено, мы обнаруживаем, что маппинг дочерней сущности (Person) исчез и его необходимо выполнить заново. Это не баг, потому что мы уже знаем, что иерархии наследования в EF могут маппироваться различными способами.


Обратите внимание, что сущность Person не содержит первичного ключа, как и все унаследованные сущности. Свойство PersonId мы также удалили за ненадобностью (у нас есть ключ в родительском классе). Маппинг Person тоже предельно прост:


Добавляем таблицу Person. Никаких условий в нашем случае использовать не надо. Обратите внимание, осиротевший столбец PersonId маппим на свойство родительского класса ContactId.

Интересно взглянуть на SQL запрос, который генерируется при выборке по EntitySet Contact, т.е. по базовому классу:



SELECT
CASE WHEN ( NOT (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL))) THEN '0X' ELSE '0X0X' END AS [C1],
[Extent1].[ContactId] AS [ContactId],
[Extent1].[FirstName] AS [FirstName],
[Extent1].[LastName] AS [LastName],
[Extent1].[Email] AS [Email],
[Extent1].[Phone] AS [Phone],
CASE WHEN ( NOT (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL))) THEN CAST(NULL AS datetime) ELSE [Project1].[BirthDate] END
AS [C2],
CASE WHEN ( NOT (([Project1].[C1] = 1) AND ([Project1].[C1] IS NOT NULL))) THEN CAST(NULL AS nvarchar(200)) ELSE [Project1].[Address]
END AS [C3]
FROM [dbo].[Contact] AS [Extent1]
LEFT OUTER JOIN (SELECT
[Extent2].[PersonId] AS [PersonId],
[Extent2].[BirthDate] AS [BirthDate],
[Extent2].[Address] AS [Address],
cast(1 as bit) AS [C1]
FROM [dbo].[Person] AS [Extent2] ) AS [Project1] ON [Extent1].[ContactId] = [Project1].[PersonId]


Тут мы видим нечто удивительное. В запросе по базовому классу тянутся данные принадлежащие классу наследнику, а также хитрые ‘0X0X’, которые можно интерпретировать, как дискриминатор типа. Это заставляет нас предположить, что экземпляры, выбранные по запросу к базовому классу могут быть приведены к классам наследникам. Проверка подтверждает это предположение:


using (TPT context = new TPT())
{
ObjectQuery<Contact> q = context.Contact;
foreach (Contact c in q)
{
if(c is Person)
Console.WriteLine("person {0} {1}", c.LastName, ((Person)c).Address);
else
Console.WriteLine("contact only {0}", c.LastName);
}
}


Выражение (c is Person) возвращает true для тех контактов, у которых есть данные в таблице Person. Причем приведение к классу наследнику не порождает подчиненных запросов, подтягивающих дополнительные данные, при инстанциации экземпляров сразу используется нужный тип и он заполняется всеми данными. По полиморфизму зачет.

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

На сегодня все.
В следующей части я расскажу о том, что такое MEST и чего не может дизайнер EDM.

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

Илья Казначеев комментирует...

А оно будет работать с БД кроме MS SQL?
А что у нее с мэппингом ассоциаций - как в hibernate, List (индексированный)/Set/Map (тернарная ассоциация)?

А что у нее с двухсторонними ассоциациями?

Мой опыт переделки самописной 3-tier к использованию ORM показывает, что дьявол там здорово в деталях. Совершенно логичные мэппинги и конструкции не работают или порождают странные результаты. Интересны edge cases, так что. На Person -> Address-то у всех ORM всё ок :)

Sergey Rozovik комментирует...

to Ilyak
>А оно будет работать с БД кроме MS SQL?
обещают поддержку чуть ли не всего, что шевелится среди БД. В механизмах пока не разбирался.

>А что у нее с двухсторонними ассоциациями?
У нее все ассоциации двухстронние. Или вы имеете ввиду "многие ко многим"? Об этом в следующей части.

> дьявол там здорово в деталях
Да, так и есть. Этот материал, в частности, собран во время анализа возможности перевода существующей системы на EF. Примеры я максимально упрощал, потому как материал и так трудный для восприятия.

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

Интересно было бы узнать более детально насчёт производительности при реальном применении.

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

Сергей, утро доброе.

1. Я поддержу предыдущего пользователя в вопросе о произвоительности. У меня нет возможности протестировать это на серьезных нагрузках, однако кое-какие выкладки имеются - о них чуть позже.
2. Хотелось бы понять вашу точку зрения на философия EF. Для меня в первую очередь это возможность проектирования архитектуры некоторого фрейма визуально, что - с моей точки зрения - позволяет концентрировать внимание на БЛ, а не сбивает его на технические детали. ОДнако контракт - это замечательно,но с технической точки зрения не всегда только им можно удовлетвориться - опять же из за требования к производительности. К примеру, легче передать dataSet = XML для дальнейшего встраивания в web dataGrid., чем гнать массив бизнес-сущностей, конвертить их и далее использовать для того же грида.
3. В принципе, базовое тестирование у меня показало, что вроде бы разницы между передачей 15000 сгенерированных классов и 1 классом с 15000 массивами, отражающими значения членов каждого из класса - нет..
С этой точки зрения перфоманс на уровне БЛ может особо и не пострадать - опять же заисит от ситуации.

Sergey Rozovik комментирует...

to Taras
C философией все понятно, уже много сказано про Концептуальную модель и роли EF в построении dataoriented систем.
Теперь важно понять на сколько реалиные возможности EF позволяют ему выполнять заявленные функции. Этим я сейчас и занимаюсь.
Уже сейчас понятно, что EF хорошо закрывает всю область доступа к данным и модели домена приложения. А домен - это половина бизнес логики. Вторая половина - это бизнес операции, и EF незатейливо нас подталкивает к сепарированной схеме реализации бизнес логики, в которой бизнес сущности отделены от бизнес операций, а последние собраны в сервисы, или сервис фасады.
Linq to entity представляет хорошие возможности для замены DataTable и прямого чтения данных ридерами.
По поводу использования дата-классов EF в качестве DTO в Service layer - тут существуют определенные проблемы, которые я сейчас пока не готов обсуждать.
По поводу производительности. 15000 объектов - это не тот тест который отражает реальность. Ну где в реальном приложении за раз потребуется поднять 15000 объектов? Такой тест может говорить лишь об эффективности маппинга и инстанциирования объектов. Но я думаю, что команда EF не зря корпела над этим несколько лет.

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

Ef это конечно хорошая штука, но давайте посмотрим с другой стороны.
1. Начнем с EF 3.5, Где бля поддержка POCO. На хуя мне бля эти сущности наследуемые от EntityObject. Я хочу сам решать от кого или чего мне наследовать мои бизнес или домайн сущности ( не знаю, как сейчас требует называть наше модное программистское сообщество). Хотя в EF 4.0 (получив множество пиздюль даже от стороников MS) они соизволили это реализовать. К слову, в NH это давно было реализовано. Ну а к вопросу поддержки POCO EF 4.0. "4.0" говорит само за себя - эти уроды реализовали это в .Net Framework 4.0, для программирования на котором необходимо приобрести "достаточно недорогую" версию VS 2010. Что я хочу сказать идите вы в жопу со своими EF 3.5 (т.к. это сырая хуйня) и идите вы в жопу с EF 4.0, т.к. я не собираюсь из - за жадности MS приобретать VS 2010, я лучше бесплатно скачаю NH и сделаю свое приложение намного производителнней и гибче, без всяких ваших графических дизайнеров (которыми пользуются ламеры).