Запросы к базе данных в Entity Framework Core

  • Михаил
  • 8 мин. на прочтение
  • 389
  • 01 Sep 2023
  • 01 Sep 2023

Для извлечения данных из базы данных Entity Framework Core использует технологию LINQ to Entities. В основе данной технологии лежит язык интегрированных запросов LINQ (Language Integrated Query). LINQ предлагает простой и интуитивно понятный подход для получения данных с помощью выражений, которые по форме близки выражениям языка SQL. Хотя при работе с базой данных мы оперируем запросами LINQ, но, реляционные базы данных как MS SQL Server или SQLite, понимают только запросы на языке SQL. Поэтому Entity Framework Core, используя выражения LINQ to Entities, транслирует их в определенные запросы, понятные для используемого источника данных. Применяют два вида записи запросов, первый похож на SQL запрос, этот способ называется синтаксис запросов, второй способ написания запросов использую лямбда выражения, такой способ называется синтаксисом методов. В статье будет применен синтаксис методов. Плюсы и минусы способов рассмотрим в другой статье. Теперь мы можем начать запрашивать данные из базы данных с помощью EF Core. Каждый запрос состоит из трех основных частей:

  • Подключение к базе данных через свойство ApplicationContext DbSet
  • Серия команд LINQ и / или EF Core
  • Выполнение запроса

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

Итак, чтобы объяснить основы запросов, мы собираемся использовать контроллер Values, как мы это делали в первой части серии и только действие Get для простоты. Мы собираемся сосредоточиться на логике EF Core, а не на веб-API в целом.

Поэтому давайте добавим наш объект контекста в конструктор Values и напишем первый запрос в действии Get:

[HttpGet]
public IActionResult Get()
{
   var students = _context.Students
          .Where(s => s.Age > 25)
          .ToList();
           
    return Ok(students);
}

Из этого запроса мы можем увидеть все упомянутые части. _context.Students - это первая часть, где мы получаем доступ к таблице Student в базе данных через свойство DbSet Students.

Where(s => s.Age> 25) - вторая часть запроса, в которой мы используем команду LINQ для выбора только необходимых строк. Далее, у нас есть метод ToList(), который выполняет этот запрос.

СОВЕТ: когда мы пишем запросы только для чтения в Entity Framework Core (результат запроса не будет использоваться для каких-либо дополнительных изменений базы данных), мы всегда должны добавлять AsNoTracking способ ускорить выполнение.

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

[HttpGet]
public IActionResult Get()
{
   var students = _context.Students
     .AsNoTracking()
     .Where(s => s.Age > 25)
     .ToList();
           
    return Ok(students);
}

Различные способы построения реляционных запросов

Существуют разные подходы к получению наших данных:

  • Eager loading - жадная загрузка
  • Explicit Loading - явная загрузка
  • Select (Projection) loading - выборка (проекция)
  • Lazy loading - ленивая загрузка


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

Запросы реляционной базы данных с жадной загрузкой в ​​EF Core
При использовании подхода "Активная загрузка" EF Core включает взаимосвязи в результат запроса. Для этого используются два разных метода: Include() и ThenInclude(). В следующем примере мы собираемся вернуть только одного учащегося со всеми соответствующими оценками, чтобы показать, как работает метод Include():

var students = _context.Students
   .Include(e => e.Evaluations)
   .FirstOrDefault();

Перед отправкой запроса на выполнение этого запроса мы должны установить библиотеку Microsoft.AspNetCore.Mvc.NewtonsoftJson и изменить класс Startup.cs:

public void ConfigureServices(IServiceCollection services)
{
   services.AddDbContext(opts =>
       opts.UseSqlServer(Configuration.GetConnectionString("sqlConnection"),
           options => options.MigrationsAssembly("EFCoreApp")));
   services.AddControllers()
      .AddNewtonsoftJson(o => o.SerializerSettings.ReferenceLoopHandling =
       Newtonsoft.Json.ReferenceLoopHandling.Ignore);
}

