Новые возможности EF Core 6.0

Версия EF Core 6.0 опубликована в NuGet. На этой странице представлен обзор интересных изменений, появившихся в этом выпуске.

Совет

Вы можете запустить и отладить весь код примеров, приведенных ниже, скачав пример кода из GitHub.

Временные таблицы SQL Server

Проблема, рассмотренная на сайте GitHub: № 4693.

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

Теперь EF Core поддерживает следующие возможности:

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

Настройка темпоральной таблицы

Для настройки таблицы в качестве темпоральной можно использовать построитель моделей. Например:

modelBuilder
    .Entity<Employee>()
    .ToTable("Employees", b => b.IsTemporal());

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

public class Employee
{
    public Guid EmployeeId { get; set; }
    public string Name { get; set; }
    public string Position { get; set; }
    public string Department { get; set; }
    public string Address { get; set; }
    public decimal AnnualSalary { get; set; }
}

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

DECLARE @historyTableSchema sysname = SCHEMA_NAME()
EXEC(N'CREATE TABLE [Employees] (
    [EmployeeId] uniqueidentifier NOT NULL,
    [Name] nvarchar(100) NULL,
    [Position] nvarchar(100) NULL,
    [Department] nvarchar(100) NULL,
    [Address] nvarchar(1024) NULL,
    [AnnualSalary] decimal(10,2) NOT NULL,
    [PeriodEnd] datetime2 GENERATED ALWAYS AS ROW END NOT NULL,
    [PeriodStart] datetime2 GENERATED ALWAYS AS ROW START NOT NULL,
    CONSTRAINT [PK_Employees] PRIMARY KEY ([EmployeeId]),
    PERIOD FOR SYSTEM_TIME([PeriodStart], [PeriodEnd])
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + N'].[EmployeeHistory]))');

Обратите внимание, что SQL Server создает два скрытых столбца datetime2 с именами PeriodEnd и PeriodStart. Эти столбцы периода представляют диапазон времени, в течение которого существовали данные в строке. Эти столбцы сопоставляются со свойствами тени в модели EF Core, что позволяет использовать их в запросах, как показано ниже.

Важно!

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

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

modelBuilder
    .Entity<Employee>()
    .ToTable(
        "Employees",
        b => b.IsTemporal(
            b =>
            {
                b.HasPeriodStart("ValidFrom");
                b.HasPeriodEnd("ValidTo");
                b.UseHistoryTable("EmployeeHistoricalData");
            }));

Это отражено в таблице, созданной SQL Server:

DECLARE @historyTableSchema sysname = SCHEMA_NAME()
EXEC(N'CREATE TABLE [Employees] (
    [EmployeeId] uniqueidentifier NOT NULL,
    [Name] nvarchar(100) NULL,
    [Position] nvarchar(100) NULL,
    [Department] nvarchar(100) NULL,
    [Address] nvarchar(1024) NULL,
    [AnnualSalary] decimal(10,2) NOT NULL,
    [ValidFrom] datetime2 GENERATED ALWAYS AS ROW START NOT NULL,
    [ValidTo] datetime2 GENERATED ALWAYS AS ROW END NOT NULL,
    CONSTRAINT [PK_Employees] PRIMARY KEY ([EmployeeId]),
    PERIOD FOR SYSTEM_TIME([ValidFrom], [ValidTo])
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [' + @historyTableSchema + N'].[EmployeeHistoricalData]))');

Использование темпоральных таблиц

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

context.AddRange(
    new Employee
    {
        Name = "Pinky Pie",
        Address = "Sugarcube Corner, Ponyville, Equestria",
        Department = "DevDiv",
        Position = "Party Organizer",
        AnnualSalary = 100.0m
    },
    new Employee
    {
        Name = "Rainbow Dash",
        Address = "Cloudominium, Ponyville, Equestria",
        Department = "DevDiv",
        Position = "Ponyville weather patrol",
        AnnualSalary = 900.0m
    },
    new Employee
    {
        Name = "Fluttershy",
        Address = "Everfree Forest, Equestria",
        Department = "DevDiv",
        Position = "Animal caretaker",
        AnnualSalary = 30.0m
    });

context.SaveChanges();

Затем эти данные можно запрашивать, обновлять и удалять в обычном порядке. Например:

var employee = context.Employees.Single(e => e.Name == "Rainbow Dash");
context.Remove(employee);
context.SaveChanges();

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

var employees = context.Employees.ToList();
foreach (var employee in employees)
{
    var employeeEntry = context.Entry(employee);
    var validFrom = employeeEntry.Property<DateTime>("ValidFrom").CurrentValue;
    var validTo = employeeEntry.Property<DateTime>("ValidTo").CurrentValue;

    Console.WriteLine($"  Employee {employee.Name} valid from {validFrom} to {validTo}");
}

Результат выполнения:

Starting data:
  Employee Pinky Pie valid from 8/26/2021 4:38:58 PM to 12/31/9999 11:59:59 PM
  Employee Rainbow Dash valid from 8/26/2021 4:38:58 PM to 12/31/9999 11:59:59 PM
  Employee Fluttershy valid from 8/26/2021 4:38:58 PM to 12/31/9999 11:59:59 PM

Обратите внимание, что столбец ValidTo (с именем PeriodEnd по умолчанию) содержит максимальное значение datetime2. Так всегда обстоит дело с текущими строками в таблице. Столбцы ValidFrom (с именем PeriodStart по умолчанию) содержат время вставки строки в формате UTC.

Выполнение запросов исторических данных

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

  • TemporalAsOf: возвращает строки, которые были активными (текущими) в заданное время в формате UTC. Это одна строка из текущей таблицы или таблицы журнала для данного первичного ключа.
  • TemporalAll: возвращает все строки в исторических данных. Обычно это множество строк из таблицы журнала и (или) текущей таблицы для данного первичного ключа.
  • TemporalFromTo: возвращает все строки, которые были активны между двумя заданными значениями времени в формате UTC. Это может быть множество строк из таблицы журнала и (или) текущей таблицы для данного первичного ключа.
  • TemporalBetween: то же, что и TemporalFromTo, за исключением того, что включаются строки, которые стали активными на верхней границе.
  • TemporalContainedIn: возвращает все строки, которые становились и переставали быть активными в период между двумя заданными значениями времени в формате UTC. Это может быть множество строк из таблицы журнала и (или) текущей таблицы для данного первичного ключа.

Примечание.

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

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

var history = context
    .Employees
    .TemporalAll()
    .Where(e => e.Name == "Rainbow Dash")
    .OrderBy(e => EF.Property<DateTime>(e, "ValidFrom"))
    .Select(
        e => new
        {
            Employee = e,
            ValidFrom = EF.Property<DateTime>(e, "ValidFrom"),
            ValidTo = EF.Property<DateTime>(e, "ValidTo")
        })
    .ToList();

foreach (var pointInTime in history)
{
    Console.WriteLine(
        $"  Employee {pointInTime.Employee.Name} was '{pointInTime.Employee.Position}' from {pointInTime.ValidFrom} to {pointInTime.ValidTo}");
}

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

Этот запрос возвращает следующие данные:

Historical data for Rainbow Dash:
  Employee Rainbow Dash was 'Ponyville weather patrol' from 8/26/2021 4:38:58 PM to 8/26/2021 4:40:29 PM
  Employee Rainbow Dash was 'Wonderbolt Trainee' from 8/26/2021 4:40:29 PM to 8/26/2021 4:41:59 PM
  Employee Rainbow Dash was 'Wonderbolt Reservist' from 8/26/2021 4:41:59 PM to 8/26/2021 4:43:29 PM
  Employee Rainbow Dash was 'Wonderbolt' from 8/26/2021 4:43:29 PM to 8/26/2021 4:44:59 PM

Обратите внимание, что последняя возвращенная строка была активна в 16:44:59 26.08.2021. Это обусловлено тем, что строка для Rainbow Dash в это время была удалена из основной таблицы. Далее будет показано, как можно восстановить эти данные.

Аналогичные запросы можно написать с помощью TemporalFromTo, TemporalBetween или TemporalContainedIn. Например:

var history = context
    .Employees
    .TemporalBetween(timeStamp2, timeStamp3)
    .Where(e => e.Name == "Rainbow Dash")
    .OrderBy(e => EF.Property<DateTime>(e, "ValidFrom"))
    .Select(
        e => new
        {
            Employee = e,
            ValidFrom = EF.Property<DateTime>(e, "ValidFrom"),
            ValidTo = EF.Property<DateTime>(e, "ValidTo")
        })
    .ToList();

Этот запрос возвращает следующие строки:

Historical data for Rainbow Dash between 8/26/2021 4:41:14 PM and 8/26/2021 4:42:44 PM:
  Employee Rainbow Dash was 'Wonderbolt Trainee' from 8/26/2021 4:40:29 PM to 8/26/2021 4:41:59 PM
  Employee Rainbow Dash was 'Wonderbolt Reservist' from 8/26/2021 4:41:59 PM to 8/26/2021 4:43:29 PM

Восстановление исторических данных

Как упоминалось выше, строка для Rainbow Dash была удалена из таблицы Employees. Это явно была ошибка, поэтому давайте вернемся к моменту времени и восстановим недостающую строку с того времени.

var employee = context
    .Employees
    .TemporalAsOf(timeStamp2)
    .Single(e => e.Name == "Rainbow Dash");

context.Add(employee);
context.SaveChanges();

Этот запрос возвращает одну строку для Rainbow Dash в том виде, в котором она была в заданное время в формате UTC. По умолчанию все запросы, использующие временные операторы, не отслеживаются, поэтому возвращенная сущность не отслеживается. Это имеет смысл, так как в настоящее время она не существует в основной таблице. Чтобы повторно вставить сущность в основную таблицу, просто пометьте ее как Added, а затем вызовите SaveChanges.

После повторной вставки строки Rainbow Dash запрос исторических данных показывает, что строка была восстановлена в том виде, в котором она была в заданное время в формате UTC.

Historical data for Rainbow Dash:
  Employee Rainbow Dash was 'Ponyville weather patrol' from 8/26/2021 4:38:58 PM to 8/26/2021 4:40:29 PM
  Employee Rainbow Dash was 'Wonderbolt Trainee' from 8/26/2021 4:40:29 PM to 8/26/2021 4:41:59 PM
  Employee Rainbow Dash was 'Wonderbolt Reservist' from 8/26/2021 4:41:59 PM to 8/26/2021 4:43:29 PM
  Employee Rainbow Dash was 'Wonderbolt' from 8/26/2021 4:43:29 PM to 8/26/2021 4:44:59 PM
  Employee Rainbow Dash was 'Wonderbolt Trainee' from 8/26/2021 4:44:59 PM to 12/31/9999 11:59:59 PM

Пакеты миграции

Проблема, рассмотренная на сайте GitHub: № 19693.

Миграции EF Core используются для создания обновлений схемы базы данных на основе изменений в модели EF. Эти обновления схемы следует применять во время развертывания приложения как часть системы непрерывной интеграции или непрерывного развертывания (CI/CD).

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

Примечание.

Более подробные сведения о миграции, пакетах и развертывании см. в статье о пакетах миграции EF Core в блоге по .NET.

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

Пакет должен включать в себя миграции. Они создаются с помощью dotnet ef migrations add, как описано в документации по миграциям. После того как миграции будут готовы к развертыванию, создайте пакет с помощью dotnet ef migrations bundle. Например:

PS C:\local\AllTogetherNow\SixOh> dotnet ef migrations bundle
Build started...
Build succeeded.
Building bundle...
Done. Migrations Bundle: C:\local\AllTogetherNow\SixOh\efbundle.exe
PS C:\local\AllTogetherNow\SixOh>

Выходные данные представляют собой исполняемый файл, подходящий для целевой операционной системы. В этом случае это Windows x64, поэтому efbundle.exe находится в локальной папке. При выполнении этого исполняемого файла применяются содержащиеся в нем миграции:

PS C:\local\AllTogetherNow\SixOh> .\efbundle.exe
Applying migration '20210903083845_MyMigration'.
Done.
PS C:\local\AllTogetherNow\SixOh>

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

PS C:\local\AllTogetherNow\SixOh> .\efbundle.exe
No migrations were applied. The database is already up to date.
Done.
PS C:\local\AllTogetherNow\SixOh>

Однако если в модель были внесены изменения, и с помощью dotnet ef migrations add созданы дополнительные миграции, они могут быть объединены в новый исполняемый файл и готовы к применению. Например:

PS C:\local\AllTogetherNow\SixOh> dotnet ef migrations add SecondMigration
Build started...
Build succeeded.
Done. To undo this action, use 'ef migrations remove'
PS C:\local\AllTogetherNow\SixOh> dotnet ef migrations add Number3
Build started...
Build succeeded.
Done. To undo this action, use 'ef migrations remove'
PS C:\local\AllTogetherNow\SixOh> dotnet ef migrations bundle --force
Build started...
Build succeeded.
Building bundle...
Done. Migrations Bundle: C:\local\AllTogetherNow\SixOh\efbundle.exe
PS C:\local\AllTogetherNow\SixOh>

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

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

PS C:\local\AllTogetherNow\SixOh> .\efbundle.exe
Applying migration '20210903084526_SecondMigration'.
Applying migration '20210903084538_Number3'.
Done.
PS C:\local\AllTogetherNow\SixOh>

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

PS C:\local\AllTogetherNow\SixOh> .\efbundle.exe --connection "Data Source=(LocalDb)\MSSQLLocalDB;Database=SixOhProduction"
Applying migration '20210903083845_MyMigration'.
Applying migration '20210903084526_SecondMigration'.
Applying migration '20210903084538_Number3'.
Done.
PS C:\local\AllTogetherNow\SixOh>

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

Другие параметры можно передать командной строке. Ниже приведены некоторые распространенные параметры.

  • --output — для указания пути создаваемого исполняемого файла.
  • --context — для указания типа DbContext, используемого в случае, если проект содержит несколько типов контекста.
  • --project — для указания используемого проекта. По умолчанию используется текущий рабочий каталог.
  • --startup-project — для указания используемого автозагружаемого проекта. По умолчанию используется текущий рабочий каталог.
  • --no-build — для предотвращения построения проекта перед выполнением команды. Этот параметр следует использовать, только если известно, что проект является актуальным.
  • --verbose — для просмотра подробных сведений о том, что делает команда. Используйте этот параметр при включении сведений в отчеты об ошибках.

