Поделиться через


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

EF Core 8.0 (EF8) был выпущен в ноябре 2023 года.

Совет

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

EF8 требует сборки пакета SDK для .NET 8 и требует выполнения среды выполнения .NET 8. EF8 не будет работать в более ранних версиях .NET и не будет работать в платформа .NET Framework.

Объекты значений с помощью сложных типов

Объекты, сохраненные в базе данных, можно разделить на три широкие категории:

  • Объекты, неструктурированные и удерживающие одно значение. Например, int, Guid, string. IPAddress Это (несколько слабо) называется "примитивными типами".
  • Объекты, структурированные для хранения нескольких значений, и где удостоверение объекта определяется значением ключа. Например Blog, Post, Customer. Они называются типами сущностей.
  • Объекты, структурированные для хранения нескольких значений, но у объекта нет ключа, определяющего удостоверение. Например, Address, Coordinate.

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

EF8 теперь поддерживает "Сложные типы" для покрытия этого третьего типа объекта. Сложные объекты типов:

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

Простой пример

Например, рассмотрим Address тип:

public class Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

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

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required Address Address { get; set; }
    public List<Order> Orders { get; } = new();
}

public class Order
{
    public int Id { get; set; }
    public required string Contents { get; set; }
    public required Address ShippingAddress { get; set; }
    public required Address BillingAddress { get; set; }
    public Customer Customer { get; set; } = null!;
}

Создадим и сохраните клиента с их адресом:

var customer = new Customer
{
    Name = "Willow",
    Address = new() { Line1 = "Barking Gate", City = "Walpole St Peter", Country = "UK", PostCode = "PE14 7AV" }
};

context.Add(customer);
await context.SaveChangesAsync();

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

INSERT INTO [Customers] ([Name], [Address_City], [Address_Country], [Address_Line1], [Address_Line2], [Address_PostCode])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3, @p4, @p5);

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

Примечание.

Мы не планируем сопоставить сложные типы с собственной таблицей. Однако в будущем выпуске мы планируем сохранить сложный тип в виде документа JSON в одном столбце. Проголосуйте за вопрос #31252 , если это важно для вас.

Теперь предположим, что мы хотим отправить заказ клиенту и использовать адрес клиента как адрес доставки по умолчанию. Естественный способ сделать это заключается в копировании Address объекта из Customer объекта Orderв . Например:

customer.Orders.Add(
    new Order { Contents = "Tesco Tasty Treats", BillingAddress = customer.Address, ShippingAddress = customer.Address, });

await context.SaveChangesAsync();

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

INSERT INTO [Orders] ([Contents], [CustomerId],
    [BillingAddress_City], [BillingAddress_Country], [BillingAddress_Line1], [BillingAddress_Line2], [BillingAddress_PostCode],
    [ShippingAddress_City], [ShippingAddress_Country], [ShippingAddress_Line1], [ShippingAddress_Line2], [ShippingAddress_PostCode])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10, @p11);

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

warn: 8/20/2023 12:48:01.678 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update) 
      The same entity is being tracked as different entity types 'Order.BillingAddress#Address' and 'Customer.Address#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
warn: 8/20/2023 12:48:01.687 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update) 
      The same entity is being tracked as different entity types 'Order.ShippingAddress#Address' and 'Customer.Address#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
warn: 8/20/2023 12:48:01.687 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update)
      The same entity is being tracked as different entity types 'Order.ShippingAddress#Address' and 'Order.BillingAddress#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
fail: 8/20/2023 12:48:01.709 CoreEventId.SaveChangesFailed[10000] (Microsoft.EntityFrameworkCore.Update) 
      An exception occurred in the database while saving changes for context type 'NewInEfCore8.ComplexTypesSample+CustomerContext'.
      System.InvalidOperationException: Cannot save instance of 'Order.ShippingAddress#Address' because it is an owned entity without any reference to its owner. Owned entities can only be saved as part of an aggregate also including the owner entity.
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.PrepareToSave()

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

Конфигурация сложных типов

Сложные типы должны быть настроены в модели с помощью атрибутов сопоставления или вызова ComplexProperty API.OnModelCreating Сложные типы не обнаруживаются по соглашению.

Например, Address тип можно настроить с помощью ComplexTypeAttribute:

[ComplexType]
public class Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

Или в OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .ComplexProperty(e => e.Address);

    modelBuilder.Entity<Order>(b =>
    {
        b.ComplexProperty(e => e.BillingAddress);
        b.ComplexProperty(e => e.ShippingAddress);
    });
}

Изменяемость

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

customer.Address.Line1 = "Peacock Lodge";
await context.SaveChangesAsync();

Это приводит к следующему обновлению базы данных при использовании SQL Server:

UPDATE [Customers] SET [Address_Line1] = @p0
OUTPUT 1
WHERE [Id] = @p1;
UPDATE [Orders] SET [BillingAddress_Line1] = @p2, [ShippingAddress_Line1] = @p3
OUTPUT 1
WHERE [Id] = @p4;

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

Совет

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

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

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

Ссылочные типы как сложные типы

Неизменяемый класс

Мы использовали простой, изменяемый class в приведенном выше примере. Чтобы предотвратить проблемы с случайной мутацией, описанной выше, мы можем сделать класс неизменяемым. Например:

public class Address
{
    public Address(string line1, string? line2, string city, string country, string postCode)
    {
        Line1 = line1;
        Line2 = line2;
        City = city;
        Country = country;
        PostCode = postCode;
    }

    public string Line1 { get; }
    public string? Line2 { get; }
    public string City { get; }
    public string Country { get; }
    public string PostCode { get; }
}

Совет

С помощью C# 12 или более поздней версии это определение класса можно упростить с помощью основного конструктора:

public class Address(string line1, string? line2, string city, string country, string postCode)
{
    public string Line1 { get; } = line1;
    public string? Line2 { get; } = line2;
    public string City { get; } = city;
    public string Country { get; } = country;
    public string PostCode { get; } = postCode;
}

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

var currentAddress = customer.Address;
customer.Address = new Address(
    "Peacock Lodge", currentAddress.Line2, currentAddress.City, currentAddress.Country, currentAddress.PostCode);

await context.SaveChangesAsync();

На этот раз вызов обновляет SaveChangesAsync только адрес клиента:

UPDATE [Customers] SET [Address_Line1] = @p0
OUTPUT 1
WHERE [Id] = @p1;

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

Неизменяемая запись

C# 9 представил типы записей, что упрощает создание и использование неизменяемых объектов. Например, Address объект можно сделать тип записи:

public record Address
{
    public Address(string line1, string? line2, string city, string country, string postCode)
    {
        Line1 = line1;
        Line2 = line2;
        City = city;
        Country = country;
        PostCode = postCode;
    }

    public string Line1 { get; init; }
    public string? Line2 { get; init; }
    public string City { get; init; }
    public string Country { get; init; }
    public string PostCode { get; init; }
}

Совет

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

public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

Для замены изменяемого объекта и вызова SaveChanges теперь требуется меньше кода:

customer.Address = customer.Address with { Line1 = "Peacock Lodge" };

await context.SaveChangesAsync();

Типы значений в виде сложных типов

Мутируемая структура

Простой мутируемый тип значения можно использовать в качестве сложного типа. Например, Address можно определить как в struct C#:

public struct Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

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

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

Неизменяемая структура

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

public readonly struct Address(string line1, string? line2, string city, string country, string postCode)
{
    public string Line1 { get; } = line1;
    public string? Line2 { get; } = line2;
    public string City { get; } = city;
    public string Country { get; } = country;
    public string PostCode { get; } = postCode;
}

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

var currentAddress = customer.Address;
customer.Address = new Address(
    "Peacock Lodge", currentAddress.Line2, currentAddress.City, currentAddress.Country, currentAddress.PostCode);

await context.SaveChangesAsync();

Неизменяемая запись структуры

В C# 10 появились struct record типы, что упрощает создание и работу с неизменяемыми записями структур, как это происходит с неизменяемыми записями классов. Например, можно определить Address как неизменяемую запись структуры:

public readonly record struct Address(string Line1, string? Line2, string City, string Country, string PostCode);

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

customer.Address = customer.Address with { Line1 = "Peacock Lodge" };

await context.SaveChangesAsync();

Вложенные сложные типы

Сложный тип может содержать свойства других сложных типов. Например, давайте будем использовать сложный Address тип из выше вместе с сложным типом, и вложить их как внутри другого сложного PhoneNumber типа:

public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

public record PhoneNumber(int CountryCode, long Number);

public record Contact
{
    public required Address Address { get; init; }
    public required PhoneNumber HomePhone { get; init; }
    public required PhoneNumber WorkPhone { get; init; }
    public required PhoneNumber MobilePhone { get; init; }
}

Здесь мы используем неизменяемые записи, так как это хорошее соответствие семантике наших сложных типов, но вложение сложных типов можно сделать с любым вкусом типа .NET.

Примечание.

Мы не используем основной конструктор для Contact типа, так как EF Core пока не поддерживает внедрение сложных значений типов конструктора. Проголосуйте за вопрос 31621 , если это важно для вас.

Мы добавим Contact в качестве свойства Customer:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required Contact Contact { get; set; }
    public List<Order> Orders { get; } = new();
}

И PhoneNumber как свойства Order:

public class Order
{
    public int Id { get; set; }
    public required string Contents { get; set; }
    public required PhoneNumber ContactPhone { get; set; }
    public required Address ShippingAddress { get; set; }
    public required Address BillingAddress { get; set; }
    public Customer Customer { get; set; } = null!;
}

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

[ComplexType]
public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

[ComplexType]
public record PhoneNumber(int CountryCode, long Number);

[ComplexType]
public record Contact
{
    public required Address Address { get; init; }
    public required PhoneNumber HomePhone { get; init; }
    public required PhoneNumber WorkPhone { get; init; }
    public required PhoneNumber MobilePhone { get; init; }
}