Это защита от ошибки «Self-referencing loop» при возврате результата из нашего API (что действительно происходит в реальных проектах). Вы можете использовать объекты DTO, чтобы избежать этой ошибки.

Теперь мы должны получить требуемый результат после отправки запроса на https://localhost: 5000/api/values

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

Мы увидим, что ядро ​​EF выбирает первого студента из таблицы Student, а затем выбирает все относительные оценки.

Важно знать, что мы можем включать все сущности в наши запросы через сущность Student, потому что она имеет отношения с другими сущностями. Вот почему у нас есть только одно свойство DbSet типа DbSet в классе ApplicationContext.

Но если мы хотим написать отдельный запрос для других сущностей, например, Evaluation, мы должны добавить дополнительное свойство DbSet

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

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

var students = _context.Students
   .Include(e => e.Evaluations)
   .Include(ss => ss.StudentSubjects)
   .ThenInclude(s => s.Subject)
   .FirstOrDefault();

Сущность Student не имеет прямого свойства навигации для сущности Subject, поэтому мы включаем свойство навигации первого уровня StudentSubjects, а затем включите свойство навигации второго уровня Subject

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

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

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

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

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

var student = _context.Students.FirstOrDefault();
_context.Entry(student)
   .Collection(e => e.Evaluations)
   .Load();
_context.Entry(student)
   .Collection(ss => ss.StudentSubjects)
   .Load();
foreach (var studentSubject in student.StudentSubjects)
{
    _context.Entry(studentSubject)
        .Reference(s => s.Subject)
        .Load();
}

В этом примере мы сначала загружаем объект Student. Затем мы включаем все оценки, связанные с выбранным учеником. Кроме того, мы включаем все связанные темы через свойство навигации StudentSubjects.

Важно отметить, что когда мы хотим включить коллекцию в основную сущность, мы должны использовать метод Collection, но когда мы включаем отдельную сущность в качестве свойства навигации, мы получаем использовать метод Reference.

Запросы в Entity Framework Core с явной загрузкой
При работе с явной загрузкой в ​​Entity Framework Core у нас есть дополнительная команда. Это позволяет применить запрос к отношению. Итак, вместо использования метода Load, как мы делали в предыдущем примере, мы собираемся использовать метод Query:

var student = _context.Students.FirstOrDefault();
var evaluationsCount = _context.Entry(student)
   .Collection(e => e.Evaluations)
   .Query()
   .Count();
var gradesPerStudent = _context.Entry(student)
   .Collection(e => e.Evaluations)
   .Query()
   .Select(e => e.Grade)
   .ToList();

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

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

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

var student = _context.Students
   .Select(s => new
   {
       s.Name,
       s.Age,
       NumberOfEvaluations = s.Evaluations.Count
   })
   .ToList();

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

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

Ленивая загрузка в Entity Framework Core
Ленивая загрузка была введена в EF Core 2.1, и мы можем использовать ее, чтобы отложить извлечение данных из базы данных до тех пор, пока они действительно не понадобятся. Эта функция может помочь в некоторых ситуациях, но она также может снизить производительность нашего приложения, и это основная причина, по которой она стала дополнительной функцией в EF Core 2.1.

Оценка клиента и сервера
Все написанные нами запросы - это те запросы, которые EF Core может преобразовывать в команды SQL (как мы видели из окна консоли). Но в EF Core есть функция под названием Client vs Server Evaluation, которая позволяет нам включать в наш запрос методы, которые нельзя преобразовать в команды SQL. Эти команды будут выполнены, как только данные будут извлечены из базы данных.

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

var student = _context.Students
   .Where(s => s.Name.Equals("John Doe"))
   .Select(s => new
   {
       s.Name,
       s.Age,
       Explanations = string.Join(",", s.Evaluations
           .Select(e => e.AdditionalExplanation))
   })
   .FirstOrDefault();

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

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

