EF Core 8의 새로운 기능

EF Core 8.0(EF8)은 2023년 11월에 릴리스되었습니다.

GitHub에서 샘플 코드를 다운로드하여 샘플을 실행하고 디버그할 수 있습니다. 각 섹션은 해당 섹션과 관련된 소스 코드에 연결됩니다.

EF8을 빌드하려면 .NET 8 SDK가 필요하며 .NET 8 런타임을 실행해야 합니다. EF8은 이전 .NET 버전에서 실행되지 않으며 .NET Framework에서 실행되지 않습니다.

복합 형식을 사용하는 값 개체

데이터베이스에 저장된 개체는 다음 세 가지 광범위한 범주로 분할할 수 있습니다.

  • 구조화되지 않고 단일 값을 보유하는 개체. 예: int, Guid, string, IPAddress. 이러한 개체를 (대략적으로) "기본 형식"이라고 합니다.
  • 여러 값을 보유하도록 구조화되고 개체의 ID가 키 값으로 정의되는 개체. 예: Blog, Post, Customer. 이러한 개체를 “엔터티 형식”이라고 합니다.
  • 여러 값을 보유하도록 구조화되었지만 이 개체에는 키 정의 ID가 없습니다. 예: 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 엔터티 형식의 단일 인스턴스가 세 개의 다른 엔터티 인스턴스에 사용되기 때문입니다. 반면에, 복합 속성 간에 동일한 인스턴스를 공유할 수 있으므로 복합 형식을 사용할 때 코드가 예상대로 작동합니다.

복합 형식의 구성

매핑 특성을 사용하거나 OnModelCreating에서 ComplexProperty API를 호출하여 모델에서 복합 형식을 구성해야 합니다. 복합 형식은 기본적으로 검색되지 않습니다.

예를 들어 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 열은 모두 동일한 인스턴스를 공유하므로 변경되었습니다. 이것은 일반적으로 우리가 원하는 것이 아닙니다.

고객 주소가 변경되면 주문 주소가 자동으로 변경되는 경우 주소를 엔터티 형식으로 매핑하는 것이 좋습니다. OrderCustomer는 탐색 속성을 통해 동일한 주소 인스턴스(이제 키로 식별됨)를 안전하게 참조할 수 있습니다.

이와 같은 문제를 처리하는 좋은 방법은 형식을 변경할 수 없게 만드는 것입니다. 실제로 이러한 불변성은 형식이 복합 형식이 되기에 적합한 후보일 때 자연스러운 경우가 종종 있습니다. 예를 들어, 일반적으로 나머지를 동일하게 유지하면서 국가를 변경하기보다는 복잡한 새 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();

복합 형식으로서의 값 형식

변경 가능한 구조체

간단한 변경 가능한 값 형식을 복합 형식으로 사용할 수 있습니다. 예를 들어 C#에서 Addressstruct로 정의할 수 있습니다.

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 형식의 모든 버전으로 수행할 수 있습니다.

참고 항목

EF Core는 아직 복합 형식 값의 생성자 주입을 지원하지 않으므로 Contact 형식의 기본 생성자는 사용하지 않으려고 합니다. 문제 #31621이 중요하면 투표하세요.

Customer의 속성으로 Contact를 추가합니다.

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();
}

또한 PhoneNumberOrder의 속성으로 추가합니다.

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 Server를 사용하는 경우 다음 SQL로 변환됩니다.

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, AddressPhoneNumber 복합 형식으로 채우기 위한 모든 항목이 반환됩니다.
  • 모든 복합 형식 값은 엔터티 형식에 대한 테이블의 열로 저장됩니다. 복합 형식은 별도의 테이블에 매핑되지 않습니다.

프로젝션

복합 형식은 쿼리에서 프로젝션할 수 있습니다. 예를 들어 주문에서 배송 주소만 선택하면

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

복합 형식 개체에는 추적에 사용할 ID가 없으므로 복합 형식의 프로젝션을 추적할 수 없습니다.

조건자에서 사용

복합 형식의 멤버는 조건자에서 사용할 수 있습니다. 예를 들어 특정 도시로 가는 모든 주문을 찾는 경우

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

SQL Server에서 다음 SQL로 변환됩니다.

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)