Или в OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>(
        b =>
        {
            b.ComplexProperty(
                e => e.Contact,
                b =>
                {
                    b.ComplexProperty(e => e.Address);
                    b.ComplexProperty(e => e.HomePhone);
                    b.ComplexProperty(e => e.WorkPhone);
                    b.ComplexProperty(e => e.MobilePhone);
                });
        });

    modelBuilder.Entity<Order>(
        b =>
        {
            b.ComplexProperty(e => e.ContactPhone);
            b.ComplexProperty(e => e.BillingAddress);
            b.ComplexProperty(e => e.ShippingAddress);
        });
}

Запросы

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

var customer = await context.Customers.FirstAsync(e => e.Id == customerId);

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

SELECT TOP(1) [c].[Id], [c].[Name], [c].[Contact_Address_City], [c].[Contact_Address_Country],
    [c].[Contact_Address_Line1], [c].[Contact_Address_Line2], [c].[Contact_Address_PostCode],
    [c].[Contact_HomePhone_CountryCode], [c].[Contact_HomePhone_Number], [c].[Contact_MobilePhone_CountryCode],
    [c].[Contact_MobilePhone_Number], [c].[Contact_WorkPhone_CountryCode], [c].[Contact_WorkPhone_Number]
FROM [Customers] AS [c]
WHERE [c].[Id] = @__customerId_0

Обратите внимание на две вещи из этого SQL:

  • Все возвращается для заполнения клиента и всех вложенных Contactтипов Addressи PhoneNumber сложных типов.
  • Все значения сложного типа хранятся в виде столбцов в таблице для типа сущности. Сложные типы никогда не сопоставляются с отдельными таблицами.

Проекции.

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

var shippingAddress = await context.Orders
    .Where(e => e.Id == orderId)
    .Select(e => e.ShippingAddress)
    .SingleAsync();

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

SELECT TOP(2) [o].[ShippingAddress_City], [o].[ShippingAddress_Country], [o].[ShippingAddress_Line1],
    [o].[ShippingAddress_Line2], [o].[ShippingAddress_PostCode]
FROM [Orders] AS [o]
WHERE [o].[Id] = @__orderId_0

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

Использование в предикаатах

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

var city = "Walpole St Peter";
var walpoleOrders = await context.Orders.Where(e => e.ShippingAddress.City == city).ToListAsync();

Который преобразуется в следующий SQL server:

SELECT [o].[Id], [o].[Contents], [o].[CustomerId], [o].[BillingAddress_City], [o].[BillingAddress_Country],
    [o].[BillingAddress_Line1], [o].[BillingAddress_Line2], [o].[BillingAddress_PostCode],
    [o].[ContactPhone_CountryCode], [o].[ContactPhone_Number], [o].[ShippingAddress_City],
    [o].[ShippingAddress_Country], [o].[ShippingAddress_Line1], [o].[ShippingAddress_Line2],
    [o].[ShippingAddress_PostCode]
FROM [Orders] AS [o]
WHERE [o].[ShippingAddress_City] = @__city_0

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

var phoneNumber = new PhoneNumber(44, 7777555777);
var customersWithNumber = await context.Customers
    .Where(
        e => e.Contact.MobilePhone == phoneNumber
             || e.Contact.WorkPhone == phoneNumber
             || e.Contact.HomePhone == phoneNumber)
    .ToListAsync();

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

SELECT [c].[Id], [c].[Name], [c].[Contact_Address_City], [c].[Contact_Address_Country], [c].[Contact_Address_Line1],
     [c].[Contact_Address_Line2], [c].[Contact_Address_PostCode], [c].[Contact_HomePhone_CountryCode],
     [c].[Contact_HomePhone_Number], [c].[Contact_MobilePhone_CountryCode], [c].[Contact_MobilePhone_Number],
     [c].[Contact_WorkPhone_CountryCode], [c].[Contact_WorkPhone_Number]
FROM [Customers] AS [c]
WHERE ([c].[Contact_MobilePhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_MobilePhone_Number] = @__entity_equality_phoneNumber_0_Number)
OR ([c].[Contact_WorkPhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_WorkPhone_Number] = @__entity_equality_phoneNumber_0_Number)
OR ([c].[Contact_HomePhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_HomePhone_Number] = @__entity_equality_phoneNumber_0_Number)

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

Манипуляция со сложными значениями типов

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

ComplexProperty Методы EntityEntry возврата записи для всего сложного объекта. Например, чтобы получить текущее значение Order.BillingAddress:

var billingAddress = context.Entry(order)
    .ComplexProperty(e => e.BillingAddress)
    .CurrentValue;

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

var postCode = context.Entry(order)
    .ComplexProperty(e => e.BillingAddress)
    .Property(e => e.PostCode)
    .CurrentValue;

К вложенным сложным типам обращаются с помощью вложенных вызовов ComplexProperty. Например, чтобы получить город из вложенного Address Contact на Customer:

var currentCity = context.Entry(customer)
    .ComplexProperty(e => e.Contact)
    .ComplexProperty(e => e.Address)
    .Property(e => e.City)
    .CurrentValue;

Другие методы доступны для чтения и изменения состояния. Например, PropertyEntry.IsModified можно использовать для задания свойства сложного типа как измененного:

context.Entry(customer)
    .ComplexProperty(e => e.Contact)
    .ComplexProperty(e => e.Address)
    .Property(e => e.PostCode)
    .IsModified = true;

Текущие ограничения

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

К сложным ограничениям типов в EF8 относятся:

  • Поддержка коллекций сложных типов. (Проблема 31237)
  • Разрешить свойства сложного типа иметь значение NULL. (Проблема 31376)
  • Сопоставлять свойства сложного типа со столбцами JSON. (Проблема 31252)
  • Внедрение конструктора для сложных типов. (Проблема 31621)
  • Добавьте поддержку начальных данных для сложных типов. (Проблема 31254)
  • Сопоставление свойств сложного типа для поставщика Cosmos. (Проблема 31253)
  • Реализуйте сложные типы для базы данных в памяти. (Проблема 31464)

Примитивные коллекции

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

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

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

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

Совет

Код, показанный здесь, поступает из PrimitiveCollectionsSample.cs.

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

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

public class PrimitiveCollections
{
    public IEnumerable<int> Ints { get; set; }
    public ICollection<string> Strings { get; set; }
    public IList<DateOnly> Dates { get; set; }
    public uint[] UnsignedInts { get; set; }
    public List<bool> Booleans { get; set; }
    public List<Uri> Urls { get; set; }
}

Примечание.

Что означает "примитивный тип" в этом контексте? По сути, то, что поставщик базы данных знает, как сопоставить, используя какое-то преобразование значений при необходимости. Например, в приведенном выше типе сущности типы intstring, DateTimeDateOnly и bool все обрабатываются без преобразования поставщиком базы данных. SQL Server не поддерживает собственные функции неподписанных значений или URI, но uint по-прежнему Uri рассматриваются как примитивные типы, так как для этих типов существуют встроенные преобразователи значений .

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

modelBuilder
    .Entity<PrimitiveCollections>()
    .Property(e => e.Booleans)
    .HasMaxLength(1024)
    .IsUnicode(false);

Или, используя атрибуты сопоставления:

[MaxLength(2500)]
[Unicode(false)]
public uint[] UnsignedInts { get; set; }

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

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder
        .Properties<List<DateOnly>>()
        .AreUnicode(false)
        .HaveMaxLength(4000);
}

Запросы с примитивными коллекциями

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

public class Pub
{
    public Pub(string name, string[] beers)
    {
        Name = name;
        Beers = beers;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string[] Beers { get; set; }
    public List<DateOnly> DaysVisited { get; private set; } = new();
}

Тип Pub содержит две примитивные коллекции:

  • Beers представляет собой массив строк, представляющих бренды пива, доступные в пабе.
  • DaysVisited — это список дат посещения паба.

Совет

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

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

public class DogWalk
{
    public DogWalk(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public Terrain Terrain { get; set; }
    public List<DateOnly> DaysVisited { get; private set; } = new();
    public Pub ClosestPub { get; set; } = null!;
}

public enum Terrain
{
    Forest,
    River,
    Hills,
    Village,
    Park,
    Beach,
}

Как Pubи, DogWalk также содержит коллекцию дат посещения, и ссылка на ближайший паб с тех пор, вы знаете, иногда собака нуждается в соусе пива после долгой прогулки.

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

var terrains = new[] { Terrain.River, Terrain.Beach, Terrain.Park };
var walksWithTerrain = await context.Walks
    .Where(e => terrains.Contains(e.Terrain))
    .Select(e => e.Name)
    .ToListAsync();

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

SELECT [w].[Name]
FROM [Walks] AS [w]
WHERE [w].[Terrain] IN (1, 5, 4)

Однако эта стратегия не работает хорошо с кэшированием запросов к базе данных; См . объявление о выпуске EF8 Preview 4 в блоге .NET для обсуждения проблемы.

Внимание

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

Для EF Core 8 теперь используется для передачи списка ландшафтов в виде одного параметра, содержащего коллекцию JSON. Например:

@__terrains_0='[1,5,4]'

Затем запрос используется OpenJson в SQL Server:

SELECT [w].[Name]
FROM [Walks] AS [w]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson(@__terrains_0) AS [t]
    WHERE CAST([t].[value] AS int) = [w].[Terrain])

Или json_each на SQLite:

SELECT "w"."Name"
FROM "Walks" AS "w"
WHERE EXISTS (
    SELECT 1
    FROM json_each(@__terrains_0) AS "t"
    WHERE "t"."value" = "w"."Terrain")

Примечание.

OpenJson доступен только в SQL Server 2016 (уровень совместимости 130) и более поздних версий. Вы можете сообщить SQL Server, что вы используете старую версию, настроив уровень совместимости в рамках UseSqlServer. Например:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseSqlServer(
            @"Data Source=(LocalDb)\MSSQLLocalDB;Database=AllTogetherNow",
            sqlServerOptionsBuilder => sqlServerOptionsBuilder.UseCompatibilityLevel(120));

Давайте попробуем другой вид Contains запроса. В этом случае мы будем искать значение коллекции параметров в столбце. Например, любой паб, который акции Heineken:

var beer = "Heineken";
var pubsWithHeineken = await context.Pubs
    .Where(e => e.Beers.Contains(beer))
    .Select(e => e.Name)
    .ToListAsync();

Существующая документация в EF7 содержит подробные сведения о сопоставлении, запросах и обновлениях JSON. Эта документация также применяется к SQLite.

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson([p].[Beers]) AS [b]
    WHERE [b].[value] = @__beer_0)

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

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