Чтобы просмотреть все доступные параметры, воспользуйтесь dotnet ef migrations bundle --help.

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

Конфигурация модели с предварительным соглашением

Проблема, рассмотренная на сайте GitHub: № 12229.

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

Для этого требовалось:

  • Настройка построителя моделей для каждого свойства
  • Атрибут сопоставления для каждого свойства
  • Явная итерация по всем свойствам всех типов сущностей и использование низкоуровневых API метаданных при построении модели.

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

EF Core 6.0 позволяет указать эту конфигурацию сопоставления один раз для данного типа. Затем она будет применена ко всем свойствам этого типа в модели. Это называется "конфигурацией модели с предварительным соглашением", так при этом настраиваются аспекты модели, которые затем используются соглашениями о построении модели. Такая конфигурация применяется путем переопределения ConfigureConventions в DbContext:

public class SomeDbContext : DbContext
{
    protected override void ConfigureConventions(
        ModelConfigurationBuilder configurationBuilder)
    {
        // Pre-convention model configuration goes here
    }
}

Рассмотрим следующие типы сущностей:

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public bool IsActive { get; set; }
    public Money AccountValue { get; set; }

    public Session CurrentSession { get; set; }

    public ICollection<Order> Orders { get; } = new List<Order>();
}

public class Order
{
    public int Id { get; set; }
    public string SpecialInstructions { get; set; }
    public DateTime OrderDate { get; set; }
    public bool IsComplete { get; set; }
    public Money Price { get; set; }
    public Money? Discount { get; set; }

    public Customer Customer { get; set; }
}

Все свойства строк можно настроить в формате ANSI (вместо Unicode) с максимальной длиной, составляющей 1024:

configurationBuilder
    .Properties<string>()
    .AreUnicode(false)
    .HaveMaxLength(1024);

Все свойства DateTime можно преобразовать в 64-разрядные целые числа в базе данных, используя преобразование по умолчанию из типа DateTime в тип long:

configurationBuilder
    .Properties<DateTime>()
    .HaveConversion<long>();

Все свойства типа bool можно преобразовать в типы integer 0 или 1, используя один из встроенных преобразователей величин:

configurationBuilder
    .Properties<bool>()
    .HaveConversion<BoolToZeroOneConverter<int>>();

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

configurationBuilder
    .IgnoreAny<Session>();

Конфигурация модели с предварительным соглашением очень полезна при работе с объектами значений. Например, тип Money в приведенной выше модели представлен структурой только для чтения:

public readonly struct Money
{
    [JsonConstructor]
    public Money(decimal amount, Currency currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public override string ToString()
        => (Currency == Currency.UsDollars ? "$" : "£") + Amount;

    public decimal Amount { get; }
    public Currency Currency { get; }
}

public enum Currency
{
    UsDollars,
    PoundsSterling
}

Затем он сериализуется в JSON и обратно с помощью преобразователя пользовательских величин:

public class MoneyConverter : ValueConverter<Money, string>
{
    public MoneyConverter()
        : base(
            v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
            v => JsonSerializer.Deserialize<Money>(v, (JsonSerializerOptions)null))
    {
    }
}

Этот преобразователь величин можно настроить один раз для всех использований сущности Money:

configurationBuilder
    .Properties<Money>()
    .HaveConversion<MoneyConverter>()
    .HaveMaxLength(64);

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

Таблицы, созданные для SQL Server с использованием миграций, показывают, каким образом конфигурация была применена ко всем сопоставленным столбцам:

CREATE TABLE [Customers] (
    [Id] int NOT NULL IDENTITY,
    [Name] varchar(1024) NULL,
    [IsActive] int NOT NULL,
    [AccountValue] nvarchar(64) NOT NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);
CREATE TABLE [Order] (
    [Id] int NOT NULL IDENTITY,
    [SpecialInstructions] varchar(1024) NULL,
    [OrderDate] bigint NOT NULL,
    [IsComplete] int NOT NULL,
    [Price] nvarchar(64) NOT NULL,
    [Discount] nvarchar(64) NULL,
    [CustomerId] int NULL,
    CONSTRAINT [PK_Order] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Order_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id])
);

Можно также указать сопоставление типов по умолчанию для заданного типа. Например:

configurationBuilder
    .DefaultTypeMapping<string>()
    .IsUnicode(false);

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

Примечание.

Дополнительные сведения и примеры конфигурации модели с предварительным соглашением см. в публикации с объявлением об Entity Framework Core 6.0, предварительная версия 6: настройка соглашений в блоге по .NET.

Скомпилированные модели

Проблема, рассмотренная на сайте GitHub: № 1906.

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

Время запуска — это время выполнения первой операции с DbContext, когда этот тип DbContext используется в приложении в первый раз. Обратите внимание, что простое создание экземпляра DbContext не приводит к инициализации модели EF. Стандартные первые операции, которые приводят к инициализации модели, включают вызов DbContext.Add или выполнение первого запроса.

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

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

dotnet ef dbcontext optimize

Параметры --output-dir и --namespace можно использовать для указания каталога и пространства имен, в которых будет создаваться скомпилированная модель. Например:

PS C:\dotnet\efdocs\samples\core\Miscellaneous\CompiledModels> dotnet ef dbcontext optimize --output-dir MyCompiledModels --namespace MyCompiledModels
Build started...
Build succeeded.
Successfully generated a compiled model, to use it call 'options.UseModel(MyCompiledModels.BlogsContextModel.Instance)'. Run this command again when the model is modified.
PS C:\dotnet\efdocs\samples\core\Miscellaneous\CompiledModels>

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

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseModel(MyCompiledModels.BlogsContextModel.Instance)
        .UseSqlite(@"Data Source=test.db");

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

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

[DbContext(typeof(BlogsContext))]
partial class BlogsContextModel : RuntimeModel
{
    private static BlogsContextModel _instance;
    public static IModel Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new BlogsContextModel();
                _instance.Initialize();
                _instance.Customize();
            }

            return _instance;
        }
    }

    partial void Initialize();

    partial void Customize();
}

Это разделяемый класс с разделяемыми методами, которые можно реализовать для настройки модели по мере необходимости.

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

public static class RuntimeModelCache
{
    private static readonly ConcurrentDictionary<string, IModel> _runtimeModels
        = new();

    public static IModel GetOrCreateModel(string connectionString)
        => _runtimeModels.GetOrAdd(
            connectionString, cs =>
            {
                if (cs.Contains("X"))
                {
                    return BlogsContextModel1.Instance;
                }

                if (cs.Contains("Y"))
                {
                    return BlogsContextModel2.Instance;
                }

                throw new InvalidOperationException("No appropriate compiled model found.");
            });
}

Ограничения

У скомпилированных моделей есть некоторые ограничения:

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

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

Тесты производительности

Совет

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

Модель в репозитории GitHub, упомянутая выше, содержит 449 типов сущностей, 6390 свойств и 720 связей. Это довольно большая модель. При использовании BenchmarkDotNet для изменения среднее время выполнения первого запроса составляет 1,02 секунды на достаточно мощном ноутбуке. Благодаря скомпилированным моделям этот показатель снижается до 117 миллисекунд на том же оборудовании. При увеличении размера модели подобное 8–10-кратное улучшение остается относительно постоянным.

Compiled model performance improvement

Примечание.

Более подробное описание производительности запуска EF Core и скомпилированных моделей см. в публикации с объявлением о выходе Entity Framework Core 6.0, предварительная версия 5: скомпилированные модели в блоге по .NET.

Улучшенная производительность по результатам теста TechEmpower Fortunes

Проблема, рассмотренная на сайте GitHub: № 23611.

Мы значительно улучшили производительность запросов для EF Core 6.0. В частности:

  • Теперь согласно результатам отраслевого теста TechEmpower Fortunes достигнуто 70-процентное повышение производительности EF Core 6.0 по сравнению с версией 5.0.
    • Это полномасштабное улучшение производительности, включая улучшения в коде теста производительности, среде выполнения .NET и т. д.
  • EF Core 6.0 на 31 % быстрее выполняет неотслеживаемые запросы.
  • При выполнении запросов выделения кучи уменьшились на 43 %.

После этих улучшений разрыв между популярной библиотекой "микро-ORM" Dapper и EF Core в тесте производительности TechEmpower Fortunes сократился с 55 % до чуть менее 5 %.

Примечание.

Подробное описание улучшений производительности запросов в EF Core 6.0 см. в публикации с объявлением о выходе Entity Framework Core 6.0, предварительная версия 4: производительность в блоге по .NET.

Улучшения поставщика Azure Cosmos DB

EF Core 6.0 содержит множество улучшений для поставщика базы данных Azure Cosmos DB.

Совет

Вы можете запустить и отладить весь код примеров для Cosmos, скачав пример кода из GitHub.

Преобразование владения по умолчанию в неявное

Проблема, рассмотренная на сайте GitHub: № 24803.

При создании модели для поставщика Azure Cosmos DB EF Core 6.0 помечает типы дочерних сущностей как принадлежащие родительской сущности по умолчанию. Это устраняет необходимость в большей OwnsMany части вызовов OwnsOne в модели Azure Cosmos DB. Это упрощает внедрение дочерних типов в документ для родительского типа, который обычно является соответствующим способом моделирования родительских и дочерних элементов в базе данных документов.

Например, рассмотрим такие типы сущностей:

public class Family
{
    [JsonPropertyName("id")]
    public string Id { get; set; }
    
    public string LastName { get; set; }
    public bool IsRegistered { get; set; }
    
    public Address Address { get; set; }

    public IList<Parent> Parents { get; } = new List<Parent>();
    public IList<Child> Children { get; } = new List<Child>();
}

public class Parent
{
    public string FamilyName { get; set; }
    public string FirstName { get; set; }
}

public class Child
{
    public string FamilyName { get; set; }
    public string FirstName { get; set; }
    public int Grade { get; set; }

    public string Gender { get; set; }

    public IList<Pet> Pets { get; } = new List<Pet>();
}

В EF Core 5.0 эти типы моделировались бы для Azure Cosmos DB со следующей конфигурацией:

modelBuilder.Entity<Family>()
    .HasPartitionKey(e => e.LastName)
    .OwnsMany(f => f.Parents);

modelBuilder.Entity<Family>()
    .OwnsMany(f => f.Children)
    .OwnsMany(c => c.Pets);

modelBuilder.Entity<Family>()
    .OwnsOne(f => f.Address);        

В EF Core 6.0 владение является неявным, что сокращает конфигурацию модели до следующего:

modelBuilder.Entity<Family>().HasPartitionKey(e => e.LastName);

Полученные документы Azure Cosmos DB содержат родители семьи, дети, домашние животные и адрес, внедренные в семейный документ. Например:

{
  "Id": "Wakefield.7",
  "LastName": "Wakefield",
  "Discriminator": "Family",
  "IsRegistered": true,
  "id": "Family|Wakefield.7",
  "Address": {
    "City": "NY",
    "County": "Manhattan",
    "State": "NY"
  },
  "Children": [
    {
      "FamilyName": "Merriam",
      "FirstName": "Jesse",
      "Gender": "female",
      "Grade": 8,
      "Pets": [
        {
          "GivenName": "Goofy"
        },
        {
          "GivenName": "Shadow"
        }
      ]
    },
    {
      "FamilyName": "Miller",
      "FirstName": "Lisa",
      "Gender": "female",
      "Grade": 1,
      "Pets": []
    }
  ],
  "Parents": [
    {
      "FamilyName": "Wakefield",
      "FirstName": "Robin"
    },
    {
      "FamilyName": "Miller",
      "FirstName": "Ben"
    }
  ],
  "_rid": "x918AKh6p20CAAAAAAAAAA==",
  "_self": "dbs/x918AA==/colls/x918AKh6p20=/docs/x918AKh6p20CAAAAAAAAAA==/",
  "_etag": "\"00000000-0000-0000-adee-87f30c8c01d7\"",
  "_attachments": "attachments/",
  "_ts": 1632121802
}

Примечание.

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

Коллекции примитивных типов

Проблема, рассмотренная на сайте GitHub: № 14762.

EF Core 6.0 изначально сопоставляет коллекции примитивных типов при использовании поставщика базы данных Azure Cosmos DB. Например, рассмотрим следующий тип сущности:

public class Book
{
    public Guid Id { get; set; }
    public string Title { get; set; }
    public IList<string> Quotes { get; set; }
    public IDictionary<string, string> Notes { get; set; }
}

И список, и словарь могут быть заполнены и вставлены в базу данных обычным способом:

using var context = new BooksContext();

var book = new Book
{
    Title = "How It Works: Incredible History",
    Quotes = new List<string>
    {
        "Thomas (Tommy) Flowers was the British engineer behind the design of the Colossus computer.",
        "Invented originally for Guinness, plastic widgets are nitrogen-filled spheres.",
        "For 20 years after its introduction in 1979, the Walkman dominated the personal stereo market."
    },
    Notes = new Dictionary<string, string>
    {
        { "121", "Fridges" },
        { "144", "Peter Higgs" },
        { "48", "Saint Mark's Basilica" },
        { "36", "The Terracotta Army" }
    }
};

context.Add(book);
context.SaveChanges();

В результате будет получен следующий документ JSON:

{
    "Id": "0b32283e-22a8-4103-bb4f-6052604868bd",
    "Discriminator": "Book",
    "Notes": {
        "36": "The Terracotta Army",
        "48": "Saint Mark's Basilica",
        "121": "Fridges",
        "144": "Peter Higgs"
    },
    "Quotes": [
        "Thomas (Tommy) Flowers was the British engineer behind the design of the Colossus computer.",
        "Invented originally for Guinness, plastic widgets are nitrogen-filled spheres.",
        "For 20 years after its introduction in 1979, the Walkman dominated the personal stereo market."
    ],
    "Title": "How It Works: Incredible History",
    "id": "Book|0b32283e-22a8-4103-bb4f-6052604868bd",
    "_rid": "t-E3AIxaencBAAAAAAAAAA==",
    "_self": "dbs/t-E3AA==/colls/t-E3AIxaenc=/docs/t-E3AIxaencBAAAAAAAAAA==/",
    "_etag": "\"00000000-0000-0000-9b50-fc769dc901d7\"",
    "_attachments": "attachments/",
    "_ts": 1630075016
}