복합 형식의 각 멤버를 확장하여 일치하는지 확인합니다. ID에 대한 키가 없는 복합 형식에 대해 일치를 확인하므로, 모든 멤버가 같은 경우에만 복합 형식 인스턴스가 다른 복합 형식 인스턴스와 같은 것입니다. 또한 레코드 형식에 대해 .NET에서 정의한 일치 조건을 따릅니다.

복합 형식 값 조작

EF8에서는 복합 형식의 현재 및 원래 값과 속성 값이 수정되었는지 여부와 같은 추적 정보에 액세스할 수 있습니다. API 복합 형식은 엔터티 형식에 이미 사용되는 변경 내용 추적 API를 확장한 것입니다.

EntityEntryComplexProperty 메서드는 전체 복합 개체에 대한 항목을 반환합니다. 예를 들어 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에 대한 중첩된 호출을 사용하여 액세스됩니다. 예를 들어 Customer에 대해 Contact의 중첩된 Address에서 구/군/시를 가져오려고 할 수 있습니다.

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을 serialization 형식으로 사용하여 두 번째 옵션에 대한 기본 제공 지원을 포함합니다. 최신 관계형 데이터베이스에는 JSON을 쿼리하고 조작하기 위한 기본 제공 메커니즘이 포함되어 있으므로 JSON 열은 필요할 때 실제로 해당 테이블을 만드는 오버헤드 없이 테이블로 처리될 수 있습니다. 이러한 동일한 메커니즘을 사용하면 JSON을 매개 변수로 전달한 다음 쿼리의 테이블 반환 매개 변수와 비슷한 방식으로 사용할 수 있습니다. 나중에 이에 대해 자세히 설명합니다.

여기에 표시된 코드는 PrimitiveCollectionsSample.cs에서 제공됩니다.

기본 속성 작성

EF Core는 T이(가) 기본 형식인 모든 IEnumerable<T> 속성을 데이터베이스의 JSON 열에 매핑할 수 있습니다. getter와 setter가 모두 있는 공용 속성에 대한 규칙에 의해 수행됩니다. 예를 들어 다음 엔터티 형식의 모든 속성은 규칙에 따라 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; }
}

참고 항목

이 컨텍스트에서 "기본 형식"은 무엇을 의미합니까? 기본적으로 데이터베이스 공급자는 필요한 경우 일종의 값 변환을 사용하여 매핑하는 방법을 알고 있습니다. 예를 들어 위의 엔터티 형식에서 형식 int, string, DateTime, DateOnlybool 모두 데이터베이스 공급자가 변환하지 않고 처리합니다. SQL Server는 서명되지 않은 ints 또는 URI에 대한 기본 지원을 제공하지 않지만 uintUri은(는) 이러한 형식에 대한 기본 값 변환기가 있기 때문에 여전히 기본 형식으로 처리됩니다.

기본적으로 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)

그러나 이 전략은 데이터베이스 쿼리 캐싱과 잘 작동하지 않습니다. 이 문제에 대한 설명은 .NET 블로그에서 EF8 미리 보기 4 발표를 참조하세요.

Important

여기서 값의 인라인 처리는 SQL 삽입 공격 가능성이 없는 방식으로 수행됩니다. 아래에 설명된 JSON 사용 변경은 성능에 관한 것이며 보안과는 아무런 관련이 없습니다.

EF Core 8의 경우 기본값은 이제 지형 목록을 JSON 컬렉션을 포함하는 단일 매개 변수로 전달하는 것입니다. 예시:

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

그런 다음, 쿼리는 SQL Server에서 OpenJson을(를) 사용합니다.

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])

또는 SQLite에서 json_each을(를) 사용합니다.

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) 이상에서만 사용할 수 있습니다. 호환성 수준을 UseSqlServer의 일부로 구성하여 이전 버전을 사용 중임을 SQL Server에 알릴 수 있습니다. 예시:

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

다른 종류의 Contains 쿼리를 시도해 보겠습니다. 이 경우 열에서 매개 변수 컬렉션의 값을 찾습니다. 예를 들어 하이네켄을 보유하고 있는 펍은 다음과 같습니다.

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)