var beers = new[] { "Carling", "Heineken", "Stella Artois", "Carlsberg" };
var pubsWithLager = await context.Pubs
    .Where(e => beers.Any(b => e.Beers.Contains(b)))
    .Select(e => e.Name)
    .ToListAsync();

Это преобразуется в следующее в SQL Server:

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson(@__beers_0) AS [b]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson([p].[Beers]) AS [b0]
        WHERE [b0].[value] = [b].[value] OR ([b0].[value] IS NULL AND [b].[value] IS NULL)))

Ниже @__beers_0 приведено ["Carling","Heineken","Stella Artois","Carlsberg"]значение параметра.

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

var thisYear = DateTime.Now.Year;
var pubsVisitedThisYear = await context.Pubs
    .Where(e => e.DaysVisited.Any(v => v.Year == thisYear))
    .Select(e => e.Name)
    .ToListAsync();

Это преобразуется в следующее в SQL Server:

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson([p].[DaysVisited]) AS [d]
    WHERE DATEPART(year, CAST([d].[value] AS date)) = @__thisYear_0)

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

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

var pubsVisitedInOrder = await context.Pubs
    .Select(e => new
    {
        e.Name,
        FirstVisited = e.DaysVisited.OrderBy(v => v).First(),
        LastVisited = e.DaysVisited.OrderByDescending(v => v).First(),
    })
    .OrderBy(p => p.FirstVisited)
    .ToListAsync();

Это преобразуется в следующее в SQL Server:

SELECT [p].[Name], (
    SELECT TOP(1) CAST([d0].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d0]
    ORDER BY CAST([d0].[value] AS date)) AS [FirstVisited], (
    SELECT TOP(1) CAST([d1].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d1]
    ORDER BY CAST([d1].[value] AS date) DESC) AS [LastVisited]
FROM [Pubs] AS [p]
ORDER BY (
    SELECT TOP(1) CAST([d].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d]
    ORDER BY CAST([d].[value] AS date))

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

var walksWithADrink = await context.Walks.Select(
    w => new
    {
        WalkName = w.Name,
        PubName = w.ClosestPub.Name,
        Count = w.DaysVisited.Count(v => w.ClosestPub.DaysVisited.Contains(v)),
        TotalCount = w.DaysVisited.Count
    }).ToListAsync();

Это преобразуется в следующее в SQL Server:

SELECT [w].[Name] AS [WalkName], [p].[Name] AS [PubName], (
    SELECT COUNT(*)
    FROM OpenJson([w].[DaysVisited]) AS [d]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson([p].[DaysVisited]) AS [d0]
        WHERE CAST([d0].[value] AS date) = CAST([d].[value] AS date) OR ([d0].[value] IS NULL AND [d].[value] IS NULL))) AS [Count], (
    SELECT COUNT(*)
    FROM OpenJson([w].[DaysVisited]) AS [d1]) AS [TotalCount]
FROM [Walks] AS [w]
INNER JOIN [Pubs] AS [p] ON [w].[ClosestPubId] = [p].[Id]

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

The Prince of Wales Feathers was visited 5 times in 8 "Ailsworth to Nene" walks.
The Prince of Wales Feathers was visited 6 times in 9 "Caster Hanglands" walks.
The Royal Oak was visited 6 times in 8 "Ferry Meadows" walks.
The White Swan was visited 7 times in 9 "Woodnewton" walks.
The Eltisley was visited 6 times in 8 "Eltisley" walks.
Farr Bay Inn was visited 7 times in 11 "Farr Beach" walks.
Farr Bay Inn was visited 7 times in 9 "Newlands" walks.

Похоже, пиво и собака прогулки являются выигрышной комбинацией!

Примитивные коллекции в документах JSON

Во всех приведенных выше примерах столбец для примитивной коллекции содержит JSON. Однако это не то же самое, что сопоставление типа принадлежащих сущностей с столбцом, содержащим документ JSON, который был представлен в EF7. Но что делать, если сам документ JSON содержит примитивную коллекцию? Ну, все приведенные выше запросы по-прежнему работают так же! Например, предположим, что мы перемещаем дни посещения данных в принадлежащий тип Visits , сопоставленный с документом JSON:

public class Pub
{
    public Pub(string name)
    {
        Name = name;
    }

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

public class Visits
{
    public string? LocationTag { get; set; }
    public List<DateOnly> DaysVisited { get; set; } = null!;
}

Совет

Код, показанный здесь, поступает из PrimitiveCollectionsInJsonSample.cs.

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

var walksWithADrink = await context.Walks.Select(
    w => new
    {
        WalkName = w.Name,
        PubName = w.ClosestPub.Name,
        WalkLocationTag = w.Visits.LocationTag,
        PubLocationTag = w.ClosestPub.Visits.LocationTag,
        Count = w.Visits.DaysVisited.Count(v => w.ClosestPub.Visits.DaysVisited.Contains(v)),
        TotalCount = w.Visits.DaysVisited.Count
    }).ToListAsync();

Это преобразуется в следующее в SQL Server:

SELECT [w].[Name] AS [WalkName], [p].[Name] AS [PubName], JSON_VALUE([w].[Visits], '$.LocationTag') AS [WalkLocationTag], JSON_VALUE([p].[Visits], '$.LocationTag') AS [PubLocationTag], (
    SELECT COUNT(*)
    FROM OpenJson(JSON_VALUE([w].[Visits], '$.DaysVisited')) AS [d]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson(JSON_VALUE([p].[Visits], '$.DaysVisited')) AS [d0]
        WHERE CAST([d0].[value] AS date) = CAST([d].[value] AS date) OR ([d0].[value] IS NULL AND [d].[value] IS NULL))) AS [Count], (
    SELECT COUNT(*)
    FROM OpenJson(JSON_VALUE([w].[Visits], '$.DaysVisited')) AS [d1]) AS [TotalCount]
FROM [Walks] AS [w]
INNER JOIN [Pubs] AS [p] ON [w].[ClosestPubId] = [p].[Id]

И аналогичный запрос при использовании SQLite:

SELECT "w"."Name" AS "WalkName", "p"."Name" AS "PubName", "w"."Visits" ->> 'LocationTag' AS "WalkLocationTag", "p"."Visits" ->> 'LocationTag' AS "PubLocationTag", (
    SELECT COUNT(*)
    FROM json_each("w"."Visits" ->> 'DaysVisited') AS "d"
    WHERE EXISTS (
        SELECT 1
        FROM json_each("p"."Visits" ->> 'DaysVisited') AS "d0"
        WHERE "d0"."value" = "d"."value")) AS "Count", json_array_length("w"."Visits" ->> 'DaysVisited') AS "TotalCount"
FROM "Walks" AS "w"
INNER JOIN "Pubs" AS "p" ON "w"."ClosestPubId" = "p"."Id"

Совет

Обратите внимание, что в SQLite EF Core теперь используется ->> оператор, что приводит к тому, что запросы, которые проще читать и часто более эффективно.

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

Мы упомянули выше, что другой вариант для примитивных коллекций заключается в сопоставлении их с другой таблицей. Поддержка первого класса отслеживается вопросом No 25163; не забудьте проголосовать за этот вопрос, если это важно для вас. Пока это не будет реализовано, лучший подход — создать тип оболочки для примитива. Например, создадим тип для Beer:

[Owned]
public class Beer
{
    public Beer(string name)
    {
        Name = name;
    }

    public string Name { get; private set; }
}

Обратите внимание, что тип просто упаковывает примитивное значение- он не имеет первичного ключа или внешних ключей, определенных. Затем этот тип можно использовать в Pub классе:

public class Pub
{
    public Pub(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public List<Beer> Beers { get; set; } = new();
    public List<DateOnly> DaysVisited { get; private set; } = new();
}

EF теперь создаст таблицу Beer , синтезируя первичный ключ и внешние ключевые столбцы обратно в таблицу Pubs . Например, в SQL Server.

CREATE TABLE [Beer] (
    [PubId] int NOT NULL,
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Beer] PRIMARY KEY ([PubId], [Id]),
    CONSTRAINT [FK_Beer_Pubs_PubId] FOREIGN KEY ([PubId]) REFERENCES [Pubs] ([Id]) ON DELETE CASCADE

Улучшения сопоставления столбцов JSON

EF8 включает улучшения поддержки сопоставления столбцов JSON, появившиеся в EF7.

Совет

Код, показанный здесь, поступает из JsonColumnsSample.cs.

Преобразование доступа к элементам в массивы JSON

EF8 поддерживает индексирование в массивах JSON при выполнении запросов. Например, следующий запрос проверяет, были ли первые два обновления сделаны до заданной даты.

var cutoff = DateOnly.FromDateTime(DateTime.UtcNow - TimeSpan.FromDays(365));
var updatedPosts = await context.Posts
    .Where(
        p => p.Metadata!.Updates[0].UpdatedOn < cutoff
             && p.Metadata!.Updates[1].UpdatedOn < cutoff)
    .ToListAsync();

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

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) < @__cutoff_0
  AND CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) < @__cutoff_0

Примечание.

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

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

var postsAndRecentUpdatesNullable = await context.Posts
    .Select(p => new
    {
        p.Title,
        LatestUpdate = (DateOnly?)p.Metadata!.Updates[0].UpdatedOn,
        SecondLatestUpdate = (DateOnly?)p.Metadata.Updates[1].UpdatedOn
    })
    .ToListAsync();

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

SELECT [p].[Title],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) AS [LatestUpdate],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) AS [SecondLatestUpdate]
FROM [Posts] AS [p]

