Часть VII Entity Framework Core

Глава 22 Введение в Entity Framework Core

В предыдущей главе были исследованы основы ADO.NET. Инфраструктура ADO.NET позволяет программистам приложений .NET (относительно прямолинейно) работать с реляционными данными, начиная с выхода первоначальной версии платформы .NET В пакете обновлений .NET 3.5 Service Pack 1 компания Microsoft предложила новый компонент API-интерфейса ADO.NET под названием Entity Framework (EF).

Общая цель EF — предоставить возможность взаимодействия с данными из реляционных баз данных с использованием объектной модели, которая отображается напрямую на бизнес-объекты (или объекты предметной области) в создаваемом приложении. Например, вместо того, чтобы трактовать пакет данных как коллекцию строк и столбцов, вы можете оперировать с коллекцией строго типизированных объектов, называемых сущностями. Такие сущности хранятся в специализированных классах коллекций, поддерживающих LINQ, что позволяет выполнять операции доступа к данным в коде С#. Классы коллекций обеспечивают средства запрашивания хранилища данных с применением той же грамматики LINQ, которая была раскрыта в главе 13.

Подобно .NET Core инфраструктура Entity Framework Core представляет собой полностью переписанную инфраструктуру Entity Framework 6. Она построена на основе .NET Core, давая возможность инфраструктуре EF Core функционировать на множестве платформ. Переписывание EF Core позволило добавить к EF Core новые средства и улучшения в плане производительности, которые не получилось бы разумно реализовать в EF 6.

Воссоздание целой инфраструктуры с нуля требует внимательного анализа того, какие средства будут поддерживаться новой инфраструктурой, а от каких придется отказаться. Одним из средств EF 6, которые отсутствуют в EF Core (и вряд ли когда-либо будут добавлены), является поддержка визуального конструктора сущностей (Entity Designer). В EF Core поддерживается парадигма разработки "сначала код". Если вы уже имели дело с упомянутой парадигмой, тогда можете проигнорировать приведенное предостережение.


На заметку! Инфраструктуру EF Core можно использовать с существующими базами данных, а также с пустыми и/или новыми базами данных. Оба механизма называют парадигмой "сначала код", что вероятно нельзя считать самым удачным наименованием. Шаблоны сущностных классов и классов, производных от

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


С каждым новым выпуском в инфраструктуру EF Core добавлялись дополнительные средства, которые присутствовали в EF 6, плюс совершенно новые средства, не существующие в EF 6. С выходом выпуска 3.1 список важных функций, отсутствующих в EF Core (в сравнении с EF 6), был значительно уменьшен, а с выходом выпуска 5.0 разрыв сократился еще больше. Фактически инфраструктура EF Core располагает всем необходимым для большинства проектов.

В этой и следующей главах вы ознакомитесь с доступом к данным с применением Entity Framework Core. Вы узнаете о том, как создавать модель предметной области, сопоставлять сущностные классы и свойства с таблицами и столбцами базы данных, реализовывать отслеживание изменений, использовать интерфейс командной строки (command-line interface — CLI) инфраструктуры EF Core для создания шаблонных классов и миграций, а также освоите роль класса

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

К тому моменту, когда вы завершите изучение этих двух глав, у вас будет финальная версия уровня доступа к данным для базы данных

AutoLot
. Прежде чем заняться непосредственно инфраструктурой EF Core, давайте обсудим инструменты объектно-реляционного отображения в целом.


На заметку! Двух глав далеко не достаточно, чтобы охватить все аспекты инфраструктуры Entity Framework Core, т.к. ей посвящены целые книги (по объему сравнимые с настоящей). Цель предлагаемых глав — предложить вам практические знания, которые позволят приступить к применению EF Core для разработки своей линейки бизнес-приложений.

Инструменты объектно-реляционного отображения

Инфраструктура ADO.NET снабжает вас структурой, которая позволяет выбирать, вставлять, обновлять и удалять данные с помощью объектов подключений, команд и чтения данных. Тем не менее, такие аспекты ADO.NET вынуждают обходиться с извлеченными данными в манере, тесно связанной с физической схемой базы данных. В качестве примера вспомните, что при получении записей из базы данных вы открываете объект подключения, создаете и выполняете объект команды и затем используете объект чтения данных для прохода по записям с применением имен столбцов, зависящих от базы данных.

При работе с ADO.NET вы всегда обязаны помнить о физической структуре серверной базы данных. Вы должны знать схему каждой таблицы данных, создавать потенциально сложные запросы SQL для взаимодействия с таблицей (таблицами) данных, отслеживать изменения в извлеченных (или добавленных) данных и т.д. В итоге вы можете быть вынуждены записывать довольно многословный код С#, поскольку сам язык C# не позволяет работать непосредственно со схемой базы данных.

Хуже того, обычный способ создания физической базы данных прямо сосредоточен на конструкциях базы данных, таких как внешние ключи, представления, хранимые процедуры и нормализация данных, а не на объектно-ориентированном программировании.

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

Доступность инфраструктур объектно-реляционного отображения (object-relation-al mapping — ORM) в .NET значительно улучшила ситуацию с доступом к данным, управляя вместо разработчика большинством задач создания, чтения, обновления и удаления (

create
,
read
,
update
,
delete
— CRUD). Разработчик создает отображение между объектами .NET и реляционной базой данных, а инфраструктура ORM управляет подключениями, генерацией запросов, отслеживанием изменений и хранением данных. В итоге разработчик получает возможность целиком сосредоточиться на бизнес-потребностях приложения.


На заметку! Важно помнить, что инфраструктуры ORM не являются инструментами, которые с легкостью решат все проблемы.С каждым решением связаны компромиссы. Инфраструктуры ORM сокращают объем работы разработчикам, создающим уровни доступа к данным, но могут также привносить проблемы с производительностью и масштабированием в случае ненадлежащего применения. Используйте инфраструктуры ORM для операций CRUD и задействуйте мощь своей базы данных для операций, основанных на множествах.


Хотя разные инфраструктуры ORM имеют небольшие отличия в том, как они работают, или каким образом применяются, все они по существу представляют собой одни и те же фрагменты и части, преследующие ту же самую цель — облегчить выполнение операций доступ к данным. Сущности являются классами, которые отображаются на таблицы базы данных. Специализированный тип коллекции содержит одну или большее количество сущностей. Механизм отслеживания изменений следит за состоянием объектов и любыми связанными с ними изменениями, добавлениями и/или удалениями, а центральная конструкция управляет операциями как руководитель.

Роль Entity Framework Core

"За кулисами" EF Core использует инфраструктуру ADO.NET, которая уже была исследована в предыдущей главе. Подобно любому взаимодействию ADO.NET с хранилищем данных EF Core применяет для этого поставщик данных ADO.NET. Прежде чем поставщик данных ADO.NET можно будет использовать в EF Core, его потребуется обновить для полной интеграции с EF Core. Из-за такой добавленной функциональности доступных поставщиков данных EF Core может оказаться меньше, чем поставщиков данных ADO.NET.

Преимущество инфраструктуры EF Core, применяющей шаблон поставщиков баз данных ADO.NET, заключается в том, что она позволяет объединять в одном проекте парадигмы доступа к данным EF Core и ADO.NET, расширяя ваши возможности. Например, в случае использования EF Core с целью предоставления подключения, схемы и имени таблицы для операций массового копирования задействуются возможности сопоставления EF Core и функциональность программы массового копирования, встроенная в ADO.NET. Такой смешанный подход делает EF Core просто еще одним инструментом в вашем арсенале.

Когда вы оцените объем связующего кода для базового доступа к данным, поддерживаемый инфраструктурой EF Core в согласованной и эффективной манере, по всей видимости, она станет вашим основным механизмом при доступе к данным.


На заметку! Многие сторонние СУБД (скажем, Oracle и MySQL) предлагают поставщики данных, осведомленные об инфраструктуре EF Core. Если вы имеете дело не с SQL Server, тогда обратитесь за детальными сведениями к разработчику СУБД или ознакомьтесь с перечнем доступных поставщиков данных EF Core по ссылке

https://docs.microsoft.com/ru-ru/ef/core/providers/
.


Инфраструктура EF Core лучше всего вписывается в процесс разработки в случае применения подходов в стиле "формы поверх данных" (или "API-интерфейс поверх данных"). Оптимальными для EF Core являются операции над небольшим количеством сущностей, использующие шаблон единицы работы с целью обеспечения согласованности. Она не очень хорошо подходит для выполнения крупномасштабных операций над данными вроде тех, что встречаются приложениях хранилищ данных типа "извлечение, трансформация, загрузка" (extract-transform-load — ETL) или в больших системах построения отчетов.

Строительные блоки Entity Framework Core

К главным компонентам EF Core относятся

DbContext
,
ChangeTracker
, специализированный тип коллекции
DbSet
, поставщики баз данных и сущности приложения. Для проработки примеров в текущем разделе создайте новый проект консольного приложения по имени
AutoLot.Samples
и добавьте к нему пакеты
Microsoft.EntityFrameworkCore
,
Microsoft.EntityFrameworkCore.Design
и
Microsoft.EntityFrameworkCore.SqlServer
:


dotnet new sln -n Chapter22_AllProjects

dotnet new console -lang c# -n AutoLot.Samples -o .\AutoLot.Samples -f net5.0

dotnet sln .\Chapter22_AllProjects.sln add .\AutoLot.Samples

dotnet add AutoLot.Samples package Microsoft.EntityFrameworkCore

dotnet add AutoLot.Samples package Microsoft.EntityFrameworkCore.Design

dotnet add AutoLot.Samples package Microsoft.EntityFrameworkCore.SqlServer

Класс DbContext

Класс

DbContext
входит в состав главных компонентов EF Core и предоставляет доступ к базе данных через свойство
Database
. Объект
DbContext
управляет экземпляром
ChangeTracker
, поддерживает виртуальный метод
OnModelCreating()
для доступа к текучему API-интерфейсу (Fluent API), хранит все свойства
DbSet
и предлагает метод
SaveChanges()
, позволяющий сохранять данные в хранилище. Он применяется не напрямую, а через специальный класс, унаследованный от
DbContext
. Именно в этом классе размещены все свойства типа
DbSet
. В табл. 22.1 описаны некоторые часто используемые члены класса
DbContext
.


Создание класса, производного от DbContext

Первый шаг в EF Core заключается в создании специального класса, унаследованного от

DbContext
. Затем добавляется конструктор, который принимает строго типизированный экземпляр
DbContextOptions
(рассматривается далее) и передает его конструктору базового класса:


namespace AutoLot.Samples

{

  public class ApplicationDbContext : DbContext

  {

   public ApplicationDbContext(DbContextOptions options)

       : base(options)

   {

   }

  }

}


Именно производный от

DbContext
класс применяется для доступа к базе данных и работает с сущностями, средством отслеживания изменений и всеми компонентами EF Core.

Конфигурирование экземпляра DbContext

Экземпляр

DbContext
конфигурируется с использованием экземпляра класса
DbContextOptions
. Экземпляр
DbContextOptions
создается с применением
DbContextOptionsBuilder
, т.к. класс
DbContextOptions
не рассчитан на создание экземпляров непосредственно в коде. Через экземпляр
DbContextOptionsBuilder
выбирается поставщик базы данных (наряду с любыми настройками, касающимися поставщика) и устанавливаются общие параметры экземпляра
DbContext
инфраструктуры EF Core (наподобие ведения журнала). Затем свойство
Options
внедряется в базовый класс
DbContext
во время выполнения.

Такая возможность динамического конфигурирования позволяет изменять настройки во время выполнения, просто выбирая разные параметры (скажем, поставщик MySQL вместо SQL Server) и создавая новый экземпляр производного класса

DbContext
.

Фабрика DbContext этапа проектирования

Фабрика

DbContext
этапа проектирования представляет собой класс, который реализует интерфейс
IDesignTimeDbContextFactory
, где
Т
— класс, производный от
DbContext
. Интерфейс
IDesignTimeDbContextFactory
имеет один метод
CreateDbContext()
, который должен быть реализован для создания экземпляра производного класса
DbContext
.

В показанном ниже классе

ApplicationDbContextFactory
с помощью метода
CreateDbContext()
создается строго типизированный экземпляр
DbContextOptionsBuilder
для класса
ApplicationDbContext
, устанавливается поставщик баз данных SQL Server (с использованием строки подключения к экземпляру Docker из главы 21), после чего создается и возвращается новый экземпляр
ApplicationDbContext
:


using System;

using Microsoft.EntityFrameworkCore;

using Microsoft.EntityFrameworkCore.Design;


namespace AutoLot.Samples

{

  public class ApplicationDbContextFactory : IDesignTimeDbContextFactory

Context>

  {

  public ApplicationDbContext CreateDbContext(string[] args)

   {

    var optionsBuilder = new DbContextOptionsBuilder();

    var connectionString =

      @"server=.,5433;Database=AutoLotSamples;

     User Id=sa;Password=
P@ssw0rd;";

    optionsBuilder.UseSqlServer(connectionString);

    Console.WriteLine(connectionString);

    return new ApplicationDbContext(optionsBuilder.Options);

   }

  }

}


Интерфейс командной строки задействует фабрику контекстов, чтобы создать экземпляр производного класса

DbContext
, предназначенный для выполнения действий вроде создания и применения миграций базы данных. Поскольку фабрика является конструкцией этапа проектирования и не используется во время выполнения, строка подключения к базе данных разработки обычно будет жестко закодированной. В версии EF Core 5 появилась возможность передавать методу
CreateDbContext()
аргументы из командной строки, о чем пойдет речь позже в главе.

Метод OnModelCreating()

Базовый класс

DbContext
открывает доступ к методу
OnModelCreating()
, который применяется для придания формы сущностям, используя Fluent API. Детали подробно раскрываются далее в главе, а пока добавьте в класс
ApplicationDbContext
следующий код:


protected override void OnModelCreating(ModelBuilder modelBuilder)

{

  // Обращения к Fluent API.

  OnModelCreatingPartial(modelBuilder);

}

partial void OnModelCreatingPartial(ModelBuilder modelBuilder);

Сохранение изменений

Чтобы заставить

DbContext
и
ChangeTracker
сохранить любые изменения, внесенные в отслеживаемые сущности, вызовите метод
SaveChanges()
(или
SaveChangesAsync()
) на экземпляре класса, производного от
DbContext
:


static void SampleSaveChanges()

{

  // Фабрика не предназначена для такого использования,

  // но это демонстрационный код

   var context = new ApplicationDbContextFactory().CreateDbContext(null);

   // Внести какие-нибудь изменения.

   context.SaveChanges();

}


В оставшемся материале главы (и книги) вы обнаружите много примеров сохранения изменений.

Поддержка транзакций и точек сохранения

Исполняющая среда EF Core помещает каждый вызов

SaveChanges()/SaveChangesAsync()
внутрь неявной транзакции, использующей уровень изоляции базы данных. Чтобы добиться большей степени контроля, можете включить экземпляр производного класса
DbContext
в явную транзакцию. Для выполнения явной транзакции создайте транзакцию с применением свойства
Database
класса, производного от
DbContext
. Управляйте своими операциями обычным образом и затем предпримите фиксацию или откат транзакции. Ниже приведен фрагмент кода, где все демонстрируется:


using var trans = context.Database.BeginTransaction();

try

{

  // Создать, изменить, удалить запись.

  context.SaveChanges();

  trans.Commit();

}

catch (Exception ex)

{

  trans.Rollback();

}


В версии EF Core 5 были введены точки сохранения для транзакций EF Core. Когда вызывается метод

SaveChanges()/SaveChangesAsync()
, а транзакция уже выполняется, исполняющая среда EF Core создает в этой транзакции точку сохранения. Если вызов терпит неудачу, то откат транзакции происходит в точку сохранения, а не в начало транзакции. Точками сохранения можно также управлять в коде, вызывая методы
CreateSavePoint()
и
RollbackToSavepoint()
для транзакции:


using var trans = context.Database.BeginTransaction();

try

{

  // Создать, изменить, удалить запись.

  trans.CreateSavepoint("check point 1");

  context.SaveChanges();

  trans.Commit();

}

catch (Exception ex)

{

  trans. RollbackToSavepoint("check point 1");

}

Транзакции и стратегии выполнения

В случае активной стратегии выполнения (как при использовании

EnableRetryOnFailure()
) перед созданием явной транзакции вы должны получить ссылку на текущую стратегию выполнения, которая применяется EF Core. Затем вызовите на этой стратегии метод
Execute()
, чтобы создать явную транзакцию:


var strategy = context.Database.CreateExecutionStrategy();

strategy.Execute(() =>

{

  using var trans = context.Database.BeginTransaction();

  try

  {

   actionToExecute();

   trans.Commit();

  }

  catch (Exception ex)

  {

   trans.Rollback();

  }

});

События SavingChanges/SavedChanges

В версии EF Core 5 появились три новых события, которые инициируются методами

SaveChanges()/SaveChangesAsync()
. Событие
SavingChanges
запускается при вызове
SaveChanges()
, но перед выполнением операторов SQL в хранилище данных, а событие
SavedChanges
— после завершения работы метода
SaveChanges()
. В следующем (простейшем) коде демонстрируются события и их обработчики в действии:


public ApplicationDbContext(DbContextOptions options)

   : base(options)