EF는 기본 컬렉션에 날짜가 포함되어 있다는 것을 알고 있으므로 쿼리는 여기에 날짜별 함수 DATEPART을(를) 사용합니다. 그런 것처럼 보이지 않을 수도 있지만 이것은 실제로 정말 중요합니다. 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이 포함됩니다. 그러나 EF7에서 도입된 JSON 문서가 포함된 열에 소유 엔터티 형식을 매핑하는 것과는 다릅니다. 그러나 JSON 문서 자체에 기본 컬렉션이 포함되어 있으면 어떻게 될까요? 위의 모든 쿼리는 여전히 같은 방식으로 작동합니다! 예를 들어 방문한 날 데이터를 JSON 문서에 매핑된 소유 형식 Visits(으)로 이동했다고 생각해 보세요.

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에서 ->> 연산자를 사용하므로 읽기 쉽고 성능이 더 좋은 쿼리가 생성됩니다.

기본 컬렉션을 테이블에 매핑

위에서 언급한 기본 컬렉션의 또 다른 옵션은 다른 테이블에 매핑하는 것입니다. 이에 대한 첫 번째 클래스 지원은 문제 #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에는 EF7에 도입된 JSON 열 매핑 지원이 개선되었습니다.

여기에 표시된 코드는 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 Server를 사용하는 경우 다음 SQL로 변환됩니다.

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 Server를 사용하는 경우 다음 SQL로 변환됩니다.

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]

위에서 설명한 대로 JSON_VALUE은(는) 배열의 요소가 없으면 null을 반환합니다. 이는 쿼리에서 프로젝션된 값을 nullable DateOnly(으)로 캐스팅하여 처리됩니다. 값을 캐스팅하는 대안은 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 Server를 사용하는 경우 다음 SQL로 변환됩니다.

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 Server를 사용하는 경우 다음 SQL로 변환됩니다.

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]))

SQLite의 JSON 열

EF7은 Azure SQL/SQL Server를 사용할 때 JSON 열에 매핑하기 위한 지원을 도입했습니다. EF8은 이 지원을 SQLite 데이터베이스로 확장합니다. SQL Server 지원에는 다음이 포함됩니다.

  • .NET 형식에서 빌드된 집계를 SQLite 열에 저장된 JSON 문서로 매핑
  • JSON 열에 대한 쿼리(예: 문서 요소별 필터링 및 정렬)
  • JSON 문서의 요소를 결과로 프로젝션하는 쿼리
  • JSON 문서의 변경 내용 업데이트 및 저장

기존 EF7의 새로운 기능 설명서에서는 JSON 매핑, 쿼리 및 업데이트에 대한 자세한 정보를 제공합니다. 이 설명서는 이제 SQLite에도 적용됩니다.

EF7 설명서에 표시된 코드는 SQLite에서도 실행되도록 업데이트되었으며 JsonColumnsSample.cs에서 찾을 수 있습니다.

JSON 열에 대한 쿼리

SQLite의 JSON 열에 대한 쿼리는 json_extract 함수를 사용합니다. 예를 들어 위에서 참조한 설명서의 "Chigley의 작성자" 쿼리는 다음과 같습니다.

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

SQLite를 사용하는 경우 다음 SQL로 변환됩니다.

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

JSON 열 업데이트

업데이트의 경우 EF는 SQLite에서 json_set 함수를 사용합니다. 예를 들어 문서에서 단일 속성을 업데이트하는 경우:

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']

SQLite에서 json_set 함수를 사용합니다.

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

.NET 및 EF Core의 HierarchyId

Azure SQL 및 SQL Server에는 계층적 데이터를 저장하는 데 사용되는 hierarchyid(이)라는 특수 데이터 형식이 있습니다. 이 경우 "계층적 데이터"는 기본적으로 각 항목에 부모 및/또는 자식이 있을 수 있는 트리 구조를 형성하는 데이터를 의미합니다. 이러한 데이터의 예는 다음과 같습니다.

  • 조직 구조
  • 파일 시스템
  • 프로젝트의 태스크 집합
  • 언어 용어의 분류
  • 웹 페이지 간 링크의 그래프