Как отмечалось выше, возвращает значение NULL, JSON_VALUE если элемент массива не существует. Это обрабатывается в запросе путем приведения проецируемого значения к значению DateOnlyNULL. Альтернативой приведения значения является фильтрация результатов запроса, чтобы JSON_VALUE никогда не возвращать значение NULL. Например:

var postsAndRecentUpdates = await context.Posts
    .Where(p => p.Metadata!.Updates[0].UpdatedOn != null
                && p.Metadata!.Updates[1].UpdatedOn != null)
    .Select(p => new
    {
        p.Title,
        LatestUpdate = p.Metadata!.Updates[0].UpdatedOn,
        SecondLatestUpdate = p.Metadata.Updates[1].UpdatedOn
    })
    .ToListAsync();

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

SELECT [p].[Title],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) AS [LatestUpdate],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) AS [SecondLatestUpdate]
FROM [Posts] AS [p]
      WHERE (CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) IS NOT NULL)
        AND (CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) IS NOT NULL)

Преобразование запросов в внедренные коллекции

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

var searchTerms = new[] { "Search #2", "Search #3", "Search #5", "Search #8", "Search #13", "Search #21", "Search #34" };

var postsWithSearchTerms = await context.Posts
    .Where(post => post.Metadata!.TopSearches.Any(s => searchTerms.Contains(s.Term)))
    .ToListAsync();

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

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OPENJSON([p].[Metadata], '$.TopSearches') WITH (
        [Count] int '$.Count',
        [Term] nvarchar(max) '$.Term'
    ) AS [t]
    WHERE EXISTS (
        SELECT 1
        FROM OPENJSON(@__searchTerms_0) WITH ([value] nvarchar(max) '$') AS [s]
        WHERE [s].[value] = [t].[Term]))

Столбцы JSON для SQLite

EF7 представила поддержку сопоставления столбцов JSON при использовании Azure SQL/SQL Server. EF8 расширяет эту поддержку для баз данных SQLite. Что касается поддержки SQL Server, это включает в себя:

  • Сопоставление агрегатов, созданных из типов .NET, с документами JSON, хранящимися в столбцах SQLite
  • Запросы к столбцам JSON, таким как фильтрация и сортировка по элементам документов
  • Запросы к элементам проекта из документа JSON в результаты
  • Обновление и сохранение изменений в документах JSON

Существующая документация в EF7 содержит подробные сведения о сопоставлении, запросах и обновлениях JSON. Эта документация также применяется к SQLite.

Совет

Код, показанный в документации EF7, также обновлен для запуска в SQLite, можно найти в JsonColumnsSample.cs.

Запросы в столбцы JSON

Запросы к столбцам JSON в SQLite используют функцию json_extract . Например, запрос "авторов в Chigley" из документации, указанной выше:

var authorsInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .ToListAsync();

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

SELECT "a"."Id", "a"."Name", "a"."Contact"
FROM "Authors" AS "a"
WHERE json_extract("a"."Contact", '$.Address.City') = 'Chigley'

Обновление столбцов JSON

Для обновлений EF использует функцию json_set в SQLite. Например, при обновлении одного свойства в документе:

var arthur = await context.Authors.SingleAsync(author => author.Name.StartsWith("Arthur"));

arthur.Contact.Address.Country = "United Kingdom";

await context.SaveChangesAsync();

EF создает следующие параметры:

info: 3/10/2023 10:51:33.127 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='["United Kingdom"]' (Nullable = false) (Size = 18), @p1='4'], CommandType='Text', CommandTimeout='30']

Что использует функцию json_set в SQLite:

UPDATE "Authors" SET "Contact" = json_set("Contact", '$.Address.Country', json_extract(@p0, '$[0]'))
WHERE "Id" = @p1
RETURNING 1;

HierarchyId в .NET и EF Core

Sql Azure и SQL Server имеют специальный тип hierarchyid данных, который используется для хранения иерархических данных. В этом случае "иерархические данные" по сути означает, что данные, формющие структуру дерева, где каждый элемент может иметь родительский и /или дочерний элемент. Примерами таких данных являются:

  • Организационная структура
  • Файловая система
  • группа задач в проекте;
  • Классификация языковых терминов
  • Диаграмма связей между веб-страницами

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

Поддержка в .NET и EF Core

Официальная поддержка типа SQL Server hierarchyid только недавно пришла на современные платформы .NET (т. е. .NET Core). Эта поддержка поддерживается в виде пакета NuGet Microsoft.SqlServer.Types , который обеспечивает низкоуровневые типы SQL Server. В этом случае вызывается SqlHierarchyIdнизкоуровневый тип.

На следующем уровне появился новый пакет Microsoft.EntityFrameworkCore.SqlServer.Abstractions , который включает более высокий HierarchyId тип, предназначенный для использования в типах сущностей.

Совет

Тип HierarchyId является более идиоматичным для норм .NET, чем SqlHierarchyId, который вместо этого моделиируется после того, как платформа .NET Framework типы размещаются в ядре СУБД SQL Server. HierarchyId предназначен для работы с EF Core, но его также можно использовать за пределами EF Core в других приложениях. Пакет Microsoft.EntityFrameworkCore.SqlServer.Abstractions не ссылается на другие пакеты и поэтому имеет минимальное влияние на развернутый размер приложения и зависимости.

HierarchyId Для использования функций EF Core, таких как запросы и обновления, требуется пакет Microsoft.EntityFrameworkCore.SqlServer.HierarchyId. Этот пакет приводит Microsoft.EntityFrameworkCore.SqlServer.Abstractions к транзитивным зависимостям и Microsoft.SqlServer.Types поэтому часто является единственным необходимым пакетом. После установки пакета использование HierarchyId включается путем вызова UseHierarchyId в рамках вызова UseSqlServerприложения. Рассмотрим пример.

options.UseSqlServer(
    connectionString,
    x => x.UseHierarchyId());

Примечание.

Неофициальная поддержка hierarchyid в EF Core доступна на протяжении многих лет с помощью пакета EntityFrameworkCore.SqlServer.HierarchyId . Этот пакет поддерживается как совместная работа между сообществом и командой EF. Теперь, когда в .NET есть официальная поддержка hierarchyid кода из форм пакета сообщества с разрешением исходных участников, основа официального пакета, описанного здесь. Большое спасибо всем участникам в течение многих лет, включая @aljones, @cutig3r, @huan086, @kmataru, @mehdihaghshenas и @vyrotek

Иерархии моделирования

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

public class Halfling
{
    public Halfling(HierarchyId pathFromPatriarch, string name, int? yearOfBirth = null)
    {
        PathFromPatriarch = pathFromPatriarch;
        Name = name;
        YearOfBirth = yearOfBirth;
    }

    public int Id { get; private set; }
    public HierarchyId PathFromPatriarch { get; set; }
    public string Name { get; set; }
    public int? YearOfBirth { get; set; }
}

Совет

Код, показанный здесь, и в приведенных ниже примерах происходит из HierarchyIdSample.cs.

Совет

При необходимости HierarchyId подходит для использования в качестве типа свойства ключа.

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

Удвоение семейного дерева

В этом дереве:

  • Балбо находится в корне дерева, представленного /.
  • Балбо имеет пять детей, представленных /1/, /2/, /3/, /4/и /5/.
  • Первый ребенок Балбо, Мунго, также имеет пять детей, представленных /1/1/, , /1/2/, /1/3//1/4/и /1/5/. Обратите внимание, что HierarchyId балбо (/1/) является префиксом для всех своих детей.
  • Аналогичным образом третий ребенок Балбо, Понто, имеет двух детей, представленных /3/1/ и /3/2/. Снова каждый из этих дочерних элементов префиксируется префиксом HierarchyId для Ponto, который представлен как /3/.
  • И так далее вниз по дереву...

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

await AddRangeAsync(
    new Halfling(HierarchyId.Parse("/"), "Balbo", 1167),
    new Halfling(HierarchyId.Parse("/1/"), "Mungo", 1207),
    new Halfling(HierarchyId.Parse("/2/"), "Pansy", 1212),
    new Halfling(HierarchyId.Parse("/3/"), "Ponto", 1216),
    new Halfling(HierarchyId.Parse("/4/"), "Largo", 1220),
    new Halfling(HierarchyId.Parse("/5/"), "Lily", 1222),
    new Halfling(HierarchyId.Parse("/1/1/"), "Bungo", 1246),
    new Halfling(HierarchyId.Parse("/1/2/"), "Belba", 1256),
    new Halfling(HierarchyId.Parse("/1/3/"), "Longo", 1260),
    new Halfling(HierarchyId.Parse("/1/4/"), "Linda", 1262),
    new Halfling(HierarchyId.Parse("/1/5/"), "Bingo", 1264),
    new Halfling(HierarchyId.Parse("/3/1/"), "Rosa", 1256),
    new Halfling(HierarchyId.Parse("/3/2/"), "Polo"),
    new Halfling(HierarchyId.Parse("/4/1/"), "Fosco", 1264),
    new Halfling(HierarchyId.Parse("/1/1/1/"), "Bilbo", 1290),
    new Halfling(HierarchyId.Parse("/1/3/1/"), "Otho", 1310),
    new Halfling(HierarchyId.Parse("/1/5/1/"), "Falco", 1303),
    new Halfling(HierarchyId.Parse("/3/2/1/"), "Posco", 1302),
    new Halfling(HierarchyId.Parse("/3/2/2/"), "Prisca", 1306),
    new Halfling(HierarchyId.Parse("/4/1/1/"), "Dora", 1302),
    new Halfling(HierarchyId.Parse("/4/1/2/"), "Drogo", 1308),
    new Halfling(HierarchyId.Parse("/4/1/3/"), "Dudo", 1311),
    new Halfling(HierarchyId.Parse("/1/3/1/1/"), "Lotho", 1310),
    new Halfling(HierarchyId.Parse("/1/5/1/1/"), "Poppy", 1344),
    new Halfling(HierarchyId.Parse("/3/2/1/1/"), "Ponto", 1346),
    new Halfling(HierarchyId.Parse("/3/2/1/2/"), "Porto", 1348),
    new Halfling(HierarchyId.Parse("/3/2/1/3/"), "Peony", 1350),
    new Halfling(HierarchyId.Parse("/4/1/2/1/"), "Frodo", 1368),
    new Halfling(HierarchyId.Parse("/4/1/3/1/"), "Daisy", 1350),
    new Halfling(HierarchyId.Parse("/3/2/1/1/1/"), "Angelica", 1381));