Затем эти коллекции можно обновить обычным способом:

book.Quotes.Add("Pressing the emergency button lowered the rods again.");
book.Notes["48"] = "Chiesa d'Oro";

context.SaveChanges();

Ограничения:

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

Преобразования во встроенные функции

Проблема, рассмотренная на сайте GitHub: № 16143.

Теперь поставщик Azure Cosmos DB преобразует больше методов библиотеки базовых классов (BCL) в встроенные функции Azure Cosmos DB. В следующих таблицах показаны преобразования, впервые представленные в EF Core 6.0.

Преобразования строк

Метод BCL Встроенная функция Примечания.
String.Length LENGTH
String.ToLower LOWER
String.TrimStart LTRIM
String.TrimEnd RTRIM
String.Trim TRIM
String.ToUpper UPPER
String.Substring SUBSTRING
Оператор + CONCAT
String.IndexOf INDEX_OF
String.Replace REPLACE
String.Equals STRINGEQUAL Только вызовы без учета регистра

Преобразования для LOWER, LTRIM, RTRIM, TRIM, UPPER и SUBSTRING представлены пользователем @Marusyk. Спасибо!

Пример:

var stringResults = await context.Triangles.Where(
        e => e.Name.Length > 4
             && e.Name.Trim().ToLower() != "obtuse"
             && e.Name.TrimStart().Substring(2, 2).Equals("uT", StringComparison.OrdinalIgnoreCase))
    .ToListAsync();

Что преобразуется в:

SELECT c
FROM root c
WHERE ((c["Discriminator"] = "Triangle") AND (((LENGTH(c["Name"]) > 4) AND (LOWER(TRIM(c["Name"])) != "obtuse")) AND STRINGEQUALS(SUBSTRING(LTRIM(c["Name"]), 2, 2), "uT", true)))

Математические преобразования

Метод BCL Встроенная функция
Math.Abs или MathF.Abs ABS
Math.Acos или MathF.Acos ACOS
Math.Asin или MathF.Asin ASIN
Math.Atan или MathF.Atan ATAN
Math.Atan2 или MathF.Atan2 ATN2
Math.Ceiling или MathF.Ceiling CEILING
Math.Cos или MathF.Cos COS
Math.Exp или MathF.Exp EXP
Math.Floor или MathF.Floor FLOOR
Math.Log или MathF.Log LOG
Math.Log10 или MathF.Log10 LOG10
Math.Pow или MathF.Pow POWER
Math.Round или MathF.Round ROUND
Math.Sign или MathF.Sign SIGN
Math.Sin или MathF.Sin SIN
Math.Sqrt или MathF.Sqrt SQRT
Math.Tan или MathF.Tan TAN
Math.Truncate или MathF.Truncate TRUNC
DbFunctions.Random RAND

Эти переводы были представлены пользователем @Marusyk. Спасибо!

Пример:

var hypotenuse = 42.42;
var mathResults = await context.Triangles.Where(
        e => (Math.Round(e.Angle1) == 90.0
              || Math.Round(e.Angle2) == 90.0)
             && (hypotenuse * Math.Sin(e.Angle1) > 30.0
                 || hypotenuse * Math.Cos(e.Angle2) > 30.0))
    .ToListAsync();

Что преобразуется в:

SELECT c
FROM root c
WHERE ((c["Discriminator"] = "Triangle") AND (((ROUND(c["Angle1"]) = 90.0) OR (ROUND(c["Angle2"]) = 90.0)) AND (((@__hypotenuse_0 * SIN(c["Angle1"])) > 30.0) OR ((@__hypotenuse_0 * COS(c["Angle2"])) > 30.0))))

Преобразования даты и времени

Метод BCL Встроенная функция
DateTime.UtcNow GetCurrentDateTime

Эти переводы были представлены пользователем @Marusyk. Спасибо!

Пример:

var timeResults = await context.Triangles.Where(
        e => e.InsertedOn <= DateTime.UtcNow)
    .ToListAsync();

Что преобразуется в:

SELECT c
FROM root c
WHERE ((c["Discriminator"] = "Triangle") AND (c["InsertedOn"] <= GetCurrentDateTime()))

Необработанные SQL-запросы с FromSql

Проблема, рассмотренная на сайте GitHub: № 17311.

Иногда вместо использования LINQ необходимо выполнить необработанный SQL-запрос. Теперь это поддерживается поставщиком Azure Cosmos DB с помощью FromSql метода. Запрос выполняется так же, как и всегда с поставщиками реляционных баз данных. Например:

var maxAngle = 60;
var results = await context.Triangles.FromSqlRaw(
        @"SELECT * FROM root c WHERE c[""Angle1""] <= {0} OR c[""Angle2""] <= {0}", maxAngle)
    .ToListAsync();

Выполнение осуществляется следующим образом:

SELECT c
FROM (
    SELECT * FROM root c WHERE c["Angle1"] <= @p0 OR c["Angle2"] <= @p0
) c

Уникальные запросы

Проблема, рассмотренная на сайте GitHub: № 16144.

Теперь можно преобразовывать простые запросы, использующие Distinct. Например:

var distinctResults = await context.Triangles
    .Select(e => e.Angle1).OrderBy(e => e).Distinct()
    .ToListAsync();

Что преобразуется в:

SELECT DISTINCT c["Angle1"]
FROM root c
WHERE (c["Discriminator"] = "Triangle")
ORDER BY c["Angle1"]

Диагностика

Проблема, рассмотренная на сайте GitHub: № 17298.

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

Примечание.

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

Вставка элемента в базу данных Azure Cosmos DB создает CosmosEventId.ExecutedCreateItem событие. Например, этот код:

var triangle = new Triangle
{
    Name = "Impossible",
    PartitionKey = "TrianglesPartition",
    Angle1 = 90,
    Angle2 = 90,
    InsertedOn = DateTime.UtcNow
};
context.Add(triangle);
context.SaveChanges();

регистрирует следующее диагностическое событие:

info: 8/30/2021 14:41:13.356 CosmosEventId.ExecutedCreateItem[30104] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed CreateItem (5 ms, 7.43 RU) ActivityId='417db46f-fcdd-49d9-a7f0-77210cd06f84', Container='Shapes', Id='Impossible', Partition='TrianglesPartition'

Получение элементов из базы данных Azure Cosmos DB с помощью запроса создает CosmosEventId.ExecutingSqlQuery событие, а затем одно или несколько CosmosEventId.ExecutedReadNext событий для чтения элементов. Например, этот код:

var equilateral = context.Triangles.Single(e => e.Name == "Equilateral");

регистрирует следующие диагностические события:

info: 8/30/2021 14:41:13.475 CosmosEventId.ExecutingSqlQuery[30100] (Microsoft.EntityFrameworkCore.Database.Command)
      Executing SQL query for container 'Shapes' in partition '(null)' [Parameters=[]]
      SELECT c
      FROM root c
      WHERE ((c["Discriminator"] = "Triangle") AND (c["id"] = "Equilateral"))
      OFFSET 0 LIMIT 2
info: 8/30/2021 14:41:13.651 CosmosEventId.ExecutedReadNext[30102] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed ReadNext (169.6126 ms, 2.93 RU) ActivityId='4e465fae-3d49-4c1f-bd04-142bc5d0b0a1', Container='Shapes', Partition='(null)', Parameters=[]
      SELECT c
      FROM root c
      WHERE ((c["Discriminator"] = "Triangle") AND (c["id"] = "Equilateral"))
      OFFSET 0 LIMIT 2

Получение одного элемента из базы данных Azure Cosmos DB с Find помощью ключа секции создает CosmosEventId.ExecutingReadItem и CosmosEventId.ExecutedReadItem события. Например, этот код:

var isosceles = context.Triangles.Find("Isosceles", "TrianglesPartition");

регистрирует следующие диагностические события:

info: 8/30/2021 14:53:39.326 CosmosEventId.ExecutingReadItem[30101] (Microsoft.EntityFrameworkCore.Database.Command)
      Reading resource 'Isosceles' item from container 'Shapes' in partition 'TrianglesPartition'.
info: 8/30/2021 14:53:39.330 CosmosEventId.ExecutedReadItem[30103] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed ReadItem (1 ms, 1 RU) ActivityId='3c278643-4e7f-4bb2-9953-6055b5f1288f', Container='Shapes', Id='Isosceles', Partition='TrianglesPartition'

Сохранение обновленного элемента в базе данных Azure Cosmos DB создает CosmosEventId.ExecutedReplaceItem событие. Например, этот код:

triangle.Angle2 = 89;
context.SaveChanges();

регистрирует следующее диагностическое событие:

info: 8/30/2021 14:53:39.343 CosmosEventId.ExecutedReplaceItem[30105] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed ReplaceItem (6 ms, 10.67 RU) ActivityId='1525b958-fea1-49e8-89f9-d429d0351fdb', Container='Shapes', Id='Impossible', Partition='TrianglesPartition'

Удаление элемента из базы данных Azure Cosmos DB создает CosmosEventId.ExecutedDeleteItem событие. Например, этот код:

context.Remove(triangle);
context.SaveChanges();

регистрирует следующее диагностическое событие:

info: 8/30/2021 14:53:39.359 CosmosEventId.ExecutedDeleteItem[30106] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DeleteItem (6 ms, 7.43 RU) ActivityId='cbc54463-405b-48e7-8c32-2c6502a4138f', Container='Shapes', Id='Impossible', Partition='TrianglesPartition'

Настройка пропускной способности

Проблема, рассмотренная на сайте GitHub: № 17301.

Модель Azure Cosmos DB теперь можно настроить с помощью ручной или автоматической пропускной способности. Эти значения подготавливают пропускную способность для базы данных. Например:

modelBuilder.HasManualThroughput(2000);
modelBuilder.HasAutoscaleThroughput(4000);

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

modelBuilder.Entity<Family>(
    entityTypeBuilder =>
    {
        entityTypeBuilder.HasManualThroughput(5000);
        entityTypeBuilder.HasAutoscaleThroughput(3000);
    });

Настройка срока жизни

Проблема, рассмотренная на сайте GitHub: № 17307.

Типы сущностей в модели Azure Cosmos DB теперь можно настроить с использованием времени и времени жизни по умолчанию для аналитического хранилища. Например:

modelBuilder.Entity<Family>(
    entityTypeBuilder =>
    {
        entityTypeBuilder.HasDefaultTimeToLive(100);
        entityTypeBuilder.HasAnalyticalStoreTimeToLive(200);
    });

Разрешение фабрики HTTP-клиента

Проблема, рассмотренная на сайте GitHub: № 21274. Эта функция представлена пользователем @dnperfors. Спасибо!

Теперь HttpClientFactory можно явно задать используемый поставщиком Azure Cosmos DB. Это может быть особенно полезно во время тестирования, например для обхода проверки сертификатов при использовании эмулятора Azure Cosmos DB в Linux:

optionsBuilder
    .EnableSensitiveDataLogging()
    .UseCosmos(
        "https://localhost:8081",
        "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==",
        "PrimitiveCollections",
        cosmosOptionsBuilder =>
        {
            cosmosOptionsBuilder.HttpClientFactory(
                () => new HttpClient(
                    new HttpClientHandler
                    {
                        ServerCertificateCustomValidationCallback =
                            HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
                    }));
        });

Примечание.

Подробный пример применения улучшений поставщика поставщика Azure Cosmos DB для Azure Cosmos DB в блоге .NET см. в подробном примере применения усовершенствований поставщика Azure Cosmos DB к существующему приложению.

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

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

Формирование шаблонов связей "многие ко многим"

Проблема, рассмотренная на сайте GitHub: № 22475.

EF Core 6.0 обнаруживает простые таблицы объединений и автоматически создает для них сопоставление "многие ко многим". Например, рассмотрим таблицы для Posts и Tags, а также таблицу объединения PostTag, соединяющую их:

CREATE TABLE [Tags] (
  [Id] int NOT NULL IDENTITY,
  [Name] nvarchar(max) NOT NULL,
  [Description] nvarchar(max) NULL,
  CONSTRAINT [PK_Tags] PRIMARY KEY ([Id]));

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    [Contents] nvarchar(max) NOT NULL,
    [PostedOn] datetime2 NOT NULL,
    [UpdatedOn] datetime2 NULL,
    CONSTRAINT [PK_Posts] PRIMARY KEY ([Id]));

CREATE TABLE [PostTag] (
    [PostsId] int NOT NULL,
    [TagsId] int NOT NULL,
    CONSTRAINT [PK_PostTag] PRIMARY KEY ([PostsId], [TagsId]),
    CONSTRAINT [FK_PostTag_Posts_TagsId] FOREIGN KEY ([TagsId]) REFERENCES [Tags] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_PostTag_Tags_PostsId] FOREIGN KEY ([PostsId]) REFERENCES [Posts] ([Id]) ON DELETE CASCADE);

Сформировать шаблоны для этих таблиц можно из командной строки. Например:

dotnet ef dbcontext scaffold "Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=BloggingWithNRTs" Microsoft.EntityFrameworkCore.SqlServer

В результате создается класс для Post:

public partial class Post
{
    public Post()
    {
        Tags = new HashSet<Tag>();
    }

    public int Id { get; set; }
    public string Title { get; set; } = null!;
    public string Contents { get; set; } = null!;
    public DateTime PostedOn { get; set; }
    public DateTime? UpdatedOn { get; set; }
    public int BlogId { get; set; }

    public virtual Blog Blog { get; set; } = null!;

    public virtual ICollection<Tag> Tags { get; set; }
}

И класса для Tag:

public partial class Tag
{
    public Tag()
    {
        Posts = new HashSet<Post>();
    }

    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? Description { get; set; }

    public virtual ICollection<Post> Posts { get; set; }
}

Но для таблицы PostTag не существует класса. Вместо этого конфигурация для связи "многие ко многим" формируется следующим образом:

entity.HasMany(d => d.Tags)
    .WithMany(p => p.Posts)
    .UsingEntity<Dictionary<string, object>>(
        "PostTag",
        l => l.HasOne<Tag>().WithMany().HasForeignKey("PostsId"),
        r => r.HasOne<Post>().WithMany().HasForeignKey("TagsId"),
        j =>
            {
                j.HasKey("PostsId", "TagsId");
                j.ToTable("PostTag");
                j.HasIndex(new[] { "TagsId" }, "IX_PostTag_TagsId");
            });

Формирование шаблонов для ссылочных типов C#, допускающих значение NULL

Проблема, рассмотренная на сайте GitHub: № 15520.

EF Core 6.0 теперь формирует модель EF и типы сущностей, которые используют ссылочные типы C#, допускающие значение NULL (NRT). Использование NRT формируется автоматически при включении поддержки NRT в проекте C#, в котором выполняется формирование кода.

Например, в следующей таблице Tags содержатся как строковые столбцы, допускающие значения NULL, так и строковые столбцы, не допускающие значения NULL.

CREATE TABLE [Tags] (
  [Id] int NOT NULL IDENTITY,
  [Name] nvarchar(max) NOT NULL,
  [Description] nvarchar(max) NULL,
  CONSTRAINT [PK_Tags] PRIMARY KEY ([Id]));

Это приводит к формированию соответствующих свойств строки, допускающих значения NULL и не допускающих значения NULL, в созданном классе:

public partial class Tag
{
    public Tag()
    {
        Posts = new HashSet<Post>();
    }

    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? Description { get; set; }

    public virtual ICollection<Post> Posts { get; set; }
}

Аналогичным образом следующие таблицы Posts содержат необходимую связь с Blogs таблицей:

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    [Contents] nvarchar(max) NOT NULL,
    [PostedOn] datetime2 NOT NULL,
    [UpdatedOn] datetime2 NULL,
    [BlogId] int NOT NULL,
    CONSTRAINT [PK_Posts] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id]));

Это приводит к формированию шаблона связи между блогами, не допускающими значения NULL (обязательно).

public partial class Blog
{
    public Blog()
    {
        Posts = new HashSet<Post>();
    }

    public int Id { get; set; }
    public string Name { get; set; } = null!;

    public virtual ICollection<Post> Posts { get; set; }
}

И публикации:

public partial class Post
{
    public Post()
    {
        Tags = new HashSet<Tag>();
    }

    public int Id { get; set; }
    public string Title { get; set; } = null!;
    public string Contents { get; set; } = null!;
    public DateTime PostedOn { get; set; }
    public DateTime? UpdatedOn { get; set; }
    public int BlogId { get; set; }

    public virtual Blog Blog { get; set; } = null!;

    public virtual ICollection<Tag> Tags { get; set; }
}

Наконец, свойства DbSet в созданном DbContext создаются с помощью NRT. Например:

public virtual DbSet<Blog> Blogs { get; set; } = null!;
public virtual DbSet<Post> Posts { get; set; } = null!;
public virtual DbSet<Tag> Tags { get; set; } = null!;

Комментарии к базе данных преобразуются в комментарии к коду

Проблема, рассмотренная на сайте GitHub: #19113. Эта функция представлена пользователем @ErikEJ. Спасибо!

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

/// <summary>
/// The Blog table.
/// </summary>
public partial class Blog
{
    /// <summary>
    /// The primary key.
    /// </summary>
    [Key]
    public int Id { get; set; }
}

Усовершенствования запросов LINQ

EF Core 6.0 включает несколько улучшений в преобразовании и выполнении запросов LINQ.

Улучшенная поддержка GroupBy

Проблемы, рассмотренные на сайте GitHub: № 12088, № 13805 и № 22609.

EF Core 6.0 содержит улучшенную поддержку запросов GroupBy. В частности в EF Core теперь имеется следующее:

  • Преобразование GroupBy, за которым следует FirstOrDefault (или аналогичный) поверх группы
  • Поддержка выбора N лучших результатов из группы
  • Развертывание навигации после применения оператора GroupBy

Ниже приведены примеры запросов из отчетов клиента и их преобразования в SQL Server.

Пример 1:

var people = context.People
    .Include(e => e.Shoes)
    .GroupBy(e => e.FirstName)
    .Select(
        g => g.OrderBy(e => e.FirstName)
            .ThenBy(e => e.LastName)
            .FirstOrDefault())
    .ToList();
SELECT [t0].[Id], [t0].[Age], [t0].[FirstName], [t0].[LastName], [t0].[MiddleInitial], [t].[FirstName], [s].[Id], [s].[Age], [s].[PersonId], [s].[Style]
FROM (
    SELECT [p].[FirstName]
    FROM [People] AS [p]
    GROUP BY [p].[FirstName]
) AS [t]
LEFT JOIN (
    SELECT [t1].[Id], [t1].[Age], [t1].[FirstName], [t1].[LastName], [t1].[MiddleInitial]
    FROM (
        SELECT [p0].[Id], [p0].[Age], [p0].[FirstName], [p0].[LastName], [p0].[MiddleInitial], ROW_NUMBER() OVER(PARTITION BY [p0].[FirstName] ORDER BY [p0].[FirstName], [p0].[LastName]) AS [row]
        FROM [People] AS [p0]
    ) AS [t1]
    WHERE [t1].[row] <= 1
) AS [t0] ON [t].[FirstName] = [t0].[FirstName]
LEFT JOIN [Shoes] AS [s] ON [t0].[Id] = [s].[PersonId]
ORDER BY [t].[FirstName], [t0].[FirstName]

Пример 2:

var group = context.People
    .Select(
        p => new
        {
            p.FirstName,
            FullName = p.FirstName + " " + p.MiddleInitial + " " + p.LastName
        })
    .GroupBy(p => p.FirstName)
    .Select(g => g.First())
    .First();
SELECT [t0].[FirstName], [t0].[FullName], [t0].[c]
FROM (
    SELECT TOP(1) [p].[FirstName]
    FROM [People] AS [p]
    GROUP BY [p].[FirstName]
) AS [t]
LEFT JOIN (
    SELECT [t1].[FirstName], [t1].[FullName], [t1].[c]
    FROM (
        SELECT [p0].[FirstName], (((COALESCE([p0].[FirstName], N'') + N' ') + COALESCE([p0].[MiddleInitial], N'')) + N' ') + COALESCE([p0].[LastName], N'') AS [FullName], 1 AS [c], ROW_NUMBER() OVER(PARTITION BY [p0].[FirstName] ORDER BY [p0].[FirstName]) AS [row]
        FROM [People] AS [p0]
    ) AS [t1]
    WHERE [t1].[row] <= 1
) AS [t0] ON [t].[FirstName] = [t0].[FirstName]

Пример 3:

var people = context.People
    .Where(e => e.MiddleInitial == "Q" && e.Age == 20)
    .GroupBy(e => e.LastName)
    .Select(g => g.First().LastName)
    .OrderBy(e => e.Length)
    .ToList();
SELECT (
    SELECT TOP(1) [p1].[LastName]
    FROM [People] AS [p1]
    WHERE (([p1].[MiddleInitial] = N'Q') AND ([p1].[Age] = 20)) AND (([p].[LastName] = [p1].[LastName]) OR ([p].[LastName] IS NULL AND [p1].[LastName] IS NULL)))
FROM [People] AS [p]
WHERE ([p].[MiddleInitial] = N'Q') AND ([p].[Age] = 20)
GROUP BY [p].[LastName]
ORDER BY CAST(LEN((
    SELECT TOP(1) [p1].[LastName]
    FROM [People] AS [p1]
    WHERE (([p1].[MiddleInitial] = N'Q') AND ([p1].[Age] = 20)) AND (([p].[LastName] = [p1].[LastName]) OR ([p].[LastName] IS NULL AND [p1].[LastName] IS NULL)))) AS int)

Пример 4:

var results = (from person in context.People
               join shoes in context.Shoes on person.Age equals shoes.Age
               group shoes by shoes.Style
               into people
               select new
               {
                   people.Key,
                   Style = people.Select(p => p.Style).FirstOrDefault(),
                   Count = people.Count()
               })
    .ToList();
SELECT [s].[Style] AS [Key], (
    SELECT TOP(1) [s0].[Style]
    FROM [People] AS [p0]
    INNER JOIN [Shoes] AS [s0] ON [p0].[Age] = [s0].[Age]
    WHERE ([s].[Style] = [s0].[Style]) OR ([s].[Style] IS NULL AND [s0].[Style] IS NULL)) AS [Style], COUNT(*) AS [Count]
FROM [People] AS [p]
INNER JOIN [Shoes] AS [s] ON [p].[Age] = [s].[Age]
GROUP BY [s].[Style]

Пример 5:

var results = context.People
    .GroupBy(e => e.FirstName)
    .Select(g => g.First().LastName)
    .OrderBy(e => e)
    .ToList();
SELECT (
    SELECT TOP(1) [p1].[LastName]
    FROM [People] AS [p1]
    WHERE ([p].[FirstName] = [p1].[FirstName]) OR ([p].[FirstName] IS NULL AND [p1].[FirstName] IS NULL))
FROM [People] AS [p]
GROUP BY [p].[FirstName]
ORDER BY (
    SELECT TOP(1) [p1].[LastName]
    FROM [People] AS [p1]
    WHERE ([p].[FirstName] = [p1].[FirstName]) OR ([p].[FirstName] IS NULL AND [p1].[FirstName] IS NULL))

Пример 6:

var results = context.People.Where(e => e.Age == 20)
    .GroupBy(e => e.Id)
    .Select(g => g.First().MiddleInitial)
    .OrderBy(e => e)
    .ToList();
SELECT (
    SELECT TOP(1) [p1].[MiddleInitial]
    FROM [People] AS [p1]
    WHERE ([p1].[Age] = 20) AND ([p].[Id] = [p1].[Id]))
FROM [People] AS [p]
WHERE [p].[Age] = 20
GROUP BY [p].[Id]
ORDER BY (
    SELECT TOP(1) [p1].[MiddleInitial]
    FROM [People] AS [p1]
    WHERE ([p1].[Age] = 20) AND ([p].[Id] = [p1].[Id]))

Пример 7:

var size = 11;
var results
    = context.People
        .Where(
            p => p.Feet.Size == size
                 && p.MiddleInitial != null
                 && p.Feet.Id != 1)
        .GroupBy(
            p => new
            {
                p.Feet.Size,
                p.Feet.Person.LastName
            })
        .Select(
            g => new
            {
                g.Key.LastName,
                g.Key.Size,
                Min = g.Min(p => p.Feet.Size),
            })
        .ToList();
Executed DbCommand (12ms) [Parameters=[@__size_0='11'], CommandType='Text', CommandTimeout='30']
SELECT [p0].[LastName], [f].[Size], MIN([f0].[Size]) AS [Min]
FROM [People] AS [p]
LEFT JOIN [Feet] AS [f] ON [p].[Id] = [f].[Id]
LEFT JOIN [People] AS [p0] ON [f].[Id] = [p0].[Id]
LEFT JOIN [Feet] AS [f0] ON [p].[Id] = [f0].[Id]
WHERE (([f].[Size] = @__size_0) AND [p].[MiddleInitial] IS NOT NULL) AND (([f].[Id] <> 1) OR [f].[Id] IS NULL)
GROUP BY [f].[Size], [p0].[LastName]

Пример 8:

var result = context.People
    .Include(x => x.Shoes)
    .Include(x => x.Feet)
    .GroupBy(
        x => new
        {
            x.Feet.Id,
            x.Feet.Size
        })
    .Select(
        x => new
        {
            Key = x.Key.Id + x.Key.Size,
            Count = x.Count(),
            Sum = x.Sum(el => el.Id),
            SumOver60 = x.Sum(el => el.Id) / (decimal)60,
            TotalCallOutCharges = x.Sum(el => el.Feet.Size == 11 ? 1 : 0)
        })
    .Count();
SELECT COUNT(*)
FROM (
    SELECT [f].[Id], [f].[Size]
    FROM [People] AS [p]
    LEFT JOIN [Feet] AS [f] ON [p].[Id] = [f].[Id]
    GROUP BY [f].[Id], [f].[Size]
) AS [t]

Пример 9:

var results = context.People
    .GroupBy(n => n.FirstName)
    .Select(g => new
    {
        Feet = g.Key,
        Total = g.Sum(n => n.Feet.Size)
    })
    .ToList();
SELECT [p].[FirstName] AS [Feet], COALESCE(SUM([f].[Size]), 0) AS [Total]
FROM [People] AS [p]
LEFT JOIN [Feet] AS [f] ON [p].[Id] = [f].[Id]
GROUP BY [p].[FirstName]

Пример 10.

var results = from Person person1
                  in from Person person2
                         in context.People
                     select person2
              join Shoes shoes
                  in context.Shoes
                  on person1.Age equals shoes.Age
              group shoes by
                  new
                  {
                      person1.Id,
                      shoes.Style,
                      shoes.Age
                  }
              into temp
              select
                  new
                  {
                      temp.Key.Id,
                      temp.Key.Age,
                      temp.Key.Style,
                      Values = from t
                                   in temp
                               select
                                   new
                                   {
                                       t.Id,
                                       t.Style,
                                       t.Age
                                   }
                  };
SELECT [t].[Id], [t].[Age], [t].[Style], [t0].[Id], [t0].[Style], [t0].[Age], [t0].[Id0]
FROM (
    SELECT [p].[Id], [s].[Age], [s].[Style]
    FROM [People] AS [p]
    INNER JOIN [Shoes] AS [s] ON [p].[Age] = [s].[Age]
    GROUP BY [p].[Id], [s].[Style], [s].[Age]
) AS [t]
LEFT JOIN (
    SELECT [s0].[Id], [s0].[Style], [s0].[Age], [p0].[Id] AS [Id0]
    FROM [People] AS [p0]
    INNER JOIN [Shoes] AS [s0] ON [p0].[Age] = [s0].[Age]
) AS [t0] ON (([t].[Id] = [t0].[Id0]) AND (([t].[Style] = [t0].[Style]) OR ([t].[Style] IS NULL AND [t0].[Style] IS NULL))) AND ([t].[Age] = [t0].[Age])
ORDER BY [t].[Id], [t].[Style], [t].[Age], [t0].[Id0]