{

  SavingChanges += (sender, args) =>

  {

   Console.WriteLine($"Saving changes for {((DbContext)sender).Database.

GetConnectionString()}");

  };

  SavedChanges += (sender, args) =>

  {

   Console.WriteLine($"Saved {args.EntitiesSavedCount} entities");

  };

  SaveChangesFailed += (sender, args) =>

  {

   Console.WriteLine($"An exception occurred! {args.Exception.Message}

entities");

  };

}

Класс DbSet

Для каждой сущности в своей объектной модели вы добавляете свойство типа

DbSet
. Класс
DbSet
представляет собой специализированную коллекцию, используемую для взаимодействия с поставщиком баз данных с целью получения, добавления, обновления и удаления записей в базе данных. Каждая коллекция
DbSet
предлагает несколько основных служб для взаимодействия с базой данных. Любые запросы LINQ, запускаемые в отношении класса
DbSet
, транслируются поставщиком базы данных в запросы к базе данных. В табл. 22.2 описан ряд основных членов класса
DbSet
.



Класс

DbSet
реализует интерфейс
IQueryable
и обычно является целью запросов LINQ to Entity. Помимо расширяющих методов, добавленных инфраструктурой EF Core, класс
DbSet
поддерживает расширяющие методы, которые вы изучили в главе 13, такие как
ForEach()
,
Select()
и
All()
.

Вы узнаете, как добавлять к классу

ApplicationDbContext
свойства типа
DbSet
, в разделе "Сущности" далее в главе.


На заметку! Многие методы из перечисленных в табл. 22.2, имеют те же самые имена, что и методы в табл. 22.1. Основное отличие в том, что методам

DbSet
уже известен тип, с которым нужно работать, и список сущностей. Методы
DbContext
обязаны определять, на чем действовать, с применением рефлексии. Методы
DbSet
используются гораздо чаще, чем методы
DbContext
.

Типы запросов

Типы запросов — это коллекции

DbSet
, которые применяются для изображения представлений, оператора SQL или таблиц без первичного ключа. В предшествующих версиях EF Core для всего упомянутого использовался тип
DbQuery
, но начиная с EF Core 3.1, тип
DbQuery
больше не употребляется. Типы запросов добавляются к производному классу
DbContext
с применением свойств
DbSet
и конфигурируются как не имеющие ключей.

Например, класс

CustomerOrderViewModel
(который вы создадите при построении полной библиотеки доступа к данным
AutoLot
) конфигурируется с атрибутом
[Keyless]
:


[Keyless]

public class CustomerOrderViewModel

{

...

}


Остальные действия по конфигурированию делаются в Fluent API. В следующем примере сущность устанавливается как не имеющая ключа, а тип запроса сопоставляется с представлением базы данных

dbo.CustomerOrderView
(обратите внимание, что вызов метод
HasNoKey()
из Fluent API не требуется, если в модели присутствует аннотация данных
Keyless
, и наоборот, но он показан ради полноты):


modelBuilder.Entity().HasNoKey().ToView("CustomerOrderView", "dbo");


Типы запросов могут также сопоставляться с запросом SQL, как показано ниже:


modelBuilder.Entity().HasNoKey().ToSqlQuery(

  @"SELECT c.FirstName, c.LastName, i.Color, i.PetName, m.Name AS Make

     FROM  dbo.Orders o

     INNER JOIN dbo.Customers c ON o.CustomerId = c.Id

     INNER JOIN dbo.Inventory  i ON o.CarId = i.Id

     INNER JOIN dbo.Makes m ON m.Id = i.MakeId");


Последние механизмы, с которыми можно использовать типы запросов — это методы

FromSqlRaw()
и
FromSqlInterpolated()
. Вот пример того же самого запроса, но с применением
FromSqlRaw()
:


public IEnumerable GetOrders()

{

  return CustomerOrderViewModels.FromSqlRaw(

   @"SELECT c.FirstName, c.LastName, i.Color, i.PetName, m.Name AS Make

      FROM  dbo.Orders o

      INNER JOIN dbo.Customers c ON o.CustomerId = c.Id

      INNER JOIN dbo.Inventory  i ON o.CarId = i.Id

      INNER JOIN dbo.Makes m ON m.Id = i.MakeId");

}

Гибкое сопоставление с запросом или таблицей

В версии EF Core 5 появилась возможность сопоставления одного и того же класса с более чем одним объектом базы данных. Такими объектами могут быть таблицы, представления или функции. Например, класс

CarViewModel
из главы 21 может отображаться на представление, которое возвращает название производителя с данными
Car
и таблицей Inventory. Затем EF Core будет запрашивать из представления и отправлять обновления таблице:


modelBuilder.Entity()

  .ToTable("Inventory")

  .ToView("InventoryWithMakesView");

Экземпляр ChangeTracker

Экземпляр

ChangeTracker
отслеживает состояние объектов, загруженных в
DbSet
внутри экземпляра
DbContext
. В табл. 22.3 описаны возможные значения для состояния объекта.



Для проверки состояния объекта используйте следующий код:


EntityState state = context.Entry(entity).State;


Вы также можете программно изменять состояние объекта с применением того же самого механизма. Чтобы изменить состояние на

Deleted
, используйте такой код:


context.Entry(entity).State = EntityState.Deleted;

События ChangeTracker

Экземпляр

ChangeTracker
способен генерировать два события:
StateChanged
и
Tracked
. Событие
StateChanged
инициируется в случае изменения состояния сущности. Оно не генерируется при отслеживании сущности в первый раз. Событие
Tracked
инициируется, когда сущность начинает отслеживаться, либо за счет добавления экземпляра
DbSet
в коде, либо при возвращении из запроса.

Сброс состояния DbContext

В версии EF Core 5 появилась возможность сброса состояния

DbContext
. Метод
ChangeTracker.Clear()
отсоединяет все сущности от свойств
DbSet
, устанавливая их состояние в
Detached
.

Сущности

Строго типизированные классы, которые сопоставляются с таблицами базы данных, официально именуются сущностями. Коллекция сущностей в приложении образует концептуальную модель физической базы данных. Выражаясь формально, такая модель называется моделью сущностных данных (entity data model — EDM) или просто моделью. Модель сопоставляется с предметной областью приложения. Сущности и их свойства отображаются на таблицы и столбцы с применением соглашений EntityFramework Core, конфигурации и Fluent API (кода). Сущности не обязаны быть сопоставленными напрямую со схемой базы данных. Вы можете структурировать сущностные классы согласно потребностям создаваемого приложения и затем отобразить свои уникальные сущности на схему базы данных.

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

Inventory
из базы данных
AutoLot
и сущностный класс
Car
из предыдущей главы. Имена отличаются, но сущность
Car
сопоставляется с таблицей
Inventory
. Исполняющая среда EF Core исследует конфигурацию сущностей в модели, чтобы отобразить клиентское представление таблицы
Inventory
(класс
Car
в примере) на корректные столбцы таблицы
Inventory
.

В последующих разделах будет показано, каким образом соглашения EF Core, аннотации данных и код (использующий Fluent API) сопоставляют сущности, свойства и отношения между сущностями в модели с таблицами, столбцами и отношениями внешних ключей в базе данных.

Сопоставление свойств со столбцами

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

Наличие поддерживающих полей предпочтительнее в двух сценариях: при использовании шаблона

INotifyPropertyChanged
в приложениях Windows Presentation Foundation (WPF) и при возникновении конфликта между стандартными значениями базы данных и стандартными значениями .NET Core. Применение EF Core с WPF обсуждается в главе 28, а стандартные значения базы данных раскрываются позже в текущей главе.

Имена, типы данных и допустимость значений

null
столбцов конфигурируются через соглашения, аннотации данных и/или Fluent API. Все указанные темы подробно рассматриваются далее в главе.

Сопоставление классов с таблицами

В EF Core доступны две схемы сопоставления классов с таблицами: "таблица на иерархию" (table-per-hierarchy — ТРН) и "таблица на тип" (table-per-type — ТРТ). Сопоставление ТРН используется по умолчанию и отображает иерархию наследования на единственную таблицу. Появившееся в версии EF Core 5 сопоставление ТРТ отображает каждый класс в иерархии на собственную таблицу.


На заметку! Классы также можно отображать на представления и низкоуровневые запросы SQL. Они называются типами запросов и обсуждаются позже в главе.

Сопоставление "таблица на иерархию" (ТРН)

Рассмотрим приведенный ниже пример, в котором класс

Car
из главы 21 разделен на два класса: базовый класс для свойств
Id
и
TimeStamp
и собственно класс
Car
с остальными свойствами. Оба класса должны быть созданы в папке
Models
проекта
AutoLot.Samples
:


using System.Collections.Generic;


namespace AutoLot.Samples.Models

{

  public abstract class BaseEntity

  {

   public int Id { get; set; }

   public byte[] TimeStamp { get; set; }

  }

}


using System.Collections.Generic;


namespace AutoLot.Samples.Models

{

  public class Car : BaseEntity

  {

   public string Color { get; set; }

   public string PetName { get; set; }

   public int MakeId { get; set; }

  }

}


Чтобы уведомить EF Core о том, что сущностный класс является частью объектной модели, предусмотрите свойство

DbSet
для сущности. Добавьте в класс
ApplicationDbContext
такой оператор
using
:


using AutoLot.Samples.Models;


Поместите следующий код в класс

ApplicationDbContext
между конструктором и методом
OnModelCreating()
:


public DbSet Cars { get; set; }


Обратите внимание, что базовый класс не добавляется в виде экземпляра

DbSet
. Хотя подробные сведения о миграциях приводятся позже в главе, давайте создадим базу данных и таблицу
Cars
. Откройте окно командной строки в папке проекта
AutoLot.Samples
и выполните показанные ниже команды:


dotnet tool install --global dotnet-ef --version 5.0.1

dotnet ef migrations add TPH
 -o Migrations

 -c AutoLot.Samples.ApplicationDbContext

dotnet ef database update TPH  -c AutoLot.Samples.ApplicationDbContext


Первая команда устанавливает инструменты командной строки EF Core как глобальные. На вашей машине это понадобится сделать только раз. Вторая команда создает в папке

Migrations
миграцию по имени ТРН с применением класса
ApplicationDbContext
в пространстве имен
AutoLot.Samples
. Третья команда обновляет базу на основе миграции ТРН.

Когда EF Core используется для создания этой таблицы в базе данных, то унаследованный класс

BaseEntity
объединяется с классом
Car
и создается единственная таблица:


CREATE TABLE [dbo].[Cars](

  [Id] [int] IDENTITY(1,1) NOT NULL,

  [MakeId] [int] NOT NULL,

  [Color] [nvarchar](max) NULL,

  [PetName] [nvarchar](max) NULL,

  [TimeStamp] [varbinary](max) NULL,

 CONSTRAINT [PK_Cars] PRIMARY KEY CLUSTERED

(

  [Id] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF,

 IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS 
= ON,

 ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]


В предыдущем примере для создания свойств таблицы и столбцов применялись соглашения EF Core (раскрываемые вскоре).

Сопоставление "таблица на тип" (ТРТ)

Для изучения схемы сопоставления ТРТ можно использовать те же самые сущности, что и ранее, даже если базовый класс помечен как абстрактный. Поскольку схема TPH применяется по умолчанию, инфраструктуру EF Core необходимо проинструктировать для отображения каждого класса на таблицу, что можно сделать с помощью аннотаций данных или Fluent API. Добавьте в

ApplicationDbContext
следующий код:


protected override void OnModelCreating(ModelBuilder modelBuilder)

{

  modelBuilder.Entity().ToTable("BaseEntities");

  modelBuilder.Entity().ToTable("Cars");

  OnModelCreatingPartial(modelBuilder);

}

partial void OnModelCreatingPartial(ModelBuilder modelBuilder);


Чтобы "сбросить" базу данных и проект, удалите папку

Migrations
и базу данных. Вот как удалить базу данных в CLI:


dotnet ef database drop -f -c AutoLot.Samples.ApplicationDbContext


Теперь создайте и примените миграцию для схемы ТРТ:


dotnet ef migrations add TPT -o Migrations -c AutoLot.Samples.ApplicationDbContext

dotnet ef database update TPT  -c AutoLot.Samples.ApplicationDbContext


При обновлении базы данных исполняющая среда EF Core создаст следующие таблицы. Индексы также показывают, что таблицы имеют сопоставление "один к одному":


CREATE TABLE [dbo].[BaseEntities](

  [Id] [int] IDENTITY(1,1) NOT NULL,

  [TimeStamp] [varbinary](max) NULL,

 CONSTRAINT [PK_BaseEntities] PRIMARY KEY CLUSTERED

(

  [Id] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF,

 IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS 
= ON, ALLOW_PAGE_LOCKS = ON,

 OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]

GO


CREATE TABLE [dbo].[Inventory](

  [Id] [int] NOT NULL,

  [MakeId] [int] NOT NULL,

  [Color] [nvarchar](max) NULL,

  [PetName] [nvarchar](max) NULL,

 CONSTRAINT [PK_Inventory] PRIMARY KEY CLUSTERED

(

  [Id] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF,

 ALLOW_ROW_LOCKS 
= ON, ALLOW_PAGE_LOCKS = ON,

 OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]

GO


ALTER TABLE [dbo].[Inventory]

WITH CHECK ADD  CONSTRAINT [FK_Inventory_BaseEntities_Id] 

FOREIGN KEY([Id])

REFERENCES [dbo].[BaseEntities] ([Id])

GO

ALTER TABLE [dbo].[Inventory] CHECK CONSTRAINT [FK_Inventory_BaseEntities_Id]

GO


На заметку! С сопоставлением TPT связаны значительные последствия в плане производительности, которые должны приниматься во внимание при выборе данной схемы сопоставления. Дополнительные сведения ищите в документации:

https://docs.microsoft.com/ru-ru/ef/core/performance/modeling-for-performance#inheritance-mapping
.


Чтобы "сбросить" базу данных и проект для подготовки к следующему набору примеров, закомментируйте код в методе

OnModelCreating()
и опять удалите папку
Migrations
вместе с базой данных:


dotnet ef database drop -f -c AutoLot.Samples.ApplicationDbContext

Навигационные свойства и внешние ключи

Навигационные свойства представляют то, каким образом сущностные классы связаны между собой, и позволяют проходить от одного экземпляра сущности к другому. По определению навигационным является любое свойство, которое отображается на нескалярный тип, как определено поставщиком базы данных. На практике навигационное свойство сопоставляется с другой сущностью (навигационное свойство типа ссылки) или с коллекцией других сущностей (навигационное свойство типа коллекций). На стороне базы данных навигационные свойства транслируются в отношения внешнего ключа между таблицами. Инфраструктура EF Core напрямую поддерживает отношения вида "один к одному", "один ко многим" и (в версии EF Core 5) "многие ко многим". Сущностные классы также могут иметь обратные навигационные свойства с самими собой, представляя самоссылающиеся таблицы.


На заметку! Объекты с навигационными свойствами удобно рассматривать как связные списки. Если навигационные свойства являются двунаправленными, тогда объекты ведут себя подобно двусвязным спискам.


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


Отсутствие свойств для внешних ключей

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

<имя навигационного свойства><имя свойства главного ключа>
или
<имя главной сущности><имя свойства главного ключа>
. Сказанное справедливо для всех видов отношений ("один ко многим", "один к одному", "многие ко многим"). Такой подход к построению сущностей с явным свойством или свойствами внешних ключей гораздо яснее, чем поручение их создания исполняющей среде EF Core.

Отношения "один ко многим"

Чтобы создать отношение "один ко многим", сущностный класс со стороны "один" (главная сущность) добавляет свойство типа коллекции сущностных классов, находящихся на стороне "многие" (зависимые сущности). Зависимая сущность также должна иметь свойства для внешнего ключа обратно к главной сущности, иначе исполняющая среда EF Core создаст теневые свойства внешних ключей, как объяснялось ранее.

Например, в базе данных, созданной в главе 21, между таблицей

Makes
(представленной сущностным классом
Make
) и таблицей
Inventory
(представленной сущностным классом
Car
) имеется отношение "один ко многим". Для упрощения примеров сущность
Car
будет отображаться на таблицу
Cars
. В следующем коде показаны двунаправленные навигационные свойства, представляющие это отношение:


using System.Collections.Generic;

namespace AutoLot.Samples.Models

{

   public class Make : BaseEntity

   {

    public string Name { get; set; }

    public IEnumerable Cars { get; set; } = new List();

   }

}


using System.Collections.Generic;

namespace AutoLot.Samples.Models

{

  public class Car : BaseEntity

  {

   public string Color { get; set; }

   public string PetName { get; set; }

   public int MakeId { get; set; }

   public Make MakeNavigation { get; set; }

  }

}


На заметку! При создании шаблонов для существующей базы данных исполняющая среда EF Core именует навигационные свойства типа ссылок аналогично обычным свойствам (скажем,

public Make {get; set;}
). В итоге могут возникать проблемы с навигацией и
IntelliSense
, не говоря уже о затруднениях при работе с кодом. Для ясности предпочтительнее добавлять к именам навигационных свойств типа ссылок суффикс
Navigation
, как демонстрировалось выше.


В примере

Car/Make
сущность
Car
является зависимой ("многие" в "один ко многим"), а сущность
Make
— главной ("один" в "один ко многим").

Добавьте в класс

ApplicationDbContext
экземпляр
DbSet
:


public DbSet Cars { get; set; }

public DbSet Makes { get; set; }


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


dotnet ef migrations add One2Many -o Migrations

 -c AutoLot.Samples.ApplicationDbContext

dotnet ef database update One2Many  -c AutoLot.Samples.ApplicationDbContext


При обновлении базы данных с применением миграции EF Core создаются следующие таблицы:


CREATE TABLE [dbo].[Makes](

  [Id] [int] IDENTITY(1,1) NOT NULL,

  [Name] [nvarchar](max) NULL,

  [TimeStamp] [varbinary](max) NULL,

 CONSTRAINT [PK_Makes] PRIMARY KEY CLUSTERED

(

  [Id] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF,

 IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS 
= ON, ALLOW_PAGE_LOCKS = ON,

 OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]

GO


CREATE TABLE [dbo].[Cars](

  [Id] [int] IDENTITY(1,1) NOT NULL,

  [Color] [nvarchar](max) NULL,

  [PetName] [nvarchar](max) NULL,

  [TimeStamp] [varbinary](max) NULL,

  [MakeId] [int] NOT NULL,

 CONSTRAINT [PK_Cars] PRIMARY KEY CLUSTERED

(

  [Id] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF,

 IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS 
= ON, ALLOW_PAGE_LOCKS = ON,

 OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]

GO

ALTER TABLE [dbo].[Cars]

WITH CHECK ADD  CONSTRAINT [FK_Cars_Makes_MakeId] FOREIGN 

KEY([MakeId])

REFERENCES [dbo].[Makes] ([Id])

ON DELETE CASCADE

GO

ALTER TABLE [dbo].[Cars] CHECK CONSTRAINT [FK_Cars_Makes_MakeId]

GO


Обратите внимание на ограничения внешнего ключа и проверки, созданные для зависимой таблицы (

Cars
).

Отношения "один к одному"

В отношениях "один к одному" обе сущности имеют навигационные свойства типа ссылок друг на друга. Хотя отношения "один к одному" четко обозначают главную и зависимую сущности, при построении таких отношений EF Core необходимо сообщить о том, какая сторона является главной, либо явно определяя внешний ключ, либо указывая главную сущность с использованием Fluent API. Если не проинформировать исполняющую среду EF Core, тогда она будет делать выбор на основе своей способности обнаруживать внешний ключ. На практике вы должны четко определять зависимую сущность, добавляя свойства внешнего ключа:


namespace AutoLot.Samples.Models

{

  public class Car : BaseEntity

  {

  public string Color { get; set; }

   public string PetName { get; set; }

   public int MakeId { get; set; }

   public Make MakeNavigation { get; set; }

   public Radio RadioNavigation { get; set; }

  }

}


namespace AutoLot.Samples.Models

{

  public class Radio : BaseEntity

  {

   public bool HasTweeters { get; set; }

   public bool HasSubWoofers { get; set; }

   public string RadioId { get; set; }

   public int CarId { get; set; }

   public Car CarNavigation { get; set; }

  }

}


Поскольку класс

Radio
имеет внешний ключ к классу
Car
(на основе соглашения, которое будет раскрыто вскоре),
Radio
является зависимой, а
Car
— главной сущностью. Исполняющая среда EF Core неявно создает обязательный уникальный индекс на свойстве внешнего ключа в зависимой сущности. Если вы хотите изменить имя индекса, тогда можете воспользоваться аннотациями данных или Fluent API.

Добавьте в класс

ApplicationDbContext
экземпляр
DbSet
:


public virtual DbSet Cars { get; set; }

public virtual DbSet Makes { get; set; }

public virtual DbSet Radios { get; set; }


Создайте миграцию и обновите базу данных с помощью таких команд:


dotnet ef migrations add One2One -o Migrations

 -c AutoLot.Samples.ApplicationDbContext

dotnet ef database update One2One  -c AutoLot.Samples.ApplicationDbContext


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

Cars
не изменяется, но создается таблица
Radios
:


CREATE TABLE [dbo].[Radios](

  [Id] [int] IDENTITY(1,1) NOT NULL,

  [HasTweeters] [bit] NOT NULL,

  [HasSubWoofers] [bit] NOT NULL,

  [RadioId] [nvarchar](max) NULL,

  [TimeStamp] [varbinary](max) NULL,

  [CarId] [int] NOT NULL,

 CONSTRAINT [PK_Radios] PRIMARY KEY CLUSTERED

(

   [Id] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF,

 IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS 
= ON, ALLOW_PAGE_LOCKS = ON,

 OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]

GO

ALTER TABLE [dbo].[Radios]

WITH CHECK ADD  CONSTRAINT [FK_Radios_Cars_CarId] FOREIGN 

KEY([CarId])

REFERENCES [dbo].[Cars] ([Id])

ON DELETE CASCADE

GO

ALTER TABLE [dbo].[Radios] CHECK CONSTRAINT [FK_Radios_Cars_CarId]

GO


Обратите внимание на ограничения внешнего ключа и проверки, созданные для зависимой таблицы (

Radios
).

Отношения "многие ко многим" (нововведение в версии EF Core 5)

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

<Сущность1Сущность2>
. Имя можно изменить в коде через Fluent API. Таблица соединения имеет отношения "один ко многим" с каждой сущностной таблицей:


namespace AutoLot.Samples.Models

{

  public class Car : BaseEntity

  {

   public string Color { get; set; }

   public string PetName { get; set; }

   public int MakeId { get; set; }

   public Make MakeNavigation { get; set; }

   public Radio RadioNavigation { get; set; }

   public IEnumerable Drivers { get; set; } = new List();

  }

}


namespace AutoLot.Samples.Models

{

  public class Driver : BaseEntity

  {

   public string FirstName { get; set; }

   public string LastName { get; set; }

   public IEnumerable Cars { get; set; } = new List();

  }

}


Эквивалентное решение можно обеспечить путем явного создания трех таблиц и именно так приходилось поступать в версиях EF Core, предшествующих EF Core 5. Вот как выглядит сокращенный пример:


public class Driver

{

  ...

  public IEnumerable CarDrivers { get; set; }

}


public class Car

{

  ...

  public IEnumerable CarDrivers { get; set; }

}


public class CarDriver

{

  public int CarId {get;set;}

  public Car CarNavigation {get;set;}

  public int DriverId {get;set;}

  public Driver DriverNavigation {get;set;}

}


Добавьте в класс

ApplicationDbContext
экземпляр
DbSet
:


public virtual DbSet Cars { get; set; }

public virtual DbSet Makes { get; set; }

public virtual DbSet Radios { get; set; }

public virtual DbSet Drivers { get; set; }


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


dotnet ef migrations add Many2Many -o Migrations

 -c AutoLot.Samples.ApplicationDbContext

dotnet ef database update many2Many  -c AutoLot.Samples.ApplicationDbContext


После обновления базы данных с применением миграции EF Core таблица

Cars
не изменяется, но создаются таблицы
Drivers
и
CarDriver
:


CREATE TABLE [dbo].[Drivers](

  [Id] [INT] IDENTITY(1,1) NOT NULL,

  [FirstName] [NVARCHAR](MAX) NULL,

  [LastName] [NVARCHAR](MAX) NULL,

  [TimeStamp] [VARBINARY](MAX) NULL,

 CONSTRAINT [PK_Drivers] PRIMARY KEY CLUSTERED

(

  [Id] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF,

 IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS 
= ON, ALLOW_PAGE_LOCKS = ON,

 OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]

GO


CREATE TABLE [dbo].[CarDriver](

  [CarsId] [int] NOT NULL,

  [DriversId] [int] NOT NULL,

 CONSTRAINT [PK_CarDriver] PRIMARY KEY CLUSTERED

(

  [CarsId] ASC,

  [DriversId] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF,

 IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS 
= ON, ALLOW_PAGE_LOCKS = ON,

 OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

) ON [PRIMARY]

GO

ALTER TABLE [dbo].[CarDriver]

WITH CHECK ADD  CONSTRAINT [FK_CarDriver_Cars_CarsId] FOREIGN 

KEY([CarsId])

REFERENCES [dbo].[Cars] ([Id])

ON DELETE CASCADE

GO

ALTER TABLE [dbo].[CarDriver] CHECK CONSTRAINT [FK_CarDriver_Cars_CarsId]

GO

ALTER TABLE [dbo].[CarDriver]

WITH CHECK ADD  CONSTRAINT [FK_CarDriver_Drivers_DriversId]

FOREIGN KEY([DriversId])

REFERENCES [dbo].[Drivers] ([Id])

ON DELETE CASCADE

GO

ALTER TABLE [dbo].[CarDriver] CHECK CONSTRAINT [FK_CarDriver_Drivers_DriversId]

GO


Обратите внимание на то, что исполняющая среда EF Core создает составной первичный ключ, ограничения проверки (внешних ключей) и каскадное поведение, чтобы обеспечить конфигурирование таблицы

CarDriver
как надлежащей таблицы соединения.


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

CarDriver
. Дополнительные сведения о проблеме доступны по ссылке
https://github.com/dotnet/efcore/issues/22475
.

Каскадное поведение

В большинстве хранилищ данных (вроде SQL Server) установлены правила, управляющие поведением при удалении строки. Если связанные (зависимые) записи тоже должны быть удалены, то такой подход называется каскадным удалением. В EF Core существуют три действия, которые могут произойти при удалении главной сущности (с зависимыми сущностями, загруженными в память):

• зависимые записи удаляются:

• зависимые внешние ключи устанавливаются в

null
;

• зависимые сущности остаются незатронутыми.


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

DeleteBehavior
посредством Fluent API. Ниже перечислены доступные варианты в перечислении:

Cascade
;

ClientCascade
;

ClientNoAction
(не рекомендуется к использованию);

ClientSetNull
;

NoAction
(не рекомендуется к использованию);

SetNull
;

Restrict
.


Указанное поведение в EF Core инициируется только после удаления сущности и вызова метода

SaveChanges()
на экземпляре класса, унаследованного от
DbContext
. Дополнительные сведения о том, когда EF Core взаимодействует с хранилищем данных, ищите в разделе "Выполнение запросов" далее в главе.

Необязательные отношения

Вспомните из табл. 22.4, что необязательными отношениями считаются такие, в которых зависимая сущность может устанавливать значение или значения внешних ключей в

null
. Для необязательных отношений стандартным поведением является
ClientSetNull
. В табл. 22.5 описано каскадное поведение с зависимыми сущностями и влияние на записи базы данных при использовании SQL Server.


Обязательные отношения

Обязательные отношения — это такие отношения, при которых зависимая сущность не может устанавливать значение или значения внешних ключей в

null
. Для обязательных отношений стандартным поведением является
Cascade
. В табл. 22.6 описано каскадное поведение с зависимыми сущностями и влияние на записи базы данных при использовании SQL Server.


Соглашения, связанные с сущностями

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



Во всех предшествующих примерах навигационных свойств для построения отношений между таблицами были задействованы соглашения EF Core.

Отображение свойств на столбцы

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

null
, устанавливаются в хранилище данных как не
null
, а свойства, допускающие
null
, устанавливаются так, чтобы значение
null
было разрешено. Инфраструктура EF Core поддерживает ссылочные типы, допускающие
null
, которые появились в C# 8. Для поддерживающих полей EF Core ожидает их именования с применением одного из следующих соглашений (в порядке старшинства):

_<имя свойства в "верблюжьем" стиле>

_<имя свойства>

m_<имя свойства в "верблюжьем" стиле>

m_<имя свойства>


В случае обновления свойства

Color
класса
Car
для использования поддерживающего поля (по соглашению) оно получило бы имя
_color
,
_Color
,
m_color
или
m_Color
, как показано ниже:


private string _color = "Gold";

public string Color

{

  get => _color;

  set => _color = value;

}

Аннотации данных Entity Framework

Аннотации данных — это атрибуты С#, которые применяются для дальнейшего придания формы вашим сущностям. В табл. 22.8 описаны самые часто используемые аннотации данных, предназначенные для определения деталей того, как ваши сущностные классы и свойства сопоставляются с таблицами и полями базы данных. Аннотации данных переопределяют любые конфликтующие соглашения. В оставшемся материале главы и книги вы увидите еще много аннотаций, которые можно применять для уточнения сущностей в модели.



В следующем коде показан класс

BaseEntity
с аннотацией, которая объявляет поле
Id
первичным ключом. Вторая аннотация свойства
Id
указывает, что оно является столбцом
Identity
в базе данных SQL Server. Свойство
TimeStamp
в SQL Server будет столбцом
timestamp/rowversion
(для проверки параллелизма, рассматриваемой позже в главе).


using System.ComponentModel.DataAnnotations;

using System.ComponentModel.DataAnnotations.Schema;

public abstract class BaseEntity

{

  [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]

  public int Id { get; set; }

  [TimeStamp]

  public byte[] TimeStamp { get; set; }

}


Вот класс

Car
и аннотации данных, которые придают ему форму в базе данных:


using System.Collections.Generic;

using System.ComponentModel.DataAnnotations;

using System.ComponentModel.DataAnnotations.Schema;

using Microsoft.EntityFrameworkCore;


[Table("Inventory", Schema="dbo")]

[Index(nameof(MakeId), Name = "IX_Inventory_MakeId")]

public class Car : BaseEntity

{

  [Required, StringLength(50)]

  public string Color { get; set; }

  [Required, StringLength(50)]

  public string PetName { get; set; }

  public int MakeId { get; set; }

  [ForeignKey(nameof(MakeId))]

  public Make MakeNavigation { get; set; }

  [InverseProperty(nameof(Driver.Cars))]

  public IEnumerable Drivers { get; set; }

}


Атрибут

[Table]
сопоставляет класс
Car
с таблицей Inventory в схеме
dbo
(атрибут
[Column]
применяется для изменения имени столбца или типа данных). Атрибут
[Index]
создает индекс на внешнем ключе
MakeId
. Два строковых поля установлены как
[Required]
и имеющие максимальную длину(
StringLength
) в 50 символов. Атрибуты
[InverseProperty]
и
[ForeignKey]
объясняются в следующем разделе.

Ниже перечислены отличия от соглашений EF Core:

• переименование таблицы из

Cars
в
Inventory
;

• изменение типа данных столбца

TimeStamp
из
varbinary(max)
на
timestamp
в SQL Server;

• установка типа данных и допустимости значения

null
для столбцов
Color
и
PetName
вместо
nvarchar(max)/null
в
nvarchar(50)/
не
null
;

• переименование индекса в

MakeId
.


Остальные используемые аннотации соответствуют конфигурации, определенной соглашениями EF Core.

Если вы создадите миграцию и попробуете ее применить, то миграция потерпит неудачу. СУБД SQL Server не разрешает изменять любой тип данных существующего столбца на

timestamp
. Столбец должен быть удален и затем воссоздан. К сожалению, инфраструктура миграций не позволяет удалять и воссоздавать, а пытается изменить столбец.

Вот как проще всего решить проблему: поместить свойство

TimeStamp
в комментарий внутри базовой сущности, создать и применить миграцию, убрать комментарий со свойства
TimeStamp
и затем создать и применить еще одну миграцию.

Закомментируйте свойство

TimeStamp
вместе с аннотацией данных и выполните следующие команды:


dotnet ef migrations add RemoveTimeStamp -o Migrations

 -c AutoLot.Samples.

ApplicationDbContext

dotnet ef database update RemoveTimeStamp

 -c AutoLot.Samples.ApplicationDbContext


Уберите комментарий со свойства

TimeStamp
и аннотации данных и выполните показанные далее команды, чтобы добавить свойство
TimeStamp
в таблицу как столбец
timestamp
:


dotnet ef migrations add ReplaceTimeStamp -o Migrations

 -c AutoLot.Samples.

ApplicationDbContext

dotnet ef database update ReplaceTimeStamp

 -c AutoLot.Samples.ApplicationDbContext


Теперь база данных соответствует вашей модели.

Аннотации и навигационные свойства

Аннотация

ForeignKey
позволяет EF Core знать, какое свойство является поддерживающим полем для навигационного свойства. По соглашению
<ИмяТипа>Id
автоматически станет свойством внешнего ключа, но в предыдущем примере оно было установлено явно. Такой подход обеспечивает отличающиеся стили именования, а также наличие в таблице более одного внешнего ключа. Кроме того, улучшается читабельность кода.

Свойство

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

Интерфейс Fluent API

С помощью интерфейса Fluent API сущности приложения конфигурируются посредством кода С#. Методы предоставляются экземпляром

ModelBuilder
, доступным в методе
OnModelCreating()
класса
DbContext
. Интерфейс Fluent API является самым мощным способом конфигурирования и переопределяет любые конфликтующие между собой соглашения или аннотации данных. Некоторые конфигурационные параметры доступны только через Fluent API, скажем, стандартные значения для настроек и каскадное поведение для навигационных свойств.

Отображение классов и свойств

В следующем коде воспроизведен предыдущий пример

Car
с использованием Fluent API вместо аннотаций данных (здесь не показаны навигационные свойства, которые будут раскрыты ниже):


modelBuilder.Entity(entity =>

{

  entity.ToTable("Inventory","dbo");

  entity.HasKey(e=>e.Id);

  entity.HasIndex(e => e.MakeId, "IX_Inventory_MakeId");

  entity.Property(e => e.Color)

   .IsRequired()

   .HasMaxLength(50);

  entity.Property(e => e.PetName)

   .IsRequired()

   .HasMaxLength(50);

 entity.Property(e => e.TimeStamp)

   .IsRowVersion()

   .IsConcurrencyToken();

});


Если создать и запустить миграцию прямо сейчас, то вы обнаружите, что ничего не изменилось, поскольку вызываемые методы Fluent API соответствуют текущей конфигурации, определенной соглашениями и аннотациями данных.

Стандартные значения

Интерфейс Fluent API предлагает методы, позволяющие устанавливать стандартные значения для столбцов. Стандартное значение может иметь тип значения или быть строкой SQL. Например, вот как установить стандартное значение

Color
для новой сущности
Car
в
Black
:


modelBuilder.Entity(entity =>

{

...

  entity.Property(e => e.Color)

  .HasColumnName("CarColor")

  .IsRequired()

  .HasMaxLength(50)

  .HasDefaultValue("Black");

});


Чтобы установить значение для функции базы данных (вроде

getdate()
), применяйте метод
HasDefaultValueSql()
. Предположим, что в класс
Car
было добавлено свойство
DateTime
по имени
DateBuilt
, а стандартным значением должна быть текущая дата, получаемая с использованием метода
getdate()
в SQL Server. Столбцы конфигурируются следующим образом:


modelBuilder.Entity(entity =>

{

  ...

  entity.Property(e => e.DateBuilt)

  .HasDefaultValueSql("getdate()");

});


Как и в случае применения SQL для вставки записи, если свойство, которое отображается на столбец со стандартным значением, имеет значение, когда EF Core вставляет запись, то вместо стандартного значения столбца будет использоваться значение свойства. Если значение свойства равно

null
, тогда применяется стандартное значение столбца.

Проблема возникает при наличии стандартного значения у типа данных свойства. Вспомните, что стандартное значение для числовых типов составляет

0
, а для булевских —
false
. Если вы установите значение числового свойства в
0
или булевского свойства в
false
и затем вставите такую сущность, тогда инфраструктура EF Core будет трактовать это свойство как не имеющее установленного значения. При отображении свойства на столбец со стандартным значением используется стандартное значение в определении столбца.

Например, добавьте в класс

Car
свойство типа
bool
по имени
IsDrivable
. Установите в
true
стандартное значение для отображения свойства на столбец:


// Car.cs

public class Car : BaseEntity

{

  ...

  public bool IsDrivable { get; set; }

}


// ApplicationDbContext

protected override void OnModelCreating(ModelBuilder modelBuilder)

{

  modelBuilder.Entity(entity =>

  {

  ...

  entity.Property(e => e.IsDrivable).HasDefaultValue(true);

});


В случае если вы сохраните новую запись с

IsDrivable = false
, то значение
false
игнорируется (т.к. оно является стандартным значением для булевского типа) и будет применяться стандартное значение. Таким образом, значение для
IsDrivable
всегда будет равно
true
! Одно из решений предусматривает превращение вашего открытого свойства (и, следовательно, столбца) в допускающее
null
, но это может не соответствовать бизнес-потребностям.

Другое решение предлагается инфраструктурой EF Core, в частности, ее работой с поддерживающими полями. Вспомните, что если поддерживающее поле существует (и идентифицируется как таковое для свойства через соглашение, аннотацию данных или Fluent API), тогда для действий по чтению и записи EF Core будет использовать поддерживающее поле, а не открытое свойство.

Если вы модифицируете

IsDrivable
с целью применения поддерживающего поля, допускающего
null
(но оставите свойство не допускающим
null
), то EF Core будет выполнять чтение и запись, используя поддерживающее поле, а не свойство. Стандартным значением для булевского типа, допускающего
null
, является
null
— не
false
. Описанное изменение обеспечит ожидаемое поведение свойства:


public class Car

{

  ...

  private bool? _isDrivable;

  public bool IsDrivable

  {

   get => _isDrivable ?? true;

    set => _isDrivable = value;

  }


Для информирования EF Core о поддерживающем поле используется Fluent API:


modelBuilder.Entity(entity =>

{

  entity.Property(p => p.IsDrivable)

   .HasField("_isDrivable")

   .HasDefaultValue(true);

});


На заметку! В приведенном примере вызов метода

HasField()
не обязателен, потому что имя поддерживающего поля следует соглашениям об именовании. Он включен в целях демонстрации применения Fluent API для указания поддерживающего поля.


Исполняющая среда EF Core транслирует поле в показанное ниже определение SQL:


CREATE TABLE [dbo].[Inventory](

...

  [IsDrivable] [BIT] NOT NULL,

...

GO

ALTER TABLE [dbo].[Inventory] ADD  DEFAULT (CONVERT([BIT],(1)))

FOR [IsDrivable]

GO

Вычисляемые столбцы

Столбцы также могут вычисляться на основе возможностей хранилища данных. Для SQL Server есть два варианта: вычислять значение, основываясь на других полях в той же самой записи, либо использовать скалярную функцию. Скажем, чтобы создать в таблице

Inventory
вычисляемый столбец, который объединяет значения
PetName
и
Color
для создания
DisplayName
, применяйте функцию
HasComputedColumnSql()
:


modelBuilder.Entity(entity =>

{

  entity.Property(p => p.FullName)

   .HasComputedColumnSql("[PetName] + ' (' + [Color] + ')'");

});


В версии EF Core 5 появилась возможность сохранения вычисляемых значений, так что значение вычисляется только при создании или обновлении строки. Хотя в SQL Server упомянутая возможность поддерживается, она присутствует не во всех хранилищах данных, поэтому проверяйте документацию по своему поставщику баз данных:


modelBuilder.Entity(entity =>

{

  entity.Property(p => p.FullName)

   .HasComputedColumnSql("[PetName] + ' (' + [Color] + ')'", stored:true);

});

Отношения "один ко многим"

Чтобы определить отношение "один ко многим" с помощью Fluent API, выберите одну из сущностей, подлежащих обновлению. Обе стороны навигационной цепочки устанавливаются в одном блоке кода:


modelBuilder.Entity(entity =>

{

  ...

  entity.HasOne(d => d.MakeNavigation)

   .WithMany(p => p.Cars)

   .HasForeignKey(d => d.MakeId)

   .OnDelete(DeleteBehavior.ClientSetNull)

   .HasConstraintName("FK_Inventory_Makes_MakeId");

});


Если вы выберете в качестве основы для конфигурации навигационной сущности главную сущность, тогда код будет выглядеть примерно так:


modelBuilder.Entity(entity =>

{

  ...

  entity.HasMany(e=>e.Cars)

   .WithOne(c=>c.MakeNavigation)

   .HasForeignKey(c=>c.MakeId)

   .OnDelete(DeleteBehavior.ClientSetNull)

   .HasConstraintName("FK_Inventory_Makes_MakeId");

 });

Отношения "один к одному"

Отношения "один к одному" конфигурируются аналогично, но только вместо метода

WithMany()
интерфейса Fluent API используется метод
WithOne()
. К зависимой сущности добавляется уникальный индекс. Вот код для отношения между сущностями
Car
и
Radio
, где применяется зависимая сущность (
Radio
):


modelBuilder.Entity(entity =>

{

  entity.HasIndex(e => e.CarId, "IX_Radios_CarId")

   .IsUnique();


  entity.HasOne(d => d.CarNavigation)

   .WithOne(p => p.RadioNavigation)

   .HasForeignKey(d => d.CarId);

});


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

Car
и
Radio
, где для отношения используется главная сущность:


modelBuilder.Entity(entity =>

{

  entity.HasIndex(e => e.CarId, "IX_Radios_CarId")

   .IsUnique();

});


modelBuilder.Entity(entity =>

{

  entity.HasOne(d => d.RadioNavigation)

   .WithOne(p => p.CarNavigation)

   .HasForeignKey(d => d.CarId);

});

Отношения "многие ко многим"

Отношения "многие ко многим" гораздо легче настраивать посредством Fluent API. Имена полей внешних ключей, имена индексов и каскадное поведение могут быть установлены в операторах, определяющих отношение. Ниже показан пример отношения "многие ко многим", переделанный с применением Fluent API (имена ключей и столбцов были изменены, чтобы улучшить читабельность):


modelBuilder.Entity()

  .HasMany(p => p.Drivers)

  .WithMany(p => p.Cars)

  .UsingEntity>(

   "CarDriver",

   j => j

  .HasOne()

    .WithMany()

    .HasForeignKey("DriverId")

    .HasConstraintName("FK_CarDriver_Drivers_DriverId")

    .OnDelete(DeleteBehavior.Cascade),

   j => j

    .HasOne()

    .WithMany()

    .HasForeignKey("CarId")

    .HasConstraintName("FK_CarDriver_Cars_CarId")

    .OnDelete(DeleteBehavior.ClientCascade));

Соглашения, аннотации данных и Fluent API — что выбрать?

В настоящий момент вас может интересовать, какой из вариантов следует выбирать для формирования ваших сущностей, а также их связей друг с другом и с хранилищем данных? Ответ: все три. Соглашения активны всегда (если только вы не переопределите их посредством аннотаций данных или Fluent API). С помощью аннотаций данных можно делать почти все то, на что способны методы Fluent API, и хранить информацию в самом сущностном классе, повышая в ряде случаев читабельность кода и удобство его сопровождения. Из трех вариантов наиболее мощным является Fluent API, но код скрыт в классе

DbContext
. Независимо от того, используете вы аннотации данных или Fluent API, имейте в виду, что аннотации данных переопределяют встроенные соглашения, а методы Fluent API переопределяют вообще все.

Выполнение запросов

Запросы на извлечение данных создаются посредством запросов LINQ в отношении свойств

DbSet
. На стороне сервера механизм трансляции LINQ поставщика баз данных видоизменяет запрос LINQ с учетом специфичного для базы данных языка (скажем, Т-SQL). Запросы LINQ, охватывающие (или потенциально охватывающие) множество записей, не выполняются до тех пор, пока не начнется проход по результатам запросов (например, с применением
foreach
) или не произойдет привязка к элементу управления для их отображения (наподобие визуальной сетки данных). Такое отложенное выполнение позволяет строить запросы в коде, не испытывая проблем с производительностью из-за частого взаимодействия с базой данных.

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


var cars = Context.Cars.Where(x=>x.Color == "Yellow");


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

ToList()
:


var cars = Context.Cars.Where(x=>x.Color == "Yellow").ToList();


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


var query = Context.Cars.AsQueryable();

query = query.Where(x=>x.Color == "Yellow");

var cars = query.ToList();


Запросы с одной записью (как в случае применения

First()/FirstOrDefault()
) выполняются немедленно при вызове действия (такого как
FirstOrDefault()
), а операторы создания, обновления и удаления выполняются немедленно, когда запускается метод
DbContext.SaveChanges()
.

Смешанное выполнение на клиентской и серверной сторонах

В предшествующих версиях EF Core была введена возможность смешивания выполнения на стороне сервера и на стороне клиента. Это означало, что где-то в середине оператора LINQ можно было бы вызвать функцию C# и по существу свести на нет все преимущества, описанные в предыдущем разделе. Часть до вызова функции C# выполнится на стороне сервера, но затем все результаты (в данной точке запроса) доставляются на сторону клиента и остаток запроса будет выполнен как LINQ to Objects. В итоге возможность смешанного выполнения привнесла больше проблем, нежели решила, и в выпуске EF Core 3.1 такая функциональность была изменена. Теперь выполнять на стороне клиента можно только последний узел оператора LINQ.

Сравнение отслеживаемых и неотслеживаемых запросов

При чтении информации из базы данных в экземпляр

DbSet
сущности (по умолчанию) отслеживаются компонентом
ChangeTracker
, что обычно и требуется в приложении. Как только начинается отслеживание экземпляра компонентом
ChangeTracker
, любые последующие обращения к базе данных за тем же самым элементом (на основе первичного ключа) будут приводить к обновлению элемента, а не к его дублированию.

Однако временами из базы данных необходимо получать данные, отслеживать которые с помощью

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

Чтобы загрузить экземпляр

DbSet
, не помещая данные в
ChangeTracker
, добавьте к оператору LINQ вызов
AsNoTracking()
, который указывает EF Core о необходимости извлечения данных без их помещения в
ChangeTracker
. Например, для загрузки записи
Car
без ее добавления в
ChangeTracker
введите следующий код:


public virtual Car? FindAsNoTracking(int id)

  => Table.AsNoTracking().FirstOrDefault(x => x.Id == id);


Преимущество показанного кода в том, что он не увеличивает нагрузку на память, но с ним связан и недостаток: дополнительные вызовы для извлечения той же самой записи

Car
создадут ее добавочные копии. За счет потребления большего объема памяти и чуть более длительного выполнения запрос можно модифицировать, чтобы гарантировать наличие только одного экземпляра несопоставленной сущности
Car
:


public virtual Car? FindAsNoTracking(int id)

  => Table.AsNoTrackingWithIdentityResolution().FirstOrDefault(x => x.Id == id);

Важные функциональные средства EF Core

Многие функциональные средства из EF 6 были воспроизведены в EF Core, а с каждым выпуском добавляются новые возможности. Множество средств в EF Core усовершенствовано как с точки зрения функциональности, так и в плане производительности. В дополнение к средствам, воспроизведенным из EF 6, инфраструктура EF Core располагает многочисленными новыми возможностями, которые в предыдущей версии отсутствовали. Ниже приведены наиболее важные функциональные средства инфраструктуры EF Core (в произвольном порядке).


На заметку! Фрагменты кода в текущем разделе взяты прямо из завершенной библиотеки доступа к данным

AutoLot
, которая будет построена в следующей главе.

Обработка значений, генерируемых базой данных

Помимо отслеживания изменений и генерации запросов SQL из LINQ существенным преимуществом использования EF Core по сравнению с низкоуровневой инфраструктурой ADO.NET является гладкая обработка значений, генерируемых базой данных. После добавления или обновления сущности исполняющая среда EF Core запрашивает любые данные, генерируемые базой, и автоматически обновляет сущность с применением корректных значений. При работе с низкоуровневой инфраструктурой ADO.NET это пришлось бы делать самостоятельно.

Например, таблица

Inventory
имеет целочисленный первичный ключ, который определяется в SQL Server как столбец
Identity
. Столбцы
Identity
заполняются СУБД SQL Server уникальными числами (из последовательности) при добавлении записи и не могут обновляться во время обычных обновлений (исключая особый случай
IDENTITY_INSERT
). Кроме того, таблица
Inventory
содержит столбец
TimeStamp
для проверки параллелизма. Проверка параллелизма рассматривается далее, а пока достаточно знать, что столбец
TimeStamp
поддерживается SQL Server и обновляется при любом действии добавления или редактирования.

В качестве примера возьмем добавление новой записи

Car
в таблицу
Inventory
. В приведенном ниже коде создается новый экземпляр
Car
, который добавляется к экземпляру
DbSet
класса, производного от
DbContext
, и вызывается метод
SaveChanges()
для сохранения данных:


var car = new Car

{

  Color = "Yellow",

  MakeId = 1,

  PetName = "Herbie"

};

Context.Cars.Add(car);

Context.SaveChanges();


При выполнении метода

SaveChanges()
в таблицу вставляется новая запись, после чего исполняющей среде EF Core возвращаются значения
Id
и
TimeStamp
из таблицы, причем свойства сущности обновляются надлежащим образом:


INSERT INTO [Dbo].[Inventory] ([Color], [MakeId], [PetName])

VALUES (N'Yellow', 1, N'Herbie');

SELECT [Id], [TimeStamp]

FROM [Dbo].[Inventory]

WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();


На заметку! Фактически EF Core выполняет параметризованные запросы, но приводимые примеры упрощены ради читабельности.


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

Car
запрашивается и возвращается только значение
TimeStamp
.

Проверка параллелизма

Проблемы с параллелизмом возникают, когда два отдельных процесса (пользователя или системы) пытаются почти одновременно обновить ту же самую запись. Скажем, пользователи User 1 и User 2 получают данные для Customer А. Пользователь User 1 обновляет адрес и сохраняет изменения. Пользователь User 2 обновляет кредитный риск и пытается сохранить ту же запись. Если сохранение для пользователя User 2 сработало, тогда изменения от пользователя User 1 будут отменены, т.к. после того, как пользователь User 2 извлек запись, адрес изменился. Другой вариант — отказ сохранения для пользователя User 2, когда изменения для User 1 записываются, но изменения для User 2 — нет.

Обработка описанной ситуации зависит от требований приложения. Решения простираются от бездействия (второе обновление переписывает первое) и применения оптимистического параллелизма (второе обновление терпит неудачу) до более сложных подходов, таких как проверка индивидуальных полей. За исключением варианта бездействия (повсеместно считающегося признаком плохого стиля программирования) разработчики обязаны знать, когда возникают проблемы с параллелизмом, чтобы иметь возможность обработать их надлежащим образом.

К счастью, многие современные СУБД оснащены инструментами, которые помогают разработчикам решать проблемы с параллелизмом. В SQL Server имеется встроенный тип данных под названием

timestamp
— синоним для
rowversion
. Если столбец определен с типом данных
timestamp
, то при добавлении записи в базу данных значение для этого столбца создается СУБД SQL Server, а при обновлении записи значение столбца тоже обновляется. Фактически гарантируется, что значение будет уникальным и управляться СУБД SQL Server.

В EF Core можно задействовать тип данных

timestamp
из SQL Server, реализуя внутри сущности свойство
TimeStamp
(представляемое в C# как
byte[]
). Свойства сущностей, определенные с применением атрибута
TimeStamp
либо Fluent API, предназначены для добавления в конструкцию
where
при обновлении или удалении записей. Вместо того чтобы просто использовать значение (значения) первичного ключа, в конструкцию
where
генерируемого оператора SQL добавляется значение свойства
timestamp
, что ограничивает результаты записями, у которых совпадают значения первичного ключа и отметки времени. Если запись была обновлена другим пользователем (или системой), тогда значения отметок времени не совпадут, так что оператор
update
не обновит, а оператор
delete
не удалит запись. Вот пример запроса обновления, в котором применяется столбец
TimeStamp
:


UPDATE [Dbo].[Inventory] SET [Color] = N'Yellow'

WHERE [Id] = 1 AND [TimeStamp] = 0x000000000000081F;


Когда хранилище сообщает о количестве затронутых записей, отличающемся от количества записей, изменения которых ожидает

ChangeTracker
, исполняющая среда EF Core генерирует исключение
DbUpdateConcurrencyException
и выполняет откат всей транзакции. Экземпляр
DbUpdateConcurrencyException
содержит информацию о записях, которые не были сохранены, куда входят первоначальные значения (полученные в результате загрузки из базы данных) и текущие значения (после их обновления пользователем/системой). Кроме того, существует метод для получения текущих значений в базе данных (требующий еще одного обращения к серверу). Располагая настолько большим количеством информации, разработчик затем может обработать ошибку параллелизма так, как того требует приложение. Ниже приведен пример:


try

{

  // Получить запись для автомобиля (неважно какую).

  var car = Context.Cars.First();

  // Обновить базу данных извне контекста.

  Context.Database.ExecuteSqlInterpolated($"Update dbo.Inventory set Color='Pink' where Id = 
{car.Id}");

  // Обновить запись для автомобиля в ChangeTracker

  // и попробовать сохранить изменения.

  car.Color = "Yellow";

  Context.SaveChanges();

}

catch (DbUpdateConcurrencyException ex)

{

  // Получить сущность, которую не удалось обновить.

  var entry = ex.Entries[0];

  /// Получить первоначальные значения (когда сущность была загружена).

  PropertyValues originalProps = entry.OriginalValues;

  // Получить текущие значения (обновленные кодом выше).

  PropertyValues currentProps = entry.CurrentValues;

  // Получить текущие значения из хранилища данных.

  // Примечание: это требует еще одного обращения к базе данных

  //PropertyValues databaseProps = entry.GetDatabaseValues();

}

Устойчивость подключений

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

tempdb
, ограничения пользователей и т.д.), который может быть задействован EF Core. Для SQL Server кратковременные ошибки (согласно определению команды разработчиков СУБД) перехватываются экземпляром класса
SqlServerRetryingExecutionStrategy
, и если он включен в объекте производного от
DbContext
класса через
DbContextOptions
, то EF Core автоматически повторяет операцию до тех пор, пока не достигнет максимального предела повторов.

При работе с SQL Server доступен сокращенный метод, который можно использовать для включения

SqlServerRetryingExecutionStrategy
со всеми стандартными параметрами. Метод, который применяется с
SqlServerOptions
— это
EnableRetryOnFailure()
:


public ApplicationDbContext CreateDbContext(string[] args)

{

  var optionsBuilder = new DbContextOptionsBuilder();

  var connectionString = @"server=.,5433;Database=AutoLot50;

   User Id=sa;Password=P@ssw0rd;";

  optionsBuilder.UseSqlServer(connectionString,

   options => options.EnableRetryOnFailure());

  return new ApplicationDbContext(optionsBuilder.Options);


Максимальное количество повторов и предельное время между повторами можно конфигурировать в зависимости от требований приложения. Если предел повторов достигается без завершения операции, тогда EF Core уведомит приложение о проблемах с подключением путем генерации

RetryLimitExceededException
. В случае обработки это исключение способно передавать необходимую информацию пользователю, обеспечивая лучший отклик:


try

{

  Context.SaveChanges();

}

catch (RetryLimitExceededException ex)

{

  // Превышен предел повторов.

  // Требуется интеллектуальная обработка.

  Console.WriteLine($"Retry limit exceeded! {ex.Message}");

}


Для поставщиков баз данных, которые не предлагают встроенной стратегии выполнения, можно создавать специальную стратегию выполнения. Дополнительные сведения ищите в документации по EF Core:

https://docs.microsoft.com/ru-ru/ef/core/miscellaneous/connection-resiliency
.

Связанные данные

Навигационные свойства сущности используются для загрузки связанных данных сущности. Связанные данные можно загружать энергичным образом (один оператор LINQ, один запрос SQL), энергичным образом с разделением запросов (один оператор LINQ, множество запросов SQL), явным образом (множество вызовов LINQ, множество запросов SQL) или ленивым образом (один оператор LINQ, множество запросов SQL по требованию).

Помимо возможности загрузки связанных данных с применением навигационных свойств исполняющая среда EF Core будет автоматически приводить в порядок сущности по мере их загрузки в

ChangeTracker
. В качестве примера предположим, что все записи
Make
загружаются в
DbSet
, после чего все записи
Car
загружаются в
DbSet
. Несмотря на то что записи загружались по отдельности, они будут доступны друг другу через навигационные свойства.

Энергичная загрузка

Энергичная загрузка — это термин для обозначения загрузки связанных записей из множества таблиц в рамках одного обращения к базе данных. Прием аналогичен созданию запроса в Т-SQL, связывающего две или большее число таблиц с помощью соединений. Когда сущности имеют навигационные свойства, которые используются в запросах LINQ, механизм трансляции применяет соединения, чтобы получить данные из связанных таблиц, и загружает соответствующие сущности. Такое решение обычно гораздо эффективнее, чем выполнение одного запроса с целью получения данных из одной таблицы и выполнение дополнительных запросов для каждой связанной таблицы. В ситуациях, когда использовать один запрос менее эффективно, в EF Core 5 предусмотрено разделение запросов, которое рассматривается далее.

Методы

Include()
и
ThenInclude()
(для последующих навигационных свойств) применяются для обхода навигационных свойств в запросах LINQ. Если отношение является обязательным, тогда механизм трансляции LINQ создаст внутреннее соединение. Если же отношение необязательное, то механизм трансляции создаст левое соединение.

Например, чтобы загрузить все записи

Car
со связанной информацией
Make
, запустите следующий запрос LINQ:


var queryable = Context.Cars.IgnoreQueryFilters().Include(

  c => c.MakeNavigation).ToList();


Предыдущий запрос LINQ выполняет в отношении базы данных такой запрос:


SELECT [i].[Id], [i].[Color], [i].[MakeId], [i].[PetName], [i].[TimeStamp],

  [m].[Id], [m].[Name], [m].[TimeStamp]

FROM [Dbo].[Inventory] AS [i]

INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId] = [m].[Id]


В одном запросе можно использовать множество вызовов

Include()
для соединения исходной сущности сразу с несколькими сущностями. Чтобы спуститься вниз по дереву навигационных свойств, применяйте
ThenInclude()
после
Include()
. Скажем, для получения всех записей
Cars
со связанной информацией
Make
и
Order
, а также информацией
Customer
, связанной с
Order
, используйте показанный ниже оператор:


var cars = Context.Cars.Where(c => c.Orders.Any())

  .Include(c => c.MakeNavigation)

  .Include(c => c.Orders).ThenInclude(o => o.CustomerNavigation).ToList();

Фильтрованные включаемые данные

В версии EF Core 5 появилась возможность фильтрации и сортировки включаемых данных. Допустимыми операциями при навигации по коллекции являются

Where()
,
OrderBy()
,
OrderByDescending()
,
ThenBy()
,
ThenByDescending()
,
Skip()
и
Take()
. Например, если нужно получить все записи
Make
, но только со связанными записями
Car
с желтым цветом, тогда вы организуете фильтрацию навигационного свойства в лямбда-выражении такого вида:


var query = Context.Makes

   .Include(x => x.Cars.Where(x=>x.Color == "Yellow")).ToList();


В результате запустится следующий запрос:


SELECT [m].[Id], [m].[Name], [m].[TimeStamp], [t].[Id], [t].[Color],

 [t].[MakeId], [t].[PetName], [t].[TimeStamp]

FROM [dbo].[Makes] AS [m]

LEFT JOIN (

  SELECT [i].[Id], [i].[Color], [i].[MakeId], [i].[PetName], [i].[TimeStamp]

   FROM [Dbo].[Inventory] AS [i]

   WHERE [i].[Color] = N'Yellow') AS [t] ON [m].[Id] = [t].[MakeId]

ORDER BY [m].[Id], [t].[Id]

Энергичная загрузка с разделением запросов

Наличие в запросе LINQ множества вызовов

Include()
может отрицательно повлиять на производительность. Для решения проблемы в EF Core 5 были введены разделяемые запросы. Вместо выполнения одиночного запроса исполняющая среда EF Core будет разделять запрос LINQ на несколько запросов SQL и затем объединять все связанные данные. Скажем, добавив к запросу LINQ вызов
AsSplitQuery()
, можно ожидать, что предыдущий запрос будет представлен в виде множества запросов SQL:


var query = Context.Makes.AsSplitQuery()

  .Include(x => x.Cars.Where(x=>x.Color == "Yellow")).ToList();


Вот как выглядят выполняемые запросы:


SELECT [m].[Id], [m].[Name], [m].[TimeStamp]

FROM [dbo].[Makes] AS [m]

ORDER BY [m].[Id]

SELECT [t].[Id], [t].[Color], [t].[MakeId], [t].[PetName],

     [t].[TimeStamp], [m].[Id]

FROM [dbo].[Makes] AS [m]

INNER JOIN (

   SELECT [i].[Id], [i].[Color], [i].[MakeId], [i].[PetName], [i].[TimeStamp]

   FROM [Dbo].[Inventory] AS [i]

   WHERE [i].[Color] = N'Yellow'

) AS [t] ON [m].[Id] = [t].[MakeId]

ORDER BY [m].[Id]


Применению разделяемых запросов присущ и недостаток: если данные изменяются между выполнением запросов, тогда возвращаемые данные будут несогласованными.

Явная загрузка

Явная загрузка — это загрузка данных по навигационному свойству после того, как главный объект уже загружен. Такой процесс включает в себя дополнительное обращение к базе данных для получения связанных данных. Прием может быть удобен, если приложению необходимо получать связанные записи выборочно на основе какого-то действия пользователя, а не извлекать все связанные записи.

Процесс начинается с уже загруженной сущности и использования метода

Entry()
на экземпляре производного от
DbContext
класса. При запросе в отношении навигационного свойства типа ссылки (например, с целью получения информации
Make
для автомобиля) применяйте метод
Reference()
. При запросе в отношении навигационного свойства типа коллекции используйте метод
Collection()
. Выполнение запроса откладывается до вызова
Load()
,
ToList()
или агрегирующей функции (вроде
Count()
либо
Мах()
).

В следующих примерах показано, как получить связанные данные о производителе и заказах для записи

Car
:


// Получить запись Car.

var car = Context.Cars.First(x => x.Id == 1);

// Получить информацию о производителе.

Context.Entry(car).Reference(c => c.MakeNavigation).Load();

// Получить заказы, к которым относится данная запись Car.

Context.Entry(car).Collection(c => c.Orders).Query().

  IgnoreQueryFilters().Load();

Ленивая загрузка

Ленивая загрузка представляет собой загрузку записи по требованию, когда навигационное свойство применяется для доступа к связанной записи, которая пока еще не загружена в память. Ленивая загрузка — это средство EF 6, снова добавленное в версию EF Core 2.1. Хотя включение ленивой загрузки кажется разумной идеей, временами она может стать причиной возникновения проблем с производительностью в вашем приложении из-за потенциально лишних циклов взаимодействия с базой данных. В результате по умолчанию ленивая загрузка в EF Core отключена (в EF 6 она была включена).

Ленивая загрузка может быть полезна в приложениях интеллектуальных клиентов (WPF, Windows Forms), но в веб-приложениях и службах использовать ее не рекомендуется, так что в книге она не рассматривается. За дополнительными сведениями о ленивой загрузке и ее применением с EF Core обращайтесь в документацию по ссылке

https://docs.microsoft.com/ru-ru/ef/core/querying/related-data/lazy
.

Глобальные фильтры запросов

Глобальные фильтры запросов позволяют добавлять конструкцию

where
во все запросы LINQ для определенной сущности. Например, распространенное проектное решение для баз данных предусматривает использование "мягкого" удаления вместо "жесткого". В таблицу добавляется поле, указывающее состояние удаления записи. Если запись "удалена", то значение поля устанавливается в
true
(или
1
), но запись из базы данных не убирается. Прием называется "мягким" удалением. Чтобы отфильтровать записи, повергнувшиеся "мягкому" удалению, от тех, которые обрабатывались нормальными операциями, каждая конструкция
where
обязана проверять значение поля с состоянием удаления записи. Включение такого фильтра в каждый запрос может занять много времени, да и не забыть о нем довольно проблематично.

Инфраструктура EF Core позволяет добавлять к сущности глобальный фильтр запросов, который затем применяется к каждому запросу, вовлекающему эту сущность. Для описанного выше примера с "мягким" удалением вы устанавливаете фильтр на сущностном классе, чтобы исключить записи, повергнувшиеся "мягкому" удалению. К любым создаваемым EF Core запросам, затрагивающим сущности с глобальными фильтрами запросов, будут применяться их фильтры. Вам больше не придется помнить о необходимости включения конструкции

where
в каждый запрос.

Придерживаясь в книге темы автомобилей, предположим, что все записи

Car
, которые не являются управляемыми, должны отфильтровываться из нормальных запросов. Вот как можно добавить глобальный фильтр запросов с использованием Fluent API:


modelBuilder.Entity(entity =>

{

  entity.HasQueryFilter(c => c.IsDrivable == true);

  entity.Property(p => p.IsDrivable).HasField("_isDrivable").

   HasDefaultValue(true);

});


Благодаря такому глобальному фильтру запросов запросы, вовлекающие сущность

Car
, будут автоматически отфильтровывать неуправляемые автомобили. Скажем, запуск следующего запроса LINQ:


var cars = Context.Cars.ToList();


приводит к выполнению показанного ниже оператора SQL:


SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId],

     [i].[PetName], [i].[TimeStamp]

FROM [Dbo].[Inventory] AS [i]

WHERE [i].[IsDrivable] = CAST(1 AS bit)


Если вам нужно просмотреть отфильтрованные записи, тогда добавьте в запрос LINQ вызов

IgnoreQueryFilters()
, который отключает глобальные фильтры запросов для каждой сущности в запросе LINQ. Запуск представленного далее запроса LINQ:


var cars = Context.Cars.IgnoreQueryFilters().ToList();


инициирует выполнение следующего оператора SQL:


SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId],

     [i].[PetName], [i].[TimeStamp]

FROM [Dbo].[Inventory] AS [i]


Важно отметить, что вызов

IgnoreQueryFilters()
удаляет фильтр запросов для всех сущностей в запросе LINQ, в том числе и тех, которые задействованы в вызовах
Include()
или
Thenlnclude()
.

Глобальные фильтры запросов на навигационных свойствах

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

Car
, представляющий неуправляемый автомобиль. Фильтр создается на навигационном свойстве
CarNavigation
сущности
Order
:


modelBuilder.Entity().HasQueryFilter(e => e.CarNavigation.IsDrivable);


При выполнении стандартного запроса LINQ любые заказы, содержащие неуправляемый автомобиль, будут исключаться из результата. Ниже показан оператор LINQ и генерированный оператор SQL:


// Код C#

var orders = Context.Orders.ToList();

/* Сгенерированный запрос SQL */

SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp]

FROM [Dbo].[Orders] AS [o]

INNER JOIN (SELECT [i].[Id], [i].[IsDrivable]

            FROM [Dbo].[Inventory] AS [i]

            WHERE [i].[IsDrivable] = CAST(1 AS bit)) AS [t]

     ON [o].[CarId] = [t].[Id]

WHERE [t].[IsDrivable] = CAST(1 AS bit)


Для удаления фильтра запросов используйте вызов

IgnoreQueryFilters()
. Вот как выглядит модифицированный оператор LINQ и сгенерированный запрос SQL:


// Код C#

var orders = Context.Orders.IgnoreQueryFilters().ToList();

/* Сгенерированный запрос SQL */

SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp]

FROM [Dbo].[Orders] AS [o]


Здесь уместно предостеречь: исполняющая среда EF Core не обнаруживает циклические глобальные фильтры запросов, поэтому при добавлении фильтров запросов к навигационным свойствам соблюдайте осторожность.

Явная загрузка с глобальными фильтрами запросов

Глобальные фильтры запросов действуют и при явной загрузке связанных данных. Например, если вы хотите загрузить записи

Car
для
Make
, то фильтр
IsDrivable
предотвратит загрузку в память записей, представляющих неуправляемые автомобили. В качестве примера взгляните на следующий фрагмент кода:


var make = Context.Makes.First(x => x.Id == makeId);

Context.Entry(make).Collection(c=>c.Cars).Load();


К настоящему моменту не должен вызывать удивление тот факт, что сгенерированный оператор SQL включает фильтр для неуправляемых автомобилей:


SELECT [i].[Id], [i].[Color], [i].[IsDrivable],

        [i].[MakeId], [i].[PetName], [i].[TimeStamp]

FROM [Dbo].[Inventory] AS [i]

WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND ([i].[MakeId] = 1


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

Collection()
является
CollectionEntry
, который явно не реализует интерфейс
IQueryable
. Чтобы вызвать
IgnoreQueryFilters()
, сначала потребуется вызвать метод
Query()
, который возвращает экземпляр реализации
IQueryable
:


var make = Context.Makes.First(x => x.Id == makeId);

Context.Entry(make).Collection(c=>c.Cars).Query().IgnoreQueryFilters().Load();


Тот же процесс применяется в случае использования метода

Reference()
для извлечения данных из навигационного свойства типа коллекции.

Выполнение низкоуровневых запросов SQL с помощью LINQ

Иногда получить корректный оператор LINQ для компилируемого запроса сложнее, чем просто написать код SQL напрямую. К счастью, инфраструктура EF Core располагает механизмом, позволяющим выполнять низкоуровневые операторы SQL в

DbSet
. Методы
FromSqlRaw()
и
FromSqlRawInterpolated()
принимают строку, которая становится основой запроса LINQ. Такой запрос выполняется на стороне сервера.

Если низкоуровневый оператор SQL не является завершающим (скажем, хранимой процедурой, пользовательской функцией или оператором, который использует общее табличное выражение или заканчивается точкой с запятой), тогда в запрос можно добавить дополнительные операторы LINQ. Дополнительные операторы LINQ наподобие конструкций

Include()
,
OrderBy()
или
Where()
будут объединены с первоначальным низкоуровневым обращением SQL и любыми глобальными фильтрами запросов, после чего весь запрос выполнится на стороне сервера.

При использовании одного из вариантов

FromSql*()
запрос должен формироваться с использованием схемы базы данных и имени таблицы, а не имен сущностей. Метод
FromSqlRaw()
отправит строку в том виде, в каком она записана. Метод
FromSqlInterpolated()
применяет интерполяцию строк C# и каждая интерполированная строка транслируется в параметр SQL. В случае использования переменных вы должны использовать версию с интерполяцией для обеспечения дополнительной защиты, присущей параметризованным запросам.

Предположим, что для сущности

Car
установлен глобальный фильтр запросов. Тогда показанный ниже оператор LINQ получит первую запись
Inventory
со значением
Id
, равным
1
, включит связанные данные
Make
и отфильтрует записи, касающиеся неуправляемых автомобилей:


var car = Context.Cars

  .FromSqlInterpolated($"Select * from dbo.Inventory where Id = {carId}")

  .Include(x => x.MakeNavigation)

  .First();


Механизм трансляции LINQ to SQL объединяет низкоуровневый оператор SQL с остальными операторами LINQ и выполняют следующий запрос:


SELECT TOP(1) [c].[Id], [c].[Color], [c].[IsDrivable], [c].[MakeId],

              [c].[PetName], [c].[TimeStamp],

              [m].[Id], [m].[Name], [m].[TimeStamp]

FROM (Select * from dbo.Inventory where Id = 1) AS [c]

INNER JOIN [dbo].[Makes] AS [m] ON [c].[MakeId] = [m].[Id]

WHERE [c].[IsDrivable] = CAST(1 AS bit)


Имейте в виду, что есть несколько правил, которые необходимо соблюдать в случае применения низкоуровневых запросов SQL с LINQ.

• Запрос SQL должен возвращать данные для всех свойств сущностного типа.

• Имена столбцов должны совпадать с именами свойств, с которыми они сопоставляются (улучшение по сравнению с версией EF 6, где сопоставления игнорировались).

• Запрос SQL не может содержать связанные данные.

Пакетирование операторов

В EF Core значительно повышена производительность при сохранении изменений в базе данных за счет выполнения операторов в одном и более пакетов. В итоге объем взаимодействия между приложением и базой данных уменьшается, увеличивая производительность и потенциально сокращая затраты (скажем, для облачных баз данных, где за транзакции приходится платить).

Исполняющая среда EF Core пакетирует операторы создания, обновления и удаления с использованием табличных параметров. Количество операторов, которые пакетирует EF Core, зависит от поставщика баз данных. Например, в SQL Server пакетарование неэффективно для менее 4 и более 40 операторов. Независимо от количества пакетов все операторы по- прежнему выполняются в рамках транзакции. Размер пакета можно конфигурировать посредством

DbContextOptions
, но в большинстве ситуаций (если не во всех) рекомендуется позволять EF Core рассчитывать размер пакета самостоятельно.

Если бы вы вставляли четыре записи об автомобилях в одной транзакции, как показано ниже:


var cars = new List

{

  new Car { Color = "Yellow", MakeId = 1, PetName = "Herbie" },

  new Car { Color = "White", MakeId = 2, PetName = "Mach 5" },

  new Car { Color = "Pink", MakeId = 3, PetName = "Avon" },

  new Car { Color = "Blue", MakeId = 4, PetName = "Blueberry" },

};

Context.Cars.AddRange(cars);

Context.SaveChanges();


то исполняющая среда EF Core пакетировала бы операторы в одиночное обращение.

Вот как выглядел бы сгенерированный запрос:


exec sp_executesql N'SET NOCOUNT ON;

DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);

MERGE [Dbo].[Inventory] USING (

VALUES (@p0, @p1, @p2, 0),

(@p3, @p4, @p5, 1),

(@p6, @p7, @p8, 2),

(@p9, @p10, @p11, 3)) AS i ([Color], [MakeId], [PetName], _Position) ON 1=0

WHEN NOT MATCHED THEN

INSERT ([Color], [MakeId], [PetName])

VALUES (i.[Color], i.[MakeId], i.[PetName])

OUTPUT INSERTED.[Id], i._Position

INTO @inserted0;

SELECT [t].[Id], [t].[IsDrivable], [t].[TimeStamp] FROM [Dbo].[Inventory] t

INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id])

ORDER BY [i].[_Position];

',N'@p0 nvarchar(50),@p1 int,@p2 nvarchar(50),@p3 nvarchar(50),

@p4 int,@p5 nvarchar(50),@
p6 nvarchar(50),@p7 int,@p8 nvarchar(50),

@p9 nvarchar(50),@p10 int,@p11 nvarchar(50)',@
p0=N'Yellow',@p1=1,

@p2=N'Herbie',@p3=N'White',@p4=2,@p5=N'Mach 5',@p6=N'Pink',@p7=3,

@
p8=N'Avon',@p9=N'Blue',@p10=4,@p11=N'Blueberry'

Принадлежащие сущностные типы

Возможность применения класса C# в качестве свойства сущности с целью определения коллекции свойств для другой сущности впервые появилась в версии EF Core 2.0 и в последующих версиях постоянно обновлялась. Когда типы, помеченные атрибутом

[Owned]
или сконфигурированные посредством Fluent API, добавлены в виде свойств сущности, инфраструктура EF Core добавит все свойства из сущностного класса
[Owned]
к владеющему классу. В итоге увеличивается вероятность многократного использования кода С#.

"За кулисами" EF Core считает результат отношением "один к одному". Принадлежащий класс является зависимой сущностью, а владеющий класс — главной сущностью. Хотя принадлежащий класс рассматривается как сущность, он не может существовать без владеющего класса. Имена столбцов из принадлежащего класса по умолчанию получают формат

ИмяНавигационногоСвойства_ИмяСвойстваПринадлежащейСущности
(например,
PersonalNavigation_FirstName
). Стандартные имена можно изменять с применением Fluent API.

Взгляните на приведенный далее класс Person (обратите внимание на атрибут

[Owned]
):


[Owned]

public class Person

{

  [Required, StringLength(50)]

  public string FirstName { get; set; } = "New";

  [Required, StringLength(50)]

  public string LastName { get; set; } = "Customer";

}


Он используется классом

Customer
:


[Table("Customers", Schema = "Dbo")]

public partial class Customer : BaseEntity

{

  public Person PersonalInformation { get; set; } = new Person();

  [JsonIgnore]

  [InverseProperty(nameof(CreditRisk.CustomerNavigation))]

  public IEnumerable CreditRisks { get; set; } =

   new List();

  [JsonIgnore]

  [InverseProperty(nameof(Order.CustomerNavigation))]

  public IEnumerable Orders { get; set; } = new List();

}


По умолчанию два свойства

Person
отображаются на столбцы с именами
PersonalInformation_FirstName
и
PersonalInformation_LastName
. Чтобы изменить это, добавьте в метод
OnConfiguring()
следующий код Fluent API:


modelBuilder.Entity(entity =>

{

  entity.OwnsOne(o => o.PersonalInformation,

    pd =>

    {

  pd.Property(nameof(Person.FirstName))

       .HasColumnName(nameof(Person.FirstName))

       .HasColumnType("nvarchar(50)");

     pd.Property(nameof(Person.LastName))

       .HasColumnName(nameof(Person.LastName))

       .HasColumnType("nvarchar(50)");

    });

});


Вот как будет создаваться результирующая таблица (обратите внимание, что допустимость значений

null
в столбцах
FirstName
и
LastName
не соответствует аннотациям данных в принадлежащей сущности
Person
):


CREATE TABLE [dbo].[Customers](

  [Id] [int] IDENTITY(1,1) NOT NULL,

  [FirstName] [nvarchar](50) NULL,

  [LastName] [nvarchar](50) NULL,

  [TimeStamp] [timestamp] NULL,

  [FullName]  AS (([LastName]+', ')+[FirstName]),

CONSTRAINT [PK_Customers] PRIMARY KEY CLUSTERED

(

  [Id] ASC

)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF,

 IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS 
= ON, ALLOW_PAGE_LOCKS = ON,

 OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]

) ON [PRIMARY]

GO


Проблема с принадлежащими сущностями, которая может быть не видна вам, но приводить к сложной ситуации, в версии EF Core 5 устранена. Легко заметить, что класс

Person
содержит аннотации данных Required для обоих своих свойств, но оба столбца SQL Server определены как допускающие
NULL
.Так происходит из-за проблемы с тем, каким образом система миграции транслирует принадлежащие сущности, когда они используются с необязательным отношением. Для исправления проблемы потребуется сделать отношение обязательным.

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

null
на уровне проекта или в классах С#. Тогда навигационное свойство
PersonalInformation
становится не допускающим значение
null
, что исполняющая среда EF Core учитывает и соответствующим образом конфигурирует столбцы в принадлежащей сущности. Второй способ предусматривает добавление кода Fluent API для того, чтобы сделать навигационное свойство обязательным:


modelBuilder.Entity(entity =>

{

  entity.OwnsOne(o => o.PersonalInformation,

    pd =>

    {

     pd.Property(nameof(Person.FirstName))

       .HasColumnName(nameof(Person.FirstName))

       .HasColumnType("nvarchar(50)");

     pd.Property(nameof(Person.LastName))

       .HasColumnName(nameof(Person.LastName))

       .HasColumnType("nvarchar(50)");

    });

  entity.Navigation(c => c.PersonalInformation).IsRequired(true);

});


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

https://docs.microsoft.com/ru-ru/ef/core/modeling/owned-entities
.

Сопоставление с функциями базы данных

Функции SQL Server можно сопоставлять с методами C# и включать в операторы LINQ. В таком случае метод C# служит просто заполнителем, поскольку в генерируемый запрос SQL внедряется серверная функция. Поддержка сопоставления с табличными функциями была добавлена в EF Core к уже имеющейся поддержке сопоставления со скалярными функциями. Дополнительные сведения о сопоставлении с функциями базы данных ищите в документации:

https://docs.microsoft.com/ru-ru/ef/core/querying/user-defined-function-mapping
.

Команды CLI глобального инструмента EF Core

Глобальный инструмент командной строки EF Core под названием

dotnet-ef
содержит команды, необходимые для создания шаблонов существующих баз данных в коде, для создания/удаления миграций баз данных и для работы с базой данных (обновление, удаление и т.п.). Чтобы пользоваться глобальным инструментом
dotnet-ef
, вы должны его установить с помощью следующей команды (если вы прорабатывали материал главы с самого начала, то уже сделали это):


dotnet tool install -- global dotnet-ef — version 5.0.1


На заметку! Из-за того, что EF Core 5 не является выпуском с долгосрочной поддержкой (LTS) при использовании глобальных инструментов EF Core 5 потребуется указывать версию.


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


dotnet ef


Если глобальный инструмент был успешно установлен, тогда вы получите схематическое изображение единорога EF Core (талисмана команды разработчиков) и список доступных команд, подобный показанному ниже (на экране единорог выглядит лучше):


        _/\__

     ---==/   \\

  ___ ___   |.    \|\

 |__||__| |  )   \\\

 |_||_|  \_/ |  //|\\

 |__||_|   /   \\\/\\


Entity Framework Core .NET Command-line Tools 5.0.1


Usage: dotnet ef [options] [command]


Options:

  --version     Show version information

  -h|--help     Show help information

  -v|--verbose   Show verbose output.

  --no-color    Don't colorize output.

  --prefix-output  Prefix output with level.

Commands:

  database   Commands to manage the database.

  dbcontext  Commands to manage DbContext types.

  migrations  Commands to manage migrations.


Use "dotnet ef [command] --help" for more information about a command.


Использование: dotnet ef [параметры] [команда]


Параметры:

--version Показать информацию о версии.

-h|--help Показать справочную информацию.

-v \--verbose Включить многословный вывод.

--no-color Не использовать цвета в выводе.

--prefix-output Снабжать вывод префиксами для выделения уровней.


Команды:

database Команды для управления базой данных.

dbcontext Команды для управления типами DbContext.

migrations Команды для управления миграциями.


Для получения дополнительной информации о команде применяйте dotnet ef [команда] --help.


В табл. 22.9 описаны три основных команды глобального инструмента EF Core. С каждой основной командой связаны дополнительные подкоманды. Как и все команды .NET Core, каждая команда имеет развитую справочную систему, которая доступна после ввода

-h
вместе с командой.



Команды EF Core выполняются в отношении файлов проектов .NET Core (не файлов решений). Целевой проект должен ссылаться на пакет NuGet инструментов EF Core по имени

Microsoft.EntityFrameworkCore.Design
. Команды работают с файлом проекта, расположенным в том же каталоге, где команды вводятся, или с файлом проекта из другого каталога в случае ссылки на него через параметры командной строки.

Для команд CLI глобального инструмента EF Core, которым требуется экземпляр класса, производного от

DbContext
(
Database
и
Migrations
), при наличии в проекте только одного такого экземпляра именно он и будет использоваться. Если экземпляров
DbContext
несколько, тогда конкретный экземпляр необходимо указывать в параметре командной строки. Экземпляр производного от
DbContext
класса будет создаваться с применением экземпляра класса, реализующего интерфейс
IDesignTimeDbContextFactory
, если инструмент смог его обнаружить.

Если инструменту не удалось его найти, то экземпляр класса, производного от

DbContext
, будет создаваться с использованием конструктора без параметров. Если ни того и ни другого не существует, тогда команда потерпит неудачу. Обратите внимание, что вариант с конструктором без параметров требует наличия переопределенной версии
OnConfiguring()
, что не считается хорошей практикой.

Лучший (и на самом деле единственный) вариант — всегда создавать реализацию

IDesignTimeDbContextFactory
для каждого класса, производного от
DbContext
, который присутствует в приложении.



Чтобы вывести список всех аргументов и параметров для команды, введите

dotnet ef <команда> -h
в окне командной строки, например:


dotnet ef migrations add -h


На заметку! Важно отметить, что команды CLI — это не команды С#, а потому правила отмены символов обратной косой черты и кавычек здесь не применяются.

Команды для управления миграциями

Команды

migrations
используются для добавления, удаления, перечисления и создания сценариев миграций. После того, как миграция применена к базе данных, в таблице
__EFMigrationsHistory
создается запись. Команды для управления миграциями кратко описаны в табл. 22.11 и более подробно в последующих подразделах.


Команда add

Команда

add
создает новую миграцию базы данных, основываясь на текущей объектной модели. Процесс исследует каждую сущность со свойством
DbSet
в производном от
DbContext
классе (и каждую сущность, которая может быть достигнута из таких сущностей с использованием навигационных свойств) и выясняет, есть ли какие-то изменения, которые должны быть применены к базе данных. При наличии изменений генерируется надлежащий код для обновления базы данных. Вскоре вы узнаете об этом больше.

Команда

add
требует передачи параметра name, который используется при именовании созданного класса и файлов для миграции. В дополнение к общим параметрам параметр
-о <путь>
или
--output-dir <путь>
указывает, куда должны помещаться файлы миграции. Стандартный каталог называется
Migrations
и относителен к текущему пути.

Для каждой добавленной миграции создаются два файла с частичными определениями того же самого класса. Имена обоих файлов начинаются с отметки времени и наименования миграции, которое было указано в качестве параметра для команды

add
. Первый файл называется
<ГГГГММДДЧЧММСС>_<НаименованиеМиграции>.cs
, а второй —
<ГГГГММДДЧЧММСС>_<НаименованиеМиграции>.Designer.cs
. Отметка времени основана на том, когда файл был создан, и в точности совпадает для обоих файлов. Первый файл представляет код, сгенерированный для изменений базы данных в этой миграции, а конструирующий файл — код, который предназначен для создания и обновления базы данных на основе всех миграций до этой миграции включительно.

Главный файл содержит два метода,

Up()
и
Down()
. В методе
Up()
находится код для обновления базы данных с учетом изменений этой миграции. В методе
Down()
содержится код для выполнения отката изменений этой миграции. Ниже приведен неполный листинг начальной миграции, рассматриваемой ранее в главе (
One2Many
):


public partial class One2Many : Migration

{

  protected override void Up(MigrationBuilder migrationBuilder)

  {

   migrationBuilder.CreateTable(

    name: "Make",

    columns: table => new

     {

      Id = table.Column(type: "int", nullable: false)

       .Annotation("SqlServer:Identity", "1, 1"),

      Name = table.Column(type: "nvarchar(max)", nullable: true),

      TimeStamp = table.Column(type: "varbinary(max)",

                       nullable: true)

     },

     constraints: table =>

     {

      table.PrimaryKey("PK_Make", x => x.Id);

     });

   ...

   migrationBuilder.CreateIndex(

    name: "IX_Cars_MakeId",

    table: "Cars",

    column: "MakeId");

  }


  protected override void Down(MigrationBuilder migrationBuilder)

  {

   migrationBuilder.DropTable(name: "Cars");

   migrationBuilder.DropTable(name: "Make");

  }

}


Как видите, метод

Up()
создает таблицы, столбцы, индексы и т.д. Метод
Down()
удаляет созданные элементы. По мере необходимости механизм миграции будет выдавать операторы
alter
,
add
и
drop
, чтобы гарантировать соответствие базы данных вашей модели.

Конструирующий файл содержит два атрибута, которые связывают частичные определения с именем файла и классом, производным от

DbContext
. Ниже показан фрагмент листинга конструирующего класса с упомянутыми атрибутами:


[DbContext(typeof(ApplicationDbContext))]

[Migration("20201230020509_One2Many")]

partial class One2Many

{

  protected override void BuildTargetModel(ModelBuilder modelBuilder)

  {

   ...

  }

}


Первая миграция создает внутри целевого каталога дополнительный файл, именуемый в соответствии с производным от

DbContext
классом, т.е.
<ИмяПроизводногоОтDbContextКласса>ModelSnapshot.cs
. Этот файл имеет формат, совпадающий с форматом конструирующего файла, и содержит код, который представляет собой итог всех миграций. При добавлении или удалении миграций данный файл автоматически обновляется, чтобы соответствовать изменениям.


На заметку! Крайне важно не удалять файлы миграций вручную. Удаление приведет к тому, что файл

<ИмяПpoизвoднoгoOтDbContextKлacca>ModelSnapshot.cs
утратит синхронизацию с вашими миграциями, по существу нарушив их работу. Если вы собираетесь удалять файлы миграций вручную, тогда удалите их все и начните сначала. Для удаления миграции используйте команду remove, которая будет описана ниже.

Исключение таблиц из миграций

Если какая-то сущность задействована сразу в нескольких

DbContext
, то каждый
DbContext
будет создавать код в файлах миграций для любых изменений, вносимых в эту сущность. В результате возникает проблема, потому что второй сценарий миграции потерпит неудачу, если изменения уже внесены в базу данных. До выхода версии EF Core 5 единственным решением было ручное редактирование одного из файлов миграций с целью удаления таких изменений.

В версии EF Core 5 производный от

DbContext
класс может помечать сущность как исключенную из миграций, позволяя другому
DbContext
становиться системой записи для данной сущности. В следующем коде сущность исключается из миграций:


protected override void OnModelCreating(ModelBuilder modelBuilder)

{

  modelBuilder.Entity().ToTable("Logs",

   t => t.ExcludeFromMigrations());

}

Команда remove

Команда

remove
применяется для удаления миграций из проекта и всегда работает с последней миграцией (основываясь на отметках времени миграций). При удалении миграции исполняющая среда EF Core проверяет, не была ли миграция применена к базе данных, с помощью таблицы
__EFMigrationsHistory
. Если миграция применялась, тогда процесс терпит неудачу. Если же миграция не применялась или была подвергнута откату, то она удаляется, а файл моментального снимка модели обновляется.

Команда

remove
не принимает какие-либо параметры (поскольку всегда работает с последней миграцией) и использует те же самые параметры, что и команда
add
, плюс дополнительный параметр
force(—f || --force)
, который обеспечивает выполнение отката последней миграции и ее удаление за один шаг.

Команда list

Команда

list
позволяет получить все миграции для класса, производного от
DbContext
. По умолчанию она выводит список всех миграций и запрашивает базу данных с целью выяснения, были ли они применены. Если миграции не применялись, то они будут помечены как ожидающие. Один из параметров команды
list
предназначен для передачи специальной строки подключения, а другой позволяет вообще не подключаться к базе данных и просто вывести список миграций (табл. 22.12).


Команда script

Команда

script
создает сценарий SQL на основе одной или большего количества миграций и принимает два необязательных параметра, которые указывают, с какой миграции начинать и на какой миграции заканчивать. Если ни один параметр не задан, то сценарий создается для всех миграций. Параметры описаны в табл. 22.13.



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

script
, таблица
__EFMigrationsHistory
нe существует, то она создается. Кроме того, она будет обновляться для соответствия выполненным миграциям. Вот несколько примеров:


// Создать сценарий для всех миграций.

dotnet ef migrations script

// Создать сценарий для миграций от начальной до Мапу2Мапу включительно.

dotnet ef migrations script 0 Many2Many


В табл. 22.14 представлены дополнительные параметры. Параметр

позволяет указать файл для сценария (в каталоге, относительном к тому, где запускается команда), а параметр
-i
создает идемпотентный сценарий (который содержит проверку, применялась ли уже миграция, и если применялась, то пропускает ее). Параметр
--no-transaction
отключает добавление транзакций в сценарий.


Команды для управления базой данных

Для управления базой данных предназначены две команды,

drop
и
update
. Команда
drop
удаляет базу данных, если она существует, а команда
update
обновляет базу данных с использованием миграций.

Команда drop

Команда

drop
удаляет базу данных, указанную в строке подключения внутри метода
OnConfiguring()
производного от
DbContext
класса. С помощью параметра
force
можно отключить запрос на подтверждение и принудительно закрыть все подключения (табл. 22.15).


Команда update

Команда

update
принимает параметр с именем миграции и обычные параметры. Она имеет один дополнительный параметр
--connection <подключение>
, позволяющий использовать строку подключения, которая не была сконфигурирована заранее.

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

__EFMigrationsHistory
.

Если имя миграции имеет отметку времени, которая соответствует более раннему моменту, чем другие примененные миграции, тогда выполняется откат всех более поздних миграций. Когда в качестве имени миграции указывается

0
, происходит откат всех миграций и база данных становится пустой (не считая таблицы
__EFMigrationsHistory
).

Команды для управления типами DbContext

Доступны четыре команды для управления типами

DbContext
. Три из них (
list
,
info
,
script
) работают с классами, производными от
DbContext
, в вашем проекте. Команда
scaffold
создает производный от
DbContext
класс и сущности из существующей базы данных. Все четыре команды описаны в табл. 22.16.



Для команд

list
и
info
доступны обычные параметры. Команда
list
выдает список классов, производных от
DbContext
, в целевом проекте. Команда
info
предоставляет детали об указанном производном от
DbContext
классе, в том числе строку подключения, имя поставщика и источник данных. Команда
script
генерирует сценарий SQL, который создает вашу базу данных на основе объектной модели, игнорируя любые имеющиеся миграции. Команда
scaffold
используется для анализа существующей базы данных и рассматривается в следующем разделе.

Команда scaffold

Команда

scaffold
создает из существующей базы данных классы C# (производные от
DbContext
и сущностные классы ), дополненные аннотациями данных (если требуется) и командами Fluent API. В табл. 22.17 описаны два обязательных параметра: строка подключения к базе данных и полностью заданный поставщик (например,
Microsoft.EntityFrameworkCore.SqlServer
).



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



В версии EF Core 5.0 команда

scaffold
стала работать гораздо надежнее. Как видите, на выбор предлагается довольно много вариантов. Если выбран вариант с аннотациями данных (
-d
), тогда EF Core будет применять аннотации данных там, где это возможно, и заполнять отличия с использованием Fluent API. Если вариант с
-d
не выбран, то вся конфигурация (отличающаяся от соглашений) кодируется с помощью Fluent API. Вы можете указывать пространство имен, схему и местоположение для генерируемых файлов с сущностными классами и классом, производным от
DbContext
. Если вы не хотите создавать шаблон для целой базы данных, тогда можете выбрать определенные схемы и таблицы. Параметр
--no-onconfiguring
позволяет исключить метод
OnConfiguring()
из шаблонного класса, а параметр
--no-pluralize
отключает средство перевода имен в множественное число. Упомянутое средство превращает имена сущностей в единственном числе (
Car
) в имена таблиц во множественном числе (
Cars
) при создании миграций и имена таблиц во множественном числе в имена сущностей в единственном числе при создании шаблона.

Резюме

В настоящей главе вы начали ознакомление с инфраструктурой Entity Framework Core. В ней были исследованы аспекты, лежащие в основе EF Core, выполнения запросов и отслеживания изменений. Вы узнали о придании формы своей модели, соглашениях EF Core, аннотациях данных и Fluent API, а также о том, как их применение влияет на проектное решение для базы данных. Наконец, вы научились пользоваться мощным интерфейсом командной строки EF Core и глобальными инструментами.

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

AutoLot
.

Глава 23 Построение уровня доступа к данным с помощью Entity Framework Core

В предыдущей главе раскрывались детали и возможности инфраструктуры EFCore. Текущая глава сосредоточена на применении того, что вы узнали об инфраструктуре EF Core, для построения уровня доступа к данным

AutoLot
. В начале главы строятся шаблоны сущностей и производного от
DbContext
класса для базы данных из предыдущей главы. Затем используемый в проекте подход "сначала база данных" меняется на подход "сначала код", а сущности обновляются до своей финальной версии и применяются к базе данных с использованием миграций EF Core. Последним изменением, внесенным в базу данных, будет воссоздание хранимой процедуры
GetPetName
и создание нового представления базы данных (в комплекте с соответствующей моделью представления), что делается с помощью миграций.

Следующий шаг — формирование хранилищ, обеспечивающих изолированный доступ для создания, чтения, обновления и удаления (create, read, update, delete — CRUD) базы данных. Далее в целях тестирования к проекту будет добавлен код инициализации данных вместе с выборочными данными. Остаток главы посвящен испытаниям уровня доступа к данным

AutoLot
посредством автоматизированных интеграционных тестов.

"Сначала код" или "сначала база данных"

Прежде чем приступить к построению уровня доступа к данным, давайте обсудим два способа работы с EF Core и базой данных: "сначала код" или "сначала база данных". Оба они совершенно допустимы, а выбор применяемого подхода в значительной степени зависит от самой команды разработчиков.

Подход "сначала код" означает, что вы создаете и конфигурируете свои сущностные классы и производный от

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

Если у вас уже есть база данных или вы предпочитаете, чтобы проектное решение базы данных управляло разрабатываемым приложением, тогда должен применяться подход "сначала база данных". Вместо создания производного от

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

Какой бы подход вы ни выбрали, "сначала код" или "сначала база данных", имейте в виду, что он является обязательством. Если вы используете подход "сначала код", то все изменения вносятся в классы сущностей и контекста, а база данных обновляется с применением миграций. Если вы используете подход "сначала база данных", то все изменения должны вноситься в базу данных, после чего будет требоваться повторное создание шаблонов классов. Приложив некоторые усилия по планированию, вы можете переключаться с подхода "сначала база данных" на подход "сначала код" (и наоборот), но не должны вручную вносить изменения в код и базу данных одновременно.

Создание проектов AutoLot.Dal и AutoLot.Models

Уровень доступа к данным

AutoLo
t состоит из двух проектов, один из которых содержит код, специфичный для EF Core (производный от
DbContext
класс, фабрику контекстов, хранилища, миграции и т.д.), а другой — сущности имодели представлений. Создайте новое решение под названием
Chapter23_AllProjects
и добавьте в него проект библиотеки классов .NET Core по имени
AutoLot.Models
. Удалите стандартный класс, созданный шаблоном, и добавьте в проект следующие пакеты NuGet:


Microsoft.EntityFrameworkCore.Abstractions

System.Text.Json


Пакет

Microsoft.EntityFrameworkCore.Abstractions
обеспечивает доступ ко многим конструкциям EF Core (вроде аннотаций данных) и легковеснее пакета
Microsoft.EntityFrameworkCore
. Добавьте в решение еще один проект библиотеки классов .NET Core по имени
AutoLot.Dal
. Удалите стандартный класс, сгенерированный шаблоном, включите ссылку на проект
AutoLot.Models
и добавьте в проект перечисленные далее пакеты NuGet:


Microsoft.EntityFrameworkCore

Microsoft.EntityFrameworkCore.SqlServer

Microsoft.EntityFrameworkCore.Design


Пакет

Microsoft.EntityFrameworkCore
предоставляет общую функциональность для EF Core. Пакет
Microsoft.EntityFrameworkCore.SqlServer
предлагает поставщик данных SQL, а пакет
Microsoft.EntityFrameworkCore.Design
требуется для инструментов командной строки EF Core.

Чтобы выполнить все указанные ранее шаги в командной строке, введите показанные ниже команды (в каталоге, где хотите создать решение):


dotnet new sln -n Chapter23_AllProjects

dotnet new classlib -lang c# -n AutoLot.Models -o .\AutoLot.Models -f net5.0

dotnet sln .\Chapter23_AllProjects.sln add .\AutoLot.Models

dotnet add AutoLot.Models package Microsoft.EntityFrameworkCore.Abstractions

dotnet add AutoLot.Models package System.Text.Json

dotnet new classlib -lang c# -n AutoLot.Dal -o .\AutoLot.Dal -f net5.0

dotnet sln .\Chapter23_AllProjects.sln add .\AutoLot.Dal

dotnet add AutoLot.Dal reference AutoLot.Models

dotnet add AutoLot.Dal package Microsoft.EntityFrameworkCore

dotnet add AutoLot.Dal package Microsoft.EntityFrameworkCore.Design

dotnet add AutoLot.Dal package Microsoft.EntityFrameworkCore.SqlServer

dotnet add AutoLot.Dal package Microsoft.EntityFrameworkCore.Tools


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


Поступать так придется в отношении всех команд CLI, приводимых в настоящей главе. После создания проектов обновите каждый файл

*.csproj
для включения ссылочных типов, допускающих
null
, из версии C# 8. Обновление выделено полужирным:


  net5.0

  enable

Создание шаблонов для класса, производного от DbContext, и сущностных классов

Следующий шаг предусматривает формирование шаблонов для базы данных

AutoLot
из главы 21 с применением инструментов командной строки EF Core. Перейдите в каталог проекта
AutoLot.Dal
в окне командной строки или в консоли диспетчера пакетов Visual Studio.


На заметку! В папке Chapter_21 хранилища GitHub для этой книги находятся резервные копии базы данных, ориентированные на Windows и Docker. За инструкциями по восстановлению базы данных обращайтесь в главу 21.


Воспользуйтесь инструментами командной строки EF Core, чтобы сформировать для базы данных

AutoLot
шаблоны сущностных классов и класса, производного от
DbContext
. Вот как выглядит команда(которая должна вводиться в одной строке):


dotnet ef dbcontext scaffold "server=.,5433;Database=AutoLot;

User Id=sa;Password=P@ssw0rd;" 

Microsoft.EntityFrameworkCore.SqlServer

 -d -c ApplicationDbContext --context-namespace 

AutoLot.Dal.EfStructures --context-dir EfStructures

 --no-onconfiguring -n AutoLot.Models.

Entities -o ..\AutoLot.Models\Entities


Предыдущая команда формирует шаблоны для базы данных, находящейся по заданной строке подключения (для контейнера Docker, применяемого в главе 21), с использованием поставщика баз данных SQL Server. Флаг

-d
заставляет, где возможно, отдавать предпочтение аннотациям данных (перед Fluent API). В
указывается имя контекста, в
--context-namespaces
— пространство имен для контекста, в
--context-dir
— каталог (относительно каталога текущего проекта) для контекста. С помощью
--no-onconfiguring
исключается метод
OnConfiguring()
. В
задается выходной каталог для файлов сущностных классов (относительно каталога текущего проекта) и, наконец, в
-n
— пространство имен для сущностных классов. Показанная выше команда помещает все сущности в каталог
Entities
проекта
AutoLot.Models
, а класс
ApplicationDbContext
— каталог
EfStructures
проекта
AutoLot.Dal
.

Вы заметите, что шаблон для хранимой процедуры не создавался. Если бы в базе данных присутствовали какие-то представления, то для них были бы созданы шаблоны сущностей без ключей. Поскольку в EF Core не предусмотрено конструкций, напрямую отображаемых на хранимые процедуры, создать шаблон невозможно. С применением EF Core можно создавать хранимые процедуры и другие объекты SQL, но на этот раз шаблоны формируются только для таблиц и представлений.

Переключение на подход "сначала код"

Теперь, имея базу данных, для которой сформированы сущности, самое время переключиться с подхода "сначала база данных" на подход "сначала код". Для такого переключения должна быть создана фабрика контекстов, а также миграция из текущего состояния проекта. Затем либо база данных удаляется и воссоздается за счет применения миграции, либо задействуется фиктивная реализация для "обмана" инфраструктуры EF Core.

Создание фабрики экземпляров класса, производного от DbContext, на этапе проектирования

Как было указано в главе 22, инструменты командной строки EF Core используют реализацию

IDesignTimeDbContextFactory
для создания экземпляра класса, производного от
DbContext
. Создайте в каталоге
EfStructures
проекта
AutoLot.Dal
новый файл класса по имени
ApplicationDbContextFactory.cs
. Добавьте в файл класса следующие пространства имен:


using System;

using Microsoft.EntityFrameworkCore;

using Microsoft.EntityFrameworkCore.Design;


Детали фабрики раскрывались в предыдущей главе, а здесь представлен только код. Для информационных целей посредством дополнительного вызова

Console.WriteLine()
на консоль выводится строка подключения. Не забудьте привести строку подключения в соответствие со своей средой:


namespace AutoLot.Dal.EfStructures

{

  public class ApplicationDbContextFactory

    : IDesignTimeDbContextFactory
Context>

  {

   public ApplicationDbContext CreateDbContext(string[] args)

   {

    var optionsBuilder =

     new DbContextOptionsBuilder();

    var connectionString = @"server=.,5433;Database=AutoLot;

    User Id=sa;Password=P@
ssw0rd;";

    optionsBuilder.UseSqlServer(connectionString);

    Console.WriteLine(connectionString);

    return new ApplicationDbContext(optionsBuilder.Options);

   }

  }

}

Создание начальной миграции

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

AutoLot.Dal
, чтобы создать новую миграцию по имени
Initial
(используя экземпляр только что полученного класса
ApplicationDbContext
) и поместить файлы миграции в каталог
EfStructures\Migrations
проекта
AutoLot.Dal
:


dotnet ef migrations add Initial -o EfStructures\Migrations

 -c AutoLot.Dal.EfStructures.ApplicationDbContext


На заметку! Важно позаботиться о том, чтобы в сгенерированные файлы или базу данных не вносились изменения до тех пор, пока не будет создана и применена начальная миграция. Изменения на любой из сторон приведут к тому, что код и база данных утратят синхронизацию. После применения начальной миграции все изменения должны вноситься в базу данных через миграции EF Core.


Удостоверьтесь в том, что миграция была создана и ожидает применения, выполнив команду

list
:


dotnet ef migrations list -c AutoLot.Dal.EfStructures.ApplicationDbContext


Результат покажет, что миграция

Initial
ожидает обработки (ваша отметка времени будет другой). Строка подключения присутствует в выводе из-за вызова
Console.Writeline()
в методе
CreateDbContext()
:


Build started...

Build succeeded.

server=.,5433;Database=AutoLot;User Id=sa;Password=P@ssw0rd;

20201231203939_Initial (Pending)

Применение миграции

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


dotnet ef database drop -f

dotnet ef database update Initial -c AutoLot.Dal.EfStructures.ApplicationDbContext


Если вариант с удалением и повторным созданием базы данных не подходит (скажем, в случае базы данных Azure SQL), то инфраструктуре EF Core необходимо обеспечить уверенность о том, что миграция была применена. К счастью, с помощью EF Core выполнить всю работу легко. Начните с создания из миграции сценария SQL, используя следующую команду:


dotnet ef migrations script --idempotent -o FirstMigration.sql


Важными частями сценария являются те, которые создают таблицу

__EFMigrationsHistory
и затем добавляют в нее запись о миграции, указывающую на ее применение. Скопируйте эти части в новый запрос внутри Azure Data Studio или SQL Server Manager Studio. Вот необходимый код SQL (отметка времени у вас будет отличаться):


IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL

BEGIN

   CREATE TABLE [__EFMigrationsHistory] (

     [MigrationId] nvarchar(150) NOT NULL,

     [ProductVersion] nvarchar(32) NOT NULL,

     CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId])

   );

END;

GO

INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])

VALUES (N'20201231203939_Initial', N'5.0.1');


Если вы теперь запустите команду

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

Обновление модели

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

Сущности

В каталоге

Entities
проекта
AutoLot.Models
вы обнаружите пять файлов, по одному для каждой таблицы в базе данных. Несложно заметить, что имена имеют форму единственного, а не множественного числа (как в базе данных). Это особенность версии EF Core 5, где средство перевода имен в множественное число по умолчанию включено при создании шаблонов сущностей для базы данных.

Изменения, которые вы внесете в сущностные классы, включают добавление базового класса, создание принадлежащего сущностного класса

Person
, исправление имен навигационных свойств и добавление ряда дополнительных свойств. Кроме того, вы добавите новую сущность для регистрации в журнале (которая будет использоваться в главах, посвященных ASP.NET Core). В предыдущей главе подробно рассматривались соглашения EF Core, аннотации данных и Fluent API, так что в текущей главе будут приводиться в основном листинги кода с краткими описаниями.

Класс BaseEntity

Класс

BaseEntity
будет содержать столбцы
Id
и
TimeStamp
, присутствующие в каждой сущности. Создайте новый каталог по имени
Base
в каталоге
Entities
проекта
AutoLot.Models
. Поместите в этот каталог новый файл
BaseEntity.cs
со следующим кодом:


using System.ComponentModel.DataAnnotations;

using System.ComponentModel.DataAnnotations.Schema;


namespace AutoLot.Models.Entities.Base

{

  public abstract class BaseEntity

  {

   [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]

   public int Id { get; set; }

   [TimeStamp]

   public byte[]? TimeStamp { get; set; }

  }

}


Все сущности, шаблоны которых созданы из базы данных

AutoLot
, будут обновлены для применения базового класса
BaseEntity
.

Принадлежащий сущностный класс Person

Сущности

Customer
и
CreditRisk
имеют свойства
FirstName
и
LastName
. Если в каждой сущности присутствуют одни и те же свойства, тогда можно извлечь выгоду от перемещения этих свойств в принадлежащие классы. Пример с двумя свойствами тривиален, но принадлежащие сущностные классы помогают сократить дублирование кода и повысить согласованность. В дополнение к двум свойствам внутри классов определяется еще одно свойство, отображаемое на вычисляемый столбец SQL Server.

Создайте в каталоге

Entities
проекта
AutoLot.Models
новый каталог по имени
Owned
и добавьте в него новый файл
Person.cs
, содержимое которого показано ниже:


using System.ComponentModel.DataAnnotations;

using System.ComponentModel.DataAnnotations.Schema;

using Microsoft.EntityFrameworkCore;


namespace AutoLot.Models.Entities.Owned

{

  [Owned]

  public class Person

  {

   [Required, StringLength(50)]

   public string FirstName { get; set; } = "New";

   [Required, StringLength(50)]

   public string LastName { get; set; } = "Customer";

   [DatabaseGenerated(DatabaseGeneratedOption.Computed)]

   public string? FullName { get; set; }

  }

}


Свойство

FullName
допускает
null
, т.к. до сохранения в базе данных новые сущности не будут иметь установленных значений. Финальная конфигурация свойства
Fullname
обеспечивается с использованием Fluent API.

Сущность Car(Inventory)

Для таблицы

Inventory
был создан шаблон сущностного класса по имени
Inventory
, но имя
Car
предпочтительнее. Исправить ситуацию легко: измените имя файла на
Car.cs
и имя класса на
Car
. Атрибут
[Table]
применяется корректно, так что нужно просто добавить схему
dbo
. Обратите внимание, что параметр
Schema
необязателен, поскольку по умолчанию для SQL Server принимается
dbo
, но он был включен ради полноты:


[Table("Inventory", Schema = "dbo")]

[Index(nameof(MakeId), Name = "IX_Inventory_MakeId")]

public partial class Car : BaseEntity

{

  ...

}


Обновите операторы

using
следующим образом:


using System;

using System.Collections.Generic;

using System.ComponentModel;

using System.ComponentModel.DataAnnotations;

using System.ComponentModel.DataAnnotations.Schema;

using System.Text.Json.Serialization;

using AutoLot.Models.Entities.Base;

using Microsoft.EntityFrameworkCore;


Унаследуйте класс

Car
от
BaseEntity
, после чего удалите свойства
Id
и
TimeStamp
, конструктор и директиву
#pragma nullable disable
. Вот как выглядит код класса после таких изменений:


namespace AutoLot.Models.Entities

{

  [Table("Inventory", Schema = "dbo")]

  [Index(nameof(MakeId), Name = "IX_Inventory_MakeId")]

  public partial class Car : BaseEntity

  {

   public int MakeId { get; set; }

   [Required]

   [StringLength(50)]

   public string Color { get; set; }

   [Required]

   [StringLength(50)]

   public string PetName { get; set; }

   [ForeignKey(nameof(MakeId))]

   [InverseProperty("Inventories")]

   public virtual Make Make { get; set; }

   [InverseProperty(nameof(Order.Car))]

   public virtual ICollection Orders { get; set; }

  }

}


В коде все еще присутствуют проблемы, которые необходимо устранить. Свойства

Color
и
PetName
определены как не допускающие
null
, но их значения не устанавливаются в конструкторе или не инициализируются в определении свойств. Проблема решается с помощью инициализаторов свойств. Кроме того, добавьте к свойству
PetName
атрибут
[DisplayName]
, чтобы сделать название свойства более удобным для восприятия человеком. Обновите свойства, как показано ниже (изменения выделены полужирным):


[Required]

[StringLength(50)]

public string Color { get; set; } = "Gold";


[Required]

[StringLength(50)]

[DisplayName("Pet Name")]

public string PetName { get; set; } = "My Precious";


На заметку! Атрибут

[DisplayName]
используется инфраструктурой ASP.NET Core и будет описан в части VIII.


Навигационное свойство

Make
потребуется переименовать в
MakeNavigation
и сделать допускающим
null
, а в обратном навигационном свойстве вместо "магической" строки должно применяться выражение
nameof
языка С#. Наконец, нужно удалить модификатор
virtual
. После всех модификаций свойство приобретает следующий вид:


[ForeignKey(nameof(MakeId))]

[InverseProperty(nameof(Make.Cars))]

public Make? MakeNavigation { get; set; }


На заметку! Модификатор

virtual
необходим для ленивой загрузки. Поскольку ленивая загрузка в примерах книги не используется, модификатор
virtual
будет удаляться из всех свойств внутри уровня доступа к данным.


Для навигационного свойства

Orders
требуется атрибут
[Jsonlgnore]
, чтобы предотвратить циклические ссылки JSON при сериализации объектной модели. В шаблонном коде обратное навигационное свойство задействует выражение
nameof
, но его понадобится обновить, т.к. имена всех навигационных свойств типа ссылок будут содержать суффикс
Navigation
. Последнее изменение связано с тем, что свойство должно иметь тип
IEnumerable
, а не
ICollection
, и инициализироваться с применением нового экземпляра
List
. Изменение не является обязательным, потому что
ICollection
тоже будет работать. Более низкоуровневый тип
IEnumerable
предпочтительнее использовать с навигационными свойствами типа коллекций
IEnumerable
(поскольку интерфейсы
IQueryable
и
ICollection
унаследованы от
IEnumerable
). Модифицируйте код, как показано далее:


[JsonIgnore]

[InverseProperty(nameof(Order.CarNavigation))]

public IEnumerable Orders { get; set; } = new List();


Затем добавьте свойство

NotMapped
, которое будет отображать значение
Make
экземпляра
Car
, устранив необходимость в классе
CarViewModel
из главы 21. Если связанная информация
Make
была извлечена из базы данных с записью
Car
, то значение
MakeName
отображается. Если связанные данные не были извлечены, тогда для свойства отображается строка Unknown (т.е. производитель не известен). Как вы должны помнить, свойства с атрибутом
[NotMapped]
не относятся к базе данных и существуют только внутри сущности. Добавьте следующий код:


[NotMapped]

public string MakeName => MakeNavigation?.Name ?? "Unknown";


Переопределите

ToString()
для отображения информации о транспортном средстве:


public override string ToString()

{

  // Поскольку столбец PetName может быть пустым,
.

  // определить стандартное имя **No Name**

  return $"{PetName ?? "**No Name**"} is a {Color} {MakeNavigation?.Name}

   with ID {Id}.";

}


Добавьте к свойству

MakeId
атрибуты
[Required]
и
[DisplayName]
. Несмотря на то что инфраструктура EF Core считает свойство
MakeId
обязательным, т.к. оно не допускает значение
null
, механизму проверки достоверности ASP.NET Core нужен атрибут
[Required]
. Ниже приведен модифицированный код:


[Required]

[DisplayName("Make")]

public int MakeId { get; set; }


Финальное изменение заключается в добавлении свойства

IsDrivable
типа
bool
, не допускающего значения
null
, с поддерживающим полем, допускающим
null
, и отображаемым именем:


private bool? _isDrivable;

[DisplayName("Is Drivable")]

public bool IsDrivable

{

  get => _isDrivable ?? false;

  set => _isDrivable = value;

}


На этом обновление сущностного класса

Car
завершено.

Сущность Customer

Для таблицы

Customers
был создан шаблонный сущностный класс по имени
Customer
. Приведите операторы
using
к следующему виду:


using System;

using System.Collections.Generic;

using System.ComponentModel.DataAnnotations.Schema;

using System.Text.Json.Serialization;

using AutoLot.Models.Entities.Base;

using AutoLot.Models.Entities.Owned;


Унаследуйте класс

Customer
от
BaseEntityn
удалите свойства
Id
и
TimeStamp
. Удалите конструктор и директиву
#pragma nullable disable
, после чего добавьте атрибут
[Table]
со схемой. Удалите свойства
FirstName
и
LastName
, т.к. они будут заменены принадлежащим сущностным классом
Person
. Вот как выглядит код в настоящий момент:


namespace AutoLot.Models.Entities

{

  [Table("Customers", Schema = "dbo")]

  public partial class Customer : BaseEntity

  {

   [InverseProperty(nameof(CreditRisk.Customer))]

   public virtual ICollection CreditRisks { get; set; }

   [InverseProperty(nameof(Order.Customer))]

   public virtual ICollection Orders { get; set; }

  }

}


Подобно сущностному классу

Car
в коде по-прежнему присутствуют проблемы, которые необходимо устранить, к тому же понадобится добавить принадлежащий сущностный класс. К навигационным свойствам нужно добавить атрибут
[Jsonlgnore]
, атрибуты обратных навигационных свойств потребуется обновить с использованием суффикса
Navigation
, типы необходимо изменить на
IEnumerable
с инициализацией, а модификатор
virtual
удалить. Ниже показан модифицированный код:


[JsonIgnore]

[InverseProperty(nameof(CreditRisk.CustomerNavigation))]

public IEnumerable CreditRisks { get; set; } =

 new List();


[JsonIgnore]

[InverseProperty(nameof(Order.CustomerNavigation))]

public IEnumerable Orders { get; set; } = new List();


Осталось лишь добавить свойство с типом принадлежащего сущностного класса. Отношение будет позже сконфигурировано посредством Fluent API.


public Person PersonalInformation { get; set; } = new Person();


Итак, обновление сущностного класса

Customer
окончено.

Сущность Make

Для таблицы

Makes
был создан шаблонный сущностный класс по имени
Make
. Операторы
using
должны иметь следующий вид:


using System;

using System.Collections.Generic;

using System.ComponentModel;

using System.ComponentModel.DataAnnotations;

using System.ComponentModel.DataAnnotations.Schema;

using System.Text.Json.Serialization;

using AutoLot.Models.Entities.Base;

using Microsoft.EntityFrameworkCore;


Унаследуйте класс

Make
от
BaseEntity
и удалите свойства
Id
и
TimeStamp
. Удалите конструктор и директиву
#pragma nullable disable
, а затем добавьте атрибут
[Table]
со схемой. Вот текущий код сущностного класса:


namespace AutoLot.Models.Entities

{

  [Table("Makes", Schema = "dbo")]

  public partial class Make : BaseEntity

  {

   [Required]

   [StringLength(50)]

   public string Name { get; set; }

   [InverseProperty(nameof(Inventory.Make))]

   public virtual ICollection Inventories { get; set; }

  }

}


В представленном далее коде демонстрируется инициализированное свойство

Name
, не допускающее
null
, и скорректированное навигационное свойство
Cars
(обратите внимание на изменение имени
Inventory
на
Car
в выражении
nameof
):


[Required]

[StringLength(50)]

public string Name { get; set; } = "Ford";


[JsonIgnore]

[InverseProperty(nameof(Car.MakeNavigation))]

public IEnumerable Cars { get; set; } = new List();


На этом сущностный класс

Make
завершен.

Сущность CreditRisk

Для таблицы

CreditRisks
был создан шаблонный сущностный класс по имени
CreditRisk
. Приведите операторы
using
к такому виду:


using System.ComponentModel.DataAnnotations.Schema;

using AutoLot.Models.Entities.Base;

using AutoLot.Models.Entities.Owned;


Унаследуйте класс

CreditRisk
от
BaseEntityиудалите
свойства
Id
и
TimeStamp
. Удалите конструктор и директиву
#pragma nullable disable
и добавьте атрибут
[Table]
со схемой. Удалите свойства
FirstName
и
LastName
, т.к. они будут заменены принадлежащим сущностным классом
Person
. Ниже показан обновленный код сущностного класса:


namespace AutoLot.Models.Entities

{

  [Table("CreditRisks", Schema = "dbo")]

  public partial class CreditRisk : BaseEntity

  {

   public Person PersonalInformation { get; set; } = new Person();

   public int CustomerId { get; set; }

   [ForeignKey(nameof(CustomerId))]

   [InverseProperty("CreditRisks")]

   public virtual Customer Customer { get; set; }

  }

}


Исправьте навигационное свойство, для чего удалите модификатор

virtual
, используйте выражение
nameof
в атрибуте
[InverseProperty]
и добавьте к имени свойства суффикс
Navigation
:


[ForeignKey(nameof(CustomerId))]

[InverseProperty(nameof(Customer.CreditRisks))]

public Customer? CustomerNavigation { get; set; }


Финальное изменение заключается в добавлении свойства с типом принадлежащего сущностного класса. Отношение будет позже сконфигурировано посредством Fluent API.


public Person PersonalInformation { get; set; } = new Person();


Итак, сущностный класс

CreditRisk
закончен.

Сущность Order

Для таблицы

Orders
был создан шаблонный сущностный класс по имени
Order
. Модифицируйте операторы
using
следующим образом:


using System;

using System.ComponentModel.DataAnnotations.Schema;

using AutoLot.Models.Entities.Base;

using Microsoft.EntityFrameworkCore;


Унаследуйте класс

Order
от
BaseEntity
и удалите свойства
Id
и
TimeStamp
. Удалите конструктор и директиву
#pragma nullable disable
, а затем добавьте атрибут
[Table]
со схемой. Вот текущий код сущностного класса:


namespace AutoLot.Models.Entities

{

  [Table("Orders", Schema = "dbo")]

  [Index(nameof(CarId), Name = "IX_Orders_CarId")]

 [Index(nameof(CustomerId), nameof(CarId),

    Name = "IX_Orders_CustomerId_CarId", 
IsUnique = true)]

  public partial class Order : BaseEntity

  {

   public int CustomerId { get; set; }

   public int CarId { get; set; }

   [ForeignKey(nameof(CarId))]

   [InverseProperty(nameof(Inventory.Orders))]

   public virtual Inventory Car { get; set; }

   [ForeignKey(nameof(CustomerId))]

   [InverseProperty("Orders")]

   public virtual Customer { get; set; }

   }

}


К именам навигационных свойств

Car
и
Customer
необходимо добавить суффикс
Navigation
. Навигационное свойство
Car
должно иметь тип
Car
, а не
Inventory
. В выражении
nameof
в обратном навигационном свойстве нужно применять
Car.Orders
вместо
Inventory.Orders
. В навигационном свойстве
Customer
должно использоваться выражение
nameof
для
InverseProperty
. Оба свойства должны быть сделаны допускающими значение
null
. Модификатор
virtual
понадобится удалить.


[ForeignKey(nameof(CarId))]

[InverseProperty(nameof(Car.Orders))]

public Car? CarNavigation { get; set; }


[ForeignKey(nameof(CustomerId))]

[InverseProperty(nameof(Customer.Orders))]

public Customer? CustomerNavigation { get; set; }


На этом сущностный класс

Order
завершен.


На заметку! В данный момент проект должен нормально компилироваться. Проект

AutoLot.Dal
не скомпилируется до тех пор, пока не будет обновлен класс
ApplicationDbContext
.

Сущность SeriLogEntry

База данных нуждается в дополнительной таблице для хранения журнальных записей. В проектах ASP.NET Core из части VIII будет применяться инфраструктура ведения журналов SeriLog, и один из вариантов предусматривает помещение журнальных записей в таблицу SQL Server. Хотя таблица будет использоваться через несколько глав, имеет смысл добавить ее сейчас.

Эта таблица не связана ни с одной из остальных таблиц и не задействует класс

BaseEntity
. Добавьте в каталог
Entities
новый файл класса по имени
SeriLogEntry.cs
и поместите в него следующий код:


using System;

using System.ComponentModel.DataAnnotations;

using System.ComponentModel.DataAnnotations.Schema;

using System.Xml.Linq;


namespace AutoLot.Models.Entities

{

  [Table("SeriLogs", Schema = "Logging")]

  public class SeriLogEntry

  {

   [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]

   public int Id { get; set; }

   public string? Message { get; set; }

   public string? MessageTemplate { get; set; }

   [MaxLength(128)]

   public string? Level { get; set; }

   [DataType(DataType.DateTime)]

   public DateTime? TimeStamp { get; set; }

   public string? Exception { get; set; }

   public string? Properties { get; set; }

   public string? LogEvent { get; set; }

   public string? SourceContext { get; set; }

   public string? RequestPath { get; set; }

   public string? ActionName { get; set; }

   public string? ApplicationName { get; set; }

   public string? MachineName { get; set; }

   public string? FilePath { get; set; }

   public string? MemberName { get; set; }

   public int? LineNumber { get; set; }

   [NotMapped]

   public XElement? PropertiesXml

    => (Properties != null)? XElement.Parse(Properties):null;

  }

}


Итак, сущностный класс

SeriLogEntry
закончен.


На заметку! Свойство

TimeStamp
в сущностном классе
SeriLogEntry
отличается от свойства
TimeStamp
в классе
BaseEntity
. Имена совпадают, но в этой таблице оно хранит дату и время регистрации записи в журнале (что будет конфигурироваться как стандартная настройка SQL Server), а не
rowversion
в других сущностях.

Класс ApplicationDbContext

Пришло время обновить файл

ApplicationDbContext.cs
. Начните с приведения операторов
using
к такому виду:


using System;

using System.Collections;

using System.Collections.Generic;

using AutoLot.Models.Entities;

using AutoLot.Models.Entities.Owned;

using Microsoft.EntityFrameworkCore;

using Microsoft.EntityFrameworkCore.Storage;

using Microsoft.EntityFrameworkCore.ChangeTracking;

using AutoLot.Dal.Exceptions;


Файл начинается с конструктора без параметров. Удалите его, т.к. он не нужен. Следующий конструктор принимает экземпляр

DbContextOptions
и пока подходит. Перехватчики событий для
DbContext
и
ChangeTracker
добавляются позже в главе.

Свойства

DbSet
необходимо сделать допускающими
null
, имена скорректировать, а модификаторы
virtual
удалить. Теперь новую сущность для ведения журнала необходимо добавить. Перейдите к свойствам
DbSet
и модифицируйте их следующим образом:


public DbSet? LogEntries { get; set; }

public DbSet? CreditRisks { get; set; }

public DbSet? Customers { get; set; }

public DbSet? Makes { get; set; }

public DbSet? Cars { get; set; }

public DbSet? Orders { get; set; }

Обновление кода Fluent API

Код Fluent API находится в переопределенной версии метода

OnModelCreating()
и использует экземпляр класса
ModelBuilder
для обновления модели.

Сущность SeriLogEntry

Первое изменение, вносимое в метод

OnModelCreating()
, касается добавления кода Fluent API для конфигурирования сущности
SeriLogEntry
. Свойство
Properties
является XML-столбцом SQL Server, а свойство
TimeStamp
отображается в SQL Server на столбец
datetime2
со стандартным значением, установленным в результат функции
getdate()
из SQL Server. Добавьте в метод
OnModelCreating()
следующий код:


modelBuilder.Entity(entity =>

{

  entity.Property(e => e.Properties).HasColumnType("Xml");

  entity.Property(e => e.TimeStamp).HasDefaultValueSql("GetDate()");

});

Сущность CreditRisk

Далее понадобится модифицировать код сущности

CreditRisk
. Блок конфигурирования для столбца
TimeStamp
удаляется, т.к. столбец конфигурируется в
BaseEntity
. Код конфигурирования навигации должен быть скорректирован с учетом новых имен. Кроме того, выполняется утверждение о том, что навигационное свойство не равно
null
. Другое изменение связано с конфигурированием свойства типа принадлежащей сущности, чтобы сопоставить с именами столбцов для
FirstName
и
LastName
, и добавлением вычисляемого значения для свойства
FullName
. Ниже показан обновленный блок для сущности
CreditRisk
с изменениями, выделенными полужирным:


modelBuilder.Entity(entity =>

{

  entity.HasOne(d => d.CustomerNavigation)

    .WithMany(p => p!.CreditRisks)

    .HasForeignKey(d => d.CustomerId)

    .HasConstraintName("FK_CreditRisks_Customers");


  entity.OwnsOne(o => o.PersonalInformation,

   pd =>

   {

    pd.Property(nameof(Person.FirstName))

      .HasColumnName(nameof(Person.FirstName))

      .HasColumnType("nvarchar(50)");

    pd.Property(nameof(Person.LastName))

      .HasColumnName(nameof(Person.LastName))

      .HasColumnType("nvarchar(50)");

    pd.Property(p => p.FullName)

      .HasColumnName(nameof(Person.FullName))

      .HasComputedColumnSql("[LastName] + ', ' + [FirstName]");

   });

});

Сущность Customer

Следующим обновляется блок для сущности

Customer
. Здесь удаляется код для
TimeStamp
и конфигурируются свойства принадлежащего сущностного класса:


modelBuilder.Entity(entity =>

{

  entity.OwnsOne(o => o.PersonalInformation,

 pd =>

  {

   pd.Property(p
 => p.FirstName).HasColumnName(nameof(Person.
FirstName));

  pd.Property(p => p.LastName).HasColumnName(nameof(Person.LastName));

  pd.Property(p => p.FullName)

   .HasColumnName(nameof(Person.FullName))

   .HasComputedColumnSql("[LastName] + ', ' + [FirstName]");

  });

});

Сущность Make

Для сущности

Make
необходимо модифицировать блок конфигурирования, удалив свойство
TimeStamp
и добавив код, который ограничивает удаление сущности, имеющей зависимые сущности:


modelBuilder.Entity(entity =>

{

  entity.HasMany(e => e.Cars)

    .WithOne(c => c.MakeNavigation!)

    .HasForeignKey(k => k.MakeId)

    .OnDelete(DeleteBehavior.Restrict)

    .HasConstraintName("FK_Make_Inventory");

});

Сущность Order

Для сущности

Order
обновите имена навигационных свойств и добавьте утверждение, что обратные навигационные свойства не равны
null
. Вместо ограничения удалений отношение
Customer
с
Order
настраивается на каскадное удаление:


modelBuilder.Entity(entity =>

{

  entity.HasOne(d => d.CarNavigation)

   .WithMany(p => p!.Orders)

   .HasForeignKey(d => d.CarId)

   .OnDelete(DeleteBehavior.ClientSetNull)

   .HasConstraintName("FK_Orders_Inventory");


  entity.HasOne(d => d.CustomerNavigation)

   .WithMany(p => p!.Orders)

   .HasForeignKey(d => d.CustomerId)

   .OnDelete(DeleteBehavior.Cascade)

   .HasConstraintName("FK_Orders_Customers");

});


Настройте фильтр для свойства

CarNavigation
сущности
Order
, чтобы отфильтровывать неуправляемые автомобили. Обратите внимание, что этот код находится не в том же блоке, где был предыдущий код. Никаких формальных причин разносить код не существует; просто здесь демонстрируется альтернативный синтаксис для конфигурирования в отдельных блоках:


modelBuilder.Entity().HasQueryFilter(e => e.CarNavigation!.IsDrivable);

Сущность Car

Шаблонный класс содержит конфигурацию для класса

Inventory
, который понадобится изменить на
Car
. Свойство
TimeStamp
нужно удалить, а в конфигурации навигационных свойств изменить имена свойств на
MakeNavigation
и
Cars
. Сущность получает фильтр запросов для отображения по умолчанию только управляемых автомобилей и устанавливает стандартное значение свойства
IsDrivable
в
true
. Код должен иметь следующий вид:


modelBuilder.Entity(entity =>

{

  entity.HasQueryFilter(c => c.IsDrivable);

 entity.Property(p

   => p.IsDrivable).HasField("_isDrivable").HasDefaultValue(true);

 entity.HasOne(d => d.MakeNavigation)

   .WithMany(p => p.Cars)

   .HasForeignKey(d => d.MakeId)

   .OnDelete(DeleteBehavior.ClientSetNull)

   .HasConstraintName("FK_Make_Inventory");

});

Специальные исключения

Распространенный прием для обработки исключений предусматривает перехват системного исключения (и/или исключения EF Core, как в текущем примере), его регистрацию в журнале и генерацию специального исключения. Если специальное исключение перехватывается вышерасположенным методом, то разработчику известно, что исключение уже было зарегистрировано в журнале, и необходимо только отреагировать на него надлежащим образом в коде.

Создайте в проекте

AutoLot.Dal
новый каталог по имени
Exceptions
и поместите в него четыре новых файла классов,
CustomException.cs
,
CustomConcurrencyException.cs
,
CustomDbUpdateException.cs
и
CustomRetryLimitExceededException.cs
, содержимое которых показано ниже:


// CustomException.cs

using System;

namespace AutoLot.Dal.Exceptions

{

  public class CustomException : Exception

  {

   public CustomException() {}

   public CustomException(string message) : base(message) { }

   public CustomException(string message, Exception innerException)

       : base(message, innerException) { }

  }

}


// CustomConcurrencyException.cs

using Microsoft.EntityFrameworkCore;

namespace AutoLot.Dal.Exceptions

{

  public class CustomConcurrencyException : CustomException

  {

   public CustomConcurrencyException() { }

   public CustomConcurrencyException(string message) : base(message) { }

   public CustomConcurrencyException(

    string message, DbUpdateConcurrencyException innerException)

       : base(message, innerException) { }

  }

}


// CustomDbUpdateException.cs

using Microsoft.EntityFrameworkCore;

namespace AutoLot.Dal.Exceptions

{

  public class CustomDbUpdateException : CustomException

  {

    public CustomDbUpdateException() { }

   public CustomDbUpdateException(string message) : base(message) { }

   public CustomDbUpdateException(

    string message, DbUpdateException innerException)

       : base(message, innerException) { }

  }

}


// CustomRetryLimitExceededException.cs

using System;

using Microsoft.EntityFrameworkCore.Storage;

namespace AutoLot.Dal.Exceptions

{

  public class CustomRetryLimitExceededException : CustomException

  {

   public CustomRetryLimitExceededException() { }

   public CustomRetryLimitExceededException(string message)

     : base(message) { }

   public CustomRetryLimitExceededException(

    string message, RetryLimitExceededException innerException)

     : base(message, innerException) { }

  }

}


На заметку! Обработка специальных исключений была подробно раскрыта в главе 7.

Переопределение метода SaveChanges()

Как обсуждалось в предыдущей главе, метод

SaveChanges()
базового класса
DbContext
сохраняет результаты операций изменения, добавления и удаления в базе данных. Переопределение этого метода позволяет инкапсулировать обработку исключений в одном месте. Располагая специальными исключениями, добавьте оператор
using
для
AutoLot.Dal.Exceptions
в начало файла
ApplicationDbContext.cs
, после чего переопределите метод
SaveChanges()
:


public override int SaveChanges()

{

  try

  {

   return base.SaveChanges();

  }

  catch (DbUpdateConcurrencyException ex)

  {

   // Произошла ошибка параллелизма.

   // Подлежит регистрации в журнале и надлежащей обработке.

   throw new CustomConcurrencyException(

     "A concurrency error happened.", ex);

   // Произошла ошибка параллелизма

  }

  catch (RetryLimitExceededException ex)

  {

   // Подлежит регистрации в журнале и надлежащей обработке.

   throw new CustomRetryLimitExceededException(

     "There is a problem with SQl Server.", ex);

   // Возникла проблема c SQL Server

  }

  catch (DbUpdateException ex)

  {

   // Подлежит регистрации в журнале и надлежащей обработке.

   throw new CustomDbUpdateException(

     "An error occurred updating the database", ex);

   // Произошла ошибка при обновлении базы данных

  }

  catch (Exception ex)

  {

   // Подлежит регистрации в журнале и надлежащей обработке.

   throw new CustomException(

     "An error occurred updating the database", ex);

   // Произошла ошибка при обновлении базы данных

  }

}

Обработка событий DbContext и ChangeTracker

Перейдите к конструктору класса

ApplicationDbContext
и добавьте три события
DbContext
, которые обсуждались в предыдущей главе:


public ApplicationDbContext(DbContextOptions options)

  : base(options)

{

  base.SavingChanges += (sender, args) =>

  {

   Console.WriteLine($"Saving changes for {((ApplicationDbContext)

    sender)!.Database!.
GetConnectionString()}");

  };

  base.SavedChanges += (sender, args) =>

  {

   Console.WriteLine($"Saved {args!.EntitiesSavedCount} changes for

    {((ApplicationDbContext)sender)!.Database!.GetConnectionString()}");

  };

  base.SaveChangesFailed += (sender, args) =>

  {

   Console.WriteLine(

    $"An exception occurred! {args.Exception.Message} entities");

  };

}


Затем добавьте обработчики для событий

StateChanged
и
Tracked
класса
ChangeTracker
:


public ApplicationDbContext(DbContextOptions options)

  : base(options)

{

  ...

  ChangeTracker.Tracked += ChangeTracker_Tracked;

  ChangeTracker.StateChanged += ChangeTracker_StateChanged;

}


Аргументы события

Tracked
содержат ссылку на сущность, которая инициировала событие, и указывают, было оно получено из запроса (загруженного из базы данных) или добавлено программно. Добавьте в класс
ApplicationDbContext
следующий обработчик событий:


private void ChangeTracker_Tracked(object? sender, EntityTrackedEventArgs e)

{

  var source = (e.FromQuery) ? "Database" : "Code";

  if (e.Entry.Entity is Car c)

  {

   Console.WriteLine($"Car entry {c.PetName} was added from {source}");

  }

}


Событие

StateChanged
инициируется при изменении состояния сущности. Одно из применений этого события — аудит. Поместите в класс
ApplicationDbContext
приведенный ниже обработчик событий. Если свойство
NewState
сущности имеет значение
Unchanged
, тогда выполняется проверка свойства
OldState
для выяснения, сущность была добавлена или же модифицирована.


private void ChangeTracker_StateChanged(object? sender,

                     EntityStateChangedEventArgs e)

{

  if (e.Entry.Entity is not Car c)

  {

   return;

  }

  var action = string.Empty;

  Console.WriteLine(
$"Car {c.PetName}

      was {e.OldState} before the state changed to 
{e.NewState}");

  switch (e.NewState)

  {

   case EntityState.Unchanged:

    action = e.OldState switch

    {

     EntityState.Added => "Added",

     EntityState.Modified => "Edited",

     _ => action

    };

    Console.WriteLine($"The object was {action}");

    break;

  }

}

Создание миграции и обновление базы данных

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

AutoLot.Dal
следующие команды (каждая команда должна вводиться в одной строке):


dotnet ef migrations add UpdatedEntities -o EfStructures\Migrations

 -c AutoLot.Dal.
EfStructures.ApplicationDbContext


dotnet ef database update UpdatedEntities

 -c AutoLot.Dal.EfStructures.ApplicationDbContext

Добавление представления базы данных и хранимой процедуры

Осталось внести в базу данных два изменения: создать хранимую процедуру

GetPetName
, рассмотренную в главе 21, и добавить представление базы данных, которое объединяет таблицу
Orders
с деталями
Customer
,
Car
и
Make
.

Добавление класса MigrationHelpers

Хранимая процедура и представление будут создаваться с использованием миграции, которая требует написания кода вручную. Причина поступать так (вместо того, чтобы просто открыть Azure Data Studio и запустить код T-SQL) — желание поместить полное конфигурирование базы данных в один процесс. Когда все содержится в миграциях, единственный вызов

dotnet ef database update
гарантирует, что база данных является актуальной, включая конфигурацию EF Core и специальный код SQL.

Выполнение команды

the dotnet ef migrations add
при отсутствии изменений в модели все равно приводит к созданию файлов миграции, имеющих правильную отметку времени, с пустыми методами
Up()
и
Down()
. Введите показанную ниже команду для создания пустой миграции (но не применения миграции):


dotnet ef migrations add SQL -o EfStructures\Migrations

 -c AutoLot.Dal.EfStructures.
ApplicationDbContext


Создайте в каталоге

EfStructures
проекта
AutoLot.Dal
новый файл по имени
MigrationHelpers.cs
. Добавьте оператор
using
для пространства имен
Microsoft.EntityFrameworkCore.Migrations
, сделайте класс открытым и статическим и поместите в него следующие методы, которые используют
MigrationBuilder
для запуска операторов SQL в отношении базы данных:


namespace AutoLot.Dal.EfStructures

{

  public static class MigrationHelpers

  {

   public static void CreateSproc(MigrationBuilder migrationBuilder)

   {

    migrationBuilder.Sql($@"

      exec (N'

      CREATE PROCEDURE [dbo].[GetPetName]

        @carID int,

        @petName nvarchar(50) output

      AS

      SELECT @petName = PetName from dbo.Inventory where Id = @carID

    ')");

   }

   public static void DropSproc(MigrationBuilder migrationBuilder)

   {

    migrationBuilder.Sql("DROP PROCEDURE [dbo].[GetPetName]");

   }


   public static void CreateCustomerOrderView(

    MigrationBuilder migrationBuilder)

   {

    migrationBuilder.Sql($@"

      exec (N'

      CREATE VIEW [dbo].[CustomerOrderView]

      AS

    SELECT dbo.Customers.FirstName, dbo.Customers.LastName,

       dbo.Inventory.Color, dbo.Inventory.PetName,

        dbo.Inventory.IsDrivable,

       dbo.Makes.Name AS Make

      FROM dbo.Orders

      INNER JOIN dbo.Customers ON dbo.Orders.CustomerId = dbo.Customers.Id

      INNER JOIN dbo.Inventory ON dbo.Orders.CarId = dbo.Inventory.Id

      INNER JOIN dbo.Makes ON dbo.Makes.Id = dbo.Inventory.MakeId

    ')");

   }


   public static void DropCustomerOrderView(MigrationBuilder migrationBuilder)

   {

    migrationBuilder.Sql("EXEC (N' DROP VIEW [dbo].[CustomerOrderView] ')");

   }

  }

}

Обновление и применение миграции

Для каждого объекта SQL Server в классе

MigrationHelpers
имеется два метода: один создает объект, другой удаляет объект. Вспомните, что при применении миграции выполняется метод
Up()
, а при откате миграции — метод
Down()
. Вызовы статических методов создания должны попасть в метод
Up()
миграции, тогда как вызовы статических методов удаления — в метод
Down()
миграции. В результате применения миграции создаются два объекта SQL Server, которые в случае отката миграции благополучно удаляются. Ниже приведен модифицированный код миграции:


namespace AutoLot.Dal.EfStructures.Migrations

{

  public partial class SQL : Migration

  {

   protected override void Up(MigrationBuilder migrationBuilder)

   {

    MigrationHelpers.CreateSproc(migrationBuilder);

    MigrationHelpers.CreateCustomerOrderView(migrationBuilder);

   }


   protected override void Down(MigrationBuilder migrationBuilder)

   {

    MigrationHelpers.DropSproc(migrationBuilder);

    MigrationHelpers.DropCustomerOrderView(migrationBuilder);

   }

  }

}


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


dotnet ef database update -c AutoLot.Dal.EfStructures.ApplicationDbContext


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

Up()
вызов статического метода, создающего хранимую процедуру:


protected override void Up(MigrationBuilder migrationBuilder)

{

// MigrationHelpers.CreateSproc(migrationBuilder);

  MigrationHelpers.CreateCustomerOrderView(migrationBuilder);

}


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


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

Добавление модели представления

Теперь, когда представление SQL Server на месте, самое время создать модель представления, которая будет использоваться для отображения данных из представления. Модель представления будет добавлена как

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

Добавление класса модели представления

Добавьте в проект

AutoLot.Models
новый каталог по имени
ViewModels
, создайте в нем файл класса
CustomerOrderViewModel.cs
и поместите в него такие операторы
using
:


using System.ComponentModel.DataAnnotations.Schema;

using Microsoft.EntityFrameworkCore;

Приведите код к следующему виду:

namespace AutoLot.Models.ViewModels

{

  [Keyless]

  public class CustomerOrderViewModel

  {

   public string? FirstName { get; set; }

   public string? LastName { get; set; }

   public string? Color { get; set; }

   public string? PetName { get; set; }

   public string? Make { get; set; }

   public bool? IsDrivable { get;set; }

   [NotMapped]

   public string FullDetail =>

   $"{FirstName} {LastName} ordered a {Color} {Make} named {PetName}";

  public override string ToString() => FullDetail;

  }

}


Аннотация данных

[KeyLess]
указывает, что класс является сущностью, работающей с данными, которые не имеют первичного ключа и могут быть оптимизированы как данные только для чтения (с точки зрения базы данных). Первые пять свойств соответствуют данным, поступающим из представления. Свойство
FullDetail
декорировано посредством аннотации данных
[NotMapped]
, которая информирует инфраструктуру EF Core о том, что это свойство не должно включаться в базу данных, и не может поступать из базы данных в результате операций запросов. Инфраструктура EF Core также игнорирует переопределенную версию метода
ToString()
.

Добавление класса модели представления к ApplicationDbContext

Финальный шаг предусматривает регистрацию и конфигурирование

CustomerOrderViewModel
в
ApplicationDbContext
.

Добавьте к

ApplicationDbContext
оператор
using
для
AutoLot.Models.ViewModels
и затем свойство
DbSet
:


public virtual DbSet?

 CustomerOrderViewModels { get; set; }


Помимо добавления свойства

DbSet
необходимо с помощью Fluent API сопоставить модель представления с представлением SQL Server. Метод
HasNoKey()
из Fluent API и аннотация данных
[KeyLess]
делают то же самое, но метод Fluent API замещает аннотацию данных. Ради ясности рекомендуется оставлять аннотацию данных на месте. Добавьте в метод
OnModelCreating()
следующий код:


modelBuilder.Entity(entity =>

{

  entity.HasNoKey().ToView("CustomerOrderView","dbo");

});

Добавление хранилищ

Распространенный паттерн проектирования для доступа к данным называется "Хранилище" (Repository). Согласно описанию Мартина Фаулера (

http://www.martinfowler.com/eaaCatalog/repository.html
) ядро этого паттерна является посредником между уровнями предметной области и сопоставления с данными. Наличие обобщенного хранилища, которое содержит общий код доступа к данным, помогает устранить дублирование кода. Наличие специфических хранилищ и интерфейсов, производных от базового хранилища, также хорошо подходит для работы с инфраструктурой внедрения зависимостей в ASP.NET Core.

Каждая сущность предметной области внутри уровня доступа к данным

AutoLot
будет иметь строго типизированное хранилище для инкапсуляции всей работы по доступу к данным. Первым делом создайте в проекте
AutoLot.Dal
новый каталог по имени
Repos
, предназначенный для хранения всех классов.


На заметку! Не воспринимайте следующий раздел как буквальную интерпретацию паттерна проектирования "Хранилище". Если вас интересует исходный паттерн, который послужил мотивом для создания приведенной здесь версии, тогда почитайте о нем по ссылке

http://www.martinfowler.com/eaaCatalog/repository.html
.

Добавление базового интерфейса IRepo

Базовый интерфейс

IRepo
предоставляет множество общих методов, используемых при доступе к данным. Добавьте в проект
AutoLot.Dal
новый каталог по имени
Repos
и создайте в нем еще один каталог под названием
Base
. Поместите в каталог
Repos\Base
новый файл интерфейса по имени
IRepo.cs
. Обновите операторы
using
, как показано ниже:


using System;

using System.Collections.Generic;


Так выглядит полный интерфейс:


namespace AutoLot.Dal.Repos.Base

{

  public interface IRepo: IDisposable

  {

   int Add(T entity, bool persist = true);

   int AddRange(IEnumerable entities, bool persist = true);

   int Update(T entity, bool persist = true);

   int UpdateRange(IEnumerable entities, bool persist = true);

   int Delete(int id, byte[] timeStamp, bool persist = true);

   int Delete(T entity, bool persist = true);

   int DeleteRange(IEnumerable entities, bool persist = true);

   T? Find(int? id);

   T? FindAsNoTracking(int id);

   T? FindIgnoreQueryFilters(int id);

   IEnumerable GetAll();

   IEnumerable GetAllIgnoreQueryFilters();

   void ExecuteQuery(string sql, object[] sqlParametersObjects);

   int SaveChanges();

  }

}

Добавление класса BaseRepo

Добавьте в каталог

Repos\Base
файл класса по имени
BaseRepo.cs
. Класс
BaseRepo
будет реализовывать интерфейс
IRepo
и предлагать основную функциональность для хранилищ, специфичных к типам (рассматриваются далее). Приведите операторы
using
к следующему виду:


using System;

using System.Collections.Generic;

using System.Linq;

using AutoLot.Dal.EfStructures;

using AutoLot.Dal.Exceptions;

using AutoLot.Models.Entities.Base;

using Microsoft.EntityFrameworkCore;


Сделайте класс обобщенным с типом

Т
и добавьте к нему ограничения
BaseEntity
и
new()
, что сузит набор типов до классов, которые имеют конструктор без параметров. Реализуйте интерфейс
IRepo
:


public abstract class BaseRepo : IRepo where T : BaseEntity, new()


Классу хранилища нужен экземпляр

ApplicationDbContext
, внедренный через конструктор. В случае использования с контейнером внедрения зависимостей ASP.NET Core временем жизни контекста будет управлять контейнер. Второй конструктор будет принимать
DbContextOptions
и должен создавать экземпляр
ApplicationDbContext
, который понадобится освобождать. Поскольку этот класс является абстрактным, оба конструктора определяются как защищенные. Добавьте в открытый класс
ApplicationDbContext
следующий код:


private readonly bool _disposeContext;

public ApplicationDbContext Context { get; }

protected BaseRepo(ApplicationDbContext context)

{

  Context = context;

  _disposeContext = false;

}


protected BaseRepo(DbContextOptions options) : this(new 

ApplicationDbContext(options))

{

  _disposeContext = true;

}


public void Dispose()

{

  Dispose(true);

  GC.SuppressFinalize(this);

}

private bool _isDisposed;

protected virtual void Dispose(bool disposing)

{

  if (_isDisposed)

  {

   return;

  }

  if (disposing)

  {

   if (_disposeContext)

   {

    Context.Dispose();

   }

  }

  _isDisposed = true;

}


~BaseRepo()

{

  Dispose(false);

}


На свойства

DbSet
класса
ApplicationDbContext
можно ссылаться с использованием метода
Context.Set()
. Создайте открытое свойство по имени
Table
типа
DbSet
и установите его начальное значение в конструкторе:


public DbSet Table { get; }

protected BaseRepo(ApplicationDbContext context)

{

  Context = context;

  Table = Context.Set();

  _disposeContext = false;

}

Реализация метода SaveChanges()

Класс

BaseRepo
имеет метод
SaveChanges()
, который вызывает переопределенную версию
SaveChanges()
и демонстрирует обработку специальных исключений. Добавьте в класс
BaseRepo
показанный ниже код:


public int SaveChanges()

{

  try

  {

   return Context.SaveChanges();

  }

  catch (CustomException ex)

  {

   // Подлежит надлежащей обработке -- уже зарегистрировано в журнале.

   throw;

  }

  catch (Exception ex)

  {

   // Подлежит регистрации в журнале и надлежащей обработке.

   throw new CustomException("An error occurred updating the database", ex);

  }

}

Реализация общих методов чтения

Следующий комплект методов возвращает записи с применением операторов LINQ. Метод

Find()
принимает первичный ключ (ключи) и сначала выполняет поиск в
ChangeTracker
. Если сущность уже отслеживается, тогда возвращается отслеживаемый экземпляр, иначе запись извлекается из базы данных.


public virtual T? Find(int? id) => Table.Find(id);


Дополнительные два метода

Find()
расширяют базовый метод
Find()
. Приведенный далее метод демонстрирует извлечение записи, но без ее добавления в
ChangeTracker
, используя
AsNoTrackingWithldentityResolution()
. Добавьте в класс показанный ниже код:


public virtual T? FindAsNoTracking(int id) =>

  Table.AsNoTrackingWithIdentityResolution().FirstOrDefault(x => x.Id == id);


Другая вариация удаляет из сущности фильтры запросов и затем применяет сокращенную версию (пропускающую метод

Where()
) для получения
FirstOrDefault()
. Добавьте в класс следующий код:


public T? FindIgnoreQueryFilters(int id) =>

  Table.IgnoreQueryFilters().FirstOrDefault(x => x.Id == id);


Методы

GetAll()
возвращают все записи из таблицы. Первый метод извлекает их в порядке, поддерживаемом в базе данных, а второй по очереди обрабатывает все фильтры запросов:


public virtual IEnumerable GetAll() => Table;

public virtual IEnumerable GetAllIgnoreQueryFilters()

  => Table.IgnoreQueryFilters();


Метод

ExecuteQuery()
предназначен для выполнения хранимых процедур:


public void ExecuteQuery(string sql, object[] sqlParametersObjects)

  => Context.Database.ExecuteSqlRaw(sql, sqlParametersObjects);

Реализация методов добавления, обновления и удаления

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

DbSet
. Параметр
persist
определяет, выполняет ли хранилище вызов
SaveChanges()
сразу же после вызова методов добавления, обновления и удаления. Все методы помечены как
virtual
, чтобы сделать возможным дальнейшее переопределение. Добавьте в класс показанный ниже код:


public virtual int Add(T entity, bool persist = true)

{

  Table.Add(entity);

  return persist ? SaveChanges() : 0;

}

public virtual int AddRange(IEnumerable entities, bool persist = true)

{

  Table.AddRange(entities);

  return persist ? SaveChanges() : 0;

}

public virtual int Update(T entity, bool persist = true)

{

  Table.Update(entity);

  return persist ? SaveChanges() : 0;

}

public virtual int UpdateRange(IEnumerable entities, bool persist = true)

{

  Table.UpdateRange(entities);

  return persist ? SaveChanges() : 0;

}

public virtual int Delete(T entity, bool persist = true)

{

  Table.Remove(entity);

  return persist ? SaveChanges() : 0;

}

public virtual int DeleteRange(IEnumerable entities, bool persist = true)

{

  Table.RemoveRange(entities);

  return persist ? SaveChanges() : 0;

}


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

EntityState
, что часто происходит при работе с ASP.NET Core с целью сокращения сетевого трафика:


public int Delete(int id, byte[] timeStamp, bool persist = true)

{

  var entity = new T {Id = id, TimeStamp = timeStamp};

  Context.Entry(entity).State = EntityState.Deleted;

  return persist ? SaveChanges() : 0;

}


Итак, класс

BaseRepo
завершен, и можно приступать к построению хранилищ, специфичных для сущностей.

Интерфейсы хранилищ, специфичных для сущностей

Каждая сущность будет иметь строго типизированное хранилище, производное от

BaseRepo
, и интерфейс, который реализует
IRepo
. Создайте в каталоге
Repos
проекта
AutoLot.Dal
новый каталог по имени
Interfaces
и добавьте в него пять файлов интерфейсов:


ICarRepo.cs

ICreditRiskRepo.cs

ICustomerRepo.cs

IMakelRepo.cs

IOrderRepo.cs


Содержимое интерфейсов будет представлено в последующих разделах.

Интерфейс хранилища данных об автомобилях

Откройте файл

ICarRepo.cs
и поместите в его начало такие операторы
using
:


using System.Collections.Generic;

using AutoLot.Models.Entities;

using AutoLot.Dal.Repos.Base;


Измените интерфейс на

public
и реализуйте
IRepo
, как показано ниже:


namespace AutoLot.Dal.Repos.Interfaces

{

  public interface ICarRepo : IRepo

  {

   IEnumerable GetAllBy(int makeId);

   string GetPetName(int id);

  }

}

Интерфейс хранилища данных о кредитных рисках

Откройте файл

ICreditRiskRepo.cs
. Интерфейс
ICreditRiskRep
не добавляет никакой функциональности сверх той, что предоставляется в
BaseRepo
. Обновите код следующим образом:


using AutoLot.Models.Entities;

using AutoLot.Dal.Repos.Base;

namespace AutoLot.Dal.Repos.Interfaces

{

  public interface ICreditRiskRepo : IRepo

  {

  }

}

Интерфейс хранилища данных о заказчиках

Откройте файл

ICustomerRepo.cs
. Интерфейс
ICustomerRepo
не добавляет никакой функциональности сверх той, что предоставляется в
BaseRepo
. Приведите код к такому виду:


using AutoLot.Models.Entities;

using AutoLot.Dal.Repos.Base;

namespace AutoLot.Dal.Repos.Interfaces

{

  public interface ICustomerRepo : IRepo

  {

  }

}

Интерфейс хранилища данных о производителях

Откройте файл

IMakeRepo.cs
. Интерфейс
IMakeRepo
не добавляет никакой функциональности сверх той, что предоставляется в
BaseRepo
. Обновите код, как показано ниже:


using AutoLot.Models.Entities;

using AutoLot.Dal.Repos.Base;

namespace AutoLot.Dal.Repos.Interfaces

{

  public interface IMakeRepo : IRepo

  {

  }

}

Интерфейс хранилища данных о заказах

Откройте файл

IOrderRepo.cs
. Поместите в начало файла следующие операторы
using
:


using System.Collections.Generic;

using System.Linq;

using AutoLot.Models.Entities;

using AutoLot.Dal.Repos.Base;

using AutoLot.Models.ViewModels;


Измените интерфейс на

public
и реализуйте
IRepo
:


namespace AutoLot.Dal.Repos.Interfaces

{

  public interface IOrderRepo : IRepo

  {

   IQueryable GetOrdersViewModel();

  }

}


Интерфейс на этом завершен, т.к. все необходимые конечные точки API раскрыты в базовом классе.

Реализация классов хранилищ, специфичных для сущностей

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

Repos
проекта
AutoLot.Dal
пять новых файлов классов хранилищ:


CarRepo.cs

CreditRiskRepo.cs

CustomerRepo.cs

MakeRepo.cs

OrderRepo.cs


Классы хранилищ будут реализованы в последующих разделах.

Хранилище данных об автомобилях

Откройте файл класса

CarRepo.cs
и поместите в его начало показанные ниже операторы
using
:


using System.Collections.Generic;

using System.Data;

using System.Linq;

using AutoLot.Dal.EfStructures;

using AutoLot.Models.Entities;

using AutoLot.Dal.Repos.Base;

using AutoLot.Dal.Repos.Interfaces;

using Microsoft.Data.SqlClient;

using Microsoft.EntityFrameworkCore;


Измените класс на

public
, унаследуйте его от
BaseRepo
и реализуйте
ICarRepo
:


namespace AutoLot.Dal.Repos

{

  public class CarRepo : BaseRepo, ICarRepo

  {

  }

}


Каждый класс хранилища должен реализовывать два конструктора из

BaseRepo
:


public CarRepo(ApplicationDbContext context) : base(context)

{

}

internal CarRepo(DbContextOptions options)

  :
 base(options)

{

}


Добавьте переопределенные версии методов

GetAll()
и
GetAllIgnoreQueryFilters()
для включения свойства
MakeNavigation
и упорядочения по значению
PetName
:


public override IEnumerable GetAll()

  => Table

       .Include(c => c.MakeNavigation)

       .OrderBy(o => o.PetName);

public override IEnumerable GetAllIgnoreQueryFilters()

  => Table

       .Include(c => c.MakeNavigation)

       .OrderBy(o => o.PetName)

       .IgnoreQueryFilters();


Реализуйте метод

GetAllBy()
. Перед выполнением он обязан установить фильтр для контекста. Включите навигационное свойство
Make
и отсортируйте по значению
PetName
:


public IEnumerable GetAllBy(int makeId)

{

  return Table

   .Where(x => x.MakeId == makeId)

   .Include(c => c.MakeNavigation)

   .OrderBy(c => c.PetName);

}


Добавьте переопределенную версию

Find()
, в которой включается свойство
MakeNavigation
, а фильтры запросов игнорируются:


public override Car? Find(int? id)

  => Table

     .IgnoreQueryFilters()

     .Where(x => x.Id == id)

     .Include(m => m.MakeNavigation)

     .FirstOrDefault();


Добавьте метод, который позволяет получить значение

PetName
, используя хранимую процедуру:


public string GetPetName(int id)

{

  var parameterId = new SqlParameter

  {

   ParameterName = "@carId",

   SqlDbType = SqlDbType.Int,

   Value = id,

  };

  var parameterName = new SqlParameter

  {

   ParameterName = "@petName",

   SqlDbType = SqlDbType.NVarChar,

   Size = 50,

   Direction = ParameterDirection.Output

  };

  _ = Context.Database

   .ExecuteSqlRaw("EXEC [dbo].[GetPetName] @carId, @petName OUTPUT",

   parameterId, 
parameterName);

  return (string)parameterName.Value;

}

Хранилище данных о кредитных рисках

Откройте файл класса

CreditRiskRepo.cs
и поместите в его начало следующие операторы
using
:


using AutoLot.Dal.EfStructures;

using AutoLot.Dal.Models.Entities;

using AutoLot.Dal.Repos.Base;

using AutoLot.Dal.Repos.Interfaces;

using Microsoft.EntityFrameworkCore;


Измените класс на

public
, унаследуйте его от
BaseRepo
, реализуйте
ICreditRiskRepo
и добавьте два обязательных конструктора:


namespace AutoLot.Dal.Repos

{

  public class CreditRiskRepo : BaseRepo, ICreditRiskRepo

  {

   public CreditRiskRepo(ApplicationDbContext context) : base(context)

   {

   }

   internal CreditRiskRepo(

    DbContextOptions options)

   : base(options)

   {

   }

  }

}

Хранилище данных о заказчиках

Откройте файл класса

CustomerRepo.cs
и поместите в его начало приведенные далее операторы
using
:


using System.Collections.Generic;

using System.Linq;

using AutoLot.Dal.EfStructures;

using AutoLot.Dal.Models.Entities;

using AutoLot.Dal.Repos.Base;

using AutoLot.Dal.Repos.Interfaces;

using Microsoft.EntityFrameworkCore;


Измените класс на

public
, унаследуйте его от
BaseRepo
, реализуйте
ICustomerRepo
и добавьте два обязательных конструктора:


namespace AutoLot.Dal.Repos

{

  public class CustomerRepo : BaseRepo, ICustomerRepo

  {

   public CustomerRepo(ApplicationDbContext context)

    : base(context)

   {

   }

   internal CustomerRepo(

    DbContextOptions options)

    : base(options)

   {

   }

  }

}


Наконец, добавьте метод, который возвращает все записи

Customer
с их заказами, отсортированные по значениям
LastName
:


public override IEnumerable GetAll()

  => Table

    .Include(c => c.Orders)

    .OrderBy(o => o.PersonalInformation.LastName);

Хранилище данных о производителях

Откройте файл класса

MakeRepo.cs
и поместите в его начало перечисленные ниже операторы
using
:


using System.Collections.Generic;

using System.Linq;

using AutoLot.Dal.EfStructures;

using AutoLot.Dal.Models.Entities;

using AutoLot.Dal.Repos.Base;

using AutoLot.Dal.Repos.Interfaces;

using Microsoft.EntityFrameworkCore;


Измените класс на

public
, унаследуйте его от
BaseRepo
, реализуйте
IMakeRepo
и добавьте два обязательных конструктора:


namespace AutoLot.Dal.Repos

{

  public class MakeRepo : BaseRepo, IMakeRepo

  {

   public MakeRepo(ApplicationDbContext context)

    : base(context)

   {

   }

   internal MakeRepo(

    DbContextOptions options)

    : base(options)

   {

   }

  }

}


Переопределите методы

GetAll()
, чтобы они сортировали значения
Make
по названиям:


public override IEnumerable GetAll()

  => Table.OrderBy(m => m.Name);

public override IEnumerable GetAllIgnoreQueryFilters()

  => Table.IgnoreQueryFilters().OrderBy(m => m.Name);

Хранилище данных о заказах

Откройте файл класса

OrderRepo.cs
и поместите в его начало следующие операторы
using
:


using AutoLot.Dal.EfStructures;

using AutoLot.Dal.Models.Entities;

using AutoLot.Dal.Repos.Base;

using AutoLot.Dal.Repos.Interfaces;

using Microsoft.EntityFrameworkCore;


Измените класс на

public
, унаследуйте его от
BaseRepo
и реализуйте
IOrderRepo
:


namespace AutoLot.Dal.Repos

{

  public class OrderRepo : BaseRepo, IOrderRepo

  {

   public OrderRepo(ApplicationDbContext context)

    : base(context)

   {

   }

   internal OrderRepo(

    DbContextOptions options)

    : base(options)

   {

   }

  }

}


Реализуйте метод

GetOrderViewModel()
, который возвращает экземпляр реализации
IQueryable
из представления базы данных:


public IQueryable GetOrdersViewModel()

{

  return Context.CustomerOrderViewModels!.AsQueryable();

}


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

Программная работа с базой данных и миграциями

Свойство

Database
класса
DbContext
предлагает программные методы для удаления и создания базы данных, а также для запуска всех миграций. В табл. 23.1 описаны методы, соответствующие указанным операциям.



Как упоминалось в табл. 23.1, метод

EnsureCreated()
создает базу данных, если она не существует, после чего создает таблицы, столбцы и индексы на основе сущностной модели. Никаких миграций он не применяет.

Если вы используете миграции, тогда при работе с базой данных будут возникать ошибки, и вам придется прибегнуть к уловке (как делалось ранее), чтобы заставить инфраструктуру EF Core "поверить" в то, что миграции были применены. Кроме того, вам нужно будет вручную применить к базе данных любые специальные объекты SQL. В случае работы с миграциями для программного создания базы данных всегда используйте метод

Migrate()
, а не
EnsureCreated()
.

Удаление, создание и очистка базы данных

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

AutoLot.Dal
новый каталог по имени
Initialization
и поместите в него новый файл класса
SampleDatalnitializer.cs
. Вот как должны выглядеть операторы
using
в начале файла:


using System;

using System.Collections.Generic;

using System.Linq;

using AutoLot.Dal.EfStructures;

using AutoLot.Models.Entities;

using AutoLot.Models.Entities.Base;

using Microsoft.EntityFrameworkCore;

using Microsoft.EntityFrameworkCore.Storage;


Сделайте класс открытым и статическим:


namespace AutoLot.Dal.Initialization

{

  public static class SampleDataInitializer

  {

  }

}


Создайте метод по имени

DropAndCreateDatabase()
, который в качестве единственного параметра принимает экземпляр
ApplicationDbContext
. Этот метод использует свойство
Database
экземпляра
ApplicationDbContext
, чтобы сначала удалить базу данных (с помощью метода
EnsureDeleted()
) и затем создать ее заново (посредством метода
Migrate()
):


public static void DropAndCreateDatabase(ApplicationDbContext context)

{

  context.Database.EnsureDeleted();

  context.Database.Migrate();

}


Создайте еще один метод по имени

ClearData()
, который удаляет все данные из базы данных и сбрасывает значения идентичности для первичного ключа каждой таблицы. Метод проходит по списку сущностей предметной области и применяет свойство
Model
класса
DbContext
для получения схемы и имени таблицы, на которые отображается каждая сущность. Затем он выполняет оператор
DELETE
и сбрасывает идентичность для каждой таблицы, используя метод
ExecuteSqlRaw()
на свойстве
Database
класса
DbContext
:


internal static void ClearData(ApplicationDbContext context)

{

  var entities = new[]

  {

   typeof(Order).FullName,

   typeof(Customer).FullName,

   typeof(Car).FullName,

   typeof(Make).FullName,

   typeof(CreditRisk).FullName

  };

  foreach (var entityName in entities)

  {

   var entity = context.Model.FindEntityType(entityName);

   var tableName = entity.GetTableName();

   var schemaName = entity.GetSchema();

   context.Database.ExecuteSqlRaw($"DELETE FROM {schemaName}.{tableName}");

   context.Database.ExecuteSqlRaw($"DBCC CHECKIDENT (\"{schemaName}.

    {tableName}\", 
RESEED, 1);");

  }

}


На заметку! Метод

ExecuteSqlRaw()
фасадного экземпляра базы данных должен применяться осторожно, чтобы избежать потенциальных атак внедрением в SQL. Теперь, когда вы можете удалять и создавать базу данных и очищать данные, пора заняться методами, которые будут добавлять выборочные данные.

Инициализация базы данных

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

Создание выборочных данных

Добавьте в каталог

Initialization
новый файл по имени
SampleData.cs
. Сделайте его открытым и статическим и поместите в него следующие операторы
using
:


using System.Collections.Generic;

using AutoLot.Dal.Entities;

using AutoLot.Dal.Entities.Owned;


namespace AutoLot.Dal.Initialization

{

  public static class SampleData

  {

  }

}


Класс

SampleData
содержит пять статических методов, которые создают выборочные данные:


{

  new() {Id = 1, PersonalInformation = new() {FirstName = "Dave",

                        LastName = "Brenner"}},

  new() {Id = 2, PersonalInformation = new() {FirstName = "Matt",

                        LastName = "Walton"}},

  new() {Id = 3, PersonalInformation = new() {FirstName = "Steve",

                       LastName = "Hagen"}},

  new() {Id = 4, PersonalInformation = new() {FirstName = "Pat",

                        LastName = "Walton"}},

  new() {Id = 5, PersonalInformation = new() {FirstName = "Bad",

                        LastName = "Customer"}},

};


public static List Makes => new()

{

  new() {Id = 1, Name = "VW"},

  new() {Id = 2, Name = "Ford"},

  new() {Id = 3, Name = "Saab"},

  new() {Id = 4, Name = "Yugo"},

  new() {Id = 5, Name = "BMW"},

  new() {Id = 6, Name = "Pinto"},

};


public static List Inventory => new()

{

  new() {Id = 1, MakeId = 1, Color = "Black", PetName = "Zippy"},

  new() {Id = 2, MakeId = 2, Color = "Rust", PetName = "Rusty"},

  new() {Id = 3, MakeId = 3, Color = "Black", PetName = "Mel"},

  new() {Id = 4, MakeId = 4, Color = "Yellow", PetName = "Clunker"},

  new() {Id = 5, MakeId = 5, Color = "Black", PetName = "Bimmer"},

  new() {Id = 6, MakeId = 5, Color = "Green", PetName = "Hank"},

  new() {Id = 7, MakeId = 5, Color = "Pink", PetName = "Pinky"},

  new() {Id = 8, MakeId = 6, Color = "Black", PetName = "Pete"},

  new() {Id = 9, MakeId = 4, Color = "Brown", PetName = "Brownie"},

  new() {Id = 10, MakeId = 1, Color = "Rust", PetName = "Lemon",

                        IsDrivable = false},

};


public static List Orders => new()

{

  new() {Id = 1, CustomerId = 1, CarId = 5},

  new() {Id = 2, CustomerId = 2, CarId = 1},

  new() {Id = 3, CustomerId = 3, CarId = 4},

  new() {Id = 4, CustomerId = 4, CarId = 7},

  new() {Id = 5, CustomerId = 5, CarId = 10},

};


public static List CreditRisks => new()

{

  new()

  {

   Id = 1,

   CustomerId = Customers[4].Id,

   PersonalInformation = new()

   {

    FirstName = Customers[4].PersonalInformation.FirstName,

    LastName = Customers[4].PersonalInformation.LastName

   }

  }

};

Загрузка выборочных данных

Внутренний метод

SeedData()
в классе
SampleDatalnitializer
добавляет данные из методов класса
SampleData
к экземпляру
ApplicationDbContext
и сохраняет данные в базе данных:


internal static void SeedData(ApplicationDbContext context)

{

  try

  {

   ProcessInsert(context, context.Customers!, SampleData.Customers);

   ProcessInsert(context, context.Makes!, SampleData.Makes);

   ProcessInsert(context, context.Cars!, SampleData.Inventory);

   ProcessInsert(context, context.Orders!, SampleData.Orders);

   ProcessInsert(context, context.CreditRisks!, SampleData.CreditRisks);

  }

  catch (Exception ex)

  {

   Console.WriteLine(ex);

  // Поместить сюда точку останова, чтобы выяснить,

   // в чем заключается проблема.

   throw;

  }

  static void ProcessInsert(

   ApplicationDbContext context,

   DbSet table,

   List records) where TEntity : BaseEntity

  {

   if (table.Any())

   {

    return;

   }

   IExecutionStrategy strategy = context.Database.CreateExecutionStrategy();

   strategy.Execute(() =>

   {

    using var transaction = context.Database.BeginTransaction();

    try

    {

     var metaData = context.Model.FindEntityType(typeof(TEntity).FullName);

     context.Database.ExecuteSqlRaw(

       $"SET IDENTITY_INSERT {metaData.GetSchema()}.

       {metaData.GetTableName()} ON");

     table.AddRange(records);

     context.SaveChanges();

     context.Database.ExecuteSqlRaw(

       $"SET IDENTITY_INSERT {metaData.GetSchema()}.

       {metaData.GetTableName()} OFF");

     transaction.Commit();

    }

    catch (Exception)

    {

     transaction.Rollback();

    }

    });

  }

}


Для обработки данных в методе

SeedData()
используется локальная функция. Сначала она проверяет, содержит ли таблица какие-то записи, и если нет, то переходит к обработке выборочных данных. Из фасадного экземпляра базы данных создается экземпляр реализации
IExecutionStrategy
, применяемый для создания явной транзакции, которая необходима для включения и отключения вставки идентичности. Записи добавляются; если все прошло успешно, тогда транзакция фиксируется, а в противном случае подвергается откату.

Приведенные далее два открытых метода используются для сброса базы данных. Метод

InitializeData()
удаляет и воссоздает базу данных перед ее заполнением начальными данными, а метод
ClearDatabase()
просто удаляет все записи, сбрасывает идентичность и заполняет базу начальными данными:


public static void InitializeData(ApplicationDbContext context)

{

  DropAndCreateDatabase(context);

  SeedData(context);

}


public static void ClearAndReseedDatabase(ApplicationDbContext context)

{

  ClearData(context);

  SeedData(context);

}

Настройка тестов

Вместо создания клиентского приложения для испытания скомпилированного уровня доступа к данным

AutoLot
будет применяться автоматизированное интеграционное тестирование. Тесты продемонстрируют обращение к базе данных на предмет создания, чтения, обновления и удаления, что позволит исследовать код без накладных расходов по построению еще одного приложения. Каждый тест, рассматриваемый в этом разделе, будет выполнять запрос (создание, чтение, обновление или удаление) и иметь один и более операторов
Assert
для проверки, получен ли ожидаемый результат.

Создание проекта

Первым делом необходимо настроить платформу интеграционного тестирования с использованием xUnit — инфраструктуры тестирования, совместимой с .NET Core. Начните с добавления нового по имени

AutoLot.Dal.Tests
, который в Visual Studio носит название xUnit Test Project (.NET Core) (Проект тестирования xUnit (.NET Core)).


На заметку! Модульные тесты предназначены для тестирования одной единицы кода. Формально повсюду в главе создаются интеграционные тесты, т.к. производится тестирование кода C# и EF Core на всем пути к базе данных и обратно.


Введите следующую команду в окне командной строки:


dotnet new xunit -lang c# -n AutoLot.Dal.Tests -o .\AutoLot.Dal.Tests -f net5.0

dotnet sln .\Chapter23_AllProjects.sln add AutoLot.Dal.Tests


Добавьте в проект

AutoLot.Dal.Tests
перечисленные ниже пакеты NuGet:


Microsoft.EntityFrameworkCore

Microsoft.EntityFrameworkCore.SqlServer

Microsoft.Extensions.Configuration.Json


Поскольку версия пакета Microsoft.NET.Test.Sdk, поставляемая с шаблоном проектов xUnit, обычно отстает от текущей доступной версии, воспользуйтесь диспетчером пакетов NuGet для обновления всех пакетов NuGet. Затем добавьте ссылки на проекты

AutoLot.Models
и
AutoLot.Dal
.

В случае работы с CLI выполните приведенные далее команды(обратите внимание, что команды удаляют и повторно добавляют пакет

Microsoft.NET.Test.Sdk
, чтобы гарантировать ссылку на самую последнюю версию):


dotnet add AutoLot.Dal.Tests package Microsoft.EntityFrameworkCore

dotnet add AutoLot.Dal.Tests package Microsoft.EntityFrameworkCore.SqlServer

dotnet add AutoLot.Dal.Tests package Microsoft.Extensions.Configuration.Json

dotnet remove AutoLot.Dal.Tests package Microsoft.NET.Test.Sdk

dotnet add AutoLot.Dal.Tests package Microsoft.NET.Test.Sdk

dotnet add AutoLot.Dal.Tests reference AutoLot.Dal

dotnet add AutoLot.Dal.Tests reference AutoLot.Models

Конфигурирование проекта

Для извлечения строки подключения во время выполнения будут задействованы конфигурационные возможности .NET Core, предусматривающие работу с файлом JSON. Добавьте в проект файл JSON по имени

appsettings.json
и поместите в него информацию о своей строке подключения в следующем формате (надлежащим образом скорректировав ее):


{

  "ConnectionStrings": {

   "AutoLot": "server=.,5433;Database=AutoLotFinal;

   User Id=sa;Password=P@ssw0rd;"

  }

}


Модифицируйте файл проекта, чтобы файл

appsettings.json
копировался в выходной каталог при каждой компиляции проекта, для чего добавьте в файл
AutoLot.Dal.Tests.csproj
такой элемент
ItemGroup
:


 

   Always

 

Создание класса TestHelpers

Класс

TestHelpers
будет обрабатывать конфигурацию приложения, а также создавать новый экземпляр
ApplicationDbContext
. Создайте в корневом каталоге проекта новый файл открытого статического класса по имени
TestHelpers.cs
. Приведите операторы
using
к следующему виду:


using System.IO;

using AutoLot.Dal.EfStructures;

using Microsoft.EntityFrameworkCore;

using Microsoft.EntityFrameworkCore.Storage;

using Microsoft.Extensions.Configuration;


namespace AutoLot.Dal.Tests

{

  public static class TestHelpers

  {

  }

}


Определите два открытых статических метода, предназначенные для создания экземпляров реализации

IConfiguration
и класса
ApplicationDbContext
. Добавьте в класс показанный ниже код:


public static IConfiguration GetConfiguration() =>

  new ConfigurationBuilder()

   .SetBasePath(Directory.GetCurrentDirectory())

   .AddJsonFile("appsettings.json", true, true)

   .Build();


public static ApplicationDbContext GetContext(IConfiguration configuration)

{

  var optionsBuilder = new DbContextOptionsBuilder();

  var connectionString = configuration.GetConnectionString("AutoLot");

  optionsBuilder.UseSqlServer(connectionString,

   sqlOptions => sqlOptions.EnableRetryOn
Failure());

  return new ApplicationDbContext(optionsBuilder.Options);

}


Как вероятно вы помните, выделенный полужирным вызов

EnableRetryOnFailure()
выбирает стратегию повтора SQL Server, которая будет автоматически повторять операции, потерпевших неудачу из-за кратковременных ошибок.

Добавьте еще один статический метод, который будет создавать новый экземпляр

ApplicationDbContext
с применением того же самого подключения и транзакции, что и в переданном исходном контексте. Этот метод демонстрирует способ создания экземпляра
ApplicationDbContext
из существующего экземпляра с целью совместного использования подключения и транзакции:


public static ApplicationDbContext GetSecondContext(

  ApplicationDbContext oldContext,

  IDbContextTransaction trans)

{

  var optionsBuilder = new DbContextOptionsBuilder();

  optionsBuilder.UseSqlServer(

   oldContext.Database.GetDbConnection(),

   sqlServerOptions => sqlServerOptions.EnableRetryOnFailure());

  var context = new ApplicationDbContext(optionsBuilder.Options);

  context.Database.UseTransaction(trans.GetDbTransaction());

  return context;

}

Добавление класса BaseTest

Создайте в проекте новый каталог по имени

Base
и добавьте туда новый файл класса
BaseTest.cs
. Модифицируйте операторы
using
следующим образом:


using System;

using System.Data;

using AutoLot.Dal.EfStructures;

using Microsoft.EntityFrameworkCore;

using Microsoft.EntityFrameworkCore.Storage;

using Microsoft.Extensions.Configuration;


Сделайте класс абстрактным и реализующим

IDisposable
. Добавьте два защищенных свойства
readonly
для хранения экземпляров реализации
IConfiguration
икласса
ApplicationDbContext
и освободите экземпляр
ApplicationDbContext
в виртуальном методе
Dispose()
:


namespace AutoLot.Dal.Tests.Base

{

  public abstract class BaseTest : IDisposable

  {

   protected readonly IConfiguration Configuration;

   protected readonly ApplicationDbContext Context;

  public virtual void Dispose()

   {

    Context.Dispose();

   }

  }

}


Инфраструктура тестирования xUnit предоставляет механизм для запуска кода до и после прогона каждого теста. Классы тестов (называемые оснастками), которые реализуют интерфейс

IDisposable
, перед прогоном каждого теста будут выполнять код в конструкторе класса (в конструкторе базового класса и конструкторе производного класса в этом случае), называемый настройкой теста, а после прогона каждого теста — код в методе
Dispose()
(в производном и в базовом классах), называемый освобождением теста.

Добавьте защищенный конструктор, который создает экземпляр реализации

IConfiguration
и присваивает его защищенной переменной класса. С применением конфигурации создайте экземпляр
ApplicationDbContext
, используя класс
TestHelpers
, и присвойте его защищенной переменной класса:


protected BaseTest()

{

  Configuration = TestHelpers.GetConfiguration();

  Context = TestHelpers.GetContext(Configuration);

}

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

Последние два метода в классе

BaseTest
позволяют выполнять тестовые методы в транзакциях. Методы будут принимать в единственном параметре делегат
Action
, создавать явную транзакцию (или вовлекать существующую транзакцию), выполнять делегат
Action
и затем проводить откат транзакции. Так делается для того, чтобы любые тесты создания/обновления/удаления оставляли базу данных в состоянии, в котором она пребывала до прогона теста. Поскольку класс
ApplicationDbContext
сконфигурирован с целью включения повторений при возникновении кратковременных ошибок, весь процесс обязан выполняться в соответствии со стратегией выполнения
ApplicationDbContext
.

Метод

ExecutelnATransaction()
выполняется с применением одиночного экземпляра
ApplicationDbContext
. Метод
ExecutelnASharedTransaction()
позволяет нескольким экземплярам
ApplicationDbContext
совместно использовать транзакцию. Вы узнаете больше об упомянутых методах позже в главе, а пока добавьте в свой класс
BaseTest
следующий код:


protected void ExecuteInATransaction(Action actionToExecute)

{

  var strategy = Context.Database.CreateExecutionStrategy();

  strategy.Execute(() =>

  {

   using var trans = Context.Database.BeginTransaction();

   actionToExecute();

   trans.Rollback();

  });

}


protected void ExecuteInASharedTransaction(Action

actionToExecute)

{

  var strategy = Context.Database.CreateExecutionStrategy();

  strategy.Execute(() =>

  {

   using IDbContextTransaction trans =

    Context.Database.BeginTransaction(IsolationLevel.ReadUncommitted);

   actionToExecute(trans);

   trans.Rollback();

  });

}

Добавление класса тестовой оснастки EnsureAutoLotDatabase

Инфраструктура тестирования xUnit предоставляет механизм, который позволяет запускать код до прогона любого теста (называется настройкой оснастки) и после прогона всех тестов (называется освобождением оснастки). Обычно поступать так не рекомендуется, но в рассматриваемом случае желательно удостовериться, что база данных создана и загружена данными до прогона любого теста, а не до прогона каждого теста. Классы тестов, которые реализуют

IClassFixture where Т: TestFixtureClass
, должны будут выполнять код конструктора типа
Т
(т.е.
TestFixtureClass
) до прогона любого теста и код метода
Dispose()
после завершения всех тестов.

Создайте в каталоге

Base
новый файл класса по имени
EnsureAutoLotDatabaseTestFixture.cs
и реализуйте интерфейс
IDisposable
. Сделайте класс открытым и запечатанным, а также добавьте показанные далее операторы
using
:


using System;

using AutoLot.Dal.Initialization;


namespace AutoLot.Dal.Tests.Base

{

  public sealed class EnsureAutoLotDatabaseTestFixture : IDisposable

  {

  }

}


В конструкторе понадобится создать экземпляр реализации

IConfiguration
и с его помощью создать экземпляр
ApplicationDbContext
. Затем нужно вызвать метод
ClearAndReseedDatabase()
класса
SampleDatalnitializer
и в заключение освободить экземпляр контекста. В приводимых здесь примерах метод
Dispose()
не обязан выполнять какую-то работу (но должен присутствовать для соответствия шаблону с интерфейсом
IDisposable
). Вот как выглядит конструктор и метод
Dispose()
:


public EnsureAutoLotDatabaseTestFixture()

{

  var configuration =  TestHelpers.GetConfiguration();

  var context = TestHelpers.GetContext(configuration);

  SampleDataInitializer.ClearAndReseedDatabase(context);

  context.Dispose();

}


public void Dispose()

{

}

Добавление классов интеграционных тестов

Теперь необходимо создать классы, которые будут поддерживать автоматизированные тесты. Такие классы называют тестовыми оснастками. Добавьте в проект

AutoLot.Dal
. Tests новый каталог по имени
IntegrationTests
и поместите в него четыре файла с именами
CarTests.cs
,
CustomerTests.cs
,
MakeTests.cs
и
OrderTests.cs
.

В зависимости от возможностей средства запуска тестов тесты xUnit выполняются последовательно внутри тестовой оснастки (класса), но параллельно во всех тестовых оснастках (классах). Это может оказаться проблематичным при прогоне интеграционных тестов, взаимодействующих с единственной базой данных. Выполнение можно сделать последовательным для всех тестовых оснасток, добавив их в одну и ту же тестовую коллекцию. Тестовые коллекции определяются по имени с применением атрибута

[Collection]
к классу. Поместите перед всеми четырьмя классами следующий атрибут
[Collection]
:


[Collection("Integration Tests")]


Унаследуйте все четыре класса от

BaseTest
, реализуйте интерфейс
IClassFixture
и приведите операторы
using
к показанному далее виду:


// CarTests.cs

using System.Collections.Generic;

using System.Linq;

using AutoLot.Dal.Exceptions;

using AutoLot.Dal.Repos;

using AutoLot.Dal.Tests.Base;

using AutoLot.Models.Entities;

using Microsoft.EntityFrameworkCore;

using Microsoft.EntityFrameworkCore.ChangeTracking;

using Microsoft.EntityFrameworkCore.Query;

using Microsoft.EntityFrameworkCore.Storage;

using Xunit;


namespace AutoLot.Dal.Tests.IntegrationTests

{

  [Collection("Integation Tests")]

  public class CarTests : BaseTest,

   IClassFixture

  {

  }

}


// CustomerTests.cs

using System.Collections.Generic;

using System;

using System.Linq;

using System.Linq.Expressions;

using AutoLot.Dal.Tests.Base;

using AutoLot.Models.Entities;

using Microsoft.EntityFrameworkCore;

using Xunit;

namespace AutoLot.Dal.Tests.IntegrationTests

{

  [Collection("Integation Tests")]

  public class CustomerTests : BaseTest,

   IClassFixture

  {

  }

}


// MakeTests.cs

using System.Linq;

using AutoLot.Dal.Repos;

using AutoLot.Dal.Repos.Interfaces;

using AutoLot.Dal.Tests.Base;

using AutoLot.Models.Entities;

using Microsoft.EntityFrameworkCore;

using Microsoft.EntityFrameworkCore.ChangeTracking;

using Xunit;

namespace AutoLot.Dal.Tests.IntegrationTests

{

  [Collection("Integation Tests")]

  public class MakeTests : BaseTest,

   IClassFixture

  {

  }

}


// OrderTests.cs

using System.Linq;

using AutoLot.Dal.Repos;

using AutoLot.Dal.Repos.Interfaces;

using AutoLot.Dal.Tests.Base;

using Microsoft.EntityFrameworkCore;

using Xunit;

namespace AutoLot.Dal.Tests.IntegrationTests

{

  [Collection("Integation Tests")]

  public class OrderTests : BaseTest,

   IClassFixture

  {

  }

}


Добавьте в класс

MakeTests
конструктор, который создает экземпляр
MakeRepo
и присваивает его закрытой переменной
readonly
уровня класса. Переопределите метод
Dispose()
и освободите в нем экземпляр
MakeRepo
:


[Collection("Integration Tests")]

public class MakeTests : BaseTest,

 IClassFixture

{

  private readonly IMakeRepo _repo;

  public MakeTests()

  {

   _repo = new MakeRepo(Context);

  }

  public override void Dispose()

  {

   _repo.Dispose();

  }

  ...

}


Повторите те же действия для класса

OrderTests
, но с использованием
OrderRepo
вместо
MakeRepo
:


[Collection("Integration Tests")]

public class OrderTests : BaseTest,

  IClassFixture

{

  private readonly IOrderRepo _repo;

  public OrderTests()

  {

   _repo = new OrderRepo(Context);

  }

  public override void Dispose()

  {

   _repo.Dispose();

  }

  ...

}

Тестовые методы [Fact] и [Theory]

Тестовые методы без параметров называются фактами (и задействуют атрибут

[Fact]
). Тестовые методы, которые принимают параметры, называются теориями (они используют атрибут
[Theory]
) и могут выполнять множество итераций с разными значениями, передаваемыми в качестве параметров. Чтобы взглянуть на такие виды тестов, создайте в проекте
AutoLot.Dal.Tests
новый файл класса по имени
SampleTests.cs
. Вот как выглядит оператор
using
:


using Xunit;

namespace AutoLot.Dal.Tests

{

  public class SampleTests

  {

  }

}


Начните с создания теста

[Fact]
. В тесте
[Fact]
все значения содержатся внутри тестового метода. Следующий простой пример проверяет, что 3 + 2 = 5:


[Fact]

public void SimpleFactTest()

{

  Assert.Equal(5,3+2);

}


Что касается теста

[Theory]
, то значения передаются тестовому методу и могут поступать из атрибута
[InlineData]
, методов или классов. Здесь будет использоваться только атрибут
[InlineData]
. Создайте показанный ниже тест, которому предоставляются разные слагаемые и ожидаемый результат:


[Theory]

[InlineData(3,2,5)]

[InlineData(1,-1,0)]

public void SimpleTheoryTest(int addend1, int addend2, int expectedResult)

{

  Assert.Equal(expectedResult,addend1+addend2);

}


На заметку! За дополнительными сведениями об инфраструктуре тестирования xUnit обращайтесь в документацию по ссылке

https://xunit.net/
.

Выполнение тестов

Хотя тесты xUnit можно запускать из командной строки (с применением

dotnet test
), разработчикам лучше использовать для этого Visual Studio. Выберите в меню Test (Тестирование) пункт Test Explorer (Проводник тестов), чтобы получить возможность прогонять и отлаживать все или выбранные тесты.

Запрашивание базы данных

Вспомните, что создание экземпляров сущностей из базы данных обычно предусматривает выполнение оператора LINQ в отношении свойств

DbSet
. Поставщик баз данных и механизм трансляции LINQ преобразуют операторы LINQ в запросы SQL, с помощью которых из базы данных читаются соответствующие данные. Данные можно также загружать посредством метода
FromSqlRaw()
или
FromSqlInterpolated()
с применением низкоуровневых запросов SQL. Сущности, загружаемые в коллекции
DbSet
, по умолчанию добавляются в
ChangeTracker
, но отслеживание можно отключать. Данные, загружаемые в коллекции
DbSet
без ключей, никогда не отслеживаются.

Если связанные сущности уже загружены в

DbSet
, тогда EF Core будет связывать новые экземпляры по навигационным свойствам. Например, если сущности
Car
загружаются в коллекцию
DbSet
и затем связанные сущности
Order
загружаются в коллекцию
DbSet
того же самого экземпляра
ApplicationDbContext
, то навигационное свойство
Car.Orders
будет возвращать связанные сущности без повторного запрашивания базы данных.

Многие демонстрируемые здесь методы имеют асинхронные версии. Синтаксис запросов LINQ структурно одинаков, поэтому будут использоваться только синхронные версии.

Состояние сущности

Когда сущность создается за счет чтения данных из базы, значение

EntityState
устанавливается в
Unchanged
.

Запросы LINQ

Тип коллекции

DbSet
реализует (помимо прочих) интерфейс
IQueryable
, что позволяет применять команды LINQ языка C# для создания запросов, извлекающих информацию из базы данных. Наряду с тем, что все операторы LINQ языка C# доступны для использования с типом коллекции
DbSet
, некоторые операторы LINQ могут не поддерживаться поставщиком баз данных, а в EF Core появились дополнительные операторы LINQ. Неподдерживаемый оператор LINQ, который невозможно транслировать в язык запросов поставщика баз данных, приведет к генерации исключения времени выполнения, если только он не является последним в цепочке операторов LINQ. Когда неподдерживаемый оператор LINQ находится в самом конце цепочки LINQ, он выполнится на клиентской стороне (в коде С#).


На заметку! Настоящая книга не задумывалась как полный справочник по LINQ, так что в ней приводится не особо много примеров. С дополнительными примерами запросов LINQ можно ознакомиться по ссылке

https://code.msdn.microsoft.com/101-LINQ-Samples-3fb9811b
.

Выполнение запросов LINQ

Вспомните, что при использовании LINQ для запрашивания из базы данных списка сущностей запрос не выполняется до тех пор, пока не начнется проход по результатам запроса, пока запрос не будет преобразован в

List
(или объект) либо же пока не произойдет привязка запроса к списковому элементу управления (вроде сетки данных). Запрос единственной записи выполняется немедленно в случае применения вызова
First()
,
Single()
и т.д.

Нововведением версии EF Core 5 стало то, что в большинстве запросов LINQ можно вызывать метод

ToQueryString()
для исследования запроса, который выполняется в отношении базы данных. Для разделяемых запросов метод
ToQueryString()
возвращает только первый запрос, который будет выполняться. В рассматриваемых далее тестах это значение по возможности присваивается переменной (
qs
), чтобы вы могли изучить запрос во время отладки тестов.

Первый набор тестов (если только специально не указано иначе) находится в файле класса

CustomerTests.cs
.

Получение всех записей

Чтобы получить все записи из таблицы, просто используйте свойство

DbSet
на прямую без каких-либо операторов LINQ. Добавьте приведенный ниже тест
[Fact]
:


[Fact]

public void ShouldGetAllOfTheCustomers()

{

  var qs = Context.Customers.ToQueryString();

  var customers = Context.Customers.ToList();

  Assert.Equal(5, customers.Count);

}


Выделенный полужирным оператор транслируется в следующий код SQL:


SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

     [c].[LastName] 
FROM [Dbo].[Customers] AS [c]


Тот же самый процесс применяется для сущностей без ключей, подобных модели представления

CustomerOrderViewModel
, которая сконфигурирован на получение своих данных из представления
CustomerOrderView
:


modelBuilder.Entity().HasNoKey()

  .ToView("CustomerOrderView", "dbo");


Экземпляр

DbSet
для моделей представлений предлагает всю мощь запросов
DbSet
для сущности с ключом. Отличие касается возможностей обновления. Изменения модели представления не могут быть сохранены в базе данных, тогда как изменения сущностей с ключами — могут. Добавьте в файл класса
OrderTest.cs
показанный далее тест, чтобы продемонстрировать получение данных из представления:


public void ShouldGetAllViewModels()

{

  var qs = Context.Orders.ToQueryString();

  var orders = Context.Orders.ToList();

  Assert.NotEmpty(orders);

  Assert.Equal(5,orders.Count);

}


Выделенный полужирным оператор транслируется в следующий код SQL:


SELECT [c].[Color], [c].[FirstName], [c].[IsDrivable], [c].[LastName],

     [c].[Make], [c].
[PetName] 
FROM [dbo].[CustomerOrderView] AS [c]

Фильтрация записей

Метод

Where()
используется для фильтрации записей из
DbSet
. Несколько вызовов
Where()
можно плавно объединять в цепочку для динамического построения запроса. Выстроенные в цепочку вызовы
Where()
всегда объединяются с помощью операции "И". Для объединения условий с применением операции "ИЛИ" необходимо использовать один вызов
Where()
.

Приведенный ниже тест возвращает заказчиков с фамилией, начинающейся с буквы "W" (нечувствительно к регистру символов):


[Fact]

public void ShouldGetCustomersWithLastNameW()

{

  IQueryable query = Context.Customers

   .Where(x => x.PersonalInformation.LastName.StartsWith("W"));

  var qs = query.ToQueryString();

  List customers = query.ToList();

  Assert.Equal(2, customers.Count);

}


Запрос LINQ транслируется в следующий код SQL:


SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

     [c].[LastName] 
FROM [Dbo].[Customers] AS [c]

WHERE [c].[LastName] IS NOT NULL AND ([c].[LastName] LIKE N'W%')


Показанный далее тест возвращает заказчиков с фамилией, начинающейся с буквы "W" (нечувствительно к регистру символов), и именем, начинающимся с буквы "М" (нечувствительно к регистру символов), а также демонстрирует объединение вызовов

Where()
в цепочку в запросе LINQ:


[Fact]

public void ShouldGetCustomersWithLastNameWAndFirstNameM()

{

  IQueryable query = Context.Customers

   .Where(x => x.PersonalInformation.LastName.StartsWith("W"))

   .Where(x => x.PersonalInformation.FirstName.StartsWith("M"));

  var qs = query.ToQueryString();

  List customers = query.ToList();

  Assert.Single(customers);

}


Следующий тест возвращает заказчиков с фамилией, начинающейся с буквы "W" (нечувствительно к регистру символов), и именем, начинающимся с буквы "М" (нечувствительно к регистру символов), с применением единственного вызова

Where()
:


[Fact]

public void ShouldGetCustomersWithLastNameWAndFirstNameM()

{

  IQueryable query = Context.Customers

   .Where(x => x.PersonalInformation.LastName.StartsWith("W") &&

         x.PersonalInformation.FirstName.StartsWith("M"));

  var qs = query.ToQueryString();

  List customers = query.ToList();

  Assert.Single(customers);

}


Оба запроса транслируются в такой код SQL:


SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

    [c].[LastName] 
FROM [Dbo].[Customers] AS [c]

WHERE ([c].[LastName] IS NOT NULL AND ([c].[LastName] LIKE N'W%'))

AND ([c].[FirstName] IS NOT NULL AND ([c].[FirstName] LIKE N'M%'))


Приведенный ниже тест возвращает заказчиков с фамилией, начинающейся с буквы "W" (нечувствительно к регистру символов), или именем, начинающимся с буквы "H" (нечувствительно к регистру символов):


[Fact]

public void ShouldGetCustomersWithLastNameWOrH()

{

  IQueryable query = Context.Customers

   .Where(x => x.PersonalInformation.LastName.StartsWith("W") ||

         x.PersonalInformation.LastName.StartsWith("H"));

  var qs = query.ToQueryString();

  List customers = query.ToList();

  Assert.Equal(3, customers.Count);

}


Запрос LINQ транслируется в следующий код SQL:


SELECT [c].[Id], [c].[TimeStamp], [c].
[FirstName], [c].[FullName],

     [c].[LastName] 
FROM [Dbo].[Customers] AS [c]

WHERE ([c].[LastName] IS NOT NULL AND ([c].[LastName] LIKE N'W%'))

OR ([c].[LastName] IS NOT NULL AND ([c].[LastName] LIKE N'H%'))


Показанный далее тест возвращает заказчиков с фамилией, начинающейся с буквы "W" (нечувствительно к регистру символов), или именем, начинающимся с буквы "Н" (нечувствительно к регистру символов), и демонстрирует использование метода

EF.Functions.Like()
. Обратите внимание, что включать групповой символ (
%
) вы должны самостоятельно.


[Fact]

public void ShouldGetCustomersWithLastNameWOrH()

{

  IQueryable query = Context.Customers

   .Where(x => EF.Functions.Like(x.PersonalInformation.LastName, "W%") ||

         EF.Functions.Like(x.PersonalInformation.LastName, "H%"));

  var qs = query.ToQueryString();

  List customers = query.ToList();

  Assert.Equal(3, customers.Count);

}


Запрос LINQ транслируется в следующий код SQL (обратите внимание, что проверка на

null
не делается):


SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

     [c].[LastName] 
FROM [Dbo].[Customers] AS [c]

WHERE ([c].[LastName] LIKE N'W%') OR ([c].[LastName] LIKE N'H%')


В приведенном ниже тесте из класса

CarTests.cs
применяется
[Theory]
для проверки количества записей Car в таблице
Inventory
на основе
MakeId
(метод
IgnoreQueryFilters()
рассматривался в разделе "Глобальные фильтры запросов" главы 22):


[Theory]

[InlineData(1, 2)]

[InlineData(2, 1)]

[InlineData(3, 1)]

[InlineData(4, 2)]

[InlineData(5, 3)]

[InlineData(6, 1)]

public void ShouldGetTheCarsByMake(int makeId, int expectedCount)

{

  IQueryable query =

   Context.Cars.IgnoreQueryFilters().Where(x => x.MakeId == makeId);

  var qs = query.ToQueryString();

  var cars = query.ToList();

  Assert.Equal(expectedCount, cars.Count);

}


Каждая строка

[InlineData]
становится уникальным тестом в средстве запуска тестов. В этом примере обрабатываются шесть тестов и в отношении базы данных выполняются шесть запросов. Вот как выглядит код SQL для одного из тестов (единственным отличием в запросах для других тестов в
[Theory]
будет значение
MakeId
):


DECLARE @__makeId_0 int = 1;

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName],

     [i].[TimeStamp] 
FROM [dbo].[Inventory] AS [i]

WHERE [i].[MakeId] = @__makeId_0


Следующий тест

[Theory]
показывает фильтрованный запрос с
CustomerOrderViewModel
(поместите тест в файл класса
OrderTests.cs
):


[Theory]

[InlineData("Black",2)]

[InlineData("Rust",1)]

[InlineData("Yellow",1)]

[InlineData("Green",0)]

[InlineData("Pink",1)]

[InlineData("Brown",0)]

public void ShouldGetAllViewModelsByColor(string color, int expectedCount)

{

   var query = _repo.GetOrdersViewModel().Where(x=>x.Color == color);

   var qs = query.ToQueryString();

   var orders = query.ToList();

   Assert.Equal(expectedCount,orders.Count);

}


Для первого теста

[InlineData]
генерируется такой запрос:


DECLARE @__color_0 nvarchar(4000) = N'Black';

SELECT [c].[Color], [c].[FirstName], [c].[IsDrivable], [c].[LastName],

    [c].[Make], [c].
[PetName] 
FROM [dbo].[CustomerOrderView] AS [c]

WHERE [c].[Color] = @__color_0

Сортировка записей

Методы

OrderBy()
и
OrderByDescending()
устанавливают для запроса сортировку (сортировки) по возрастанию и по убыванию. Если требуются дальнейшие сортировки, тогда используйте методы
ThenBy()
и
ThenByDescending()
. Сортировка демонстрируется в тесте ниже:


[Fact]

public void ShouldSortByLastNameThenFirstName()

{

  // Сортировать по фамилии, затем по имени.

  var query = Context.Customers

   .OrderBy(x => x.PersonalInformation.LastName)

   .ThenBy(x => x.PersonalInformation.FirstName);

  var qs = query.ToQueryString();

  var customers = query.ToList();

  // Если есть только один пользователь, то проверять нечего.

  if (customers.Count <= 1) { return; }

  for (int x = 0; x < customers.Count - 1; x++)

  {

   var pi = customers[x].PersonalInformation;

   var pi2 = customers[x + 1].PersonalInformation;

   var compareLastName = string.Compare(pi.LastName,

     pi2.LastName, StringComparison.CurrentCultureIgnoreCase);

   Assert.True(compareLastName <= 0);

   if (compareLastName != 0) continue;

   var compareFirstName = string.Compare(pi.FirstName,

     pi2.FirstName, StringComparison.CurrentCultureIgnoreCase);

   Assert.True(compareFirstName <= 0);

  }

}


Предыдущий запрос LINQ транслируется следующим образом:


SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

     [c].[LastName]
FROM [Dbo].[Customers] AS [c]

ORDER BY [c].[LastName], [c].[FirstName]

Сортировка записей в обратном порядке

Метод

Reverse()
меняет порядок сортировки на противоположный, как видно в представленном далее тесте:


[Fact]

public void ShouldSortByFirstNameThenLastNameUsingReverse()

{

  // Сортировать по фамилии, затем по имени,

  // и изменить порядок сортировки на противоположный.

  var query = Context.Customers

   .OrderBy(x => x.PersonalInformation.LastName)

   .ThenBy(x => x.PersonalInformation.FirstName)

   .Reverse();

  var qs = query.ToQueryString();

  var customers = query.ToList();

  // Если есть только один пользователь, то проверять нечего.

  if (customers.Count <= 1) { return; }

  for (int x = 0; x < customers.Count - 1; x++)

  {

   var pi1 = customers[x].PersonalInformation;

   var pi2 = customers[x + 1].PersonalInformation;

   var compareLastName = string.Compare(pi1.LastName,

   pi2.LastName, StringComparison.CurrentCultureIgnoreCase);

   Assert.True(compareLastName >= 0);

   if (compareLastName != 0) continue;

   var compareFirstName = string.Compare(pi1.FirstName,

   pi2.FirstName, StringComparison.CurrentCultureIgnoreCase);

   Assert.True(compareFirstName >= 0);

  }

}


Вот во что транслируется предыдущий запрос LINQ:


SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

     [c].[LastName] FROM [Dbo].[Customers] AS [c]

ORDER BY [c].[LastName] DESC, [c].[FirstName] DESC

Извлечение одиночной записи

Существуют три главных метода для возвращения одиночной записи посредством запроса:

First()/FirstOrDefault()
,
Last()/LastOrDefault()
и
Single()/SingleOrDefault()
. Хотя все они возвращают одиночную запись, принятые в них подходы отличаются. Методы и их варианты более подробно описаны ниже.

• Метод

First()
возвращает первую запись, которая соответствует условию запроса и любым конструкциям упорядочения. Если конструкции упорядочения не указаны, то возвращаемая запись основывается на порядке, установленном в базе данных. Если запись не возвращается, тогда генерируется исключение.

• Поведение метода

FirstOrDefault()
совпадает с поведением
First()
, но при отсутствии записей, соответствующих запросу,
FirstOrDefault()
возвращает стандартное значение для типа (
null
).

• Метод

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

• Поведение метода

SingleOrDefault()
совпадает с поведением
Single()
, но при отсутствии записей, соответствующих запросу,
SingleOrDefault()
возвращает стандартное значение для типа (
null
).

• Метод

Last()
возвращает последнюю запись, которая соответствует условию запроса и любым конструкциям упорядочения. Если конструкции упорядочения не указаны, то возвращаемая запись основывается на порядке, установленном в базе данных. Если запись не возвращается, тогда генерируется исключение.

• Поведение метода

LastOrDefault()
совпадает с поведением
Last()
, но при отсутствии записей, соответствующих запросу,
LastOrDefault()
возвращает стандартное значение для типа (
null
).


Все методы могут также принимать

Expression>
(лямбда-выражение) для фильтрации результирующего набора. Это означает, что вы можете помещать выражение
Where()
внутрь вызова
First()/Single()
. Следующие операторы эквивалентны:


Context.Customers.Where(c=>c.Id < 5).First();

Context.Customers.First(c=>c.Id < 5);


Из-за немедленного выполнения операторов LINQ, извлекающих одиночную запись, метод

ToQueryString()
оказывается недоступным. Приводимые трансляции запросов в код SQL получены с применением профилировщика SQL Server.

Использование First()/FirstOrDefault()

При использовании формы

First()
и
FirstOrDefault()
без параметров будет возвращаться первая запись (на основе порядка в базе данных или предшествующих конструкций упорядочения).

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


[Fact]

public void GetFirstMatchingRecordDatabaseOrder()

{

  // Получить первую запись на основе порядка в базе данных.

  var customer = Context.Customers.First();

  Assert.Equal(1, customer.Id);

}


Предыдущий запрос LINQ транслируется в такой код SQL:


SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

        [c].[LastName] 
FROM [Dbo].[Customers] AS [c]


Следующий тест получает первую запись на основе порядка "фамилия, имя":


[Fact]

public void GetFirstMatchingRecordNameOrder()

{

  // Получить первую запись на основе порядка "фамилия, имя".

  var customer = Context.Customers

    .OrderBy(x => x.PersonalInformation.LastName)

    .ThenBy(x => x.PersonalInformation.FirstName)

    .First();

  Assert.Equal(1, customer.Id);

}


Предыдущий запрос LINQ транслируется в такой код SQL:


SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

        [c].[LastName] 
FROM [Dbo].[Customers] AS [c]

ORDER BY [c].[LastName], [c].[FirstName]


Приведенный ниже тест выдвигает утверждение о том, что если для

First()
не найдено соответствие, тогда генерируется исключение:


[Fact]

public void FirstShouldThrowExceptionIfNoneMatch()

{

  // Фильтровать на основе Id.

  // Сгенерировать исключение, если соответствие не найдено.

  Assert.Throws(()

   => Context.Customers.First(x => x.Id == 10));

}


Предыдущий запрос LINQ транслируется в такой код SQL:


SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

        [c].[LastName] 
FROM [Dbo].[Customers] AS [c]

WHERE [c].[Id] = 10


На заметку!

Assert.Throws()
— это специальный тип утверждения, который ожидает, что код в выражении сгенерирует исключение. Если исключение не было сгенерировано, тогда утверждение терпит неудачу.


В случае применения метода

FirstOrDefault()
, если соответствие не найдено, то результатом будет null, а не исключение:


[Fact]

public void FirstOrDefaultShouldReturnDefaultIfNoneMatch()

{

  // Expression> - это лямбда-выражение.

  Expression> expression = x => x.Id == 10;

  // Возвращает null, если ничего не найдено.

  var customer = Context.Customers.FirstOrDefault(expression);

  Assert.Null(customer);

}


Предыдущий запрос LINQ транслируется в тот же код SQL, что и ранее:


SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

        [c].[LastName] 
FROM [Dbo].[Customers] AS [c]

WHERE [c].[Id] = 10

Использование Last()/LastOrDefault()

При использовании формы

Last()
и
LastOrDefault()
без параметров будет возвращаться последняя запись (на основе предшествующих конструкций упорядочения). Показанный далее тест получает последнюю запись на основе порядка "фамилия, имя":


[Fact]

public void GetLastMatchingRecordNameOrder()

{

  // Получить последнюю запись на основе порядка "фамилия, имя".

  var customer = Context.Customers

    .OrderBy(x => x.PersonalInformation.LastName)

    .ThenBy(x => x.PersonalInformation.FirstName)

    .Last();

  Assert.Equal(4, customer.Id);

}


Инфраструктура EF Core инвертирует операторы

ORDER BY
и затем получает результат с помощью
ТОР(1)
. Вот как выглядит выполняемый запрос:


SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

        [c].[LastName] 
FROM [Dbo].[Customers] AS [c]

ORDER BY [c].[LastName] DESC, [c].[FirstName] DESC

Использование Single()/SingleOrDefault()

Концептуально

Single()/SingleOrDefault()
работает аналогично
First()/FirstOrDefault()
. Основное отличие в том, что метод
Single()/SingleOrDefault()
возвращает
TOP(2)
, а не
ТОР(1)
, и генерирует исключение, если из базы данных возвращаются две записи. Следующий тест извлекает одиночную запись, в которой значение
Id
равно 1:


[Fact]

public void GetOneMatchingRecordWithSingle()

{

  // Получить первую запись на основе порядка в базе данных.

  var customer = Context.Customers.Single(x => x.Id == 1);

  Assert.Equal(1, customer.Id);

}


Предыдущий запрос LINQ транслируется в такой код SQL:


SELECT TOP(2) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

        [c].[LastName] 
FROM [Dbo].[Customers] AS [c]

WHERE [c].[Id] = 1


Если запись не возвращается, тогда метод

Single()
генерирует исключение:


[Fact]

public void SingleShouldThrowExceptionIfNoneMatch()

{

  // Фильтровать на основе Id.

  // Сгенерировать исключение, если соответствие не найдено.

  Assert.Throws(()

   => Context.Customers.Single(x => x.Id == 10));

}


Предыдущий запрос LINQ транслируется в такой код SQL:


SELECT TOP(2) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

        [c].[LastName] 
FROM [Dbo].[Customers] AS [c]

WHERE [c].[Id] = 10


Если при использовании

Single()
или
SingleOrDefault()
возвращается больше чем одна запись, тогда генерируется исключение:


[Fact]

public void SingleShouldThrowExceptionIfMoreThenOneMatch()

{

  // Сгенерировать исключение, если найдено более одного соответствия.

  Assert.Throws(()

   => Context.Customers.Single());

}


[Fact]

public void SingleOrDefaultShouldThrowExceptionIfMoreThenOneMatch()

{

  // Сгенерировать исключение, если найдено более одного соответствия.

  Assert.Throws(()

   => Context.Customers.SingleOrDefault());

}


Предыдущий запрос LINQ транслируется в такой код SQL:


SELECT TOP(2) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

        [c].[LastName] 
FROM [Dbo].[Customers] AS [c]


Если никакие данные не возвращаются в случае применения

SingleOrDefault()
, то результатом будет
null
, а не исключение:


[Fact]

public void SingleOrDefaultShouldReturnDefaultIfNoneMatch()

{

  // Expression> - это лямбда-выражение.

  Expression> expression = x => x.Id == 10;

  // Возвращается null, когда ничего не найдено.

  var customer = Context.Customers.SingleOrDefault(expression);

  Assert.Null(customer);

}


Предыдущий запрос LINQ транслируется в такой код SQL:


SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName],

        [c].[LastName] 
FROM [Dbo].[Customers] AS [c]

WHERE [c].[Id] = 10

Глобальные фильтры запросов

Вспомните о наличии для сущности

Car
глобального фильтра запросов, который отбрасывает данные об автомобилях со значением свойства
IsDrivable
, равным
false
:


modelBuilder.Entity(entity =>

{

  entity.HasQueryFilter(c => c.IsDrivable);

  ...

});


Откройте файл класса

CarTests.cs
и добавьте показанный далее тест (все тесты в последующих разделах находятся в
СаrTests.cs
, если не указано иначе):


[Fact]

public void ShouldReturnDrivableCarsWithQueryFilterSet()

{

  IQueryable query = Context.Cars;

  var qs = query.ToQueryString();

  var cars = query.ToList();

  Assert.NotEmpty(cars);

  Assert.Equal(9, cars.Count);

}


Также вспомните, что в процессе инициализации данных были созданы 10 записей об автомобилях,из которых один установлен как неуправляемый. При запуске запроса применяется глобальный фильтр запросов и выполняется следующий код SQL:


SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName],

    [i].[TimeStamp] 
FROM [dbo].[Inventory] AS [i]

WHERE [i].[IsDrivable] = CAST(1 AS bit)


На заметку! Как вскоре будет показано, глобальные фильтры запросов также применяются при загрузке связанных сущностей и при использовании методов

FromSqlRaw()
и
FromSqlInterpolated()
.

Отключение глобальных фильтров запросов

Чтобы отключить глобальные фильтры запросов для сущностей в запросе, добавьте к запросу LINQ вызов метода

IgnoreQueryFilters()
. Он заблокирует все фильтры для всех сущностей в запросе. Если есть несколько сущностей с глобальными фильтрами запросов и некоторые фильтры сущностей нужны, тогда потребуется поместить их в методы
Where()
оператора LINQ. Добавьте в файл класса
CarTests.cs
приведенный ниже тест, который отключает фильтр запросов и возвращает все записи:


[Fact]

public void ShouldGetAllOfTheCars()

{

  IQueryable query = Context.Cars.IgnoreQueryFilters();

  var qs = query.ToQueryString();

  var cars = query.ToList();

  Assert.Equal(10, cars.Count);

}


Как и можно было ожидать, в сгенерированном коде SQL больше нет конструкции

WHERE
, устраняющей записи для неуправляемых автомобилей:


SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName],

     [i].[TimeStamp] 
FROM [dbo].[Inventory] AS [i]

Фильтры запросов для навигационных свойств

Помимо глобального фильтра запросов для сущности

Car
был добавлен фильтр запросов к свойству
CarNavigation
сущности
Order
:


modelBuilder.Entity().HasQueryFilter(e => e.CarNavigation!.IsDrivable);


Чтобы увидеть его в действии, добавьте в файл класса

OrderTests.cs
следующий тест:


[Fact]

public void ShouldGetAllOrdersExceptFiltered()

{

   var query = Context.Orders.AsQueryable();

   var qs = query.ToQueryString();

   var orders = query.ToList();

   Assert.NotEmpty(orders);

   Assert.Equal(4,orders.Count);

}


Вот сгенерированный код SQL:


SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp]

FROM [Dbo].[Orders] AS [o]

INNER JOIN (

   SELECT [i].[Id], [i].[IsDrivable]

   FROM [dbo].[Inventory] AS [i]

   WHERE [i].[IsDrivable] = CAST(1 AS bit)\r\n) AS [t]

   ON [o].[CarId] = [t].[Id]

WHERE [t].[IsDrivable] = CAST(1 AS bit)


Поскольку навигационное свойство

CarNavigation
является обязательным, механизм трансляции запросов использует конструкцию
INNER JOIN
, исключая записи
Order
, где
Car
соответствует неуправляемому автомобилю. Для возвращения всех записей добавьте в запрос LINQ вызов
IgnoreQueryFilters()
.

Энергичная загрузка связанных данных

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

Include()
указывает соединение со связанной сущностью, а метод
ThenInclude()
используется для последующих соединений. Оба метода будут задействованы в рассматриваемых далее тестах. Как упоминалось ранее, когда методы
Include()/ThenInclude()
транслируются в SQL, для обязательных отношений применяется внутреннее соединение, а для необязательных — левое соединение.

Поместите в файл класса

CarTests.cs
следующий тест, чтобы продемонстрировать одиночный вызов
Include()
:


[Fact]

public void ShouldGetAllOfTheCarsWithMakes()

{

 IIncludableQueryable query =

  Context.Cars.Include(c => c.MakeNavigation);

  var queryString = query.ToQueryString();

  var cars = query.ToList();

  Assert.Equal(9, cars.Count);

}


Тест добавляет к результатам свойство

MakeNavigation
, выполняя внутреннее соединение с помощью показанного ниже кода SQL. Обратите внимание, что глобальный фильтр запросов действует:


SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName],

     [i].
[TimeStamp],
 [m].[Id], [m].[Name], [m].[TimeStamp]

FROM [dbo].[Inventory] AS [i]

INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId] = [m].[Id]

WHERE [i].[IsDrivable] = CAST(1 AS bit)


Во втором тесте используется два набора связанных данных. Первый — это получение информации

Make
(как и в предыдущем тесте), а второй — получение сущностей
Order
и затем присоединенных к ним сущностей
Customer
. Полный тест также отфильтровывает записи
Car
, для которых есть записи
Order
. Для необязательных отношений генерируются левые соединения:


[Fact]

public void ShouldGetCarsOnOrderWithRelatedProperties()

{

  IIncludableQueryable query = Context.Cars

   .Where(c => c.Orders.Any())

   .Include(c => c.MakeNavigation)

   .Include(c => c.Orders).ThenInclude(o => o.CustomerNavigation);

  var queryString = query.ToQueryString();

  var cars = query.ToList();

  Assert.Equal(4, cars.Count);

  cars.ForEach(c =>

  {

   Assert.NotNull(c.MakeNavigation);

   Assert.NotNull(c.Orders.ToList()[0].CustomerNavigation);

  });

}


Вот сгенерированный запрос:


SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName],

     [i].
[TimeStamp], 
[m].[Id], [m].[Name], [m].[TimeStamp], [t0].[Id],

    [t0].[CarId], [t0].[CustomerId], [
t0].[TimeStamp], [t0].[Id0],

     [t0].[TimeStamp0], [t0].[FirstName], [t0].[FullName],

   [t0].[LastName], [t0].[Id1]

FROM [dbo].[Inventory] AS [i]

   INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId]=[m].[Id]

   LEFT JOIN(SELECT [o].[Id], [o].[CarId], [o].[CustomerId],
 [o].[TimeStamp],

     [c].[Id] AS [Id0], [c].[TimeStamp] AS [TimeStamp0],

    [c].[FirstName], [c].[FullName],
 [c].[LastName], [t].[Id] AS [Id1]

     FROM [dbo].[Orders] AS [o]

      INNER JOIN(SELECT [i0].[Id], [i0].[IsDrivable]

       FROM [dbo].[Inventory] AS [i0]

      WHERE [i0].[IsDrivable]=CAST(1 AS BIT)) AS [t] ON

         [o].
[CarId]=[t].[Id]

      INNER JOIN [dbo].[Customers] AS [c] ON [o].[CustomerId]=[c].[Id]

    WHERE [t].[IsDrivable]=CAST(1 AS BIT)) AS [t0] ON [i].[Id]=[t0].[CarId]

  WHERE([i].[IsDrivable]=CAST(1 AS BIT))AND EXISTS (SELECT 1

   FROM [dbo].[Orders] AS [o0]

    INNER JOIN(SELECT [i1].[Id], [i1].
[Color], [i1].[IsDrivable],

              [i1].
[MakeId], [i1].[PetName], [i1].[TimeStamp]

   FROM [dbo].[Inventory] AS 
[i1]

   WHERE [i1].
[IsDrivable]=CAST(1 AS BIT)) AS [t1] ON [o0].[CarId]=[t1].[Id]

   WHERE([t1].[IsDrivable]=CAST(1 AS BIT)) 
AND([i].[Id]=[o0].[CarId]))