await SaveChangesAsync();

Совет

При необходимости десятичные значения можно использовать для создания новых узлов между двумя существующими узлами. Например, /3/2.5/2/ идет между /3/2/2/ и /3/3/2/.

Запросы иерархий

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

Метод Description
GetAncestor(int n) Возвращает уровни узлов n вверх по иерархическим деревьям.
GetDescendant(HierarchyId? child1, HierarchyId? child2) Возвращает значение потомка узла, которое больше child1 и меньше child2.
GetLevel() Возвращает уровень этого узла в иерархическом дереве.
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) Получает значение, представляющее расположение нового узла, имеющего путь от newRoot равного пути к этому пути oldRoot , эффективно перемещая его в новое расположение.
IsDescendantOf(HierarchyId? parent) Возвращает значение, указывающее, является ли этот узел потомком parent.

Кроме того, операторы ==, !=, <<=> и >= можно использовать.

Ниже приведены примеры использования этих методов в запросах LINQ.

Получение сущностей на заданном уровне в дереве

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

var generation = await context.Halflings.Where(halfling => halfling.PathFromPatriarch.GetLevel() == level).ToListAsync();

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

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].GetLevel() = @__level_0

Выполнение этого цикла можно получить полузащиты для каждого поколения:

Generation 0: Balbo
Generation 1: Mungo, Pansy, Ponto, Largo, Lily
Generation 2: Bungo, Belba, Longo, Linda, Bingo, Rosa, Polo, Fosco
Generation 3: Bilbo, Otho, Falco, Posco, Prisca, Dora, Drogo, Dudo
Generation 4: Lotho, Poppy, Ponto, Porto, Peony, Frodo, Daisy
Generation 5: Angelica

Получение прямого предка сущности

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

async Task<Halfling?> FindDirectAncestor(string name)
    => await context.Halflings
        .SingleOrDefaultAsync(
            ancestor => ancestor.PathFromPatriarch == context.Halflings
                .Single(descendent => descendent.Name == name).PathFromPatriarch
                .GetAncestor(1));

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

SELECT TOP(2) [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch] = (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0).GetAncestor(1)

Выполнение этого запроса для полузащиты "Bilbo" возвращает значение "Bungo".

Получение прямых потомков сущности

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

IQueryable<Halfling> FindDirectDescendents(string name)
    => context.Halflings.Where(
        descendent => descendent.PathFromPatriarch.GetAncestor(1) == context.Halflings
            .Single(ancestor => ancestor.Name == name).PathFromPatriarch);

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

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].GetAncestor(1) = (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0)

Выполнение этого запроса для полузащиты "Mungo" возвращает "Bungo", "Белба", "Longo" и "Линда".

Получение всех предков сущности

GetAncestor это полезно для поиска вверх или вниз один уровень или, действительно, указанное количество уровней. С другой стороны, IsDescendantOf полезно найти всех предков или зависимых. Например, следующий запрос используется IsDescendantOf для поиска всех предков полузащиты, учитывая имя таймлинга:

IQueryable<Halfling> FindAllAncestors(string name)
    => context.Halflings.Where(
            ancestor => context.Halflings
                .Single(
                    descendent =>
                        descendent.Name == name
                        && ancestor.Id != descendent.Id)
                .PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch))
        .OrderByDescending(ancestor => ancestor.PathFromPatriarch.GetLevel());

Внимание

IsDescendantOf возвращает значение true для себя, поэтому оно отфильтровывается в приведенном выше запросе.

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

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0 AND [h].[Id] <> [h0].[Id]).IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel() DESC

Выполнение этого запроса для полузащиты "Bilbo" возвращает "Bungo", "Mungo" и "Balbo".

Получение всех потомков сущности

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

IQueryable<Halfling> FindAllDescendents(string name)
    => context.Halflings.Where(
            descendent => descendent.PathFromPatriarch.IsDescendantOf(
                context.Halflings
                    .Single(
                        ancestor =>
                            ancestor.Name == name
                            && descendent.Id != ancestor.Id)
                    .PathFromPatriarch))
        .OrderBy(descendent => descendent.PathFromPatriarch.GetLevel());

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

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].IsDescendantOf((
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0 AND [h].[Id] <> [h0].[Id])) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel()

Выполнение этого запроса для полузащиты "Mungo" возвращает "Bungo", "Belba", "Longo", "Linda", "Bingo", "Bilbo", "Otho", "Falco", "Lotho" и "Poppy".

Поиск общего предка

Одним из наиболее распространенных вопросов, задаваемых об этом конкретном дереве семьи, является "кто общий предок Фродо и Бильбо?" Мы можем использовать IsDescendantOf для написания такого запроса:

async Task<Halfling?> FindCommonAncestor(Halfling first, Halfling second)
    => await context.Halflings
        .Where(
            ancestor => first.PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch)
                        && second.PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch))
        .OrderByDescending(ancestor => ancestor.PathFromPatriarch.GetLevel())
        .FirstOrDefaultAsync();

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

SELECT TOP(1) [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE @__first_PathFromPatriarch_0.IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
  AND @__second_PathFromPatriarch_1.IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel() DESC

Выполнение этого запроса с "Bilbo" и "Frodo" сообщает нам, что их общий предок — "Balbo".

Обновление иерархий

Обычные механизмы отслеживания изменений и SaveChanges можно использовать для обновления hierarchyid столбцов.

Повторное создание родительского элемента в под иерархии

Например, я уверен, что мы все помни скандал SR 1752 (a.k.a. "LongoGate") когда тестирование ДНК показало, что Лонго не был на самом деле сын Мунго, но на самом деле сын Понто! Одним из выпадов из этого скандала было то, что семейное дерево должно быть перезаписано. В частности, Лонго и все его потомки должны быть переучены из Мунго в Понто. GetReparentedValue это можно использовать для этого. Например, сначала "Longo" и все его потомки запрашиваются:

var longoAndDescendents = await context.Halflings.Where(
        descendent => descendent.PathFromPatriarch.IsDescendantOf(
            context.Halflings.Single(ancestor => ancestor.Name == "Longo").PathFromPatriarch))
    .ToListAsync();

Затем GetReparentedValue используется для обновления HierarchyId longo и каждого потомка, за которым следует вызов SaveChangesAsync:

foreach (var descendent in longoAndDescendents)
{
    descendent.PathFromPatriarch
        = descendent.PathFromPatriarch.GetReparentedValue(
            mungo.PathFromPatriarch, ponto.PathFromPatriarch)!;
}

await context.SaveChangesAsync();

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

SET NOCOUNT ON;
UPDATE [Halflings] SET [PathFromPatriarch] = @p0
OUTPUT 1
WHERE [Id] = @p1;
UPDATE [Halflings] SET [PathFromPatriarch] = @p2
OUTPUT 1
WHERE [Id] = @p3;
UPDATE [Halflings] SET [PathFromPatriarch] = @p4
OUTPUT 1
WHERE [Id] = @p5;

Использование следующих параметров:

 @p1='9',
 @p0='0x7BC0' (Nullable = false) (Size = 2) (DbType = Object),
 @p3='16',
 @p2='0x7BD6' (Nullable = false) (Size = 2) (DbType = Object),
 @p5='23',
 @p4='0x7BD6B0' (Nullable = false) (Size = 3) (DbType = Object)

Примечание.

Значения параметров для HierarchyId свойств отправляются в базу данных в их компактном двоичном формате.

После обновления запрос на потомки "Mungo" возвращает значение "Bungo", "Белба", "Линда", "Бинго", "Bilbo", "Фалько" и "Poppy", при запросе к потомкам "Понто" возвращается "Longo", "Роза", "Поло", "Otho", "Posco", "Приска", "Лото", "Понто", "Порто", "Пиони" и "Анжелика".

Необработанные запросы SQL для несопоставленных типов

EF7 представила необработанные запросы SQL, возвращающие скалярные типы. Это улучшено в EF8, чтобы включить необработанные запросы SQL, возвращающие любой тип CLR, доступный для сопоставления, без включения этого типа в модель EF.

Совет

Код, показанный здесь, поступает из RawSqlSample.cs.

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

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    [Content] nvarchar(max) NOT NULL,
    [PublishedOn] date NOT NULL,
    [BlogId] int NOT NULL,
);

SqlQuery можно использовать для запроса этой таблицы и возврата экземпляров BlogPost типа со свойствами, соответствующими столбцам в таблице:

Например:

public class BlogPost
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateOnly PublishedOn { get; set; }
    public int BlogId { get; set; }
}

Например:

var start = new DateOnly(2022, 1, 1);
var end = new DateOnly(2023, 1, 1);
var postsIn2022 =
    await context.Database
        .SqlQuery<BlogPost>($"SELECT * FROM Posts as p WHERE p.PublishedOn >= {start} AND p.PublishedOn < {end}")
        .ToListAsync();

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

SELECT * FROM Posts as p WHERE p.PublishedOn >= @p0 AND p.PublishedOn < @p1

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

public class BlogPost
{
    public BlogPost(string blogTitle, string content, DateOnly publishedOn)
    {
        BlogTitle = blogTitle;
        Content = content;
        PublishedOn = publishedOn;
    }

    public int Id { get; private set; }

    [Column("Title")]
    public string BlogTitle { get; set; }

    public string Content { get; set; }
    public DateOnly PublishedOn { get; set; }
    public int BlogId { get; set; }
}

Примечание.

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

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