Пример 11.

var grouping = context.People
    .GroupBy(i => i.LastName)
    .Select(g => new { LastName = g.Key, Count = g.Count() , First = g.FirstOrDefault(), Take = g.Take(2)})
    .OrderByDescending(e => e.LastName)
    .ToList();
SELECT [t].[LastName], [t].[c], [t0].[Id], [t2].[Id], [t2].[Age], [t2].[FirstName], [t2].[LastName], [t2].[MiddleInitial], [t0].[Age], [t0].[FirstName], [t0].[LastName], [t0].[MiddleInitial]
FROM (
    SELECT [p].[LastName], COUNT(*) AS [c]
    FROM [People] AS [p]
    GROUP BY [p].[LastName]
) AS [t]
LEFT JOIN (
    SELECT [t1].[Id], [t1].[Age], [t1].[FirstName], [t1].[LastName], [t1].[MiddleInitial]
    FROM (
        SELECT [p0].[Id], [p0].[Age], [p0].[FirstName], [p0].[LastName], [p0].[MiddleInitial], ROW_NUMBER() OVER(PARTITION BY [p0].[LastName] ORDER BY [p0].[Id]) AS [row]
        FROM [People] AS [p0]
    ) AS [t1]
    WHERE [t1].[row] <= 1
) AS [t0] ON [t].[LastName] = [t0].[LastName]
LEFT JOIN (
    SELECT [t3].[Id], [t3].[Age], [t3].[FirstName], [t3].[LastName], [t3].[MiddleInitial]
    FROM (
        SELECT [p1].[Id], [p1].[Age], [p1].[FirstName], [p1].[LastName], [p1].[MiddleInitial], ROW_NUMBER() OVER(PARTITION BY [p1].[LastName] ORDER BY [p1].[Id]) AS [row]
        FROM [People] AS [p1]
    ) AS [t3]
    WHERE [t3].[row] <= 2
) AS [t2] ON [t].[LastName] = [t2].[LastName]
ORDER BY [t].[LastName] DESC, [t0].[Id], [t2].[LastName], [t2].[Id]

Пример 12:

var grouping = context.People
    .Include(e => e.Shoes)
    .OrderBy(e => e.FirstName)
    .ThenBy(e => e.LastName)
    .GroupBy(e => e.FirstName)
    .Select(g => new { Name = g.Key, People = g.ToList()})
    .ToList();
SELECT [t].[FirstName], [t0].[Id], [t0].[Age], [t0].[FirstName], [t0].[LastName], [t0].[MiddleInitial], [t0].[Id0], [t0].[Age0], [t0].[PersonId], [t0].[Style]
FROM (
    SELECT [p].[FirstName]
    FROM [People] AS [p]
    GROUP BY [p].[FirstName]
) AS [t]
LEFT JOIN (
    SELECT [p0].[Id], [p0].[Age], [p0].[FirstName], [p0].[LastName], [p0].[MiddleInitial], [s].[Id] AS [Id0], [s].[Age] AS [Age0], [s].[PersonId], [s].[Style]
    FROM [People] AS [p0]
    LEFT JOIN [Shoes] AS [s] ON [p0].[Id] = [s].[PersonId]
) AS [t0] ON [t].[FirstName] = [t0].[FirstName]
ORDER BY [t].[FirstName], [t0].[Id]

Пример 13:

var grouping = context.People
    .GroupBy(m => new {m.FirstName, m.MiddleInitial })
    .Select(am => new
    {
        Key = am.Key,
        Items = am.ToList()
    })
    .ToList();
SELECT [t].[FirstName], [t].[MiddleInitial], [p0].[Id], [p0].[Age], [p0].[FirstName], [p0].[LastName], [p0].[MiddleInitial]
FROM (
    SELECT [p].[FirstName], [p].[MiddleInitial]
    FROM [People] AS [p]
    GROUP BY [p].[FirstName], [p].[MiddleInitial]
) AS [t]
LEFT JOIN [People] AS [p0] ON (([t].[FirstName] = [p0].[FirstName]) OR ([t].[FirstName] IS NULL AND [p0].[FirstName] IS NULL)) AND (([t].[MiddleInitial] = [p0].[MiddleInitial]) OR ([t].[MiddleInitial] IS NULL AND [p0].[MiddleInitial] IS NULL))
ORDER BY [t].[FirstName], [t].[MiddleInitial]

Модель

В этих примерах используются следующие типы сущностей:

public class Person
{
    public int Id { get; set; }
    public int Age { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string MiddleInitial { get; set; }
    public Feet Feet { get; set; }
    public ICollection<Shoes> Shoes { get; } = new List<Shoes>();
}

public class Shoes
{
    public int Id { get; set; }
    public int Age { get; set; }
    public string Style { get; set; }
    public Person Person { get; set; }
}

public class Feet
{
    public int Id { get; set; }
    public int Size { get; set; }
    public Person Person { get; set; }
}

Преобразование String.Concat с несколькими аргументами

Проблема, рассмотренная на сайте GitHub: № 23859. Эта функция представлена пользователем @wmeints. Спасибо!

Начиная с EF Core 6.0, вызовы String.Concat с несколькими аргументами теперь преобразуются в SQL. Например, следующий запрос:

var shards = context.Shards
    .Where(e => string.Concat(e.Token1, e.Token2, e.Token3) != e.TokensProcessed).ToList();

будет преобразован в следующий SQL при использовании SQL Server:

SELECT [s].[Id], [s].[Token1], [s].[Token2], [s].[Token3], [s].[TokensProcessed]
FROM [Shards] AS [s]
WHERE (([s].[Token1] + ([s].[Token2] + [s].[Token3])) <> [s].[TokensProcessed]) OR [s].[TokensProcessed] IS NULL

Отлажена интеграция с System.Linq.Async

Проблема, рассмотренная на сайте GitHub: № 24041.

Пакет System.Linq.Async обеспечивает асинхронную обработку LINQ на стороне клиента. Использование этого пакета с предыдущими версиями EF Core вызывало проблемы из-за конфликта пространств имен для асинхронных методов LINQ. В EF Core 6.0 мы использовали преимущества сопоставления шаблонов C# для IAsyncEnumerable<T> с таким расчетом, чтобы предоставляемый EF Core класс DbSet<TEntity> не требовал непосредственной реализации интерфейса.

Обратите внимание, что большинству приложений не требуется использовать System.Linq.Async, так как запросы EF Core обычно полностью преобразуются на сервере.

Проблема, рассмотренная на сайте GitHub: № 23921.

В EF Core 6.0 мы ослабили требования к параметрам для FreeText(DbFunctions, String, String) и Contains. Таким образом, эти функции можно использовать с двоичными столбцами или со столбцами, сопоставленными с помощью преобразователя величин. Допустим, у вас есть тип сущности со свойством Name, определенным в виде объекта значения:

public class Customer
{
    public int Id { get; set; }

    public Name Name{ get; set; }
}

public class Name
{
    public string First { get; set; }
    public string MiddleInitial { get; set; }
    public string Last { get; set; }
}

Это свойство сопоставляется с JSON в базе данных:

modelBuilder.Entity<Customer>()
    .Property(e => e.Name)
    .HasConversion(
        v => JsonSerializer.Serialize(v, (JsonSerializerOptions)null),
        v => JsonSerializer.Deserialize<Name>(v, (JsonSerializerOptions)null));

Теперь запрос можно выполнить с помощью Contains или FreeText, даже если тип свойства Name отличается от string. Например:

var result = context.Customers.Where(e => EF.Functions.Contains(e.Name, "Martin")).ToList();

В результате будет создан следующий код SQL, если используется SQL Server:

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE CONTAINS([c].[Name], N'Martin')

Преобразование ToString в SQLite

Проблема, рассмотренная на сайте GitHub: #17223. Эта функция представлена пользователем @ralmsdeveloper. Спасибо!

Теперь при использовании поставщика базы данных SQLite вызовы в ToString() преобразуются в SQL. Это может быть полезно для поиска текста, включающего столбцы, отличные от строковых. Например, рассмотрим тип сущности User, который хранит номера телефонов в виде числовых значений:

public class User
{
    public int Id { get; set; }
    public string Username { get; set; }
    public long PhoneNumber { get; set; }
}

Для преобразования числа в строку в базе данных можно использовать ToString. Затем можно использовать эту строку с функцией, например LIKE для поиска чисел, соответствующих шаблону. Например, чтобы найти все числа, содержащие "555":

var users = context.Users.Where(u => EF.Functions.Like(u.PhoneNumber.ToString(), "%555%")).ToList();

Выполняется преобразование в следующий SQL при использовании базы данных SQLite:

SELECT "u"."Id", "u"."PhoneNumber", "u"."Username"
FROM "Users" AS "u"
WHERE CAST("u"."PhoneNumber" AS TEXT) LIKE '%555%'

Обратите внимание, что преобразование ToString() для SQL Server уже поддерживается в EF Core 5.0 и может также поддерживаться другими поставщиками баз данных.

EF.Functions.Random

Проблема, рассмотренная на сайте GitHub: #16141. Эта функция представлена пользователем @RaymondHuy. Спасибо!

EF.Functions.Random сопоставляется с функцией базы данных, возвращающей псевдослучайное число от 0 до 1, не включая эти числа. Переводы были реализованы в репозитории EF Core для SQL Server, SQLite и Azure Cosmos DB. Например, рассмотрим тип сущности User со свойством Popularity:

public class User
{
    public int Id { get; set; }
    public string Username { get; set; }
    public int Popularity { get; set; }
}

Popularity может иметь значения от 1 до 5 включительно. С помощью EF.Functions.Random можно написать запрос, возвращающий всех пользователей со случайной популярностью:

var users = context.Users.Where(u => u.Popularity == (int)(EF.Functions.Random() * 4.0) + 1).ToList();

При использовании базы данных SQL Server выполняется преобразование в следующий SQL:

SELECT [u].[Id], [u].[Popularity], [u].[Username]
FROM [Users] AS [u]
WHERE [u].[Popularity] = (CAST((RAND() * 4.0E0) AS int) + 1)

Улучшенное преобразование SQL Server для IsNullOrWhitespace

Проблема, рассмотренная на сайте GitHub: #22916. Эта функция представлена пользователем @Marusyk. Спасибо!

Обратите внимание на следующий запрос:

var users = context.Users.Where(
    e => string.IsNullOrWhiteSpace(e.FirstName)
         || string.IsNullOrWhiteSpace(e.LastName)).ToList();

В версиях, предшествующих EF Core 6.0, этот параметр преобразовывался в следующее в SQL Server:

SELECT [u].[Id], [u].[FirstName], [u].[LastName]
FROM [Users] AS [u]
WHERE ([u].[FirstName] IS NULL OR (LTRIM(RTRIM([u].[FirstName])) = N'')) OR ([u].[LastName] IS NULL OR (LTRIM(RTRIM([u].[LastName])) = N''))

В EF Core 6.0 это преобразование было улучшено:

SELECT [u].[Id], [u].[FirstName], [u].[LastName]
FROM [Users] AS [u]
WHERE ([u].[FirstName] IS NULL OR ([u].[FirstName] = N'')) OR ([u].[LastName] IS NULL OR ([u].[LastName] = N''))

Определяющий запрос для поставщика выполняющейся в памяти базы данных

Проблема, рассмотренная на сайте GitHub: № 24600.

Новый метод ToInMemoryQuery можно использовать для написания определяющего запроса к базе данных в памяти для данного типа сущности. Это наиболее полезно для создания эквивалентных представлений в базе данных в памяти, особенно когда эти представления возвращают нестрогие типы сущностей. Например, рассмотрим базу данных клиентов для клиентов в Соединенном Королевстве. У каждого клиента есть адрес:

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public Address Address { get; set; }
}

public class Address
{
    public int Id { get; set; }
    public string House { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
}

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

public class CustomerDensity
{
    public string Postcode { get; set; }
    public int CustomerCount { get; set; }
}

И определите для него свойство DbSet в DbContext, а также наборы для других типов сущностей верхнего уровня:

public DbSet<Customer> Customers { get; set; }
public DbSet<CustomerDensity> CustomerDensities { get; set; }

Затем в OnModelCreating можно написать запрос LINQ, определяющий возвращаемые данные для CustomerDensities:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .Entity<CustomerDensity>()
        .HasNoKey()
        .ToInMemoryQuery(
            () => Customers
                .GroupBy(c => c.Address.Postcode.Substring(0, 3))
                .Select(
                    g =>
                        new CustomerDensity
                        {
                            Postcode = g.Key,
                            CustomerCount = g.Count()
                        }));
}

Затем его можно запросить так же, как и любое другое свойство DbSet:

var results = context.CustomerDensities.ToList();

Преобразование подстроки с одним параметром

Проблема, рассмотренная на сайте GitHub: № 20173. Эта функция представлена пользователем @stevendarby. Спасибо!

EF Core 6.0 теперь преобразует использование string.Substring с одним аргументом. Например:

var result = context.Customers
    .Select(a => new { Name = a.Name.Substring(3) })
    .FirstOrDefault(a => a.Name == "hur");

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

SELECT TOP(1) SUBSTRING([c].[Name], 3 + 1, LEN([c].[Name])) AS [Name]
FROM [Customers] AS [c]
WHERE SUBSTRING([c].[Name], 3 + 1, LEN([c].[Name])) = N'hur'

Разделенные запросы для коллекций, не связанных с навигацией

Проблема, рассмотренная на сайте GitHub: № 21234.

EF Core поддерживает разделение одного запроса LINQ на несколько SQL-запросов. В EF Core 6.0 эта поддержка была расширена и включает в себя случаи, когда в проекции запроса содержатся коллекции, не связанные с навигацией.

Ниже приведены примеры запросов, в которых показано преобразование SQL Server в один или несколько запросов.

Пример 1:

Запрос LINQ:

context.Customers
    .Select(
        c => new
        {
            c,
            Orders = c.Orders
                .Where(o => o.Id > 1)
        })
    .ToList();

Один SQL-запрос:

SELECT [c].[Id], [t].[Id], [t].[CustomerId], [t].[OrderDate]
FROM [Customers] AS [c]
LEFT JOIN (
    SELECT [o].[Id], [o].[CustomerId], [o].[OrderDate]
    FROM [Order] AS [o]
    WHERE [o].[Id] > 1
) AS [t] ON [c].[Id] = [t].[CustomerId]
ORDER BY [c].[Id]

Несколько SQL-запросов:

SELECT [c].[Id]
FROM [Customers] AS [c]
ORDER BY [c].[Id]

SELECT [t].[Id], [t].[CustomerId], [t].[OrderDate], [c].[Id]
FROM [Customers] AS [c]
INNER JOIN (
    SELECT [o].[Id], [o].[CustomerId], [o].[OrderDate]
    FROM [Order] AS [o]
    WHERE [o].[Id] > 1
) AS [t] ON [c].[Id] = [t].[CustomerId]
ORDER BY [c].[Id]

Пример 2:

Запрос LINQ:

context.Customers
    .Select(
        c => new
        {
            c,
            OrderDates = c.Orders
                .Where(o => o.Id > 1)
                .Select(o => o.OrderDate)
        })
    .ToList();

Один SQL-запрос:

SELECT [c].[Id], [t].[OrderDate], [t].[Id]
FROM [Customers] AS [c]
  LEFT JOIN (
  SELECT [o].[OrderDate], [o].[Id], [o].[CustomerId]
  FROM [Order] AS [o]
  WHERE [o].[Id] > 1
  ) AS [t] ON [c].[Id] = [t].[CustomerId]
ORDER BY [c].[Id]

Несколько SQL-запросов:

SELECT [c].[Id]
FROM [Customers] AS [c]
ORDER BY [c].[Id]

SELECT [t].[Id], [t].[CustomerId], [t].[OrderDate], [c].[Id]
FROM [Customers] AS [c]
INNER JOIN (
    SELECT [o].[Id], [o].[CustomerId], [o].[OrderDate]
    FROM [Order] AS [o]
    WHERE [o].[Id] > 1
) AS [t] ON [c].[Id] = [t].[CustomerId]
ORDER BY [c].[Id]

Пример 3:

Запрос LINQ:

context.Customers
    .Select(
        c => new
        {
            c,
            OrderDates = c.Orders
                .Where(o => o.Id > 1)
                .Select(o => o.OrderDate)
                .Distinct()
        })
    .ToList();

Один SQL-запрос:

SELECT [c].[Id], [t].[OrderDate]
FROM [Customers] AS [c]
  OUTER APPLY (
  SELECT DISTINCT [o].[OrderDate]
  FROM [Order] AS [o]
  WHERE ([c].[Id] = [o].[CustomerId]) AND ([o].[Id] > 1)
  ) AS [t]
ORDER BY [c].[Id]

Несколько SQL-запросов:

SELECT [c].[Id]
FROM [Customers] AS [c]
ORDER BY [c].[Id]

SELECT [t].[OrderDate], [c].[Id]
FROM [Customers] AS [c]
  CROSS APPLY (
  SELECT DISTINCT [o].[OrderDate]
  FROM [Order] AS [o]
  WHERE ([c].[Id] = [o].[CustomerId]) AND ([o].[Id] > 1)
  ) AS [t]
ORDER BY [c].[Id]

Удаление последнего предложения ORDER BY при присоединении для коллекций

Проблема, рассмотренная на сайте GitHub: № 19828.

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

В качестве примера рассмотрим запрос:

context.Customers
    .Select(
        e => new
        {
            e.Id,
            FirstOrder = e.Orders.Where(i => i.Id == 1).ToList()
        })
    .ToList();

При использовании EF Core 5.0 в SQL Server этот запрос преобразуется в следующий:

SELECT [c].[Id], [t].[Id], [t].[CustomerId], [t].[OrderDate]
FROM [Customers] AS [c]
LEFT JOIN (
    SELECT [o].[Id], [o].[CustomerId], [o].[OrderDate]
    FROM [Order] AS [o]
    WHERE [o].[Id] = 1
) AS [t] ON [c].[Id] = [t].[CustomerId]
ORDER BY [c].[Id], [t].[Id]

При использовании EF Core 6.0 он преобразуется в следующий:

SELECT [c].[Id], [t].[Id], [t].[CustomerId], [t].[OrderDate]
FROM [Customers] AS [c]
LEFT JOIN (
    SELECT [o].[Id], [o].[CustomerId], [o].[OrderDate]
    FROM [Order] AS [o]
    WHERE [o].[Id] = 1
) AS [t] ON [c].[Id] = [t].[CustomerId]
ORDER BY [c].[Id]

Пометка запросов именем файла и номером строки

Проблема, рассмотренная на сайте GitHub: № 14176. Эта функция представлена пользователем @michalczerwinski. Спасибо!

Теги запросов допускают добавление тега текстурирования в запрос LINQ, который затем включается в созданный SQL-запрос. В EF Core 6.0 это можно использовать для обозначения запросов с именем файла и номером строки кода LINQ. Например:

var results1 = context
    .Customers
    .TagWithCallSite()
    .Where(c => c.Name.StartsWith("A"))
    .ToList();

В результате будет создан следующий код SQL, если используется SQL Server:

-- file: C:\dotnet\efdocs\samples\core\Miscellaneous\NewInEFCore6\TagWithFileAndLineSample.cs:21

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE [c].[Name] IS NOT NULL AND ([c].[Name] LIKE N'A%')

Изменения в необязательной обработке зависимостей

Проблема, рассмотренная на сайте GitHub: № 24558.

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

Например, рассмотрим класс Customer, которому принадлежит каждый клиент Address:

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public Address Address { get; set; }
}

public class Address
{
    public string House { get; set; }
    public string Street { get; set; }
    public string City { get; set; }

    [Required]
    public string Postcode { get; set; }
}

Адрес является необязательным, и это означает, что можно сохранить клиента без адреса:

context.Customers1.Add(
    new()
    {
        Name = "Foul Ole Ron"
    });

Однако если у клиента есть адрес, то этот адрес должен иметь по крайней мере почтовый индекс, отличный от NULL:

context.Customers1.Add(
    new()
    {
        Name = "Havelock Vetinari",
        Address = new()
        {
            Postcode = "AN1 1PL",
        }
    });

Это гарантируется путем пометки Postcode свойства как Required.

Теперь при запросе клиентов, если значение в столбце почтового индекса имеет значение NULL, это означает, что у клиента нет адреса, а свойство навигации Customer.Address остается NULL. Например, просматривая клиентов и проверяя, имеет ли адрес значение NULL:

foreach (var customer in context.Customers1)
{
    Console.Write(customer.Name);

    if (customer.Address == null)
    {
        Console.WriteLine(" has no address.");
    }
    else
    {
        Console.WriteLine($" has postcode {customer.Address.Postcode}.");
    }
}

Будут получены следующие выходные данные:

Foul Ole Ron has no address.
Havelock Vetinari has postcode AN1 1PL.

Вместо этого следует рассмотреть случай, когда не требуется свойство адреса:

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public Address Address { get; set; }
}

public class Address
{
    public string House { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
}

Теперь можно сохранить и клиента без адреса, и клиента с адресом, в котором все свойства адреса имеют значение NULL:

context.Customers2.Add(
    new()
    {
        Name = "Foul Ole Ron"
    });

context.Customers2.Add(
    new()
    {
        Name = "Havelock Vetinari",
        Address = new()
    });

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

Id  Name               House   Street  City    Postcode
1   Foul Ole Ron       NULL    NULL    NULL    NULL
2   Havelock Vetinari  NULL    NULL    NULL    NULL

По этой причине в EF Core 6.0 будет отображаться соответствующее предупреждение при сохранении необязательного зависимого объекта, в котором все его свойства имеют значение NULL. Например:

warn: 9/27/2021 09:25:01.338 RelationalEventId.OptionalDependentWithAllNullPropertiesWarning[20704] (Microsoft.EntityFrameworkCore.Update) Сущность типа "Address" со значениями основных ключей {CustomerId: -2147482646} является необязательно зависимой с использованием общего доступа к таблице. Сущность не имеет свойства со значением, отличным от значения по умолчанию, чтобы определить, существует ли сущность. Это означает, что при запросе экземпляр объекта не будет создан вместо экземпляра со всеми свойствами, для которых заданы значения по умолчанию. Все вложенные зависимые элементы также будут потеряны. Либо не сохраняйте экземпляры со значениями по умолчанию, либо пометьте входящую навигацию в соответствии с требованиями модели.

Еще более сложным является случай, когда необязательный зависимый элемент выступает в качестве участника для дополнительного необязательного зависимого, также сопоставленного с той же таблицей. EF Core 6.0 не выдает предупреждение, а запрещает вложенные необязательные зависимые объекты. Например, рассмотрим следующую модель, где ContactInfo принадлежит Customer, а Address — ContactInfo:

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ContactInfo ContactInfo { get; set; }
}

public class ContactInfo
{
    public string Phone { get; set; }
    public Address Address { get; set; }
}

public class Address
{
    public string House { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
}

Если ContactInfo.Phone имеет значение NULL, то EF Core не будет создавать экземпляр Address, если связь является необязательной, даже если сам адрес может содержать данные. Для модели такого типа EF Core 6.0 выдаст следующее исключение:

System.InvalidOperationException: тип сущности ContactInfo представляет собой необязательный зависимый объект с общим доступом к таблицам, содержащий другие зависимые объекты без обязательных необщедоступных свойств, определяющих существование сущности. Если все свойства, допускающие значение NULL, содержат значение NULL в базе данных, то экземпляр объекта не будет создан в запросе, что приведет к потере вложенных зависимых элементов. Добавьте обязательное свойство, чтобы создать экземпляры со значениями NULL для других свойств или пометить входящую навигацию как обязательную, чтобы всегда создавать экземпляр.

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

  1. Сделайте зависимый элемент обязательным. Это означает, что зависимый объект всегда будет иметь значение после его запроса, даже если все его свойства имеют значение NULL.
  2. Убедитесь, что зависимый объект содержит по крайней мере одно обязательное свойство, как описано выше.
  3. Сохраните необязательные зависимые элементы в отдельную таблицу вместо предоставления общего доступа к таблице с основным объектом.

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

public class Customer
{
    public int Id { get; set; }
    public string Name { get; set; }

    [Required]
    public Address Address { get; set; }
}

public class Address
{
    public string House { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
}

Или указав, что он является обязательным, в OnModelCreating:

modelBuilder.Entity<WithRequiredNavigation.Customer>(
    b =>
        {
            b.OwnsOne(e => e.Address);
            b.Navigation(e => e.Address).IsRequired();
        });

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

modelBuilder
    .Entity<WithDifferentTable.Customer>(
        b =>
            {
                b.ToTable("Customers");
                b.OwnsOne(
                    e => e.Address,
                    b => b.ToTable("CustomerAddresses"));
            });

Дополнительные примеры необязательных зависимых объектов см. в разделе OptionalDependentsSample в GitHub.

Новые атрибуты сопоставления

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

UnicodeAttribute

Проблема, рассмотренная на сайте GitHub: #19794. Эта функция представлена пользователем @RaymondHuy. Спасибо!

Начиная с EF Core 6.0 свойство строки теперь можно сопоставить со столбцом, не поддерживающим Юникод, с помощью атрибута сопоставления без непосредственного указания типа базы данных. Например, рассмотрим тип сущности Book со свойством для международного стандартного книжного номера (ISBN) в формате "ISBN 978-3-16-148410-0":

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }

    [Unicode(false)]
    [MaxLength(22)]
    public string Isbn { get; set; }
}

Поскольку ISBN не может содержать символы, отличные от Юникода, атрибут Unicode будет использовать строковый тип, отличный от Юникода. Кроме того, MaxLength используется для ограничения размера столбца базы данных. Например, при использовании SQL Server в результате получается столбец базы данных varchar(22):

CREATE TABLE [Book] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NULL,
    [Isbn] varchar(22) NULL,
    CONSTRAINT [PK_Book] PRIMARY KEY ([Id]));

Примечание.

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

PrecisionAttribute

Проблема, рассмотренная на сайте GitHub: #17914. Эта функция представлена пользователем @RaymondHuy. Спасибо!

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

public class Product
{
    public int Id { get; set; }

    [Precision(precision: 10, scale: 2)]
    public decimal Price { get; set; }
}

EF Core будет сопоставлять это свойство со столбцом базы данных с точностью 10 и масштабом 2. Например, в SQL Server.

CREATE TABLE [Product] (
    [Id] int NOT NULL IDENTITY,
    [Price] decimal(10,2) NOT NULL,
    CONSTRAINT [PK_Product] PRIMARY KEY ([Id]));

EntityTypeConfigurationAttribute

Проблема, рассмотренная на сайте GitHub: #23163. Эта функция представлена пользователем @KaloyanIT. Спасибо!

Экземпляры IEntityTypeConfiguration<TEntity> позволяют конфигурации ModelBuilder для каждого типа сущности содержаться в своем собственном классе конфигурации. Например:

public class BookConfiguration : IEntityTypeConfiguration<Book>
{
    public void Configure(EntityTypeBuilder<Book> builder)
    {
        builder
            .Property(e => e.Isbn)
            .IsUnicode(false)
            .HasMaxLength(22);
    }
}

Обычно этот класс конфигурации должен быть создан и вызван из DbContext.OnModelCreating. Например:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    new BookConfiguration().Configure(modelBuilder.Entity<Book>());
}

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

[EntityTypeConfiguration(typeof(BookConfiguration))]
public class Book
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Isbn { get; set; }
}

Этот атрибут означает, что EF Core будет использовать указанную реализацию IEntityTypeConfiguration при включении типа сущности Book в модель. Для включения типа сущности в модель применяется один из обычных механизмов. Например, создав свойство DbSet<TEntity> для типа сущности:

public class BooksContext : DbContext
{
    public DbSet<Book> Books { get; set; }

    //...

Или зарегистрировав его в OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Book>();
}

Примечание.

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

Усовершенствования создания моделей

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

Поддержка разреженных столбцов SQL Server

Проблема, рассмотренная на сайте GitHub: #8023.

Разреженные столбцы SQL Server — это обычные столбцы, оптимизированные для хранения значений NULL. Это может быть полезно при использовании сопоставления наследования одной таблицы на иерархию, когда свойства редко используемого подтипа приводят к значению NULL в столбце для большинства строк в таблице. Например, рассмотрим класс ForumModerator, который является расширением ForumUser:

public class ForumUser
{
    public int Id { get; set; }
    public string Username { get; set; }
}

public class ForumModerator : ForumUser
{
    public string ForumName { get; set; }
}

Среди миллионов пользователей может быть всего несколько модераторов. Это означает, что здесь может оказаться целесообразным сопоставить ForumName как разреженный. Теперь это можно настроить с помощью IsSparse в OnModelCreating. Например:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder
        .Entity<ForumModerator>()
        .Property(e => e.ForumName)
        .IsSparse();
}

Затем миграции EF Core отметят столбец как разреженный. Например:

CREATE TABLE [ForumUser] (
    [Id] int NOT NULL IDENTITY,
    [Username] nvarchar(max) NULL,
    [Discriminator] nvarchar(max) NOT NULL,
    [ForumName] nvarchar(max) SPARSE NULL,
    CONSTRAINT [PK_ForumUser] PRIMARY KEY ([Id]));

Примечание.

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

Улучшения в API HasConversion

Проблема, рассмотренная на сайте GitHub: № 25468.

До EF Core 6.0 универсальные перегрузки методов HasConversion использовали универсальный параметр для указания типа, в который необходимо выполнить преобразование. Например, рассмотрим перечисление Currency:

public enum Currency
{
    UsDollars,
    PoundsSterling,
    Euros
}

EF Core можно настроить на сохранение значений этого перечисления в виде строк "UsDollars", "PoundsStirling" и "Euros" с помощью HasConversion<string>. Например:

modelBuilder.Entity<TestEntity1>()
    .Property(e => e.Currency)
    .HasConversion<string>();

Начиная с EF Core 6.0, универсальный тип может вместо этого указывать тип преобразователя величин. Это может быть один из встроенных преобразователей величин. Например, чтобы сохранить значения перечисления в виде 16-разрядных чисел в базе данных.

modelBuilder.Entity<TestEntity2>()
    .Property(e => e.Currency)
    .HasConversion<EnumToNumberConverter<Currency, short>>();

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

public class CurrencyToSymbolConverter : ValueConverter<Currency, string>
{
    public CurrencyToSymbolConverter()
        : base(
            v => v == Currency.PoundsSterling ? "£" : v == Currency.Euros ? "€" : "$",
            v => v == "£" ? Currency.PoundsSterling : v == "€" ? Currency.Euros : Currency.UsDollars)
    {
    }
}

Это можно настроить с помощью универсального метода HasConversion:

modelBuilder.Entity<TestEntity3>()
    .Property(e => e.Currency)
    .HasConversion<CurrencyToSymbolConverter>();

Сокращение конфигурации для связей "многие ко многим"

Проблема, рассмотренная на сайте GitHub: № 21535.

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

modelBuilder.Entity<Cat>()
    .HasMany(e => e.Humans)
    .WithMany(e => e.Cats);

В обоих случаях EF Core создает общую сущность с типом, основанную на Dictionary<string, object>, чтобы она выступала как сущность JOIN между двумя типами. Начиная с EF Core 6.0, в конфигурацию можно добавить UsingEntity, чтобы изменить только этот тип, без необходимости дополнительной настройки. Например:

modelBuilder.Entity<Cat>()
    .HasMany(e => e.Humans)
    .WithMany(e => e.Cats)
    .UsingEntity<CatHuman>();

Кроме того, тип сущности объединения можно настроить дополнительно, не требуя явного указания связей слева и справа. Например:

modelBuilder.Entity<Cat>()
    .HasMany(e => e.Humans)
    .WithMany(e => e.Cats)
    .UsingEntity<CatHuman>(
        e => e.HasKey(e => new { e.CatsId, e.HumansId }));

И, наконец, можно предоставить полную конфигурацию. Например:

modelBuilder.Entity<Cat>()
    .HasMany(e => e.Humans)
    .WithMany(e => e.Cats)
    .UsingEntity<CatHuman>(
        e => e.HasOne<Human>().WithMany().HasForeignKey(e => e.CatsId),
        e => e.HasOne<Cat>().WithMany().HasForeignKey(e => e.HumansId),
        e => e.HasKey(e => new { e.CatsId, e.HumansId }));

Разрешение преобразователям величин преобразовывать значения NULL

Проблема, рассмотренная на сайте GitHub: № 13850.

Важно!

Из-за описанных ниже проблем конструкторы для ValueConverter, которые допускают преобразование значений NULL, были помечены с помощью [EntityFrameworkInternal] для выпуска EF Core 6.0. Использование этих конструкторов приведет к формированию предупреждения сборки.

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

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

Это нетривиальные проблемы, который сложно обнаружить. Поэтому мы пометили эту функцию как внутреннюю для EF Core 6.0. Вы по-прежнему можете использовать ее, но будет возникать предупреждение компилятора. Предупреждение можно отключить с помощью #pragma warning disable EF1001.

Одним из примеров того, где может быть полезно преобразование значений NULL, является случай, когда база данных содержит значения NULL, но тип сущности хочет использовать какое-либо другое значение по умолчанию для свойства. Например, рассмотрим перечисление, где значение по умолчанию — "Unknown":

public enum Breed
{
    Unknown,
    Burmese,
    Tonkinese
}

Однако база данных может иметь значения NULL, если порода неизвестна. В EF Core 6.0 для учета этого можно использовать преобразователь величин:

    public class BreedConverter : ValueConverter<Breed, string>
    {
#pragma warning disable EF1001
        public BreedConverter()
            : base(
                v => v == Breed.Unknown ? null : v.ToString(),
                v => v == null ? Breed.Unknown : Enum.Parse<Breed>(v),
                convertsNulls: true)
        {
        }
#pragma warning restore EF1001
    }

Для кошек породы "Unknown" в базе данных в столбце Breed будет указано значение NULL. Например:

context.AddRange(
    new Cat { Name = "Mac", Breed = Breed.Unknown },
    new Cat { Name = "Clippy", Breed = Breed.Burmese },
    new Cat { Name = "Sid", Breed = Breed.Tonkinese });

context.SaveChanges();

Который создает следующие инструкции INSERT для SQL Server:

info: 9/27/2021 19:43:55.966 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (16ms) [Parameters=[@p0=NULL (Size = 4000), @p1='Mac' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Cats] ([Breed], [Name])
      VALUES (@p0, @p1);
      SELECT [Id]
      FROM [Cats]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
info: 9/27/2021 19:43:55.983 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='Burmese' (Size = 4000), @p1='Clippy' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Cats] ([Breed], [Name])
      VALUES (@p0, @p1);
      SELECT [Id]
      FROM [Cats]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
info: 9/27/2021 19:43:55.983 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='Tonkinese' (Size = 4000), @p1='Sid' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Cats] ([Breed], [Name])
      VALUES (@p0, @p1);
      SELECT [Id]
      FROM [Cats]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

Усовершенствования фабрики DbContext

AddDbContextFactory также регистрирует DbContext напрямую

Проблема, рассмотренная на сайте GitHub: № 25164.

Иногда полезно иметь как тип DbContext, так и фабрику для контекстов этого типа, зарегистрированных в контейнере внедрения зависимостей приложений (D.I.). Это позволяет, например, определить экземпляр DbContext в области запроса, тогда как фабрику можно использовать для создания нескольких независимых экземпляров, когда это необходимо.

Для этого AddDbContextFactory теперь также регистрирует тип DbContext в качестве службы с областью действия. Например, рассмотрим эту регистрацию в контейнере DI приложения:

var container = services
    .AddDbContextFactory<SomeDbContext>(
        builder => builder.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=EFCoreSample;ConnectRetryCount=0"))
    .BuildServiceProvider();

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

var factory = container.GetService<IDbContextFactory<SomeDbContext>>();
using (var context = factory.CreateDbContext())
{
    // Contexts obtained from the factory must be explicitly disposed
}

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

Кроме того, экземпляр DbContext можно разрешить непосредственно из области контейнера:

using (var scope = container.CreateScope())
{
    var context = scope.ServiceProvider.GetService<SomeDbContext>();
    // Context is disposed when the scope is disposed
}

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

В общих чертах это означает, что любой DbContext фабрики можно внедрить в другие типы DI. Например:

private class MyController2
{
    private readonly IDbContextFactory<SomeDbContext> _contextFactory;

    public MyController2(IDbContextFactory<SomeDbContext> contextFactory)
    {
        _contextFactory = contextFactory;
    }

    public void DoSomething()
    {
        using var context1 = _contextFactory.CreateDbContext();
        using var context2 = _contextFactory.CreateDbContext();

        var results1 = context1.Blogs.ToList();
        var results2 = context2.Blogs.ToList();

        // Contexts obtained from the factory must be explicitly disposed
    }
}

Или сделайте так:

private class MyController1
{
    private readonly SomeDbContext _context;

    public MyController1(SomeDbContext context)
    {
        _context = context;
    }

    public void DoSomething()
    {
        var results = _context.Blogs.ToList();

        // Injected context is disposed when the request scope is disposed
    }
}

DbContextFactory игнорирует конструктор без параметров DbContext

Проблема, рассмотренная на сайте GitHub: № 24124.

EF Core 6.0 теперь допускает использование конструктора DbContext без параметров и конструктора, который допускает использование DbContextOptions для того же типа контекста, когда фабрика зарегистрирована с помощью AddDbContextFactory. Например, контекст, используемый в приведенных выше примерах, содержит оба конструктора:

public class SomeDbContext : DbContext
{
    public SomeDbContext()
    {
    }

    public SomeDbContext(DbContextOptions<SomeDbContext> options)
        : base(options)
    {
    }

    public DbSet<Blog> Blogs { get; set; }
}

Пулы DbContext можно использовать без внедрения зависимостей.

Проблема, рассмотренная на сайте GitHub: № 24137.

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

var options = new DbContextOptionsBuilder<SomeDbContext>()
    .EnableSensitiveDataLogging()
    .UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=EFCoreSample;ConnectRetryCount=0")
    .Options;

var factory = new PooledDbContextFactory<SomeDbContext>(options);

Затем фабрику можно использовать для создания экземпляров и пула. Например:

for (var i = 0; i < 2; i++)
{
    using var context1 = factory.CreateDbContext();
    Console.WriteLine($"Created DbContext with ID {context1.ContextId}");

    using var context2 = factory.CreateDbContext();
    Console.WriteLine($"Created DbContext with ID {context2.ContextId}");
}

Экземпляры возвращаются в пул при их удалении.

Прочие улучшения

И, наконец, EF Core включает несколько улучшений в областях, не описанных выше.

Использование [ColumnAttribute.Order] при создании таблиц

Проблема GitHub: № 10059.

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

public class EntityBase
{
    public int Id { get; set; }
    public DateTime UpdatedOn { get; set; }
    public DateTime CreatedOn { get; set; }
}

public class PersonBase : EntityBase
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

public class Employee : PersonBase
{
    public string Department { get; set; }
    public decimal AnnualSalary { get; set; }
    public Address Address { get; set; }
}

[Owned]
public class Address
{
    public string House { get; set; }
    public string Street { get; set; }
    public string City { get; set; }

    [Required]
    public string Postcode { get; set; }
}

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

CREATE TABLE [EmployeesWithoutOrdering] (
    [Id] int NOT NULL IDENTITY,
    [Department] nvarchar(max) NULL,
    [AnnualSalary] decimal(18,2) NOT NULL,
    [Address_House] nvarchar(max) NULL,
    [Address_Street] nvarchar(max) NULL,
    [Address_City] nvarchar(max) NULL,
    [Address_Postcode] nvarchar(max) NULL,
    [UpdatedOn] datetime2 NOT NULL,
    [CreatedOn] datetime2 NOT NULL,
    [FirstName] nvarchar(max) NULL,
    [LastName] nvarchar(max) NULL,
    CONSTRAINT [PK_EmployeesWithoutOrdering] PRIMARY KEY ([Id]));

В EF Core 6.0 можно использовать ColumnAttribute для указания другого порядка столбцов. Например:

public class EntityBase
{
    [Column(Order = 1)]
    public int Id { get; set; }

    [Column(Order = 98)]
    public DateTime UpdatedOn { get; set; }

    [Column(Order = 99)]
    public DateTime CreatedOn { get; set; }
}

public class PersonBase : EntityBase
{
    [Column(Order = 2)]
    public string FirstName { get; set; }

    [Column(Order = 3)]
    public string LastName { get; set; }
}

public class Employee : PersonBase
{
    [Column(Order = 20)]
    public string Department { get; set; }

    [Column(Order = 21)]
    public decimal AnnualSalary { get; set; }

    public Address Address { get; set; }
}

[Owned]
public class Address
{
    [Column("House", Order = 10)]
    public string House { get; set; }

    [Column("Street", Order = 11)]
    public string Street { get; set; }

    [Column("City", Order = 12)]
    public string City { get; set; }

    [Required]
    [Column("Postcode", Order = 13)]
    public string Postcode { get; set; }
}

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

CREATE TABLE [EmployeesWithOrdering] (
    [Id] int NOT NULL IDENTITY,
    [FirstName] nvarchar(max) NULL,
    [LastName] nvarchar(max) NULL,
    [House] nvarchar(max) NULL,
    [Street] nvarchar(max) NULL,
    [City] nvarchar(max) NULL,
    [Postcode] nvarchar(max) NULL,
    [Department] nvarchar(max) NULL,
    [AnnualSalary] decimal(18,2) NOT NULL,
    [UpdatedOn] datetime2 NOT NULL,
    [CreatedOn] datetime2 NOT NULL,
    CONSTRAINT [PK_EmployeesWithOrdering] PRIMARY KEY ([Id]));

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

В этом примере также показано, как ColumnAttribute можно использовать для указания имени и порядка столбцов.

Упорядочивание столбцов также можно настроить с помощью API ModelBuilder в OnModelCreating. Например:

modelBuilder.Entity<UsingModelBuilder.Employee>(
    entityBuilder =>
    {
        entityBuilder.Property(e => e.Id).HasColumnOrder(1);
        entityBuilder.Property(e => e.FirstName).HasColumnOrder(2);
        entityBuilder.Property(e => e.LastName).HasColumnOrder(3);

        entityBuilder.OwnsOne(
            e => e.Address,
            ownedBuilder =>
            {
                ownedBuilder.Property(e => e.House).HasColumnName("House").HasColumnOrder(4);
                ownedBuilder.Property(e => e.Street).HasColumnName("Street").HasColumnOrder(5);
                ownedBuilder.Property(e => e.City).HasColumnName("City").HasColumnOrder(6);
                ownedBuilder.Property(e => e.Postcode).HasColumnName("Postcode").HasColumnOrder(7).IsRequired();
            });

        entityBuilder.Property(e => e.Department).HasColumnOrder(8);
        entityBuilder.Property(e => e.AnnualSalary).HasColumnOrder(9);
        entityBuilder.Property(e => e.UpdatedOn).HasColumnOrder(10);
        entityBuilder.Property(e => e.CreatedOn).HasColumnOrder(11);
    });

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

Важно!

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

Минимальный API EF Core

Проблема, рассмотренная на сайте GitHub: № 25192.

В .NET Core 6.0 включены обновленные шаблоны, в которых реализованы упрощенные "минимальные API", что позволяет исключить большой объем стандартного кода, который обычно необходим в приложениях .NET.

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

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSqlite<MyDbContext>("Data Source=mydatabase.db");
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSqlServer<MyDbContext>(@"Server=(localdb)\mssqllocaldb;Database=MyDatabase");
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddCosmos<MyDbContext>(
    "https://localhost:8081",
    "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==");

Они точно эквивалентны следующим:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<MyDbContext>(
    options => options.UseSqlite("Data Source=mydatabase.db"));
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<MyDbContext>(
    options => options.UseSqlServer(@"Server=(localdb)\mssqllocaldb;Database=MyDatabase;ConnectRetryCount=0"));
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<MyDbContext>(
    options => options.UseCosmos(
        "https://localhost:8081",
        "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="));

Примечание.

Минимальные API EF Core поддерживают только базовую регистрацию и настройку DbContext и поставщика. Используйте AddDbContext, AddDbContextPool, AddDbContextFactory и т. д., чтобы получить доступ ко всем типам регистрации и конфигурации, доступным в EF Core.

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

Сохранение контекста синхронизации в SaveChangesAsync

Проблема, рассмотренная на сайте GitHub: № 23971.

Мы изменили код EF Core в выпуске 5.0 и задали для Task.ConfigureAwait значение false во всех случаях, где для асинхронного кода применяется await. Это лучшее решение во всех отношениях при использовании EF Core. Но SaveChangesAsync — это особый случай, так как EF Core задаст созданные значения для отслеживаемых сущностей по завершении асинхронной операции с базой данных. Такие изменения могут активировать уведомления, которые должны выполняться в потоке пользовательского интерфейса. Поэтому мы отменяем это изменение в EF Core 6.0 только для метода SaveChangesAsync.

База данных в памяти: проверка того, что значения обязательных свойств не равны NULL

Проблема, рассмотренная на сайте GitHub: #10613. Эта функция представлена пользователем @fagnercarvalho. Спасибо!

База данных в памяти EF Core теперь будет вызывать исключение, если предпринимается попытка сохранить значение NULL для свойства, помеченного как обязательное. Например, рассмотрим тип User с обязательным свойством Username:

public class User
{
    public int Id { get; set; }

    [Required]
    public string Username { get; set; }
}

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

Microsoft.EntityFrameworkCore.DbUpdateException: отсутствуют обязательные свойства "{'Username'}" для экземпляра типа сущности "Пользователь" со значением ключа "{ID: 1}".

При необходимости эту проверку можно отключить. Например:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .LogTo(Console.WriteLine, new[] { InMemoryEventId.ChangesSaved })
        .UseInMemoryDatabase("UserContextWithNullCheckingDisabled", b => b.EnableNullChecks(false));
}