그런 다음 데이터베이스는 계층 구조를 사용하여 이 데이터에 대해 쿼리를 실행할 수 있습니다. 예를 들어 쿼리는 지정된 항목의 상위 항목과 종속 항목을 찾거나 계층 구조의 특정 깊이에서 모든 항목을 찾을 수 있습니다.

.NET 및 EF Core의 지원

SQL Server hierarchyid 유형에 대한 공식 지원은 최근에 최신 .NET 플랫폼(예: ".NET Core")에 있습니다. 이 지원은 낮은 수준의 SQL Server 특정 형식을 제공하는 Microsoft.SqlServer.Types NuGet 패키지 형식입니다. 이 경우 하위 수준 형식을 SqlHierarchyId(이)라고 합니다.

다음 수준에서는 엔터티 형식에서 사용하기 위한 상위 수준 HierarchyId 형식을 포함하는 새 Microsoft.EntityFrameworkCore.SqlServer.Abstractions 패키지가 도입되었습니다.

HierarchyId 형식은 SqlHierarchyId보다 .NET의 표준에 더 특화되어 있으며, 대신 .NET Framework 형식이 SQL Server 데이터베이스 엔진 내에서 호스트되는 방식을 모델링합니다. HierarchyId은(는) EF Core에서 작동하도록 설계되었지만 다른 애플리케이션에서는 EF Core 외부에서도 사용할 수 있습니다. Microsoft.EntityFrameworkCore.SqlServer.Abstractions 패키지는 다른 패키지를 참조하지 않으므로 배포된 애플리케이션 크기 및 종속성에 최소한의 영향을 줍니다.

쿼리 및 업데이트와 같은 EF Core 기능에 HierarchyId을(를) 사용하려면 Microsoft.EntityFrameworkCore.SqlServer.HierarchyId 패키지가 필요합니다. 이 패키지는 Microsoft.EntityFrameworkCore.SqlServer.AbstractionsMicrosoft.SqlServer.Types을(를) 전이적 종속성으로 제공하므로 종종 필요한 유일한 패키지입니다. 패키지가 설치되면 애플리케이션의 UseSqlServer 호출의 일부로 UseHierarchyId을(를) 호출하여 HierarchyId을(를) 사용할 수 있습니다. 예시:

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

참고 항목

EF Core의 hierarchyid에 대한 비공식적 지원은 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는 이러한 경로에 대해 컴팩트한 이진 형식을 사용하지만 코드를 사용할 때 사람이 읽을 수 있는 문자열 표현을 구문 분석하는 것이 일반적입니다. 이 표현에서 각 수준의 위치는 / 문자로 구분됩니다. 예를 들어 아래 다이어그램에서 가계도를 고려합니다.

미성년의 가계도

이 트리에서는 다음과 같습니다.

  • Balbo는 /(으)로 나타나는 트리의 루트에 있습니다.
  • Balbo에게는 다섯 명의 자녀가 있으며 /1/, /2/, /3/, /4/, /5/로 표시됩니다.
  • Balbo의 첫 아이인 Mungo도 자녀가 다섯 명 있으며 /1/1/, /1/2/, /1/3/, /1/4/, /1/5/로 표시됩니다. Balbo의 HierarchyId(/1/)은(는) 모든 자녀의 접두사입니다.
  • 마찬가지로 Balbo의 셋째 아이 Ponto에게는 두 명의 자녀가 있으며 /3/1//3/2/(으)로 표시됩니다. 다시 아이들 각각은 Ponto에 대해 HierarchyId이(가) 접두사로 붙어 /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();

필요한 경우 10진수 값을 사용하여 두 기존 노드 사이에 새 노드를 만들 수 있습니다. 예를 들어 /3/2.5/2/은(는) /3/2/2//3/3/2/ 사이에 옵니다.

계층 구조 쿼리

HierarchyId은(는) LINQ 쿼리에서 사용할 수 있는 여러 메서드를 노출합니다.