public class PostSummary
{
    public string BlogName { get; set; } = null!;
    public string PostTitle { get; set; } = null!;
    public DateOnly? PublishedOn { get; set; }
}

Запросы можно выполнять SqlQuery так же, как и ранее:


var cutoffDate = new DateOnly(2022, 1, 1);
var summaries =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
               FROM Posts AS p
               INNER JOIN Blogs AS b ON p.BlogId = b.Id
               WHERE p.PublishedOn >= {cutoffDate}")
        .ToListAsync();

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

var summariesIn2022 =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
               FROM Posts AS p
               INNER JOIN Blogs AS b ON p.BlogId = b.Id")
        .Where(p => p.PublishedOn >= cutoffDate && p.PublishedOn < end)
        .ToListAsync();

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

SELECT [n].[BlogName], [n].[PostTitle], [n].[PublishedOn]
FROM (
         SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
         FROM Posts AS p
                  INNER JOIN Blogs AS b ON p.BlogId = b.Id
     ) AS [n]
WHERE [n].[PublishedOn] >= @__cutoffDate_1 AND [n].[PublishedOn] < @__end_2

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

var summaries =
    await context.Posts.Select(
            p => new PostSummary
            {
                BlogName = p.Blog.Name,
                PostTitle = p.Title,
                PublishedOn = p.PublishedOn,
            })
        .Where(p => p.PublishedOn >= start && p.PublishedOn < end)
        .ToListAsync();

Что преобразуется в гораздо более чистый SQL:

SELECT [b].[Name] AS [BlogName], [p].[Title] AS [PostTitle], [p].[PublishedOn]
FROM [Posts] AS [p]
INNER JOIN [Blogs] AS [b] ON [p].[BlogId] = [b].[Id]
WHERE [p].[PublishedOn] >= @__start_0 AND [p].[PublishedOn] < @__end_1

Совет

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

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

var summariesFromView =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT * FROM PostAndBlogSummariesView")
        .Where(p => p.PublishedOn >= cutoffDate && p.PublishedOn < end)
        .ToListAsync();

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

var summariesFromFunc =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT * FROM GetPostsPublishedAfter({cutoffDate})")
        .Where(p => p.PublishedOn < end)
        .ToListAsync();

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

var summariesFromStoredProc =
    await context.Database.SqlQuery<PostSummary>(
            @$"exec GetRecentPostSummariesProc")
        .ToListAsync();

Усовершенствования для отложенной загрузки

Отложенная загрузка запросов без отслеживания

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

Совет

Код для примеров ленивой загрузки, показанных ниже, происходит из LazyLoadingSample.cs.

Например, рассмотрим запрос без отслеживания для блогов:

var blogs = await context.Blogs.AsNoTracking().ToListAsync();

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

Console.WriteLine();
Console.Write("Choose a blog: ");
if (int.TryParse(ReadLine(), out var blogId))
{
    Console.WriteLine("Posts:");
    foreach (var post in blogs[blogId - 1].Posts)
    {
        Console.WriteLine($"  {post.Title}");
    }
}

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

foreach (var blog in blogs)
{
    if (context.Entry(blog).Collection(e => e.Posts).IsLoaded)
    {
        Console.WriteLine($" Posts for blog '{blog.Name}' are loaded.");
    }
}

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

  • Отложенная загрузка будет выполнена только до тех пор, пока DbContext не будет удалена сущность.
  • Сущности, запрашиваемые таким образом, поддерживают ссылку на их DbContext, даже если они не отслеживаются им. Будьте внимательны, чтобы избежать утечки памяти, если экземпляры сущностей будут иметь длительные сроки существования.
  • Явно отсоединяя сущность, задав его состояние EntityState.Detached для отключения ссылки на DbContext отложенную загрузку, больше не будет работать.
  • Помните, что все отложенные операции загрузки используют синхронный ввод-вывод, так как нет способа доступа к свойству асинхронно.

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

Явная загрузка из неуправляемых сущностей

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

await context.Entry(blog).Collection(e => e.Posts).LoadAsync();

Отказ от отложенной загрузки для определенных навигаций

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

modelBuilder
    .Entity<Post>()
    .Navigation(p => p.Author)
    .EnableLazyLoading(false);

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

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

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

optionsBuilder.UseLazyLoadingProxies(b => b.IgnoreNonVirtualNavigations());

Доступ к отслеживаемых сущностям

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

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

EF8 содержит новые общедоступные API, чтобы приложения теперь могли использовать эти структуры данных для эффективного поиска отслеживаемых сущностей. Эти API доступны через LocalView<TEntity> тип сущности. Например, чтобы подстановка отслеживаемой сущности по первичному ключу:

var blogEntry = context.Blogs.Local.FindEntry(2)!;

Совет

Код, показанный здесь, поступает из LookupByKeySample.cs.

Метод FindEntry возвращает либо EntityEntry<TEntity> отслеживаемую сущность, либо null если сущность с заданным ключом не отслеживается. Как и все методы LocalView, база данных никогда не запрашивается, даже если сущность не найдена. Возвращаемая запись содержит саму сущность, а также сведения об отслеживании. Например:

Console.WriteLine($"Blog '{blogEntry.Entity.Name}' with key {blogEntry.Entity.Id} is tracked in the '{blogEntry.State}' state.");

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

var siteEntry = context.Websites.Local.FindEntry(nameof(Website.Uri), new Uri("https://www.bricelam.net/"))!;

Или искать уникальный внешний ключ:

var blogAtSiteEntry = context.Blogs.Local.FindEntry(nameof(Blog.SiteUri), new Uri("https://www.bricelam.net/"))!;

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

var postEntries = context.Posts.Local.GetEntries(nameof(Post.BlogId), 2);

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

var archivedPostEntries = context.Posts.Local.GetEntries(nameof(Post.Archived), true);

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

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

var postTagEntry = context.Set<PostTag>().Local.FindEntryUntyped(new object[] { 4, "TagEF" });

Построение модели

Дискриминационные столбцы имеют максимальную длину

В EF8 строковые дискриминационные столбцы, используемые для сопоставления наследования TPH, теперь настраиваются с максимальной длиной. Эта длина вычисляется как наименьшее число Fibonacci, которое охватывает все определенные дискриминационные значения. Например, рассмотрим следующую иерархию:

public abstract class Document
{
    public int Id { get; set; }
    public string Title { get; set; }
}

public abstract class Book : Document
{
    public string? Isbn { get; set; }
}

public class PaperbackEdition : Book
{
}

public class HardbackEdition : Book
{
}

public class Magazine : Document
{
    public int IssueNumber { get; set; }
}

С помощью соглашения об использовании имен классов для дискриминационных значений возможные значения: PaperbackEdition, HardbackEdition и Magazine, поэтому дискриминационный столбец настроен на максимальную длину 21. Например, при использовании SQL Server:

CREATE TABLE [Documents] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    [Discriminator] nvarchar(21) NOT NULL,
    [Isbn] nvarchar(max) NULL,
    [IssueNumber] int NULL,
    CONSTRAINT [PK_Documents] PRIMARY KEY ([Id]),

Совет

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

Поддержка DateOnly/TimeOnly в SQL Server

TimeOnly Эти DateOnly типы были представлены в .NET 6 и поддерживаются для нескольких поставщиков баз данных (например, SQLite, MySQL и PostgreSQL) с момента их внедрения. Для SQL Server недавний выпуск пакета Microsoft.Data.SqlClient , предназначенный для .NET 6, позволил ErikEJ добавить поддержку этих типов на уровне ADO.NET. Это, в свою очередь, проложило путь к поддержке в EF8 для DateOnly и TimeOnly в качестве свойств в типах сущностей.

Совет

DateOnly и TimeOnly его можно использовать в EF Core 6 и 7 с помощью пакета сообщества ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly из @ErikEJ.

Например, рассмотрим следующую модель EF для британских школ:

public class School
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public DateOnly Founded { get; set; }
    public List<Term> Terms { get; } = new();
    public List<OpeningHours> OpeningHours { get; } = new();
}

public class Term
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public DateOnly FirstDay { get; set; }
    public DateOnly LastDay { get; set; }
    public School School { get; set; } = null!;
}

[Owned]
public class OpeningHours
{
    public OpeningHours(DayOfWeek dayOfWeek, TimeOnly? opensAt, TimeOnly? closesAt)
    {
        DayOfWeek = dayOfWeek;
        OpensAt = opensAt;
        ClosesAt = closesAt;
    }

    public DayOfWeek DayOfWeek { get; private set; }
    public TimeOnly? OpensAt { get; set; }
    public TimeOnly? ClosesAt { get; set; }
}

Совет

Код, показанный здесь, поступает из DateOnlyTimeOnlySample.cs.

Примечание.

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

Эти типы сущностей сопоставляют со следующими таблицами при использовании SQL Server. Обратите внимание, что DateOnly свойства сопоставляют со date столбцами и TimeOnly свойства сопоставляют столбцы time .

CREATE TABLE [Schools] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [Founded] date NOT NULL,
    CONSTRAINT [PK_Schools] PRIMARY KEY ([Id]));

CREATE TABLE [OpeningHours] (
    [SchoolId] int NOT NULL,
    [Id] int NOT NULL IDENTITY,
    [DayOfWeek] int NOT NULL,
    [OpensAt] time NULL,
    [ClosesAt] time NULL,
    CONSTRAINT [PK_OpeningHours] PRIMARY KEY ([SchoolId], [Id]),
    CONSTRAINT [FK_OpeningHours_Schools_SchoolId] FOREIGN KEY ([SchoolId]) REFERENCES [Schools] ([Id]) ON DELETE CASCADE);

CREATE TABLE [Term] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [FirstDay] date NOT NULL,
    [LastDay] date NOT NULL,
    [SchoolId] int NOT NULL,
    CONSTRAINT [PK_Term] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Term_Schools_SchoolId] FOREIGN KEY ([SchoolId]) REFERENCES [Schools] ([Id]) ON DELETE CASCADE);

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