Сведения об источнике команд для диагностики и перехватчиков

Проблема, рассмотренная на сайте GitHub: № 23719. Эта функция представлена пользователем @Giorgi. Спасибо!

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

public class CommandSourceInterceptor : DbCommandInterceptor
{
    public override InterceptionResult<DbDataReader> ReaderExecuting(
        DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
    {
        if (eventData.CommandSource == CommandSource.SaveChanges)
        {
            Console.WriteLine($"Saving changes for {eventData.Context!.GetType().Name}:");
            Console.WriteLine();
            Console.WriteLine(command.CommandText);
        }

        return result;
    }
}

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

Saving changes for CustomersContext:

SET NOCOUNT ON;
INSERT INTO [Customers] ([Name])
VALUES (@p0);
SELECT [Id]
FROM [Customers]
WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

Улучшенная обработка временных значений

Проблема, рассмотренная на сайте GitHub: № 24245.

EF Core не предоставляет временные значения для экземпляров типа сущности. Например, рассмотрим тип сущности Blog с ключом, сгенерированным хранилищем:

public class Blog
{
    public int Id { get; set; }

    public ICollection<Post> Posts { get; } = new List<Post>();
}

Свойство ключа Id получит временное значение, как только объект Blog будет записан в контекст. Например, при вызове DbContext.Add:

var blog = new Blog();
context.Add(blog);

Временное значение можно получить из средства записи контекста, но оно не задается в экземпляре сущности. Например, этот код:

Console.WriteLine($"Blog.Id value on entity instance = {blog.Id}");
Console.WriteLine($"Blog.Id value tracked by EF = {context.Entry(blog).Property(e => e.Id).CurrentValue}");

Будут получены следующие выходные данные:

Blog.Id value on entity instance = 0
Blog.Id value tracked by EF = -2147482647

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

var blog = new Blog { Id = -1 };
var post1 = new Post { Id = -1, BlogId = -1 };
var post2 = new Post { Id = -2, BlogId = -1 };

context.Add(blog).Property(e => e.Id).IsTemporary = true;
context.Add(post1).Property(e => e.Id).IsTemporary = true;
context.Add(post2).Property(e => e.Id).IsTemporary = true;

Console.WriteLine($"Blog has explicit temporary ID = {blog.Id}");
Console.WriteLine($"Post 1 has explicit temporary ID = {post1.Id} and FK to Blog = {post1.BlogId}");
Console.WriteLine($"Post 2 has explicit temporary ID = {post2.Id} and FK to Blog = {post2.BlogId}");

В EF Core 6.0 значение останется в экземпляре сущности, несмотря на то, что теперь оно помечено как временное. Например, приведенный выше код создает следующие выходные данные:

Blog has explicit temporary ID = -1
Post 1 has explicit temporary ID = -1 and FK to Blog = -1
Post 2 has explicit temporary ID = -2 and FK to Blog = -1

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

var post1 = new Post();
var post2 = new Post();

var blogIdEntry = context.Entry(blog).Property(e => e.Id);
blog.Id = blogIdEntry.CurrentValue;
blogIdEntry.IsTemporary = true;

var post1IdEntry = context.Add(post1).Property(e => e.Id);
post1.Id = post1IdEntry.CurrentValue;
post1IdEntry.IsTemporary = true;
post1.BlogId = blog.Id;

var post2IdEntry = context.Add(post2).Property(e => e.Id);
post2.Id = post2IdEntry.CurrentValue;
post2IdEntry.IsTemporary = true;
post2.BlogId = blog.Id;

Console.WriteLine($"Blog has generated temporary ID = {blog.Id}");
Console.WriteLine($"Post 1 has generated temporary ID = {post1.Id} and FK to Blog = {post1.BlogId}");
Console.WriteLine($"Post 2 has generated temporary ID = {post2.Id} and FK to Blog = {post2.BlogId}");

Результат:

Blog has generated temporary ID = -2147482647
Post 1 has generated temporary ID = -2147482647 and FK to Blog = -2147482647
Post 2 has generated temporary ID = -2147482646 and FK to Blog = -2147482647

EF Core с заметками для ссылочных типов C#, допускающих значения NULL

Проблема, рассмотренная на сайте GitHub: № 19007.

База кода EF Core теперь использует ссылочные типы C#, допускающие значение NULL (NRT) везде. Это означает, что при использовании EF Core 6.0 из собственного кода вы получите правильные указания компилятора на использование значений NULL.

Microsoft.Data.Sqlite 6.0

Совет

Вы можете запустить и отладить весь код примеров, приведенных ниже, скачав пример кода из GitHub.

Объединение подключений в пул

Проблема, рассмотренная на сайте GitHub: № 13837.

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

Console.WriteLine("Starting query...");
Console.WriteLine();

var users = context.Users.ToList();

Console.WriteLine();
Console.WriteLine("Query finished.");
Console.WriteLine();

foreach (var user in users)
{
    if (user.Username.Contains("microsoft"))
    {
        user.Username = "msft:" + user.Username;

        Console.WriteLine("Starting SaveChanges...");
        Console.WriteLine();

        context.SaveChanges();

        Console.WriteLine();
        Console.WriteLine("SaveChanges finished.");
    }
}

Выходные данные этого кода с включенным ведением журнала для подключений имеют следующий вид:

Starting query...

dbug: 8/27/2021 09:26:57.810 RelationalEventId.ConnectionOpened[20001] (Microsoft.EntityFrameworkCore.Database.Connection)
      Opened connection to database 'main' on server 'C:\dotnet\efdocs\samples\core\Miscellaneous\NewInEFCore6\bin\Debug\net6.0\test.db'.
dbug: 8/27/2021 09:26:57.813 RelationalEventId.ConnectionClosed[20003] (Microsoft.EntityFrameworkCore.Database.Connection)
      Closed connection to database 'main' on server 'test.db'.

Query finished.

Starting SaveChanges...

dbug: 8/27/2021 09:26:57.813 RelationalEventId.ConnectionOpened[20001] (Microsoft.EntityFrameworkCore.Database.Connection)
      Opened connection to database 'main' on server 'C:\dotnet\efdocs\samples\core\Miscellaneous\NewInEFCore6\bin\Debug\net6.0\test.db'.
dbug: 8/27/2021 09:26:57.814 RelationalEventId.ConnectionClosed[20003] (Microsoft.EntityFrameworkCore.Database.Connection)
      Closed connection to database 'main' on server 'test.db'.

SaveChanges finished.

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

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

SQLite немного отличается, поскольку доступ к базе данных обычно представляет собой просто доступ к файлу. Это означает, что открытие подключения к базе данных SQLite обычно происходит очень быстро. Однако это не всегда так. Например, открытие подключения к зашифрованной базе данных может выполняться очень медленно. Таким образом, теперь при использовании Microsoft.Data.Sqlite 6.0 подключения SQLite объединяются в пулы.

Поддержка DateOnly и TimeOnly

Проблема, рассмотренная на сайте GitHub: № 24506.

Microsoft.Data.Sqlite 6.0 поддерживает новые типы DateOnly и TimeOnly из .NET 6. Их также можно использовать в EF Core 6.0 с поставщиком SQLite. Как и в случае с SQLite, его собственная система типов означает, что значения из этих типов должны храниться как один из четырех поддерживаемых типов. Microsoft.Data.Sqlite сохраняет их как TEXT. Например, сущность, использующая следующие типы:

public class User
{
    public int Id { get; set; }
    public string Username { get; set; }
    
    public DateOnly Birthday { get; set; }
    public TimeOnly TokensRenewed { get; set; }
}

Сопоставляется со следующей таблицей в базе данных SQLite:

CREATE TABLE "Users" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Users" PRIMARY KEY AUTOINCREMENT,
    "Username" TEXT NULL,
    "Birthday" TEXT NOT NULL,
    "TokensRenewed" TEXT NOT NULL);

Затем эти значения можно сохранять, запрашивать и обновлять обычным образом. Например, следующий запрос LINQ EF Core:

var users = context.Users.Where(u => u.Birthday < new DateOnly(1900, 1, 1)).ToList();

Преобразуется в следующий запрос SQLite:

SELECT "u"."Id", "u"."Birthday", "u"."TokensRenewed", "u"."Username"
FROM "Users" AS "u"
WHERE "u"."Birthday" < '1900-01-01'

И возвращаются только использования с днями рождения до 1900 от РХ:

Found 'ajcvickers'
Found 'wendy'

API точек сохранения

Проблема, рассмотренная на сайте GitHub: #20228.

Мы стандартизируем общий API для точек сохранения в поставщиках ADO.NET. Microsoft.Data.Sqlite теперь поддерживает этот API, в том числе:

  • Save(String) для создания точки сохранения в транзакции;
  • Rollback(String) для отката к предыдущей точке сохранения;
  • Release(String) для освобождения точки сохранения.

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

  • создает транзакцию;
  • отправляет обновление в базу данных;
  • создает точку сохранения;
  • отправляет другое обновление в базу данных;
  • выполняет откат к точке сохранения, созданной ранее;
  • фиксирует транзакцию.
using var connection = new SqliteConnection("Command Timeout=60;DataSource=test.db");
connection.Open();

using var transaction = connection.BeginTransaction();

using (var command = connection.CreateCommand())
{
    command.CommandText = @"UPDATE Users SET Username = 'ajcvickers' WHERE Id = 1";
    command.ExecuteNonQuery();
}

transaction.Save("MySavepoint");

using (var command = connection.CreateCommand())
{
    command.CommandText = @"UPDATE Users SET Username = 'wfvickers' WHERE Id = 2";
    command.ExecuteNonQuery();
}

transaction.Rollback("MySavepoint");

transaction.Commit();

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

Время ожидания команды в строке подключения

Проблема, рассмотренная на сайте GitHub: #22505. Эта функция представлена пользователем @nmichels. Спасибо!

Поставщики ADO.NET поддерживают два отдельных времени ожидания:

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

Время ожидания команды можно задать из кода с помощью DbCommand.CommandTimeout. Многие поставщики теперь также предоставляют время ожидания команды в строке подключения. Microsoft.Data.Sqlite не исключение — в нем используется ключевое слово строки подключения Command Timeout. Например, "Command Timeout=60;DataSource=test.db" в качестве времени ожидания по умолчанию для команд, создаваемых соединением, будет использовать 60 секунд.

Совет

SQLite рассматривает Default Timeout как синоним Command Timeout, что делает их взаимозаменяемыми.