메서드 설명
GetAncestor(int n) 계층 트리에서 노드의 수준을 n단계 높입니다.
GetDescendant(HierarchyId? child1, HierarchyId? child2) child1보다 크고 child2보다 작은 하위 항목 노드의 값을 가져옵니다.
GetLevel() 계층 트리에서 이 노드의 수준을 가져옵니다.
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) oldRoot에서 이것으로의 경로와 같은 newRoot에서의 경로를 보유하여 이것을 새 위치로 이동하는 효과가 있는 새 노드의 위치를 나타내는 값을 가져옵니다
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", "Belba", "Longo", "Linda"가 반환됩니다.

엔터티의 모든 상위 항목 가져오기

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());

Important

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"가 반환됩니다.

공통 상위 항목 찾기

이 특정 가계도에 대해 묻는 가장 일반적인 질문 중 하나는 "Frodo와 Bilbo의 공통 상위 항목은 무엇입니까?"입니다. 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의 스캔들(일명 "LongoGate")을 기억할 것입니다. DNA 검사를 통해 Longo가 사실 Mungo의 아들이 아니라 실제로 Ponto의 아들이라는 사실이 밝혀졌었죠. 이 스캔들의 한 가지 결과는 가계도를 다시 작성해야 한다는 것이었습니다. 특히 Longo와 그의 모든 하위 항목은 Mungo에서 Ponto로 부모/자식 관리를 변경해야 했습니다. GetReparentedValue을(를) 사용하여 이렇게 할 수 있습니다. 예를 들어, 첫 번째로 "Longo"와 모든 하위 항목이 쿼리됩니다.

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

그런 다음 GetReparentedValue을(를) 사용하여 Longo 및 각 하위 항목에 대한 HierarchyId을(를) 업데이트한 다음 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", "Belba", "Linda", "Bingo", "Bilbo", "Falco", "Poppy"가 반환됩니다. "Ponto"의 하위 항목을 쿼리하면 "Longo", "Rosa", "Polo", "Otho", "Posco", "Prisca", "Lotho", "Ponto", "Porto", "Peony", "Angelica"를 반환합니다.

매핑되지 않은 형식에 대한 원시 SQL 쿼리

EF7에는 스칼라 형식을 반환하는 원시 SQL 쿼리가 도입되었습니다. 이는 EF8에서 EF 모델에 해당 형식을 포함하지 않고 매핑 가능한 CLR 형식을 반환하는 원시 SQL 쿼리를 포함하도록 향상되었습니다.

여기에 표시된 코드는 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의 한 가지 좋은 기능은 LINQ를 사용하여 작성할 수 있는 IQueryable을(를) 반환한다는 것입니다. 예를 들어 위의 쿼리에 '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

이 시점에서 위의 모든 작업은 SQL을 작성할 필요 없이 LINQ에서 완전히 수행할 수 있습니다. 여기에는 매핑되지 않은 형식의 인스턴스(예: 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에 대한 참조가 끊기면 지연 로드가 더 이상 작동하지 않습니다.
  • 비동기 방식으로 속성에 액세스할 방법이 없으므로 모든 지연 로드는 동기 I/O를 사용합니다.

추적되지 않은 엔터티에서 지연 로드는 지연 로드 프록시프록시없는 지연 로드 모두에 작동합니다.

추적되지 않은 엔터티에서 명시적 로드

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 상속 매핑 에 사용되는 문자열 판별자 열이 최대 길이로 구성됩니다. 이 길이는 정의된 모든 판별자 값을 포함하는 가장 작은 피보나치 숫자로 계산됩니다. 예를 들어 다음 경로 계층 구조를 고려합니다.

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]),

피보나치 숫자는 새 형식이 계층 구조에 추가될 때 열 길이를 변경하기 위해 마이그레이션이 생성되는 횟수를 제한하는 데 사용됩니다.

SQL Server에서 DateOnly/TimeOnly 지원

DateOnlyTimeOnly 형식은 .NET 6에서 도입되었으며 소개 이후 여러 데이터베이스 공급자(예: SQLite, MySQL 및 PostgreSQL)에서 지원되었습니다. SQL Server의 경우 .NET 6을 대상으로 하는 Microsoft.Data.SqlClient 패키지의 최근 릴리스를 통해 ErikEJ가 ADO.NET 수준에서 이러한 형식에 대한 지원을 추가할 수 있습니다. 이는 엔터티 형식의 속성으로 DateOnlyTimeOnly의 EF8에서 지원할 수 있는 길을 열었습니다.

@ErikEJErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly 커뮤니티 패키지를 사용하여 EF Core 6 및 7에서 DateOnlyTimeOnly을(를) 사용할 수 있습니다.

예를 들어 영국 학교의 경우 다음 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);

DateOnlyTimeOnly을(를) 사용하는 쿼리는 예상된 방식으로 작동합니다. 예를 들어 다음 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();

이 쿼리는 ToQueryString에 표시된 대로 다음 SQL로 변환됩니다.

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]

DateOnlyTimeOnly은(는) JSON 열에서도 사용할 수 있습니다. 예를 들어 OpeningHours JSON 문서로 저장하여 다음과 같은 데이터를 생성할 수 있습니다.

ID 2
이름 Farr 고등학교
설립 연도 1964-05-01
OpeningHours
[
{ "DayOfWeek": "Sunday", "ClosesAt": null, "OpensAt": null },
{ "DayOfWeek": "Monday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Tuesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Wednesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Thursday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Friday", "ClosesAt": "12:50:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Saturday", "ClosesAt": null, "OpensAt": 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();

이 쿼리는 ToQueryString에 표시된 대로 다음 SQL로 변환됩니다.

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 리버스 엔지니어링(즉, 기존 데이터베이스에서 스캐폴딩)은 이제 Synapse 서버리스 SQL 풀Dynamics 365 TDS 엔드포인트 데이터베이스를 지원합니다.

Warning

이러한 데이터베이스 시스템은 일반 SQL Server 및 Azure SQL 데이터베이스와 차이가 있습니다. 이러한 차이는 이러한 데이터베이스 시스템에 대해 쿼리를 작성하거나 다른 작업을 수행할 때 모든 EF Core 기능이 지원되지 않는다는 것을 의미합니다.

개선된 수학 변환

제네릭 수학 인터페이스는 .NET 7에 도입되었습니다. doublefloat 같은 구체적인 형식은 MathMathF의 기존 기능을 미러링하는 새로운 API를 추가하여 이러한 인터페이스를 구현했습니다.

EF Core 8은 MathMathF에 대한 공급자의 기존 SQL 변환을 사용하여 LINQ에서 이러한 제네릭 수학 API에 대한 호출을 변환합니다. 즉, 이제 EF 쿼리에서 Math.Sin 또는 double.Sin 같은 호출 중에서 자유롭게 선택할 수 있습니다.

.NET 팀과 협력하여 .NET 8에서 doublefloat에 구현되는 두 가지 새로운 제네릭 수학 메서드를 추가했습니다. 이러한 메서드도 EF Core 8에서 SQL로 변환됩니다.

.NET SQL
DegreesToRadians RADIANS
RadiansToDegrees DEGREES

마지막으로, SQLitePCLRaw 프로젝트에서 Eric Sink와 협력하여 네이티브 SQLite 라이브러리의 빌드에서 SQLite 수학 함수를 사용하도록 설정했습니다. 여기에는 EF Core SQLite 공급자를 설치할 때 기본적으로 가져오는 네이티브 라이브러리가 포함됩니다. 이를 통해 Acos, Acosh, Asin, Asinh, Atan, Atan2, Atanh, Ceiling, Cos, Cosh, DegreesToRadians, Exp, Floor, Log, Log2, Log10, Pow, RadiansToDegrees, Sign, Sin, Sinh, Sqrt, Tan, Tanh 및 Truncate를 포함하여 LINQ에서 몇 가지 새로운 SQL 번역을 사용할 수 있습니다.

보류 중인 모델 변경 내용 확인

마지막 마이그레이션 이후 모델이 변경되었는지 여부를 확인하기 위해 새 dotnet ef 명령을 추가했습니다. 이는 CI/CD 시나리오에서 사용자 또는 팀원이 마이그레이션을 추가하는 것을 잊지 않도록 하는 데 유용할 수 있습니다.

dotnet ef migrations has-pending-model-changes

dbContext.Database.HasPendingModelChanges() 메서드를 사용하여 애플리케이션 또는 테스트에서 프로그래밍 방식으로 이 검사를 수행할 수도 있습니다.

SQLite 스캐폴딩의 향상된 기능

SQLite는 INTEGER, REAL, TEXT 및 BLOB이라는 네 가지 기본 데이터 형식만 지원합니다. 이전에는 SQLite 데이터베이스를 리버스 엔지니어링하여 EF Core 모델을 스캐폴드할 때 결과 엔터티 형식에는 long, double, stringbyte[] 형식의 속성만 포함되었습니다. EF Core SQLite 공급자는 EF Core SQLite 공급자와 네 가지 기본 SQLite 형식 중 하나를 변환하여 추가 .NET 형식을 지원합니다.

이제 EF Core 8에서는 모델에서 사용할 보다 적절한 .NET 형식을 결정하기 위해 SQLite 형식 외에도 데이터 형식 및 열 형식 이름을 사용합니다. 다음 표에서는 추가 정보가 모델에서 더 나은 속성 형식으로 이어지는 몇 가지 사례를 보여 줍니다.

열 유형 이름 .NET 형식
BOOLEAN byte[]bool
SMALLINT longshort
INT longint
BIGINT long
STRING byte[]string
데이터 형식 .NET 형식
'0.0' stringdecimal
'1970-01-01' stringDateOnly
'1970-01-01 00:00:00' stringDateTime
'00:00:00' stringTimeSpan
'00000000-0000-0000-0000-000000000000' stringGuid

Sentinel 값 및 데이터베이스 기본값

데이터베이스를 사용하면 행을 삽입할 때 값이 제공되지 않는 경우 기본값을 생성하도록 열을 구성할 수 있습니다. 상수에 대해 HasDefaultValue를 사용하여 EF에서 나타낼 수 있습니다.

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

또는 임의의 SQL 절에 대한 HasDefaultValueSql:

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

아래에 표시된 코드는 DefaultConstraintSample.cs에서 왔습니다.

EF가 이를 사용하려면 열에 대한 값을 보낼 시기와 보내지 않을 시기를 결정해야 합니다. 기본적으로 EF는 CLR 기본값을 sentinel로 사용합니다. 즉, 위 예제의 Status 또는 LeaseDate 값이 이러한 형식에 대한 CLR 기본값인 경우 EF는 이를 속성이 설정되지 않았다는 의미로 해석하므로 데이터베이스에 값을 보내지 않습니다. 이는 참조 형식에 적합합니다. 예를 들어 string 속성 Statusnull인 경우 EF는 데이터베이스에 null을 보내지 않고 데이터베이스 기본값("Hidden")을 사용하도록 값을 포함하지 않습니다. 마찬가지로 DateTime 속성 LeaseDate의 경우 EF는 CLR 기본값인 1/1/0001 12:00:00 AM을 삽입하지 않고 데이터베이스 기본값이 사용되도록 이 값을 생략합니다.

그러나 경우에 따라 CLR 기본값은 삽입할 유효한 값입니다. EF8은 열의 sentinel 값을 변경할 수 있도록 하여 이를 처리합니다. 예를 들어 데이터베이스 기본값으로 구성된 정수 열을 생각해 봅니다.

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

이 경우 지정하지 않는 한 새 엔터티를 지정된 크레딧 수로 삽입하려고 합니다. 이 경우 10개의 크레딧이 할당됩니다. 그러나 이는 0이 CLR 기본값이므로 EF에서 값을 보내지 않으므로 크레딧이 0인 레코드를 삽입할 수 없음을 의미합니다. EF8에서 이 문제는 속성의 sentinel을 0에서 -1로 변경하여 해결할 수 있습니다.

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

이제 EF는 Credits-1로 설정된 경우에만 데이터베이스 기본값을 사용합니다. 다른 수량과 마찬가지로 0 값이 삽입됩니다.

EF 구성뿐만 아니라 엔터티 형식에도 이를 반영하는 것이 유용할 수 있습니다. 예시:

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

즉, 인스턴스를 만들 때 -1의 sentinel 값이 자동으로 설정됩니다. 즉, 속성이 "설정되지 않음" 상태에서 시작됩니다.

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 또는 false 상수 데이터베이스 기본값이 있는 null을 허용하지 않는 부울 열은 더 이상 null 허용으로 스캐폴딩되지 않습니다.

열거형의 데이터베이스 기본값

열거형은 일반적으로 유효한 값 집합이 매우 작고 CLR 기본값은 이러한 값 중 하나일 수 있으므로 bool 속성과 비슷한 문제가 있을 수 있습니다. 예를 들어 다음 엔터티 형식 및 열거형을 생각해 봅니다.

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속성 setter가 실제로 호출되지 않는 한 여기에 지원 필드가 유지됩니다. 즉, 지원 필드의 값은 속성의 CLR 기본값보다 속성이 설정되었는지 여부를 더 잘 나타냅니다. EF는 기본적으로 지원 필드를 사용하여 속성을 읽고 쓰기 때문에 EF에서 기본적으로 작동합니다.

더 나은 ExecuteUpdate 및 ExecuteDelete

업데이트 및 삭제를 수행하는 SQL 명령(예: ExecuteUpdateExecuteDelete 메서드로 생성된)은 단일 데이터베이스 테이블을 대상으로 해야 합니다. 그러나 EF7에서는 쿼리가 궁극적으로 단일 테이블에 영향을 주더라도ExecuteUpdateExecuteDelete에서는 여러 엔터티 형식에 액세스하는 업데이트를 지원하지 않았습니다. 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에서는 Azure SQL을 사용할 때 다음 SQL로 변환됩니다.

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

마찬가지로 업데이트가 모두 동일한 테이블을 대상으로 하는 한 Union 쿼리에서 반환된 인스턴스를 업데이트할 수 있습니다. 예를 들어 France 지역의 모든 Customer를 업데이트할 수 있으며, 동시에 France 지역의 매장을 방문한 모든 Customer를 업데이트 할 수 있습니다.

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에서 이 쿼리는 Azure SQL을 사용할 때 다음을 생성합니다.

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)"));

다음 예외를 throw합니다.

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는 EXISTS 대신 SQL IN을 사용하여 더 나은 쿼리를 생성합니다. 더 읽기 쉬운 SQL을 생성하는 것 외에도 경우에 따라 쿼리 속도가 훨씬 빨라질 수 있습니다. 예를 들어 다음 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"를 통해)을 참조하고 이는 상관 관계가 있는 하위 쿼리이므로 Blogs 테이블의 각 행에 대해 Posts 하위 쿼리를 실행해야 합니다. 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에 대한 숫자 rowversion

SQL Server 자동 낙관적 동시성rowversion을 사용하여 처리됩니다. rowversion은 데이터베이스, 클라이언트 및 서버 간에 전달되는 8바이트 불투명 값입니다. 기본적으로 SqlClient는 변경 가능한 참조 유형이 rowversion 의미 체계와 일치하지 않음에도 불구하고 rowversion 형식을 byte[]로 노출합니다. EF8에서는 대신 rowversion 열을 long 또는 ulong 속성에 쉽게 매핑할 수 있습니다. 예시:

modelBuilder.Entity<Blog>()
    .Property(e => e.RowVersion)
    .HasConversion<byte[]>()
    .IsRowVersion();

괄호 제거

읽을 수 있는 SQL 생성은 EF Core의 중요한 목표입니다. EF8에서는 불필요한 괄호를 자동으로 제거하여 생성된 SQL을 더 쉽게 읽을 수 있습니다. 예를 들어, 다음과 같은 LINQ 쿼리가 있습니다.

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

EF7을 사용하는 경우 다음 Azure SQL로 변환됩니다.

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은 데이터베이스 생성 열을 다시 가져오는 데 RETURNING/OUTPUT을 사용하도록 기본 업데이트 SQL을 변경했습니다. 이 동작이 작동하지 않는 위치를 식별하여 EF8에서 이 동작에 대한 명시적 옵트아웃을 도입하는 경우도 있습니다.

예를 들어 SQL Server/Azure SQL 공급자를 사용할 때 OUTPUT을 옵트아웃하려면 다음을 수행합니다.

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

또는 SQLite 공급자를 사용하는 경우 RETURNING을 옵트아웃합니다.

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

기타 사소한 변경

위에서 설명한 향상된 기능 외에도 EF8에 대한 많은 작은 변경 내용이 있었습니다. 다음 내용이 포함됩니다.