Необработанные команды SQL
В EF Core есть методы, которые можно использовать для написания необработанных команд SQL для извлечения данных из базы данных. Эти методы очень полезны, когда:

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


Метод FromSqlRaw
Этот метод позволяет нам добавлять необработанные команды sql в запросы EF Core:

var student = _context.Students
   .FromSqlRaw(@"SELECT * FROM Student WHERE Name = {0}", "John Doe")
   .FirstOrDefault();

Мы также можем вызывать хранимые процедуры из базы данных:

var student = _context.Students
   .FromSqlRaw("EXECUTE dbo.MyCustomProcedure")
   .ToList();

Метод FromSqlRaw - очень полезный метод, но он имеет некоторые ограничения:

  • Имена столбцов в нашем результате должны совпадать с именами столбцов, которым сопоставлены свойства.
  • Наш запрос должен возвращать данные для всех свойств объекта или типа запроса.
  • SQL-запрос не может содержать отношения, но мы всегда можем комбинировать FromSqlRaw с методом Include


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

var student = _context.Students
   .FromSqlRaw("SELECT * FROM Student WHERE Name = {0}", "John Doe")
   .Include(e => e.Evaluations)
   .FirstOrDefault();

Метод ExecuteSqlRaw

Метод ExecuteSqlRaw позволяет нам выполнять команды SQL, такие как Update, Insert, Delete. Давайте посмотрим, как мы можем это использовать:

var rowsAffected = _context.Database
   .ExecuteSqlRaw(
       @"UPDATE Student
         SET Age = {0} 
         WHERE Name = {1}", 29, "Mike Miles");
return Ok(new { RowsAffected = rowsAffected});

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

Очень важно отметить, что мы используем свойство Database для вызова этого метода, тогда как в предыдущем примере нам приходилось использовать свойство Student для FromSqlRaw метод.

Еще одна важная вещь, на которую следует обратить внимание: мы используем функцию интерполяции строк для запросов в методах FromSqlRaw и ExecuteSqlRaw, поскольку она позволяет нам чтобы поместить имя переменной в строку запроса, которую EF Core затем проверяет и преобразует в параметры. Эти параметры будут проверены, чтобы предотвратить атаки SQL-инъекций. Мы не должны использовать интерполяцию строк вне методов необработанных запросов EF Core, потому что в этом случае мы потеряем обнаружение атак Sql-инъекций.

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

var studentForUpdate = _context.Students
   .FirstOrDefault(s => s.Name.Equals("Mike Miles"));
var age = 28;
var rowsAffected = _context.Database
   .ExecuteSqlRaw(@"UPDATE Student 
                      SET Age = {0} 
                      WHERE Name = {1}", age, studentForUpdate.Name);
return Ok(new { RowsAffected = rowsAffected});

Как только мы выполним этот запрос, столбец Age изменится на 28, но давайте посмотрим, что произойдет с загруженным объектом studentForUpdate

Свойство Возраст не изменилось, хотя оно было изменено в базе данных. Конечно, это ожидаемое поведение.

Итак, теперь возникает вопрос: «Что, если мы хотим, чтобы это изменилось после выполнения метода ExecuteSqlRaw?».

Что ж, для этого нам нужно использовать метод Reload:

var rowsAffected = _context.Database
   .ExecuteSqlRaw(@"UPDATE Student 
                      SET Age = {0} 
                      WHERE Name = {1}", age, studentForUpdate.Name);
_context.Entry(studentForUpdate).Reload();

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

Заключение
Мы отлично поработали. Мы рассмотрели множество тем и много узнали о запросах в Entity Framework Core.

Итак, подводя итог, мы узнали:

  • Как работают запросы в EF Core
  • О разных типах запросов и о том, как использовать каждый из них.
  • Способ использования команд Raw SQL с различными методами EF Core

В следующей статье мы узнаем о EF Core методах, которые изменяют данные в БД.