openSchools = await context.Schools
    .Where(
        s => s.Terms.Any(
                 t => t.FirstDay <= today
                      && t.LastDay >= today)
             && s.OpeningHours.Any(
                 o => o.DayOfWeek == dayOfWeek
                      && o.OpensAt < time && o.ClosesAt >= time))
    .ToListAsync();

Этот запрос преобразуется в следующий SQL, как показано в ToQueryStringследующем примере:

DECLARE @__today_0 date = '2023-02-07';
DECLARE @__dayOfWeek_1 int = 2;
DECLARE @__time_2 time = '19:53:40.4798052';

SELECT [s].[Id], [s].[Founded], [s].[Name], [o0].[SchoolId], [o0].[Id], [o0].[ClosesAt], [o0].[DayOfWeek], [o0].[OpensAt]
FROM [Schools] AS [s]
LEFT JOIN [OpeningHours] AS [o0] ON [s].[Id] = [o0].[SchoolId]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND [t].[FirstDay] <= @__today_0 AND [t].[LastDay] >= @__today_0) AND EXISTS (
    SELECT 1
    FROM [OpeningHours] AS [o]
    WHERE [s].[Id] = [o].[SchoolId] AND [o].[DayOfWeek] = @__dayOfWeek_1 AND [o].[OpensAt] < @__time_2 AND [o].[ClosesAt] >= @__time_2)
ORDER BY [s].[Id], [o0].[SchoolId]

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

Столбец Значение
Артикул 2
Имя. Фарр средней школы
Основанный 1964-05-01
ОткрытиеHours
[
{ DayOfWeek: "sunday", "ClosesAt": null, "OpenAt": null},
{ DayOfWeek: "Понедельник", "ClosesAt": "15:35:00", "OpenAt": "08:45:00" },
{ DayOfWeek: "вторник", "ClosesAt": "15:35:00", "OpenAt": "08:45:00" },
{ DayOfWeek: "среда", "ClosesAt": "15:35:00", "OpenAt": "08:45:00" },
{ DayOfWeek: "четверг", "ClosesAt": "15:35:00", "OpenAt": "08:45:00" },
{ DayOfWeek: "пятница", "ClosesAt": "12:50:00", "OpenAt": "08:45:00" },
{ DayOfWeek: "суббота", "ClosesAt": null, "OpenAt": null}
]

Объединение двух функций из EF8 теперь можно запрашивать время открытия, индексируя в коллекцию JSON. Например:

openSchools = await context.Schools
    .Where(
        s => s.Terms.Any(
                 t => t.FirstDay <= today
                      && t.LastDay >= today)
             && s.OpeningHours[(int)dayOfWeek].OpensAt < time
             && s.OpeningHours[(int)dayOfWeek].ClosesAt >= time)
    .ToListAsync();

Этот запрос преобразуется в следующий SQL, как показано в ToQueryStringследующем примере:

DECLARE @__today_0 date = '2023-02-07';
DECLARE @__dayOfWeek_1 int = 2;
DECLARE @__time_2 time = '20:14:34.7795877';

SELECT [s].[Id], [s].[Founded], [s].[Name], [s].[OpeningHours]
FROM [Schools] AS [s]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND [t].[FirstDay] <= @__today_0
      AND [t].[LastDay] >= @__today_0)
      AND CAST(JSON_VALUE([s].[OpeningHours],'$[' + CAST(CAST(@__dayOfWeek_1 AS int) AS nvarchar(max)) + '].OpensAt') AS time) < @__time_2
      AND CAST(JSON_VALUE([s].[OpeningHours],'$[' + CAST(CAST(@__dayOfWeek_1 AS int) AS nvarchar(max)) + '].ClosesAt') AS time) >= @__time_2

Наконец, обновления и удаления можно выполнить с помощью отслеживания и saveChanges или с помощью ExecuteUpdate/ExecuteDelete. Например:

await context.Schools
    .Where(e => e.Terms.Any(t => t.LastDay.Year == 2022))
    .SelectMany(e => e.Terms)
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.LastDay, t => t.LastDay.AddDays(1)));

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

UPDATE [t0]
SET [t0].[LastDay] = DATEADD(day, CAST(1 AS int), [t0].[LastDay])
FROM [Schools] AS [s]
INNER JOIN [Term] AS [t0] ON [s].[Id] = [t0].[SchoolId]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND DATEPART(year, [t].[LastDay]) = 2022)

Обратный инженер Synapse и Dynamics 365 TDS

Реверсивная инженерия EF8 (a.k.a. создание шаблонов из существующей базы данных) теперь поддерживает бессерверный пул SQL Synapse Server и базы данных Dynamics 365 конечных точек TDS.

Предупреждение

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

Усовершенствования в математических переводах

Универсальные математические интерфейсы появились в .NET 7. Конкретные типы, такие как double и реализованы эти интерфейсы, добавляя новые API-интерфейсы, зеркально отражающие существующие функциональные возможности Math и MathF.float

EF Core 8 преобразует вызовы этих универсальных математических API в LINQ с помощью существующих переводов SQL поставщиков для Math и MathF. Это означает, что теперь вы можете выбирать между вызовами, как Math.Sin или double.Sin в запросах EF.

Мы работали с командой .NET, чтобы добавить два новых универсальных математических метода в .NET 8, реализованных и double float. Они также переводятся в SQL в EF Core 8.

.NET SQL
DegreesToRadians RADIANS
RadiansToDegrees DEGREES

Наконец, мы работали с Эриком Приемником в проекте SQLitePCLRaw, чтобы включить математические функции SQLite в своих сборках собственной библиотеки SQLite. Это включает в себя собственную библиотеку, которую вы получаете по умолчанию при установке поставщика EF Core SQLite. Это позволяет выполнять несколько новых переводов SQL в LINQ, включая Acos, Acosh, Asin, Asinh, Atan2, Atan2, Atan2, Atanh, Atanh, Cos, Cos, DegreesToRadians, Exp, Floor, Log2, Log2, Log10, Pow, RadiansToDegrees, Sign, Sinh, Sqrt, Tan, Tan, and Truncate.

Проверка ожидающих изменений модели

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

dotnet ef migrations has-pending-model-changes

Вы также можете выполнить эту проверку программным способом в приложении или тестах с помощью нового dbContext.Database.HasPendingModelChanges() метода.

Усовершенствования шаблонов SQLite

SQLite поддерживает только четыре примитивных типа данных- INTEGER, REAL, TEXT и BLOB. Ранее это означало, что при обратной инженерии базы данных SQLite для формирования шаблонов модели EF Core результирующий тип сущностей будет включать только свойства типа long, stringdoubleи byte[]. Дополнительные типы .NET поддерживаются поставщиком EF Core SQLite путем преобразования между ними и одним из четырех примитивных типов SQLite.

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

Название типа столбца Тип .NET
BOOLEAN byte[]bool
SMALLINT длинный короткий
INT длиннаяинта
BIGINT длинный
STRING байт[]string
Формат данных Тип .NET
'0.0' десятичная строка
'1970-01-01' stringDateOnly
'1970-01-01 00:00:00' строкаDateTime
'00:00:00' строкаTimeSpan
'00000000-0000-0000-0000-000000000000' строковыйguid

Значения Sentinel и значения базы данных по умолчанию

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

b.Property(e => e.Status).HasDefaultValue("Hidden");

Или HasDefaultValueSql для произвольных предложений SQL:

b.Property(e => e.LeaseDate).HasDefaultValueSql("getutcdate()");

Совет

Приведенный ниже код поступает из DefaultConstraintSample.cs.

Чтобы EF использовал это, он должен определить, когда и когда не отправлять значение для столбца. По умолчанию EF использует clR по умолчанию в качестве sentinel для этого. То есть, если значение Status или LeaseDate в приведенных выше примерах по умолчанию clR для этих типов, EF интерпретирует это, чтобы означать, что свойство не задано, и поэтому не отправляет значение в базу данных. Это хорошо подходит для ссылочных типов, например, если string свойство имеет значениеnull, то EF не отправляется null в базу данных, но не содержит никакого значения, чтобы база данных по умолчанию ("Hidden"Status) использовалась. Аналогичным образом для DateTime свойства LeaseDateEF не будет вставлять значение 1/1/0001 12:00:00 AMпо умолчанию CLR, но вместо этого опустит это значение, чтобы база данных использовалась по умолчанию.

Однако в некоторых случаях значение по умолчанию CLR является допустимым для вставки. EF8 обрабатывает это путем разрешения изменения значения sentinel для столбца. Например, рассмотрим целый столбец, настроенный с базой данных по умолчанию:

b.Property(e => e.Credits).HasDefaultValueSql(10);

В этом случае мы хотим, чтобы новая сущность была вставлена с заданным числом кредитов, если это не указано, в этом случае назначается 10 кредитов. Однако это означает, что вставка записи с нулевыми кредитами невозможна, так как нулю используется значение по умолчанию CLR, поэтому EF не будет отправлять значения. В EF8 это можно исправить, изменив sentinel для свойства с нуля на -1:

b.Property(e => e.Credits).HasDefaultValueSql(10).HasSentinel(-1);

EF теперь будет использовать только значение по умолчанию базы данных, если Credits задано -1значение ; значение нуля будет вставлено, как и любой другой объем.

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

public class Person
{
    public int Id { get; set; }
    public int Credits { get; set; } = -1;
}

Это означает, что значение sentinel значения -1 устанавливается автоматически при создании экземпляра, что означает, что свойство запускается в его состоянии "не задано".

Совет

Если вы хотите настроить ограничение по умолчанию базы данных для использования при Migrations создании столбца, но необходимо, чтобы EF всегда вставлял значение, настройте свойство как не созданное. Например, b.Property(e => e.Credits).HasDefaultValueSql(10).ValueGeneratedNever();.

Значения по умолчанию базы данных для логических значений