ORDER BY [i].[Id], [m].[Id], [t0].[Id], [t0].[Id1], [t0].[Id0];

Разделение запросов к связанным данным

Чем больше соединений добавляется в запрос LINQ, тем сложнее становится результирующий запрос. В версии EF Core 5 появилась возможность выполнять сложные соединения как разделенные запросы. Детальное обсуждение ищите в предыдущей главе, но вкратце помещение в запрос LINQ вызова метода

AsSplitQuery()
инструктирует инфраструктуру EF Core о необходимости разделения одного обращения к базе данных на несколько обращений. В итоге может повыситься эффективность, но возникает риск несогласованности данных. Добавьте в тестовую оснастку приведенный далее тест:


[Fact]

public void ShouldGetCarsOnOrderWithRelatedPropertiesAsSplitQuery()

{

  IQueryable query = Context.Cars.Where(c => c.Orders.Any())

   .Include(c => c.MakeNavigation)

   .Include(c => c.Orders).ThenInclude(o => o.CustomerNavigation)

   .AsSplitQuery();

  var cars = query.ToList();

  Assert.Equal(4, cars.Count);

  cars.ForEach(c =>

  {

   Assert.NotNull(c.MakeNavigation);

   Assert.NotNull(c.Orders.ToList()[0].CustomerNavigation);

  });

}


Метод

ToQueryString()
возвращает только первый запрос, поэтому последующие запросы были получены с применением профилировщика SQL Server:


SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId],

     [i].[PetName], [i].
[TimeStamp], [m].[Id], [m].[Name], [m].[TimeStamp]

FROM [dbo].[Inventory] AS [i]

INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId] = [m].[Id]

WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND EXISTS (

SELECT 1

   FROM [Dbo].[Orders] AS [o]

   INNER JOIN (

    SELECT [i0].[Id], [i0].[Color], [i0].[IsDrivable], [i0].[MakeId],

       [i0].[PetName], 
[i0].[TimeStamp]

   FROM [dbo].[Inventory] AS [i0]

   WHERE [i0].[IsDrivable] = CAST(1 AS bit)

   ) AS [t] ON [o].[CarId] = [t].[Id]

   WHERE ([t].[IsDrivable] = CAST(1 AS bit)) AND ([i].[Id] = [o].[CarId]))

ORDER BY [i].[Id], [m].[Id]


SELECT [t0].[Id], [t0].[CarId], [t0].[CustomerId], [t0].[TimeStamp],

     [t0].[Id1], [t0].
[TimeStamp1], [t0].[FirstName], [t0].[FullName],

     [t0].[LastName], [i].[Id], [m].[Id]

FROM [dbo].[Inventory] AS [i]

INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId] = [m].[Id]

INNER JOIN (

  SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp],

     [c].[Id] AS [Id1], [c].
[TimeStamp] AS [TimeStamp1], [c].[FirstName],

      [c].[FullName], [c].[LastName]

 FROM [Dbo].[Orders] AS [o]

 INNER JOIN (

   SELECT [i0].[Id], [i0].[IsDrivable]

  FROM [dbo].[Inventory] AS [i0]

  WHERE [i0].[IsDrivable] = CAST(1 AS bit)

 ) AS [t] ON [o].[CarId] = [t].[Id]

   INNER JOIN [Dbo].[Customers] AS [c] ON [o].[CustomerId] = [c].[Id]

   WHERE [t].[IsDrivable] = CAST(1 AS bit)

) AS [t0] ON [i].[Id] = [t0].[CarId]

WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND EXISTS (

   SELECT 1

   FROM [Dbo].[Orders] AS [o0]

   INNER JOIN (

    SELECT [i1].[Id], [i1].[Color], [i1].[IsDrivable], [i1].[MakeId],

        [i1].[PetName], 
[i1].[TimeStamp]

    FROM [dbo].[Inventory] AS [i1]

   WHERE [i1].[IsDrivable] = CAST(1 AS bit)

   ) AS [t1] ON [o0].[CarId] = [t1].[Id]

   WHERE ([t1].[IsDrivable] = CAST(1 AS bit)) AND ([i].[Id] = [o0].[CarId]))

ORDER BY [i].[Id], [m].[Id]


Будете вы разделять свои запросы или нет, зависит от существующих бизнес-требований.

Фильтрация связанных данных

В версии EF Core 5 появилась возможность фильтрации при включении навигационных свойств типа коллекций. До выхода EF Core 5 единственным способом получения отфильтрованного списка для навигационного свойства типа коллекций было использование явной загрузки. Добавьте в

MakeTests.cs
следующий тест, который демонстрирует получение записей производителей, выпускающих автомобили желтого цвета:


[Fact]

public void ShouldGetAllMakesAndCarsThatAreYellow()

{

  var query = Context.Makes.IgnoreQueryFilters()

    .Include(x => x.Cars.Where(x => x.Color == "Yellow"));

   var qs = query.ToQueryString();

  var makes = query.ToList();

  Assert.NotNull(makes);

  Assert.NotEmpty(makes);

  Assert.NotEmpty(makes.Where(x => x.Cars.Any()));

  Assert.Empty(makes.First(m => m.Id == 1).Cars);

  Assert.Empty(makes.First(m => m.Id == 2).Cars);

  Assert.Empty(makes.First(m => m.Id == 3).Cars);

  Assert.Single(makes.First(m => m.Id == 4).Cars);

  Assert.Empty(makes.First(m => m.Id == 5).Cars);

}


Ниже показан сгенерированный код SQL:


SELECT [m].[Id], [m].[Name], [m].[TimeStamp], [t].[Id], [t].[Color],

     [t].[IsDrivable], 
[t].[MakeId], [t].[PetName], [t].[TimeStamp]

FROM [dbo].[Makes] AS [m]

LEFT JOIN (

  SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId],

      [i].[PetName], 
[i].[TimeStamp]

 FROM [dbo].[Inventory] AS [i]

 WHERE [i].[Color] = N'Yellow') AS [t] ON [m].[Id] = [t].[MakeId]

ORDER BY [m].[Id], [t].[Id]


Изменение запроса на разделенный приводит к выдаче такого кода SQL (получен с использованием профилировщика SQL Server):


SELECT [m].[Id], [m].[Name], [m].[TimeStamp]

FROM [dbo].[Makes] AS [m]

ORDER BY [m].[Id]

SELECT [t].[Id], [t].[Color], [t].[IsDrivable], [t].[MakeId],

     [t].[PetName], [t].
[TimeStamp], [m].[Id]

FROM [dbo].[Makes] AS [m]

INNER JOIN (

  SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId],

      [i].[PetName], [i].
[TimeStamp]

 FROM [dbo].[Inventory] AS [i]

 WHERE [i].[Color] = N'Yellow'

) AS [t] ON [m].[Id] = [t].[MakeId]

ORDER BY [m].[Id]

Явная загрузка связанных данных

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

Entry()
класса, производного от
DbContext
. При загрузке сущностей на стороне "многие" отношения "один ко многим" используйте вызов метода
Collection()
на результате
Entry()
. Чтобы загрузить сущности на стороне "один" отношения "один ко многим" (или отношения "один к одному"), применяйте метод
Reference()
. Вызов метода
Query()
на результате
Collection()
или
Reference()
возвращает экземпляр реализации
IQueryable
, который можно использовать для получения строки запроса (как видно в приводимых далее тестах) и для управления фильтрами запросов (как показано в следующем разделе). Чтобы выполнить запрос и загрузить запись (записи), вызовите метод
Load()
на результате метода
Collection()
,
Reference()
или
Query()
. Выполнение запроса начнется немедленно после вызова
Load()
.

Представленный ниже тест (из

CarTests.cs
) демонстрирует, каким образом загрузить связанные данные через навигационное свойство типа ссылки внутри сущности
Car
:


[Fact]

public void ShouldGetReferenceRelatedInformationExplicitly()

{

  var car = Context.Cars.First(x => x.Id == 1);

  Assert.Null(car.MakeNavigation);

  var query = Context.Entry(car).Reference(c => c.MakeNavigation).Query();

  var qs = query.ToQueryString();

  query.Load();

  Assert.NotNull(car.MakeNavigation);

}


Вот сгенерированный код SQL:


DECLARE @__p_0 int = 1;

SELECT [m].[Id], [m].[Name], [m].[TimeStamp]

FROM [dbo].[Makes] AS [m]

WHERE [m].[Id] = @__p_0


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

Car
:


[Fact]

public void ShouldGetCollectionRelatedInformationExplicitly()

{

  var car = Context.Cars.First(x => x.Id == 1);

  Assert.Empty(car.Orders);

  var query = Context.Entry(car).Collection(c => c.Orders).Query();

  var qs = query.ToQueryString();

  query.Load();

  Assert.Single(car.Orders);

}


Сгенерированный код SQL выглядит так:


DECLARE @__p_0 int = 1;

SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp]

FROM [Dbo].[Orders] AS [o]

INNER JOIN (

  SELECT [i].[Id], [i].[IsDrivable]

 FROM [dbo].[Inventory] AS [i]

 WHERE [i].[IsDrivable] = CAST(1 AS bit)

) AS [t] ON [o].[CarId] = [t].[Id]

WHERE ([t].[IsDrivable] = CAST(1 AS bit)) AND ([o].[CarId] = @__p_0)

Явная загрузка связанных данных с фильтрами запросов

Глобальные фильтры запросов активны не только при формировании запросов, генерируемых для энергичной загрузки связанных данных, но и при явной загрузке связанных данных. Добавьте (в

MakeTests.cs
) приведенный далее тест:


[Theory]

[InlineData(1,1)]

[InlineData(2,1)]

[InlineData(3,1)]

[InlineData(4,2)]

[InlineData(5,3)]

[InlineData(6,1)]

public void ShouldGetAllCarsForAMakeExplicitlyWithQueryFilters(

   int makeId, int carCount)

{

  var make = Context.Makes.First(x => x.Id == makeId);

  IQueryable query = Context.Entry(make).Collection(c => c.Cars).Query();

  var qs = query.ToQueryString();

  query.Load();

  Assert.Equal(carCount,make.Cars.Count());

}


Этот тест похож на тест

ShouldGetTheCarsByMake()
из раздела "Фильтрация записей" ранее в главе. Однако вместо того, чтобы просто получить записи
Car
, которые имеют определенное значение
MakeId
, текущий тест сначала получает запись
Make
и затем явно загружает записи
Car
для находящейся в памяти записи
Make
. Ниже показан сгенерированный код SQL:


DECLARE @__p_0 int = 5;

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId],

     [i].[PetName], [i].[TimeStamp]

FROM [dbo].[Inventory] AS [i]

WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND ([i].[MakeId] = @__p_0)


Обратите внимание на то, что фильтр запросов по-прежнему применяется, хотя главной сущностью в запросе является запись

Make
. Для отключения фильтров запросов при явной загрузке записей вызовите
IgnoreQueryFilters()
в сочетании с методом
Query()
. Вот тест, который отключает фильтры запросов (находится в
MakeTests.cs
):


[Theory]

[InlineData(1, 2)]

[InlineData(2, 1)]

[InlineData(3, 1)]

[InlineData(4, 2)]

[InlineData(5, 3)]

[InlineData(6, 1)]

public void ShouldGetAllCarsForAMakeExplicitly(int makeId, int carCount)

{

  var make = Context.Makes.First(x => x.Id == makeId);

  IQueryable query =

   Context.Entry(make).Collection(c => c.Cars).Query().IgnoreQueryFilters();

  var qs = query.IgnoreQueryFilters().ToQueryString();

  query.Load();

  Assert.Equal(carCount, make.Cars.Count());

}

Выполнение запросов SQL с помощью LINQ

Если оператор LINQ для отдельного запроса слишком сложен или тестирование показывает, что производительность оказалась ниже, чем желаемая, тогда данные можно извлекать с использованием низкоуровневого оператора SQL через метод

FromSqlRaw()
или
FromSqlInterpolated()
класса
DbSet
. Оператором SQL может быть встроенный оператор
SELECT
языка Т-SQL, хранимая процедура или табличная функция. Если запрос является открытым (например, оператор Т-SQL без завершающей точки с запятой), тогда операторы LINQ можно добавлять к вызову метода
FromSqlRaw()/FromSqlInterpolated()
для дальнейшего определения генерируемого запроса. Полный запрос выполняется на серверной стороне с объединением оператора SQL и кода SQL, сгенерированного операторами LINQ.

Если оператор завершен или содержит код SQL, который не может быть достроен (скажем, задействует общие табличные выражения), то такой запрос все равно выполняется на серверной стороне, но любая дополнительная фильтрация и обработка должна делаться на клиентской стороне как LINQ to Objects. Метод

FromSqlRaw()
выполняет запрос в том виде, в котором он набран. Метод
FromSqlInterpolated()
применяет интерполяцию строк C# и помещает интерполированные значения в параметры. В следующих тестах (из
CarTests.cs
) демонстрируются примеры использования обоих методов с глобальными фильтрами запросов и без них:


[Fact]

public void ShouldNotGetTheLemonsUsingFromSql()

{

  var entity = Context.Model.FindEntityType($"{typeof(Car).FullName}");

 var tableName = entity.GetTableName();

 var schemaName = entity.GetSchema();

 var cars = Context.Cars.FromSqlRaw($"Select * from {schemaName}.{tableName}")

   .ToList();

  Assert.Equal(9, cars.Count);

}


[Fact]

public void ShouldGetTheCarsUsingFromSqlWithIgnoreQueryFilters()

{

  var entity = Context.Model.FindEntityType($"{typeof(Car).FullName}");

 var tableName = entity.GetTableName();

 var schemaName = entity.GetSchema();

 var cars = Context.Cars.FromSqlRaw($"Select * from {schemaName}.{tableName}")

   .IgnoreQueryFilters().ToList();

  Assert.Equal(10, cars.Count);

}


[Fact]

public void ShouldGetOneCarUsingInterpolation()

{

  var carId = 1;

 var car = Context.Cars

   .FromSqlInterpolated($"Select * from dbo.Inventory where Id = {carId}")

  .Include(x => x.MakeNavigation)

  .First();

 Assert.Equal("Black", car.Color);

 Assert.Equal("VW", car.MakeNavigation.Name);

}


[Theory]

[InlineData(1, 1)]

[InlineData(2, 1)]

[InlineData(3, 1)]

[InlineData(4, 2)]

[InlineData(5, 3)]

[InlineData(6, 1)]

public void ShouldGetTheCarsByMakeUsingFromSql(int makeId, int expectedCount)

{

  var entity = Context.Model.FindEntityType($"{typeof(Car).FullName}");

  var tableName = entity.GetTableName();

  var schemaName = entity.GetSchema();

  var cars = Context.Cars.FromSqlRaw($"Select * from {schemaName}.{tableName}")

   .Where(x => x.MakeId == makeId).ToList();

  Assert.Equal(expectedCount, cars.Count);

}


Во время применения методов

FromSqlRaw()/FromSqlInterpolated()
действует ряд правил: столбцы, возвращаемые из оператора SQL, должны соответствовать столбцам в модели, должны возвращаться все столбцы для модели, а возвращать связанные данные не допускается.

Методы агрегирования

В EF Core также поддерживаются методы агрегирования серверной стороны (

Мах()
,
Min()
,
Count()
,
Average()
и т.д.). Вызовы методов агрегирования можно добавлять в конец запроса LINQ с вызовами
Where()
или же сам вызов метода агрегирования может содержать выражение фильтра (подобно
First()
и
Single()
). Агрегирование выполняется на серверной стороне и из запроса возвращается одиночное значение. Глобальные фильтры запросов оказывают воздействие на методы агрегирования и могут быть отключены с помощью
IgnoreQueryFiltersсе()
. В операторы SQL, показанные в этом разделе, были получены с использованием профилировщика SQL Server.

Первый тест (из

CarTests.cs
) просто подсчитывает все записи
Car
в базе данных. Из-за того, что фильтр запросов активен, результатом подсчета будет 9:


[Fact]

public void ShouldGetTheCountOfCars()

{

  var count = Context.Cars.Count();

  Assert.Equal(9, count);

}


Ниже приведен код SQL, который выполнялся:


The executed SQL is shown here:SELECT COUNT(*)

FROM [dbo].[Inventory] AS [i]

WHERE [i].[IsDrivable] = CAST(1 AS bit)


После добавления вызова

IgnoreQueryFilters()
метод
Count()
возвращает 10 и конструкция
WHERE
удаляется из запроса SQL:


[Fact]

public void ShouldGetTheCountOfCarsIgnoreQueryFilters()

{

  var count = Context.Cars.IgnoreQueryFilters().Count();

  Assert.Equal(10, count);

}


Вот сгенерированный код SQL:


SELECT COUNT(*) FROM [dbo].[Inventory] AS [i]


Следующие тесты (из

CarTests.cs
) демонстрируют метод
Count()
с условием
WHERE
. В первом тесте выражение добавляется прямо в вызов метода
Count()
, а во втором вызов метода
Count()
помещается в конец запроса LINQ:


[Theory]

[InlineData(1, 1)]

[InlineData(2, 1)]

[InlineData(3, 1)]

[InlineData(4, 2)]

[InlineData(5, 3)]

[InlineData(6, 1)]

public void ShouldGetTheCountOfCarsByMakeP1(int makeId, int expectedCount)

{

   var count = Context.Cars.Count(x=>x.MakeId == makeId);

   Assert.Equal(expectedCount, count);

}


[Theory]

[InlineData(1, 1)]

[InlineData(2, 1)]

[InlineData(3, 1)]

[InlineData(4, 2)]

[InlineData(5, 3)]

[InlineData(6, 1)]

public void ShouldGetTheCountOfCarsByMakeP2(int makeId, int expectedCount)

{

   var count = Context.Cars.Where(x => x.MakeId == makeId).Count();

   Assert.Equal(expectedCount, count);

}


Оба теста создают те же самые обращения SQL к серверу (в каждом тесте значение для

MakeId
изменяется на основе
[InlineData]
):


exec sp_executesql N'SELECT COUNT(*)

FROM [dbo].[Inventory] AS [i]

WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND ([i].[MakeId] = @__makeId_0)'

,N'@__makeId_0 int',@__makeId_0=6

Any() и All()

Методы

Any()
и
All()
проверяют набор записей, чтобы выяснить, соответствует ли критериям любая запись (
Any()
) или же все записи (
Аll()
). Как и вызовы методов агрегирования, их можно добавлять в конец запроса LINQ с вызовами
Where()
либо же помещать выражение фильтрации в сам вызов метода. Методы
Any()
и
All()
выполняются на серверной стороне, а из запроса возвращается булевское значение. Глобальные фильтры запросов оказывают воздействие на методы
Any()
и
All()
; их можно отключить с помощью
IgnoreQueryFilters()
.

Все операторы SQL, показанные в этом разделе, были получены с применением профилировщика SQL Server. Первый тест (из

CarTests.cs
) проверяет, имеет ли любая запись Car специфическое значение
MakeId
:


[Theory]

[InlineData(1, true)]

[InlineData(11, false)]

public void ShouldCheckForAnyCarsWithMake(int makeId, bool expectedResult)

{

  var result = Context.Cars.Any(x => x.MakeId == makeId);

  Assert.Equal(expectedResult, result);

}


Для первого теста

[Theory]
выполняется следующий код SQL:


exec sp_executesql N'SELECT CASE

  WHEN EXISTS (

   SELECT 1

  FROM [dbo].[Inventory] AS [i]

  WHERE ([i].[IsDrivable] = CAST(1 AS bit))

    AND ([i].[MakeId] = @__makeId_0)) THEN 

  CAST(1 AS bit)

  ELSE CAST(0 AS bit)

END',N'@__makeId_0 int',@__makeId_0=1


Второй тест проверяет, имеют ли все записи

Car
специфическое значение
MakeId
:


[Theory]

[InlineData(1, false)]

[InlineData(11, false)]

public void ShouldCheckForAllCarsWithMake(int makeId, bool expectedResult)

{

  var result = Context.Cars.All(x => x.MakeId == makeId);

  Assert.Equal(expectedResult, result);

}


Вот код SQL, выполняемый для второго теста

[Theory]
:


exec sp_executesql N'SELECT CASE

  WHEN NOT EXISTS (

   SELECT 1

  FROM [dbo].[Inventory] AS [i]

  WHERE ([i].[IsDrivable] = CAST(1 AS bit))

    AND ([i].[MakeId] <> @__makeId_0)) THEN 

   CAST(1 AS bit)

 ELSE CAST(0 AS bit)

END',N'@__makeId_0 int',@__makeId_0=1

Получение данных из хранимых процедур

Последний шаблон извлечения данных, который необходимо изучить, предусматривает получение данных из хранимых процедур. Несмотря на некоторые пробелы EF Core в плане работы с хранимыми процедурами (по сравнению с EF 6), не забывайте, что инфраструктура EF Core построена поверх ADO.NET. Нужно просто спуститься на уровень ниже и вспомнить, как вызывались хранимые процедуры до появления инструментов объектно-реляционного отображения. Показанный далее метод в

CarRepo
создает обязательные параметры (входной и выходной), задействует свойство
Database
экземпляра
ApplicationDbContext
и вызывает
ExecuteSqlRaw()
:


public string GetPetName(int id)

{

  var parameterId = new SqlParameter

   {

   ParameterName = "@carId",

   SqlDbType = System.Data.SqlDbType.Int,

   Value = id,

  };


  var parameterName = new SqlParameter

  {

   ParameterName = "@petName",

   SqlDbType = System.Data.SqlDbType.NVarChar,

   Size = 50,

   Direction = ParameterDirection.Output

  };


  var result = Context.Database

   .ExecuteSqlRaw("EXEC [dbo].[GetPetName] @carId, @petName OUTPUT",

           parameterId, 
parameterName);

  return (string)parameterName.Value;

}


При наличии такого кода тест становится тривиальным. Добавьте в файл класса

CarTests.cs
следующий тест:


[Theory]

[InlineData(1, "Zippy")]

[InlineData(2, "Rusty")]

[InlineData(3, "Mel")]

[InlineData(4, "Clunker")]

[InlineData(5, "Bimmer")]

[InlineData(6, "Hank")]

[InlineData(7, "Pinky")]

[InlineData(8, "Pete")]

[InlineData(9, "Brownie")]

public void ShouldGetValueFromStoredProc(int id, string expectedName)

{

   Assert.Equal(expectedName, new CarRepo(Context).GetPetName(id));

}

Создание записей

Записи добавляются в базу данных за счет их создания в коде, добавления к

DbSet
и вызова метода
SaveChanges()/SaveChangesAsync()
контекста. Во время выполнения метода
SaveChanges()
объект
ChangeTracker
сообщает обо всех добавленных сущностях, а инфраструктура EF Core вместе с поставщиком баз данных создают подходящий оператор (операторы) SQL для вставки записи (записей).

Вспомните, что метод

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

Все операторы SQL, показанные далее в разделе, были получены с применением профилировщика SQL Server.


На заметку! Записи можно добавлять также с использованием класса, производного от

DbContext
. Во всех примерах для добавления записей будут применяться свойства
DbSet
. В классах
DbSet
и
DbContext
имеются асинхронные версии методов
Add()/AddRange()
, но здесь рассматриваются только синхронные версии.

Состояние сущности

Когда сущность создана с помощью кода, но еще не была добавлена в

DbSet
, значением
EntityState
является
Detached
. После добавления новой сущности в
DbSet
значение
EntityState
устанавливается в
Added
. В случае успешного выполнения
SaveChanges()
значение
EntityState
устанавливается в
Unchanged
.

Добавление одной записи

В следующем тесте демонстрируется добавление одиночной записи в таблицу

Inventory
:


[Fact]

public void ShouldAddACar()

{

  ExecuteInATransaction(RunTheTest);

  void RunTheTest()

  {

   var car = new Car

   {

    Color = "Yellow",

    MakeId = 1,

    PetName = "Herbie"

   };

   var carCount = Context.Cars.Count();

   Context.Cars.Add(car);

   Context.SaveChanges();

   var newCarCount = Context.Cars.Count();

   Assert.Equal(carCount+1,newCarCount);

  }

}


Ниже приведен выполняемый оператор SQL. Обратите внимание, что у недавно добавленной сущности запрашиваются свойства, сгенерированные базой данных (

Id
и
TimeStamp
). Когда результат запроса поступает в исполняющую среду EF Core, сущность обновляется с использованием значений серверной стороны:


exec sp_executesql N'SET NOCOUNT ON;

INSERT INTO [dbo].[Inventory] ([Color], [MakeId], [PetName])

VALUES (@p0, @p1, @p2);

SELECT [Id], [IsDrivable], [TimeStamp]

FROM [dbo].[Inventory]

WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

',N'@p0 nvarchar(50),@p1 int,@p2 nvarchar(50)',@p0=N'Yellow',@p1=1,@p2=N'Herbie'

Добавление одной записи с использованием метода Attach()

Когда первичный ключ сущности сопоставлен со столбцом идентичности в SQL Server, исполняющая среда EF Core будет трактовать экземпляр сущности как добавленный (

Added
), если значение свойства первичного ключа равно 0. Следующий тест создает новую сущность
Car
и оставляет для свойства
Id
стандартное значение 0. После присоединения сущности к
ChangeTracker
ее состояние устанавливается в
Added
и вызов
SaveChanges()
добавит сущность в базу данных:


[Fact]

public void ShouldAddACarWithAttach()

{

  ExecuteInATransaction(RunTheTest);


  void RunTheTest()

  {

   var car = new Car

   {

    Color = "Yellow",

    MakeId = 1,

    PetName = "Herbie"

   };

   var carCount = Context.Cars.Count();

   Context.Cars.Attach(car);

   Assert.Equal(EntityState.Added, Context.Entry(car).State);

   Context.SaveChanges();

   var newCarCount = Context.Cars.Count();

   Assert.Equal(carCount + 1, newCarCount);

  }

}

Добавление нескольких записей одновременно

Чтобы вставить в одной транзакции сразу несколько записей, применяйте метод

AddRange()
класса
DbSet
, как показано в приведенном далее тесте (обратите внимание, что для активизации пакетирования при сохранении данных в SQL Server должно быть инициировано не менее четырех действий):


[Fact]

public void ShouldAddMultipleCars()

{

  ExecuteInATransaction(RunTheTest);

  void RunTheTest()

  {

   // Для активизации пакетирования должны быть добавлены четыре сущности

   var cars = new List

   {

    new() { Color = "Yellow", MakeId = 1, PetName = "Herbie" },

    new() { Color = "White", MakeId = 2, PetName = "Mach 5" },

    new() { Color = "Pink", MakeId = 3, PetName = "Avon" },

    new() { Color = "Blue", MakeId = 4, PetName = "Blueberry" },

   };


 var carCount = Context.Cars.Count();

   Context.Cars.AddRange(cars);

   Context.SaveChanges();

   var newCarCount = Context.Cars.Count();

   Assert.Equal(carCount + 4, newCarCount);

  }

}


Операторы добавления пакетируются в единственное обращение к базе данных и запрашиваются все сгенерированные столбцы. Когда результаты запроса поступают в EF Core, сущности обновляются с использованием значений серверной стороны. Вот как выглядит выполняемый оператор SQL:


exec sp_executesql N'SET NOCOUNT ON;

DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);

MERGE [dbo].[Inventory] USING (

VALUES (@p0, @p1, @p2, 0),

(@p3, @p4, @p5, 1),

(@p6, @p7, @p8, 2),

(@p9, @p10, @p11, 3)) AS i ([Color], [MakeId], [PetName], _Position) ON 1=0

WHEN NOT MATCHED THEN

INSERT ([Color], [MakeId], [PetName])

VALUES (i.[Color], i.[MakeId], i.[PetName])

OUTPUT INSERTED.[Id], i._Position

INTO @inserted0;

SELECT [t].[Id], [t].[IsDrivable], [t].[TimeStamp] FROM [dbo].[Inventory] t

INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id])

ORDER BY [i].[_Position];',

N'@p0 nvarchar(50),@p1 int,@p2 nvarchar(50),@p3 nvarchar(50),

@p4 int,@p5 nvarchar(50), 
@p6 nvarchar(50),@p7 int,@p8 nvarchar(50),

@p9 nvarchar(50),@p10 int,@p11 nvarchar(50)', 
@p0=N'Yellow',@p1=1,

@p2=N'Herbie',@p3=N'White',@p4=2,@p5=N'Mach 5',@p6=N'Pink',@p7=3, 

@p8=N'Avon',@p9=N'Blue',@p10=4,@p11=N'Blueberry'

Соображения относительно столбца идентичности при добавлении записей

Когда сущность имеет числовое свойство, которое определено как первичный ключ, то такое свойство (по умолчанию) отображается на столбец идентичности (

Identity
) в SQL Server. Исполняющая среда EF Core считает сущность со стандартным (нулевым) значением для свойства ключа новой, а сущность с нестандартным значением — уже присутствующей в базе данных. Если вы создаете новую сущность и устанавливаете свойство первичного ключа в ненулевое число, после чего пытаетесь добавить ее в базу данных, то EF Core откажется добавлять запись, поскольку вставка идентичности не разрешена. Включение вставки идентичности демонстрируется в коде инициализации данных.

Добавление объектного графа

При добавлении сущности в базу данных дочерние записи могут быть добавлены в том же самом обращении без их специального добавления в собственный экземпляр

DbSet
, если они добавлены в свойство типа коллекции для родительской записи. Например, пусть создается новая сущность
Make
и в ее свойство
Cars
добавляется дочерняя запись
Car
. Когда сущность
Make
добавляется в свойство
DbSet
, исполняющая среда EF Core автоматически начинает отслеживание также и дочерней записи
Car
без необходимости в ее явном добавлении в свойство
DbSet
. Выполнение метода
SaveChanges()
приводит к совместному сохранению
Make
и
Car
, что демонстрируется в следующем тесте:


[Fact]

public void ShouldAddAnObjectGraph()

{

  ExecuteInATransaction(RunTheTest);


  void RunTheTest()

  {

   var make = new Make {Name = "Honda"};

   var car = new Car { Color = "Yellow", MakeId = 1, PetName = "Herbie" };

   // Привести свойство Cars к List из IEnumerable.

   ((List)make.Cars).Add(car);

   Context.Makes.Add(make);

   var carCount = Context.Cars.Count();

   var makeCount = Context.Makes.Count();

   Context.SaveChanges();

   var newCarCount = Context.Cars. Count();

   var newMakeCount = Context.Makes. Count();

   Assert.Equal(carCount+1,newCarCount);

   Assert.Equal(makeCount+1,newMakeCount);

  }

}


Операторы добавления не пакетируются из-за наличия менее двух операторов, а в SQL Server пакетирование начинается с четырех операторов. Ниже показаны выполняемые операторы SQL:


exec sp_executesql N'SET NOCOUNT ON;

INSERT INTO [dbo].[Makes] ([Name])

VALUES (@p0);

SELECT [Id], [TimeStamp]

FROM [dbo].[Makes]

WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();


',N'@p0 nvarchar(50)',@p0=N'Honda'


exec sp_executesql N'SET NOCOUNT ON;

INSERT INTO [dbo].[Inventory] ([Color], [MakeId], [PetName])

VALUES (@p1, @p2, @p3);

SELECT [Id], [IsDrivable], [TimeStamp]

FROM [dbo].[Inventory]

WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

',N'@p1 nvarchar(50),@p2 int,@p3 nvarchar(50)',@p1=N'Yellow',@p2=7,@p3=N'Herbie'

Обновление записей

Записи обновляются за счет их загрузки в

DbSet
как отслеживаемой сущности, их изменения посредством кода и вызова метода
SaveChanges()
контекста. При выполнении
SaveChanges()
объект
ChangeTracker
сообщает обо всех модифицированных сущностях и исполняющая среда EF Core (наряду с поставщиком баз данных) создает надлежащий оператор SQL для обновления записи (или операторы SQL, если записей несколько).

Состояние сущности

Когда сущность редактируется,

EntityState
устанавливается в
Modified
. После успешного сохранения изменений состояние возвращается к
Unchanged
.

Обновление отслеживаемых сущностей

Обновление одиночной записи очень похоже на добавление одной записи. Вам понадобится загрузить запись из базы данных, внести в нее какие-то изменения и вызвать метод

SaveChanges()
. Обратите внимание, что вам не нужно вызывать
Update()/UpdateRange()
на экземпляре
DbSet
, поскольку сущности отслеживаются. Представленный ниже тест обновляет только одну запись, но при обновлении и сохранении множества отслеживаемых сущностей процесс будет таким же:


[Fact]

public void ShouldUpdateACar()

{

  ExecuteInASharedTransaction(RunTheTest);


  void RunTheTest(IDbContextTransaction trans)

  {

   var car = Context.Cars.First(c => c.Id == 1);

   Assert.Equal("Black",car.Color);

   car.Color = "White";

   // Вызывать Update() не нужно, т.к. сущность отслеживается.

   // Context.Cars.Update(car);

   Context.SaveChanges();

   Assert.Equal("White", car.Color);

   var context2 = TestHelpers.GetSecondContext(Context, trans);

   var car2 = context2.Cars.First(c => c.Id == 1);

   Assert.Equal("White", car2.Color);

  }

}


В предыдущем коде задействована транзакция, совместно используемая двумя экземплярами

ApplicationDbContext
. Это должно обеспечить изоляцию между контекстом, выполняющим тест, и контекстом, проверяющим результат теста. Вот выполняемый оператор SQL:


exec sp_executesql N'SET NOCOUNT ON;

UPDATE [dbo].[Inventory] SET [Color] = @p0

WHERE [Id] = @p1 AND [TimeStamp] = @p2;

SELECT [TimeStamp]

FROM [dbo].[Inventory]

WHERE @@ROWCOUNT = 1 AND [Id] = @p1;


',N'@p1 int,@p0 nvarchar(50),@p2 varbinary(8)',@p1=1,@p0=N'White',

@p2=0x000000000000862D


На заметку! В показанной выше конструкции

WHERE
проверяется не только столбец
Id
, но и столбец
TimeStamp
. Проверка параллелизма будет раскрыта очень скоро .

Обновление неотслеживаемых сущностей

Неотслеживаемые сущности тоже можно использовать для обновления записей базы данных. Процесс аналогичен обновлению отслеживаемых сущностей за исключением того, что сущность создается в коде (и не запрашивается), а исполняющую среду EF Core потребуется уведомить о том, что сущность уже должна существовать в базе данных и нуждается в обновлении.

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

Update()
на экземпляре
DbSet
, который устанавливает состояние в
Modified
:


context2.Cars.Update(updatedCar);


Второй способ связан с применением экземпляра контекста и метода

Entry()
для установки состояния в
Modified
:


context2.Entry(updatedCar).State = EntityState.Modified;


В любом случае для сохранения значений все равно должен вызываться метод

SaveChanges()
.

В представленном далее тесте читается неотслеживаемая запись, из нее создается новый экземпляр класса

Car
и изменяется одно его свойство (
Color
). Затем в зависимости от того, с какой строки кода вы уберете комментарий, либо устанавливается состояние, либо использует метод
Update()
на
DbSet
. Метод
Update()
также изменяет состояние на
Modified
. Затем в тесте вызывается метод
SaveChanges()
. Все дополнительные контексты нужны для обеспечения точности теста и отсутствия пересечения между контекстами:


[Fact]

public void ShouldUpdateACarUsingState()

{

  ExecuteInASharedTransaction(RunTheTest);


  void RunTheTest(IDbContextTransaction trans)

  {

   var car = Context.Cars.AsNoTracking().First(c => c.Id == 1);

   Assert.Equal("Black", car.Color);

   var updatedCar = new Car

   {

    Color = "White", //Original is Black

    Id = car.Id,

    MakeId = car.MakeId,

    PetName = car.PetName,

    TimeStamp = car.TimeStamp

    IsDrivable = car.IsDrivable

   };


   var context2 = TestHelpers.GetSecondContext(Context, trans);

   // Либо вызвать Update(), либо модифицировать состояние.

   context2.Entry(updatedCar).State = EntityState.Modified;

   // context2.Cars.Update(updatedCar);

   context2.SaveChanges();

   var context3 =

    TestHelpers.GetSecondContext(Context, trans);

   var car2 = context3.Cars.First(c => c.Id == 1);

   Assert.Equal("White", car2.Color);

  }

}


Ниже показан выполняющийся оператор SQL:


exec sp_executesql N'SET NOCOUNT ON;

UPDATE [dbo].[Inventory] SET [Color] = @p0

WHERE [Id] = @p1 AND [TimeStamp] = @p2;

SELECT [TimeStamp]

FROM [dbo].[Inventory]

WHERE @@ROWCOUNT = 1 AND [Id] = @p1;


',N'@p1 int,@p0 nvarchar(50),@p2 varbinary(8)',@p1=1,@p0=N'White',

@p2=0x000000000000862D

Проверка параллелизма

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

TimeStamp
, то значение этого свойства используется в конструкции
WHERE
при сохранении изменений (обновлений или удалений) в базе данных. Вместо поиска только первичного ключа к запросу добавляется поиск значения
TimeStamp
, например:


UPDATE [dbo].[Inventory] SET [PetName] = @p0

WHERE [Id] = @p1 AND [TimeStamp] = @p2;


В следующем тесте демонстрируется пример создания исключения, связанного с параллелизмом, его перехвата и применения

Entries
для получения исходных значений, текущих значений и значений, которые в настоящий момент хранятся в базе данных. Получение текущих значений требует еще одного обращения к базе данных:


[Fact]

public void ShouldThrowConcurrencyException()

{

  ExecuteInATransaction(RunTheTest);


  void RunTheTest()

  {

   var car = Context.Cars.First();

   // Обновить базу данных за пределами контекста.

   Context.Database.ExecuteSqlInterpolated(

    $"Update dbo.Inventory set Color='Pink' where Id = {car.Id}");

   car.Color = "Yellow";

   var ex = Assert.Throws(

    () => Context.SaveChanges());

   var entry = ((DbUpdateConcurrencyException) ex.InnerException)?.Entries[0];

   PropertyValues originalProps = entry.OriginalValues;

   PropertyValues currentProps = entry.CurrentValues;

   // Требует еще одного обращения к базе данных.

   PropertyValues databaseProps = entry.GetDatabaseValues();

  }

}

Ниже показаны выполняемые операторы SQL. Первым из них является оператор UPDATE, а вторым — обращение для получения значений базы данных:


exec sp_executesql N'SET NOCOUNT ON;

UPDATE [dbo].[Inventory] SET [Color] = @p0

WHERE [Id] = @p1 AND [TimeStamp] = @p2;

SELECT [TimeStamp]

FROM [dbo].[Inventory]

WHERE @@ROWCOUNT = 1 AND [Id] = @p1;'

,N'@p1 int,@p0 nvarchar(50),@p2 varbinary(8)',@p1=1,@p0=N'Yellow',

@p2=0x0000000000008665


exec sp_executesql N'SELECT TOP(1) [i].[Id], [i].[Color],

   [i].[IsDrivable], [i].[MakeId], 
[i].[PetName], [i].[TimeStamp]

FROM [dbo].[Inventory] AS [i]

WHERE [i].[Id] = @__p_0',N'@__p_0 int',@__p_0=1

Удаление записей

Одиночная сущность помечается для удаления путем вызова

Remove()
на
DbSet
или установки ее состояния в
Deleted
. Список записей помечается для удаления вызовом
RemoveRange()
на
DbSet
. Процесс удаления будет вызывать эффекты каскадирования для навигационных свойств на основе правил, сконфигурированных в методе
OnModelCreating()
(и регламентированных соглашениями EF Core). Если удаление не допускается из -за политики каскадирования, тогда генерируется исключение.

Состояние сущности

Когда метод

Remove()
вызывается на отслеживаемой сущности, свойство
EntityState
устанавливается в
Deleted
. После успешного выполнения оператора удаления сущность исключается из
ChangeTracker
и состояние изменяется на
Detached
. Обратите внимание, что сущность по-прежнему существует в вашем приложении, если только она не покинула область видимости и не была подвержена сборке мусора.

Удаление отслеживаемых сущностей

Процесс удаления зеркально отображает процесс обновления. Как только сущность начала отслеживаться, вызовите

Remove()
на контексте и затем вызовите
SaveChanges()
, чтобы удалить запись из базы данных:


[Fact]

public void ShouldRemoveACar()

{

  ExecuteInATransaction(RunTheTest);

  void RunTheTest()

  {

   var carCount = Context.Cars. Count();

   var car = Context.Cars.First(c => c.Id == 2);

   Context.Cars.Remove(car);

   Context.SaveChanges();

   var newCarCount = Context.Cars.Count();

   Assert.Equal(carCount - 1, newCarCount);

   Assert.Equal(

    EntityState.Detached,

    Context.Entry(car).State);

  }

}


После вызова

SaveChanges()
экземпляр сущности все еще существует, но больше не находится в
ChangeTracker
. Состоянием
EntityState
будет
Detached
. Вот как выглядит выполняемый код SQL:


exec sp_executesql N'SET NOCOUNT ON;

DELETE FROM [dbo].[Inventory]

WHERE [Id] = @p0 AND [TimeStamp] = @p1;

SELECT @@ROWCOUNT;'

,N'@p0 int,@p1 varbinary(8)',@p0=2,

@p1=0x0000000000008680

Удаление неотслеживаемых сущностей

Неотслеживаемые сущности способны удалять записи таким же способом, каким они могут обновлять записи. Удаление производится вызовом

Remove()/RemoveRange()
или установкой состояния в
Deleted
и последующим вызовом
SaveChanges()
.

В показанном ниже тесте сначала читается запись как неотслеживаемая и на основе записи создается новый экземпляр класса

Car
. Затем либо устанавливается состояние в
Deleted
, либо применяется метод
Remove()
класса
DbSet
(в зависимости от того, какая строка кода закомментирована) и вызывается
SaveChanges()
. Все дополнительные контексты нужны для обеспечения точности теста и отсутствия пересечения между контекстами:


[Fact]

public void ShouldRemoveACarUsingState()

{

  ExecuteInASharedTransaction(RunTheTest);


  void RunTheTest(IDbContextTransaction trans)

  {

   var carCount = Context.Cars.Count();

   var car = Context.Cars.AsNoTracking().First(c => c.Id == 2);

   var context2 = TestHelpers.GetSecondContext(Context, trans);

   // Либо модифицировать состояние, либо вызвать Remove().

   context2.Entry(car).State = EntityState.Deleted;

   // context2.Cars.Remove(car);

   context2.SaveChanges();

   var newCarCount = Context.Cars.Count();

   Assert.Equal(carCount - 1, newCarCount);

   Assert.Equal(

    EntityState.Detached,

    Context.Entry(car).State);

  }

}

Перехват отказов каскадного удаления

Когда попытка удаления записи терпит неудачу из-за правил каскадирования, то исполняющая среда EFCore генерирует исключение

DbUpdateException
. Следующий тест демонстрирует это в действии:


[Fact]

public void ShouldFailToRemoveACar()

{

  ExecuteInATransaction(RunTheTest);

  void RunTheTest()

  {

   var car = Context.Cars.First(c => c.Id == 1);

   Context.Cars.Remove(car);

   Assert.Throws(

    ()=>Context.SaveChanges());

  }

}

Проверка параллелизма

Если сущность имеет свойство

TimeStamp
, то при удалении также используется проверка параллелизма. Дополнительную информацию ищите в подразделе "Проверка параллелизма" внутри раздела "Обновление записей" ранее в главе.

Резюме

В настоящей главе было закончено построение уровня доступа к данным

AutoLot
на основе сведений, полученных в предыдущей главе. С помощью инструментов командной строки EF Core вы создали шаблоны сущностей для существующей базы данных, обновили модель до финальной версии, а также создали и применили миграции. Для инкапсуляции доступа к данным вы добавили хранилища. Написанный код инициализации базы данных способен удалять и заново создавать базу данных повторяемым и надежным способом. В заключение готовый уровень доступа к данным главе был протестирован. На этом тема доступа к данным и Entity Framework Core завершена.

Загрузка...