Логические свойства представляют собой крайнюю форму этой проблемы, так как clR по умолчанию (false) является одним из двух допустимых значений. Это означает, что bool свойство с ограничением по умолчанию базы данных будет вставляться только в том случае, если это значение.true Если значение falseпо умолчанию базы данных равно, то это означает, что значение свойства равноfalse, то будет использоваться база данных по умолчанию.false В противном случае, если значение свойства равно true, true будет вставлено. Таким образом, если база данных используется falseпо умолчанию, столбец базы данных заканчивается правильным значением.

С другой стороны, если значение trueпо умолчанию базы данных равно, это означает, что значение свойства равно false, то будет использоваться база данных по умолчанию, которая имеет trueзначение ! И когда значение свойства равно true, будет true вставлено. Таким образом, значение в столбце всегда заканчивается true в базе данных независимо от значения свойства.

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

Совет

При создании шаблонов из существующей базы данных EF8 анализирует и включает простые значения по умолчанию в HasDefaultValue вызовы. (Ранее все значения по умолчанию были шаблонированы как непрозрачные HasDefaultValueSql вызовы.) Это означает, что столбцы логических true столбцов без значения NULL со значением по умолчанию базы данных или false константной базы данных больше не создаются как допустимые значения NULL.

Значения по умолчанию базы данных для перечислений

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

public class Course
{
    public int Id { get; set; }
    public Level Level { get; set; }
}

public enum Level
{
    Beginner,
    Intermediate,
    Advanced,
    Unspecified
}

Затем Level свойство настраивается с базой данных по умолчанию:

modelBuilder.Entity<Course>()
    .Property(e => e.Level)
    .HasDefaultValue(Level.Intermediate);

В этой конфигурации EF исключит отправку значения в базу данных, если она задана Level.Beginner, и вместо этого Level.Intermediate назначается базой данных. Это не то, что было предназначено!

Проблема не возникла бы, если перечисление было определено с значением "неизвестно" или "не указано" в базе данных по умолчанию:

public enum Level
{
    Unspecified,
    Beginner,
    Intermediate,
    Advanced
}

Однако не всегда можно изменить существующую перечисление, поэтому в EF8 можно снова указать sentinel. Например, вернитесь к исходному перечислению:

modelBuilder.Entity<Course>()
    .Property(e => e.Level)
    .HasDefaultValue(Level.Intermediate)
    .HasSentinel(Level.Unspecified);

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

public class Course
{
    public int Id { get; set; }
    public Level Level { get; set; } = Level.Unspecified;
}

Использование поля резервной копии, допускающего значение NULL

Более общий способ обработки проблемы, описанной выше, заключается в создании поля резервной копии, допускающего значение NULL, для свойства, не допускающего значение NULL. Например, рассмотрим следующий тип сущности со свойством bool :

public class Account
{
    public int Id { get; set; }
    public bool IsActive { get; set; }
}

Свойство можно указать поле резервной копии, допускающее значение NULL:

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

    private bool? _isActive;

    public bool IsActive
    {
        get => _isActive ?? false;
        set => _isActive = value;
    }
}

Резервное поле здесь останется null , если на самом деле не вызывается метод задания свойств. То есть значение резервного поля лучше указывает, задано ли свойство или нет, чем значение по умолчанию среды CLR свойства. Это работает вне поля с EF, так как EF будет использовать резервное поле для чтения и записи свойства по умолчанию.

Улучшение ExecuteUpdate и ExecuteDelete

Команды SQL, выполняющие обновления и удаления, такие как созданные ExecuteUpdate и ExecuteDelete методы, должны быть нацелены на одну таблицу базы данных. Однако в EF7 и ExecuteDelete не поддерживает обновления, обращающиеся к нескольким типам сущностей, ExecuteUpdate даже если запрос в конечном итоге повлиял на одну таблицу. EF8 удаляет это ограничение. Например, рассмотрим тип сущности с CustomerInfo собственным типомCustomer:

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

[Owned]
public class CustomerInfo
{
    public string? Tag { get; set; }
}

Оба этих типа сущностей сопоставляют с таблицей Customers . Однако следующее массовое обновление завершается сбоем в EF7, так как использует оба типа сущностей:

await context.Customers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(
        s => s.SetProperty(b => b.CustomerInfo.Tag, "Tagged")
            .SetProperty(b => b.Name, b => b.Name + "_Tagged"));

В EF8 теперь это преобразуется в следующий SQL при использовании SQL Azure:

UPDATE [c]
SET [c].[Name] = [c].[Name] + N'_Tagged',
    [c].[CustomerInfo_Tag] = N'Tagged'
FROM [Customers] AS [c]
WHERE [c].[Name] = @__name_0

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

await context.CustomersWithStores
    .Where(e => e.Region == "France")
    .Union(context.Stores.Where(e => e.Region == "France").SelectMany(e => e.Customers))
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Tag, "The French Connection"));

В EF8 этот запрос создает следующее при использовании SQL Azure:

UPDATE [c]
SET [c].[Tag] = N'The French Connection'
FROM [CustomersWithStores] AS [c]
INNER JOIN (
    SELECT [c0].[Id], [c0].[Name], [c0].[Region], [c0].[StoreId], [c0].[Tag]
    FROM [CustomersWithStores] AS [c0]
    WHERE [c0].[Region] = N'France'
    UNION
    SELECT [c1].[Id], [c1].[Name], [c1].[Region], [c1].[StoreId], [c1].[Tag]
    FROM [Stores] AS [s]
    INNER JOIN [CustomersWithStores] AS [c1] ON [s].[Id] = [c1].[StoreId]
    WHERE [s].[Region] = N'France'
) AS [t] ON [c].[Id] = [t].[Id]

В последнем примере в EF8 ExecuteUpdate можно использовать для обновления сущностей в иерархии TPT, если все обновленные свойства сопоставляются с одной таблицей. Например, рассмотрим эти типы сущностей, сопоставленные с помощью TPT:

[Table("TptSpecialCustomers")]
public class SpecialCustomerTpt : CustomerTpt
{
    public string? Note { get; set; }
}

[Table("TptCustomers")]
public class CustomerTpt
{
    public int Id { get; set; }
    public required string Name { get; set; }
}

С помощью EF8 Note свойство можно обновить:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Note, "Noted"));

Name Или можно обновить свойство:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Name, b => b.Name + " (Noted)"));

Однако EF8 не пытается обновить Name Note и свойства, так как они сопоставлены с разными таблицами. Например:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Note, "Noted")
        .SetProperty(b => b.Name, b => b.Name + " (Noted)"));

Вызывает следующее исключение:

The LINQ expression 'DbSet<SpecialCustomerTpt>()
    .Where(s => s.Name == __name_0)
    .ExecuteUpdate(s => s.SetProperty<string>(
        propertyExpression: b => b.Note,
        valueExpression: "Noted").SetProperty<string>(
        propertyExpression: b => b.Name,
        valueExpression: b => b.Name + " (Noted)"))' could not be translated. Additional information: Multiple 'SetProperty' invocations refer to different tables ('b => b.Note' and 'b => b.Name'). A single 'ExecuteUpdate' call can only update the columns of a single table. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

Лучшее использование IN запросов

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

var blogsWithPosts = await context.Blogs
    .Where(b => context.Posts.Select(p => p.BlogId).Contains(b.Id))
    .ToListAsync();

EF7 создает следующее для PostgreSQL:

SELECT b."Id", b."Name"
      FROM "Blogs" AS b
      WHERE EXISTS (
          SELECT 1
          FROM "Posts" AS p
          WHERE p."BlogId" = b."Id")

Так как вложенный запрос ссылается на внешнюю Blogs таблицу (черезb."Id"), это коррелированная подзапроса, то есть Posts вложенный запрос должен выполняться для каждой строки в Blogs таблице. В EF8 вместо этого создается следующий SQL:

SELECT b."Id", b."Name"
      FROM "Blogs" AS b
      WHERE b."Id" IN (
          SELECT p."BlogId"
          FROM "Posts" AS p
      )

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

Числовые строки для SQL Azure/SQL Server

Автоматическое оптимистическое параллелизм SQL Server обрабатывается с помощью rowversion столбцов. A rowversion — это 8-байтовое непрозрачное значение, передаваемое между базой данных, клиентом и сервером. По умолчанию SqlClient предоставляет rowversion типы, так как byte[], несмотря на изменяемые ссылочные типы, которые плохо соответствуют rowversion семантике. В EF8 вместо этого легко сопоставить rowversion столбцы или long ulong свойства. Например:

modelBuilder.Entity<Blog>()
    .Property(e => e.RowVersion)
    .IsRowVersion();

Удаление круглых скобок

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

await ctx.Customers  
    .Where(c => c.Id * 3 + 2 > 0 && c.FirstName != null || c.LastName != null)  
    .ToListAsync();  

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

SELECT [c].[Id], [c].[City], [c].[FirstName], [c].[LastName], [c].[Street]
FROM [Customers] AS [c]
WHERE ((([c].[Id] * 3) + 2) > 0 AND ([c].[FirstName] IS NOT NULL)) OR ([c].[LastName] IS NOT NULL)

Это улучшено до следующего при использовании EF8:

SELECT [c].[Id], [c].[City], [c].[FirstName], [c].[LastName], [c].[Street]
FROM [Customers] AS [c]
WHERE ([c].[Id] * 3 + 2 > 0 AND [c].[FirstName] IS NOT NULL) OR [c].[LastName] IS NOT NULL

Конкретное предложение RETURNING/OUTPUT

EF7 изменил sql по умолчанию, чтобы использовать RETURNING/OUTPUT для получения резервных столбцов, созданных базой данных. Некоторые случаи, когда это не работает, поэтому EF8 вводит явные отказы от этого поведения.

Например, чтобы отказаться от OUTPUT использования поставщика SQL Server или Azure SQL:

 modelBuilder.Entity<Customer>().ToTable(tb => tb.UseSqlOutputClause(false));

Или отказаться от RETURNING использования поставщика SQLite:

 modelBuilder.Entity<Customer>().ToTable(tb => tb.UseSqlReturningClause(false));

Другие незначительные изменения

В дополнение к описанным выше улучшениям было внесено много небольших изменений в EF8. В том числе: