EF Core 6.0의 새로운 기능

EF Core 6.0은 NuGet으로 이동했습니다. 이 페이지에는 이 릴리스에 도입된 중요한 변경 내용의 개요가 포함되어 있습니다.

GitHub에서 샘플 코드를 다운로드하여 아래에 표시된 샘플을 실행하고 디버그할 수 있습니다.

SQL Server temporal 테이블

GitHub 이슈: #4693.

SQL Server 임시 테이블은 데이터가 업데이트되거나 삭제된 후에도 테이블에 저장된 모든 데이터를 자동으로 추적합니다. 이렇게 하려면 주 테이블이 변경될 때마다 타임스탬프가 기록된 데이터가 저장되는 병렬 "기록 테이블"을 만듭니다. 이렇게 하면 감사, 복원 등의 기록 데이터를 실수로 변형하거나 삭제한 후 복구를 위해 쿼리할 수 있습니다.

EF Core는 이제 다음을 지원합니다.

  • 마이그레이션을 사용하여 임시 테이블 만들기
  • 다시 마이그레이션을 사용하여 기존 테이블을 임시 테이블로 변환
  • 기록 데이터 쿼리
  • 과거의 특정 시점에서 데이터 복원

임시 테이블 구성

모델 작성기를 사용하여 테이블을 임시로 구성할 수 있습니다. 예시:

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

EF Core를 사용하여 데이터베이스를 만들 때 새 테이블은 타임스탬프 및 기록 테이블에 대한 SQL Server 기본값을 사용하는 임시 테이블로 구성됩니다. 예를 들어 Employee 엔터티 형식을 고려합니다.

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

생성된 임시 테이블은 다음과 같습니다.

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

SQL Server는 PeriodEndPeriodStart라는 두 개의 숨겨진 datetime2 열을 만듭니다. 이러한 "기간 열"은 행의 데이터가 있었던 시간 범위를 나타냅니다. 이러한 열은 EF Core 모델의 섀도 속성에 매핑되므로 나중에 표시된 것처럼 쿼리에서 사용할 수 있습니다.

Important

이러한 열의 시간은 항상 SQL Server에서 생성되는 UTC 시간입니다. UTC 시간은 아래에 표시된 쿼리와 같이 임시 테이블과 관련된 모든 작업에 사용됩니다.

또한 EmployeeHistory라는 연결된 기록 테이블이 자동으로 생성됩니다. 모델 작성기의 추가 구성을 사용하여 기간 열 및 기록 테이블의 이름을 변경할 수 있습니다. 예시:

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

이는 SQL Server에서 생성한 테이블에 반영됩니다.

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

임시 테이블 사용

대부분의 경우 임시 테이블은 다른 테이블과 마찬가지로 사용됩니다. 즉, 기간 열과 기록 데이터는 애플리케이션이 무시할 수 있도록 SQL Server에서 투명하게 처리됩니다. 예를 들어 새 엔터티는 일반적인 방식으로 데이터베이스에 저장할 수 있습니다.

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

context.SaveChanges();

그런 다음, 이 데이터를 일반적인 방법으로 쿼리, 업데이트 및 삭제할 수 있습니다. 예시:

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

또한 일반 추적 쿼리 후에 추적된 엔터티에서 현재 데이터의 기간 열에 있는 값에 액세스할 수 있습니다. 예시:

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

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

다음이 출력됩니다.

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

ValidTo 열(기본적으로 PeriodEnd라고 함)에는 datetime2 max 값이 포함되어 있습니다. 이는 항상 테이블의 현재 행에 대한 사례입니다. ValidFrom 열(기본적으로 PeriodStart라고 함)에는 행이 삽입된 UTC 시간이 포함됩니다.

기록 데이터 쿼리

EF Core는 몇 가지 새로운 쿼리 연산자를 통해 기록 데이터를 포함하는 쿼리를 지원합니다.

  • TemporalAsOf: 지정된 UTC 시간에 활성 상태인 행(현재)을 반환합니다. 지정된 기본 키에 대한 현재 테이블 또는 기록 테이블의 단일 행입니다.
  • TemporalAll: 기록 데이터의 모든 행을 반환합니다. 일반적으로 지정된 기본 키에 대한 기록 테이블 또는 현재 테이블의 여러 행입니다.
  • TemporalFromTo: 지정된 두 UTC 시간 사이에 활성화된 모든 행을 반환합니다. 지정된 기본 키에 대한 기록 테이블 또는 현재 테이블의 여러 행일 수 있습니다.
  • TemporalBetween: 상위 경계에서 활성화된 행이 포함된다는 점을 제외하고 TemporalFromTo와 동일합니다.
  • TemporalContainedIn: 활성 상태를 시작하고 지정된 두 UTC 시간 사이에 활성 상태로 종료된 모든 행을 반환합니다. 지정된 기본 키에 대한 기록 테이블 또는 현재 테이블의 여러 행일 수 있습니다.

참고 항목

이러한 각 연산자에 대해 정확히 어떤 행이 포함되는지에 대한 자세한 내용은 SQL Server 임시 테이블 설명서를 참조하세요.

예를 들어 데이터를 일부 업데이트하고 삭제한 후 TemporalAll을 사용해 쿼리를 실행하여 기록 데이터를 볼 수 있습니다.

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

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

EF.Property 메서드를 사용하여 기간 열의 값에 액세스할 수 있습니다. 이는 OrderBy 절에서 데이터를 정렬한 다음, 반환된 데이터에 이러한 값을 포함하기 위해 프로젝션에 사용됩니다.

이 쿼리는 다음 데이터를 다시 가져옵니다.

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

마지막으로 반환된 행이 2021/8/26 오후 4:44:59에 활성화가 중지되었습니다. 이는 무지개 파선에 대한 행이 주 테이블에서 삭제되었기 때문입니다. 나중에 이 데이터를 복원할 수 있는 방법을 알아보겠습니다.

TemporalFromTo, TemporalBetween 또는 TemporalContainedIn을 사용하여 유사한 쿼리를 작성할 수 있습니다. 예시:

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

이 쿼리는 다음 행을 반환합니다.

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

기록 데이터 복원

위에서 설명한 것처럼 Employees 테이블에서 무지개 파선이 삭제되었습니다. 이는 분명히 실수였으므로 다시 지정 시간으로 돌아가서 누락된 행을 복원해 보겠습니다.

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

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

이 쿼리는 지정된 UTC 시간에 있었던 무지개 파선에 대해 단일 행을 반환합니다. 임시 연산자를 사용하는 모든 쿼리는 기본적으로 추적되지 않으므로 여기서 반환된 엔터티는 추적되지 않습니다. 이는 현재 주 테이블에 존재하지 않기 때문에 의미가 있습니다. 주 테이블에 엔터티를 다시 삽입하려면 Added로 표시한 다음, SaveChanges를 호출하면 됩니다.

무지개 파선 행을 다시 삽입한 후 기록 데이터를 쿼리하면 행이 지정된 UTC 시간에 있었던 것처럼 복원되었음을 알 수 있습니다.

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

마이그레이션 번들

GitHub 이슈: #19693.

EF Core 마이그레이션은 EF 모델의 변경 내용에 따라 데이터베이스 스키마 업데이트를 생성하는 데 사용됩니다. 이러한 스키마 업데이트는 종종 C.I./C.D.(연속 통합/지속적인 배포) 시스템의 일부로 애플리케이션 배포 시에 적용해야 합니다.

이제 EF Core에는 이러한 스키마 업데이트를 적용하는 새로운 방법인 마이그레이션 번들이 포함되어 있습니다. 마이그레이션 번들은 마이그레이션을 포함하는 작은 실행 파일로, 이러한 마이그레이션을 데이터베이스에 적용하는 데 필요한 코드를 포함합니다.

참고 항목

마이그레이션, 번들 및 배포에 대한 자세한 내용은 .NET 블로그의 DevOps 친화적인 EF Core 마이그레이션 번들 소개를 참조하세요.

마이그레이션 번들은 dotnet ef 명령줄 도구를 사용하여 만듭니다. 계속하기 전의 최신 버전의 도구를 설치했는지 확인합니다.

번들에는 포함할 마이그레이션이 필요합니다. 이러한 기능은 마이그레이션 설명서에 설명된 대로 dotnet ef migrations add를 사용하여 만듭니다. 마이그레이션을 배포할 준비가 되면 dotnet ef migrations bundle을 사용하여 번들을 만듭니다. 예시:

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

출력은 대상 운영 체제에 적합한 실행 파일입니다. 내 경우에는 Windows x64이므로 로컬 폴더에 efbundle.exe가 삭제됩니다. 이 실행 파일을 실행하면 그 안에 포함된 마이그레이션이 적용됩니다.

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

마이그레이션은 아직 적용되지 않은 경우에만 데이터베이스에 적용됩니다. 예를 들어, 동일한 번들을 다시 실행해도 적용되는 새 마이그레이션이 없으므로 아무 작업도 수행하지 않습니다.

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

그러나 모델이 변경되고 dotnet ef migrations add를 사용하여 더 많은 마이그레이션이 생성된 경우에는 적용할 준비가 된 새 실행 파일에 번들로 묶을 수 있습니다. 예시:

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

--force 옵션을 사용하여 기존 번들을 새 번들로 덮어쓸 수 있습니다.

이 새 번들을 실행하면 데이터베이스에 새로운 두 개의 마이그레이션이 적용됩니다.

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

기본적으로 번들은 애플리케이션 구성의 데이터베이스 연결 문자열을 사용합니다. 그러나 명령줄에서 연결 문자열을 전달하여 다른 데이터베이스를 마이그레이션할 수 있습니다. 예시:

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

이번에는 프로덕션 데이터베이스에 아직 적용되지 않았기 때문에 세 개의 마이그레이션이 모두 적용되었습니다.

다른 옵션은 명령줄에 전달할 수 있습니다. 몇 가지 일반 옵션은 다음과 같습니다.

  • --output: 만들 실행 파일의 경로를 지정합니다.
  • --context: 프로젝트에 여러 컨텍스트 형식이 포함된 경우 사용할 DbContext 형식을 지정합니다.
  • --project: 사용할 프로젝트를 지정합니다. 기본값은 현재 작업 디렉터리입니다.
  • --startup-project: 사용할 시작 프로젝트를 지정합니다. 기본값은 현재 작업 디렉터리입니다.
  • --no-build: 명령을 실행하기 전에 프로젝트가 빌드되지 않도록 합니다. 프로젝트가 최신 상태인 것으로 알려진 경우에만 사용해야 합니다.
  • --verbose: 명령이 수행하는 작업에 대한 자세한 정보를 확인합니다. 버그 보고서에 정보를 포함할 때 이 옵션을 사용합니다.

사용 가능한 모든 옵션을 보려면 dotnet ef migrations bundle --help를 사용합니다.

기본적으로 각 마이그레이션은 자체 트랜잭션에 적용됩니다. 이 영역의 향후 개선 사항에 대한 자세한 내용은 GitHub 이슈 #22616을 참조하세요.

사전 규칙 모델 구성

GitHub 이슈: #12229.

이전 버전의 EF Core에서는 해당 매핑이 기본값과 다른 경우 지정된 형식의 모든 속성에 대한 매핑을 명시적으로 구성해야 합니다. 여기에는 문자열의 최대 길이 및 10진수 전체 자릿수와 같은 "facets"와 속성 형식에 대한 값 변환이 포함됩니다.

이 경우 다음 중 하나가 필요했습니다.

  • 각 속성에 대한 모델 작성기 구성
  • 각 속성의 매핑 특성
  • 모든 엔터티 형식의 모든 속성을 명시적으로 반복하고 모델을 빌드할 때 낮은 수준의 메타데이터 API를 사용합니다.

명시적 반복은 오류가 발생하기 쉬우며 엔터티 형식 및 매핑된 속성 목록이 이 반복이 발생할 때 최종 목록이 아닐 수 있으므로 강력하게 수행하기가 어렵습니다.

EF Core 6.0에서는 지정된 형식에 대해 이 매핑 구성을 한 번 지정할 수 있습니다. 그러면 모델에서 해당 형식의 모든 속성에 적용됩니다. 이는 모델 빌드 규칙에서 사용되는 모델의 측면을 구성하기 때문에 "규칙 전 모델 구성"이라고 합니다. 이러한 구성은 DbContext에서 ConfigureConventions를 재정의하여 적용됩니다.

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

예를 들어 다음 엔터티 형식을 살펴봅니다.

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

    public Session CurrentSession { get; set; }

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

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

    public Customer Customer { get; set; }
}

모든 문자열 속성은 ANSI(유니코드 대신)로 구성할 수 있으며 최대 길이는 1024입니다.

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

DateTimes에서 longs로의 기본 변환을 사용하여 모든 DateTime 속성을 데이터베이스의 64비트 정수로 변환할 수 있습니다.

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

모든 bool 속성은 기본 제공된 값 변환기 중 하나를 사용하여 정수 0 또는 1로 변환할 수 있습니다.

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

Session이 엔터티의 임시 속성이며 지속되지 않아야 한다고 가정하면 모델의 모든 위치에서 무시될 수 있습니다.

configurationBuilder
    .IgnoreAny<Session>();

사전 규칙 모델 구성은 값 개체를 작업할 때 매우 유용합니다. 예를 들어 위 모델의 형식 Money는 읽기 전용 구조체로 표시됩니다.

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

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

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

public enum Currency
{
    UsDollars,
    PoundsSterling
}

그런 다음, 사용자 지정 값 변환기를 사용하여 JSON과 직렬화됩니다.

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

이 값 변환기는 Money의 모든 용도에 대해 한 번 구성할 수 있습니다.

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

또한 직렬화된 JSON이 저장되는 문자열 열에 대해 추가 패싯을 지정할 수 있습니다. 이 경우 열의 최대 길이는 64로 제한됩니다.

마이그레이션을 사용하여 SQL Server용으로 만든 테이블은 구성이 매핑된 모든 열에 적용된 방법을 보여 줍니다.

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

지정된 형식에 대한 기본 형식 매핑을 지정할 수도 있습니다. 예시:

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

이는 거의 필요하지 않지만 모델의 매핑된 속성과 상관없는 방식으로 쿼리에 형식이 사용되는 경우에 유용할 수 있습니다.

참고 항목

사전 규칙 모델 구성에 대한 자세한 내용과 예제는 .NET 블로그의 Entity Framework Core 6.0 미리 보기 6 발표: 규칙 구성을 참조하세요.

컴파일된 모델

GitHub 이슈: #1906.

컴파일된 모델은 대형 모델이 있는 애플리케이션의 EF Core 시작 시간을 개선할 수 있습니다. 대형 모델은 일반적으로 100~1000개의 엔터티 형식 및 관계를 의미합니다.

시작 시간은 해당 DbContext 형식이 애플리케이션에서 처음으로 사용될 때 DbContext에서 첫 번째 작업을 수행하는 시간을 의미합니다. DbContext 인스턴스를 만드는 것만으로는 EF 모델이 초기화되지 않습니다. 대신 모델이 초기화되는 일반적인 첫 번째 작업에는 DbContext.Add 호출 또는 첫 번째 쿼리 실행이 포함됩니다.

컴파일된 모델은 dotnet ef 명령줄 도구를 사용하여 생성됩니다. 계속하기 전의 최신 버전의 도구를 설치했는지 확인합니다.

dbcontext optimize 명령은 컴파일된 모델을 생성하는 데 사용됩니다. 예시:

dotnet ef dbcontext optimize

--output-dir--namespace 옵션을 사용하여 컴파일된 모델이 생성될 디렉터리 및 네임스페이스를 지정할 수 있습니다. 예시:

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

이 명령 실행의 출력에는 EF Core가 컴파일된 모델을 사용하도록 DbContext 구성에 복사해 붙여넣은 코드 조각이 포함되어 있습니다. 예시:

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

컴파일된 모델 부트스트래핑

일반적으로 생성된 부트스트래핑 코드를 확인할 필요가 없습니다. 그러나 모델이나 모델의 로드를 사용자 지정하면 도움이 되는 경우도 있습니다. 부트스트래핑 코드는 다음과 같이 표시됩니다.

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

            return _instance;
        }
    }

    partial void Initialize();

    partial void Customize();
}

필요에 따라 모델을 사용자 지정하기 위해 구현할 수 있는 부분 메서드가 있는 partial 클래스입니다.

또한 일부 런타임 구성에 따라 다른 모델을 사용할 수 있는 DbContext 형식에 대해 여러 컴파일된 모델을 생성할 수 있습니다. 이러한 모델은 위에 표시된 대로 서로 다른 폴더 및 네임스페이스에 배치되어야 합니다. 그러면 연결 문자열과 같은 런타임 정보를 검사하고 필요에 따라 올바른 모델을 반환할 수 있습니다. 예시:

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

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

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

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

제한 사항

컴파일된 모델에는 몇 가지 제한 사항이 있습니다.

이러한 제한 사항 때문에 EF Core 시작 시간이 너무 느린 경우에만 컴파일된 모델을 사용해야 합니다. 작은 모델을 컴파일하는 것은 일반적으로 가치가 없습니다.

이러한 기능을 지원하는 것이 성공에 중요한 경우 위에 연결된 적절한 문제에 투표하세요.

벤치마크

GitHub에서 샘플 코드를 다운로드하여 대형 모델을 컴파일하고 벤치마크를 실행할 수 있습니다.

위에서 참조한 GitHub 리포지토리의 모델에는 449개의 엔터티 형식, 6390개의 속성 및 720개의 관계가 포함되어 있습니다. 이는 적당히 큰 모델입니다. BenchmarkDotNet을 사용하여 측정하면 비교적 강력한 랩톱에서 처음 쿼리하는 평균 시간은 1.02초입니다. 컴파일된 모델을 사용하면 동일한 하드웨어에서 117밀리초까지 감소합니다. 이와 같이 8배에서 10배 향상된 모델은 모델 크기가 증가함에 따라 비교적 일정하게 유지됩니다.

Compiled model performance improvement

참고 항목

EF Core 시작 성능 및 컴파일된 모델에 대한 자세한 내용은 .NET 블로그의 Entity Framework Core 6.0 미리 보기 5 발표: 컴파일된 모델을 참조하세요.

TechEmpower Fortunes의 향상된 성능

GitHub 이슈: #23611.

EF Core 6.0의 쿼리 성능이 크게 향상되었습니다. 특별한 사항

  • EF Core 6.0 성능은 5.0에 비해 업계 표준 TechEmpower Fortunes 벤치마크에서 70% 더 빠릅니다.
    • 이는 벤치마크 코드, .NET 런타임 등의 개선 사항을 포함한 전체 스택 성능 향상입니다.
  • EF Core 6.0 자체는 추적되지 않은 쿼리를 실행하는 속도가 31% 더 빠릅니다.
  • 쿼리를 실행할 때 힙 할당이 43% 감소했습니다.

이러한 개선 후에는 TechEmpower Fortunes 벤치마크에서 인기 있는 "마이크로 ORM" Dapper와 EF Core 간의 격차가 55%에서 5% 미만으로 좁혀졌습니다.

참고 항목

EF Core 6.0의 쿼리 성능 향상에 대한 자세한 내용은 .NET 블로그의 Entity Framework Core 6.0 미리 보기 4 발표: 성능 버전을 참조하세요.

Azure Cosmos DB 공급자 개선 사항

EF Core 6.0에는 Azure Cosmos DB 데이터베이스 공급자에 대한 많은 개선 사항이 포함되어 있습니다.

GitHub에서 샘플 코드를 다운로드하여 모든 Cosmos 관련 샘플을 실행하고 디버그할 수 있습니다.

암시적 소유권의 기본값

GitHub 이슈: #24803.

Azure Cosmos DB 공급자에 대한 모델을 작성할 때 EF Core 6.0에서는 기본적으로 자식 엔터티 형식을 부모 엔터티가 소유하는 것으로 표시합니다. 이렇게 하면 Azure Cosmos DB 모델에서 OwnsManyOwnsOne을 호출할 필요가 거의 없습니다. 이렇게 하면 부모 형식에 대한 문서에 자식 형식을 더 쉽게 포함할 수 있으며, 일반적으로 문서 데이터베이스에서 부모 및 자식을 모델링하는 데 적절한 방법입니다.

예를 들어 다음과 같은 엔터티 형식을 고려합니다.

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

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

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

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

    public string Gender { get; set; }

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

EF Core 5.0에서는 다음 구성을 사용하여 Azure Cosmos DB에 대해 위 형식을 모델링했습니다.

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

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

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

EF Core 6.0에서는 소유권이 암시적이어서 모델 구성이 다음과 같이 축소됩니다.

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

생성된 Azure Cosmos DB 문서의 가족 문서에는 가족의 부모, 자식, 반려 동물, 주소가 포함됩니다. 예시:

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

참고 항목

소유된 형식을 추가로 구성해야 하는 경우에는 OwnsOne/OwnsMany 구성을 사용해야 합니다.

기본 형식의 컬렉션

GitHub 이슈: #14762.

기본적으로 EF Core 6.0에서는 Azure Cosmos DB 데이터베이스 공급자를 사용할 때 기본 형식의 컬렉션을 매핑합니다. 예를 들어 다음과 같은 엔터티 형식을 고려합니다.

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

목록과 사전은 모두 일반적인 방법으로 데이터베이스에 채워지고 삽입될 수 있습니다.

using var context = new BooksContext();

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

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

그러면 다음 JSON 문서가 생성됩니다.

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

그런 다음, 이러한 컬렉션을 다시 일반적인 방식으로 업데이트할 수 있습니다.

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

context.SaveChanges();

제한 사항:

  • 문자열 키가 있는 사전만 지원됩니다.
  • 기본 컬렉션의 내용에 대한 쿼리는 현재 지원되지 않습니다. 이러한 기능이 중요한 경우 #16926, #25700#25701에 투표하세요.

기본 제공 함수로 변환

GitHub 이슈: #16143.

이제 Azure Cosmos DB 공급자는 더 많은 BCL(기본 클래스 라이브러리) 메서드를 Azure Cosmos DB 기본 제공 함수로 변환합니다. 다음 표에서는 EF Core 6.0의 새로운 번역을 보여줍니다.

문자열 번역

BCL 메서드 기본 제공 함수 주의
String.Length LENGTH
String.ToLower LOWER
String.TrimStart LTRIM
String.TrimEnd RTRIM
String.Trim TRIM
String.ToUpper UPPER
String.Substring SUBSTRING
+ 연산자 CONCAT
String.IndexOf INDEX_OF
String.Replace REPLACE
String.Equals STRINGEQUAL 대/소문자를 구분하지 않는 호출 전용

LOWER, LTRIM, RTRIM, TRIM, UPPERSUBSTRING에 대한 번역은 @Marusyk가 제공했습니다. 대단히 고맙습니다!

예를 들면 다음과 같습니다.

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

다음과 같이 변환됩니다.

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

Math 변환

BCL 메서드 기본 제공 함수
Math.Abs 또는 MathF.Abs ABS
Math.Acos 또는 MathF.Acos ACOS
Math.Asin 또는 MathF.Asin ASIN
Math.Atan 또는 MathF.Atan ATAN
Math.Atan2 또는 MathF.Atan2 ATN2
Math.Ceiling 또는 MathF.Ceiling CEILING
Math.Cos 또는 MathF.Cos COS
Math.Exp 또는 MathF.Exp EXP
Math.Floor 또는 MathF.Floor FLOOR
Math.Log 또는 MathF.Log LOG
Math.Log10 또는 MathF.Log10 LOG10
Math.Pow 또는 MathF.Pow POWER
Math.Round 또는 MathF.Round ROUND
Math.Sign 또는 MathF.Sign SIGN
Math.Sin 또는 MathF.Sin SIN
Math.Sqrt 또는 MathF.Sqrt SQRT
Math.Tan 또는 MathF.Tan TAN
Math.Truncate 또는 MathF.Truncate TRUNC
DbFunctions.Random RAND

이러한 번역은 @Marusyk가 제공했습니다. 대단히 고맙습니다!

예를 들면 다음과 같습니다.

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

다음과 같이 변환됩니다.

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

DateTime 변환

BCL 메서드 기본 제공 함수
DateTime.UtcNow GetCurrentDateTime

이러한 번역은 @Marusyk가 제공했습니다. 대단히 고맙습니다!

예를 들면 다음과 같습니다.

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

다음과 같이 변환됩니다.

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

FromSql을 사용한 SQL 원시 쿼리

GitHub 이슈: #17311.

LINQ를 사용하는 대신 원시 SQL 쿼리를 실행해야 하는 경우도 있습니다. 이제는 FromSql 메서드를 사용하여 Azure Cosmos DB 공급자에서 지원됩니다. 이는 항상 관계형 공급자에서 수행한 것과 동일한 방식으로 작동합니다. 예시:

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

다음과 같이 실행됩니다.

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

고유 쿼리

GitHub 이슈: #16144.

이제 Distinct를 사용하는 간단한 쿼리가 변환됩니다. 예시:

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

다음과 같이 변환됩니다.

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

진단

GitHub 이슈: #17298.

이제 Azure Cosmos DB 공급자는 데이터베이스에서 데이터를 삽입, 쿼리, 업데이트, 삭제하기 위한 이벤트를 포함하여 더 많은 진단 정보를 로그합니다. RU(요청 단위)는 적절할 때마다 이러한 이벤트에 포함됩니다.

참고 항목

여기에 표시된 로그는 ID 값이 표시되도록 EnableSensitiveDataLogging()을 사용합니다.

Azure Cosmos DB 데이터베이스에 항목을 삽입하면 CosmosEventId.ExecutedCreateItem 이벤트가 생성됩니다. 예를 들어 이 코드는 다음과 같습니다.

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

다음 진단 이벤트를 기록합니다.

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

쿼리를 사용하여 Azure Cosmos DB 데이터베이스에서 항목을 검색하면 먼저 CosmosEventId.ExecutingSqlQuery 이벤트가 생성되고, 읽은 항목에 대해 하나 이상의 CosmosEventId.ExecutedReadNext 이벤트가 다음으로 생성됩니다. 예를 들어 이 코드는 다음과 같습니다.

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

다음 진단 이벤트를 기록합니다.

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

파티션 키와 함께 Find를 사용하여 Azure Cosmos DB 데이터베이스에서 단일 항목을 검색하면 CosmosEventId.ExecutingReadItemCosmosEventId.ExecutedReadItem 이벤트가 생성됩니다. 예를 들어 이 코드는 다음과 같습니다.

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

다음 진단 이벤트를 기록합니다.

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

업데이트된 항목을 Azure Cosmos DB 데이터베이스에 저장하면 CosmosEventId.ExecutedReplaceItem 이벤트가 생성됩니다. 예를 들어 이 코드는 다음과 같습니다.

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

다음 진단 이벤트를 기록합니다.

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

Azure Cosmos DB 데이터베이스에서 항목을 삭제하면 CosmosEventId.ExecutedDeleteItem 이벤트가 생성됩니다. 예를 들어 이 코드는 다음과 같습니다.

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

다음 진단 이벤트를 기록합니다.

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

처리량 구성

GitHub 이슈: #17301.

이제 수동 또는 자동 크기 조정 처리량으로 Azure Cosmos DB 모델을 구성할 수 있습니다. 이러한 값은 데이터베이스의 처리량을 프로비전합니다. 예시:

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

또한 해당 컨테이너에 대한 처리량을 프로비전하도록 개별 엔터티 형식을 구성할 수 있습니다. 예시:

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

TTL(Time to Live) 구성

GitHub 이슈: #17307.

이제 Azure Cosmos DB 모델의 엔터티 형식을 분석 저장소에 대한 기본 TTL(Time-to-Live) 및 TTL(Time-to-Live)로 구성할 수 있습니다. 예시:

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

HTTP 클라이언트 팩터리 해결

GitHub 이슈: #21274. 이 기능은 @dnperfors가 제공했습니다. 대단히 고맙습니다!

이제 Azure Cosmos DB 공급자에서 사용하는 HttpClientFactory를 명시적으로 설정할 수 있습니다. 이는 테스트 중에 특히 유용할 수 있습니다. 예를 들어 Linux에서 Azure Cosmos DB 에뮬레이터를 사용할 때 인증서 유효성 검사를 무시할 수 있습니다.

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

참고 항목

기존 애플리케이션에 Azure Cosmos DB 공급자 개선 사항을 적용하는 예제를 자세히 보려면 .NET Blog에서 Taking the EF Core Azure Cosmos DB Provider for a Test Drive(EF Core Azure Cosmos DB 공급자 시험 사용)를 참조하세요.

기존 데이터베이스의 스캐폴딩 기능 향상

EF Core 6.0에서는 기존 데이터베이스에서 EF 모델을 리버스 엔지니어링할 때의 몇 가지 기능이 향상되었습니다.

다 대 다 관계 스캐폴딩

GitHub 이슈: #22475.

EF Core 6.0은 단순 조인 테이블을 검색하고 자동으로 다 대 다 매핑을 생성합니다. 예를 들어 PostsTags 테이블과 이러한 테이블을 연결하는 조인 테이블 PostTag를 살펴보겠습니다.

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

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

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

이러한 테이블은 명령줄에서 스캐폴드할 수 있습니다. 예시:

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

그러면 Post에 대한 클래스가 생성됩니다.

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

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

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

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

Tag에 대한 클래스도 생성됩니다.

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

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

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

그러나 PostTag 테이블에 대한 클래스는 생성되지 않습니다. 대신 다 대 다 관계에 대한 구성이 스캐폴드됩니다.

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

C# nullable 참조 형식 스캐폴드

GitHub 이슈: #15520.

이제 EF Core 6.0은 C# NRT(Nullable 참조 형식)를 사용하는 EF 모델 및 엔터티 형식을 스캐폴드합니다. NRT 사용은 코드가 스캐폴드되는 C# 프로젝트에서 NRT 지원이 사용하도록 설정된 경우 자동으로 스캐폴드됩니다.

예를 들어 다음 Tags 테이블에는 null 허용과 null을 허용하지 않는 문자열 열이 모두 포함되어 있습니다.

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

따라서 생성된 클래스에는 null 허용과 null을 허용하지 않는 문자열 속성이 포함됩니다.

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

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

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

마찬가지로 다음 Posts 테이블에는 Blogs 테이블에 대한 필수 관계가 포함되어 있습니다.

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

따라서 Blogs 간에 null을 허용하지 않는(필수) 관계가 스캐폴딩됩니다.

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

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

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

Posts의 경우는 다음과 같습니다.

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

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

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

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

마지막으로 생성된 DbContext의 DbSet 속성이 NRT 친화적인 방식으로 만들어집니다. 예시:

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

데이터베이스 주석이 코드 주석으로 스캐폴드됨

GitHub 이슈: #19113. 이 기능은 @ErikEJ가 제공했습니다. 대단히 고맙습니다!

이제 SQL 테이블 및 열에 대한 주석이 기존 SQL Server 데이터베이스에서 EF Core 모델을 리버스 엔지니어링할 때 생성된 엔터티 형식으로 스캐폴드됩니다.

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

LINQ 쿼리 기능 향상

EF Core 6.0에서는 LINQ 쿼리의 변환 및 실행과 관련된 몇 가지 기능이 향상되었습니다.

향상된 GroupBy 지원

GitHub 이슈: #12088, #13805, #22609

EF Core 6.0에서는 GroupBy 쿼리에 대한 지원이 향상되었습니다. 특히 EF Core는 다음을 수행할 수 있습니다.

  • 그룹에 대해 GroupBy를 변환한 다음 FirstOrDefault 또는 이와 유사한 쿼리를 변환합니다.
  • 그룹에서 상위 N개 결과를 선택할 수 있습니다.
  • GroupBy 연산자가 적용된 후 탐색을 확장합니다.

다음은 SQL Server의 고객 보고서 및 해당 변환에 대한 예제 쿼리입니다.

예제 1:

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

예 2:

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

예제 3:

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

예제 4:

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

예제 5:

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

예제 6:

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

예제 7:

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

예제 8:

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

예제 9:

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

예제 10:

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

예제 11:

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

예제 12:

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

예제 13:

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

모델

이러한 예제에 사용된 엔터티 형식은 다음과 같습니다.

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

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

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

여러 인수를 사용하여 String.Concat 변환

GitHub 이슈: #23859. 이 기능은 @wmeints가 제공했습니다. 대단히 고맙습니다!

EF Core 6.0부터는 여러 인수가 있는 String.Concat에 대한 호출이 이제 SQL로 변환됩니다. 예를 들어, 다음과 같은 쿼리가 있습니다.

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

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

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

System.Linq.Async와의 원활한 통합

GitHub 이슈: #24041.

System.Linq.Async 패키지는 클라이언트 쪽 비동기 LINQ 처리를 추가합니다. 비동기 LINQ 메서드에 대한 네임스페이스 충돌 때문에 이전 버전의 EF Core와 함께 이 패키지를 사용하는 것이 번거로웠습니다. EF Core 6.0에서는 노출된 EF Core DbSet<TEntity>가 인터페이스를 직접 구현할 필요가 없도록 IAsyncEnumerable<T>에 대한 C# 패턴 일치를 활용했습니다.

EF Core 쿼리는 일반적으로 서버에서 완전히 변환되므로 대부분의 애플리케이션은 System.Linq.Async를 사용할 필요가 없습니다.

GitHub 이슈: #23921.

EF Core 6.0에서는 FreeText(DbFunctions, String, String)Contains에 대한 매개 변수 요구 사항을 완화했습니다. 이를 통해 이진 열 또는 값 변환기를 사용하여 매핑된 열과 함께 이러한 함수를 사용할 수 있습니다. 예를 들어 Name 속성이 값 개체로 정의된 엔터티 형식을 고려합니다.

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

    public Name Name{ get; set; }
}

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

이는 데이터베이스의 JSON에 매핑됩니다.

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

이제 속성 형식이 string이 아닌 Name인 경우에도 Contains 또는 FreeText를 사용하여 쿼리를 실행할 수 있습니다. 예시:

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

이를 통해 SQL Server를 사용할 때 다음 SQL이 생성됩니다.

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

SQLite에서 ToString 변환

GitHub 이슈: #17223. 이 기능은 @ralmsdeveloper가 제공했습니다. 대단히 고맙습니다!

이제 SQLite 데이터베이스 공급자를 사용하면 ToString() 호출이 SQL로 변환됩니다. 이 기능은 문자열이 아닌 열을 포함하는 텍스트 검색에 유용할 수 있습니다. 예를 들어 전화번호를 숫자 값으로 저장하는 User 엔터티 형식을 가정해 보겠습니다.

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

ToString을 사용하면 데이터베이스에서 숫자를 문자열로 변환할 수 있습니다. 그런 다음, 이 문자열을 LIKE 같은 함수에서 사용하여 패턴과 일치하는 숫자를 찾을 수 있습니다. 예를 들어 555를 포함하는 모든 숫자를 찾으려면 다음을 수행합니다.

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

그러면 SQLite 데이터베이스를 사용할 때 다음 SQL로 변환됩니다.

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

SQL Server에 대한 ToString()의 변환은 EF Core 5.0에서 이미 지원되며 다른 데이터베이스 공급자에서도 지원될 수 있습니다.

EF.Functions.Random

GitHub 이슈: #16141. 이 기능은 @RaymondHuy가 제공했습니다. 대단히 고맙습니다!

EF.Functions.Random은 0에서 1(제외) 사이의 의사 난수를 반환하는 데이터베이스 함수에 매핑됩니다. 변환은 SQL Server, SQLite 및 Azure Cosmos DB에 대한 EF Core 리포지토리에서 구현되었습니다. 예를 들어 Popularity 속성이 있는 User 엔터티 형식을 가정해 보겠습니다.

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

Popularity에는 1에서 5(포함) 사이의 값이 있을 수 있습니다. EF.Functions.Random을 사용하여 임의로 선택한 인기도를 갖는 모든 사용자를 반환하는 쿼리를 작성할 수 있습니다.

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

그러면 SQL Server 데이터베이스를 사용할 때 다음 SQL로 변환됩니다.

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

IsNullOrWhitespace에 대한 SQL Server 변환 향상

GitHub 이슈: #22916. 이 기능은 @Marusyk가 제공했습니다. 대단히 고맙습니다!

다음과 같은 쿼리를 고려해 보세요.

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

EF Core 6.0 이전에는 SQL Server에서 다음으로 변환되었습니다.

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

EF Core 6.0에서는 이 변환이 다음과 같이 향상되었습니다.

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

메모리 내 공급자에 대한 쿼리 정의

GitHub 이슈: #24600.

새 메서드 ToInMemoryQuery를 사용하여 지정된 엔터티 형식의 메모리 내 데이터베이스에 대해 정의 쿼리를 작성할 수 있습니다. 이 메서드는 메모리 내 데이터베이스에서 뷰에 해당하는 항목을 만드는 데 가장 유용합니다. 특히 해당 뷰가 키 없는 엔터티 형식을 반환하는 경우에는 더욱 유용합니다. 예를 들어 영국에 있는 고객에 대한 고객 데이터베이스를 살펴보겠습니다. 각 고객에게는 주소가 있습니다.

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

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

이제 각 우편 번호 영역에 속한 고객 수를 보여 주는 데이터를 보려고 한다고 가정해 보겠습니다. 키 없는 엔터티 형식을 만들어 이를 나타낼 수 있습니다.

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

또한 다른 최상위 엔터티 형식에 대한 집합과 함께 DbContext의 집합에 대해 DbSet 속성을 정의합니다.

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

그런 다음, OnModelCreating에서 CustomerDensities에 대해 반환될 데이터를 정의하는 LINQ 쿼리를 작성할 수 있습니다.

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

그러면 다른 모든 DbSet 속성과 마찬가지로 쿼리할 수 있습니다.

var results = context.CustomerDensities.ToList();

단일 매개 변수를 통해 Substring 변환

GitHub 이슈: #20173. 이 기능은 @stevendarby가 제공했습니다. 대단히 고맙습니다!

이제 EF Core 6.0은 단일 인수를 통해 string.Substring의 용도를 변환합니다. 예시:

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

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

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

비 탐색 컬렉션에 대한 분할 쿼리

GitHub 이슈: #21234.

EF Core에서는 단일 LINQ 쿼리를 여러 SQL 쿼리로 분할할 수 있습니다. EF Core 6.0에서는 비 탐색 컬렉션이 쿼리 프로젝션에 포함되는 경우를 포함하도록 이러한 지원이 확장되었습니다.

다음은 SQL Server에서 단일 쿼리 또는 여러 쿼리로의 변환을 보여 주는 예제 쿼리입니다.

예제 1:

LINQ 쿼리:

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

단일 SQL 쿼리:

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

여러 SQL 쿼리:

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

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

예 2:

LINQ 쿼리:

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

단일 SQL 쿼리:

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

여러 SQL 쿼리:

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

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

예제 3:

LINQ 쿼리:

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

단일 SQL 쿼리:

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

여러 SQL 쿼리:

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

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

컬렉션에 조인할 때 마지막 ORDER BY 절 제거

GitHub 이슈: #19828.

관련된 일 대 다 엔터티를 로드할 때 EF Core는 ORDER BY 절을 추가하여 지정된 엔터티에 대한 모든 관련 엔터티가 함께 그룹화되도록 합니다. 그러나 마지막 ORDER BY 절은 EF가 필요한 그룹화를 생성하는 데 필요하지 않으며 성능에 영향을 줄 수 있습니다. 따라서 EF Core 6.0에서는 이 절이 제거되었습니다.

다음 쿼리를 예로 들 수 있습니다.

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

SQL Server에서 EF Core 5.0을 사용하는 경우 이 쿼리는 다음과 같이 변환됩니다.

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

EF Core 6.0을 사용하는 경우에는 다음과 같이 변환됩니다.

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

파일 이름 및 줄 번호로 쿼리 태그 지정

GitHub 이슈: #14176. 이 기능은 @michalczerwinski가 제공했습니다. 대단히 고맙습니다!

쿼리 태그를 사용하면 LINQ 쿼리에 텍스처 태그를 추가하여 생성된 SQL에 포함할 수 있습니다. EF Core 6.0에서는 LINQ 코드의 파일 이름 및 줄 번호로 쿼리에 태그를 지정하는 데 사용할 수 있습니다. 예시:

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

SQL Server를 사용하는 경우 다음 SQL이 생성됩니다.

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

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

소유된 선택적 종속 처리에 대한 변경 내용

GitHub 이슈: #24558.

선택적 종속 엔터티가 주 엔터티와 테이블을 공유하는 경우 존재하는지 여부를 확인하기가 어렵습니다. 이는 종속 엔터티가 존재하는지 여부에 관계없이 주 엔터티에는 종속 엔터티가 필요하여 종속 엔터티에 대한 테이블에 행이 생성되기 때문입니다. 이 엔터티를 명확하게 처리하려면 종속 엔터티에 필수 속성이 하나 이상 있도록 해야 합니다. 필수 속성은 null일 수 없기 때문에 해당 속성에 대한 열의 값이 null이면 종속 엔터티가 존재하지 않는다는 의미입니다.

예를 들어 Customer 클래스가 있고 각 고객에게는 소유한 Address가 있다고 가정해 보겠습니다.

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

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

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

주소는 선택 사항입니다. 즉, 주소가 없는 고객을 저장할 수 있습니다.

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

그러나 고객에게 주소가 있는 경우 해당 주소에는 최소한 null이 아닌 우편 번호가 있어야 합니다.

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

따라서 Postcode 속성을 Required로 표시해야 합니다.

이제 고객을 쿼리할 때 Postcode 열이 null이면 고객에게 주소가 없다는 의미이며 Customer.Address 탐색 속성은 null로 유지됩니다. 예를 들어 고객을 반복하여 Address가 null인지 확인하면 다음과 같습니다.

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

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

다음 결과가 생성됩니다.

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

주소 속성이 필요하지 않은 경우를 가정해 보겠습니다.

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

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

이제 주소가 없는 고객과 주소가 있는 고객을 모두 저장할 수 있으며, 모든 주소 속성이 null입니다.

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

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

그러나 데이터베이스에서는 데이터베이스 열을 직접 쿼리하여 확인할 수 있기 때문에 이러한 두 경우를 구분할 수 없습니다.

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

따라서 EF Core 6.0에서는 모든 속성이 null인 선택적 종속 엔터티를 저장하면 경고가 표시됩니다. 예시:

경고: 2021/9/27 09:25:01.338 RelationalEventId.OptionalDependentWithAllNullPropertiesWarning[20704] (Microsoft.EntityFrameworkCore.Update) 기본 키 값이 {CustomerId: -2147482646}인 ‘Address’ 형식의 엔터티는 테이블 공유를 사용하는 선택적 종속 엔터티입니다. 이 엔터티에는 엔터티 존재 여부를 식별하는 기본값이 아닌 값을 갖는 속성이 없습니다. 즉, 쿼리하면 모든 속성이 기본값으로 설정된 인스턴스 대신 개체 인스턴스가 만들어지지 않습니다. 중첩된 모든 종속 엔터티도 손실됩니다. 기본값만 있는 인스턴스를 저장하거나 모델에서 들어오는 탐색을 필수로 표시하지 마세요.

선택적 종속 엔터티 자체가 다음 선택적 종속 엔터티의 주 엔터티 역할을 하고 동일한 테이블에 매핑되는 경우에는 훨씬 더 어려워집니다. 단순 경고가 아니라 EF Core 6.0에서는 중첩된 선택적 종속 엔터티를 허용하지 않습니다. 예를 들어 다음과 같은 모델을 가정해 보겠습니다. 여기서 ContactInfoCustomer가 소유하고 AddressContactInfo가 소유합니다.

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

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

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

이제 ContactInfo.Phone이 null이면 주소 자체에 데이터가 있을 수 있는 경우에도 관계가 선택 항목인 경우 EF Core는 Address의 인스턴스를 만들지 않습니다. 이러한 종류의 모델에 대해 EF Core 6.0은 다음 예외를 throw합니다.

System.InvalidOperationException: ‘ContactInfo’ 엔터티 형식은 엔터티가 존재하는지 식별하기 위해 필요한 비공유 속성이 없는 기타 종속 항목을 포함하고 테이블 공유를 사용하는 선택적 종속 항목입니다. 모든 null 허용 속성이 데이터베이스의 null 값을 포함한다면 개체 인스턴스가 쿼리에 생성되지 않으며 중첩된 종속 항목의 값은 손실됩니다. 필수 속성을 추가하여 다른 속성에 대해 null 값이 포함된 인스턴스를 만들거나 들어오는 탐색을 필수로 표시하여 항상 인스턴스를 만듭니다.

따라서 선택적 종속 엔터티가 모든 Nullable 속성 값을 포함하고 해당 주 엔터티와 테이블을 공유하는 경우를 방지해야 합니다. 다음과 같은 세 가지 간단한 방법으로 이를 방지할 수 있습니다.

  1. 종속 엔터티를 필수로 표시합니다. 즉, 모든 속성이 null인 경우에도 쿼리된 후 종속 엔터티는 항상 값을 갖게 됩니다.
  2. 위에서 설명한 대로 종속 엔터티에 하나 이상의 필수 속성이 포함되도록 합니다.
  3. 주 엔터티와 테이블을 공유하는 대신 고유한 테이블에 선택적 종속 엔터티를 저장합니다.

종속 엔터티는 탐색에서 Required 특성을 사용하여 필수로 만들 수 있습니다.

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

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

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

또는 OnModelCreating에서 필수가 되도록 지정합니다.

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

OnModelCreating에서 사용할 테이블을 지정하여 종속 엔터티를 다른 테이블에 저장할 수 있습니다.

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

중첩된 선택적 종속 엔터티가 있는 경우를 포함하여 선택적 종속 엔터티에 대한 추가 예제는 GitHub의 OptionalDependentsSample을 참조하세요.

새 매핑 특성

EF Core 6.0에서는 데이터베이스에 매핑되는 방식을 변경하기 위해 코드에 적용할 수 있는 몇 가지 새로운 특성을 제공합니다.

UnicodeAttribute

GitHub 이슈: #19794. 이 기능은 @RaymondHuy가 제공했습니다. 대단히 고맙습니다!

EF Core 6.0부터 ‘데이터베이스 형식을 직접 지정하지 않고’ 매핑 특성을 사용하여 유니코드를 지원하지 않는 열에 문자열 속성을 매핑할 수 있습니다. 예를 들어 “ISBN 978-3-16-148410-0” 형식의 ISBN(International Standard Book Number)에 대한 속성이 있는 Book 엔터티 형식을 가정해 보겠습니다.

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

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

ISBN은 유니코드가 아닌 문자를 포함할 수 없으므로 Unicode 특성을 사용하면 유니코드가 아닌 문자열 형식이 사용됩니다. 또한 MaxLength를 사용하여 데이터베이스 열의 크기를 제한합니다. 예를 들어 SQL Server를 사용하는 경우 다음과 같이 varchar(22)의 데이터베이스 열이 생성됩니다.

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

참고 항목

EF Core는 기본적으로 유니코드 열에 문자열 속성을 매핑합니다. UnicodeAttribute는 데이터베이스 시스템이 유니코드 형식만 지원하는 경우 무시됩니다.

PrecisionAttribute

GitHub 이슈: #17914. 이 기능은 @RaymondHuy가 제공했습니다. 대단히 고맙습니다!

이제 ‘데이터베이스 형식을 직접 지정하지 않고’ 매핑 특성을 사용하여 데이터베이스 열의 전체 자릿수와 소수 자릿수를 구성할 수 있습니다. 예를 들어 10진 Price 속성이 있는 Product 엔터티 형식을 가정해 보겠습니다.

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

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

EF Core는 전체 자릿수가 10이고 소수 자릿수가 2인 데이터베이스 열에 이 속성을 매핑합니다. 예를 들어 SQL Server에서는 다음과 같이 표시됩니다.

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

EntityTypeConfigurationAttribute

GitHub 이슈: #23163. 이 기능은 @KaloyanIT가 제공했습니다. 대단히 고맙습니다!

IEntityTypeConfiguration<TEntity> 인스턴스를 사용하면 각 엔터티 형식에 대한 ModelBuilder 구성을 자체 구성 클래스에 포함할 수 있습니다. 예시:

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

일반적으로 이 구성 클래스는 DbContext.OnModelCreating에서 인스턴스화하고 호출해야 합니다. 예시:

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

EF Core 6.0부터는 EF Core에서 적절한 구성을 찾아 사용할 수 있도록 엔터티 형식에 EntityTypeConfigurationAttribute를 배치할 수 있습니다. 예시:

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

이 특성은 Book 엔터티 형식이 모델에 포함될 때마다 EF Core에서 지정된 IEntityTypeConfiguration 구현을 사용함을 의미합니다. 엔터티 형식은 일반적인 메커니즘 중 하나를 사용하여 모델에 포함됩니다. 예를 들어 엔터티 형식에 대한 DbSet<TEntity> 속성을 만들어 포함됩니다.

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

    //...

또는 OnModelCreating에 등록하여 포함됩니다.

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

참고 항목

EntityTypeConfigurationAttribute 형식은 어셈블리에서 자동으로 검색되지 않습니다. 엔터티 형식을 모델에 추가해야 해당 엔터티 형식에서 특성이 검색됩니다.

모델 빌드 기능 향상

새로운 매핑 특성 외에도 EF Core 6.0에서는 몇 가지 향상된 모델 빌드 프로세스 기능을 제공합니다.

SQL Server 스파스 열에 대한 지원

GitHub 이슈: #8023.

SQL Server 스파스 열은 null 값을 저장하도록 최적화된 일반 열입니다. 이 열은 거의 사용되지 않는 하위 형식의 속성으로 인해 테이블에 있는 대부분 행에 대해 null 열 값이 생성되는 TPH 상속 매핑을 사용할 때 유용할 수 있습니다. 예를 들어 ForumUser에서 확장되는 ForumModerator 클래스를 가정해 보겠습니다.

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

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

수백만 명의 사용자가 있을 수 있으며, 그중 일부만 중재자입니다. 즉, 여기서는 ForumName을 스파스로 매핑하는 것이 적합할 수 있습니다. 이제 OnModelCreating에서 IsSparse를 사용하여 구성할 수 있습니다. 예시:

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

그러면 EF Core 마이그레이션에서 열을 스파스로 표시합니다. 예시:

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

참고 항목

스파스 열에는 제한이 있습니다. 스파스 열이 시나리오에 적합한 선택인지 확인하려면 SQL Server 스파스 열 설명서를 참조해야 합니다.

향상된 HasConversion API 기능

GitHub 이슈: #25468.

EF Core 6.0 이전에는 HasConversion 메서드의 제네릭 오버로드에서 제네릭 매개 변수를 사용하여 ‘변환할 형식’을 지정했습니다. 예를 들어 Currency 열거형을 살펴보겠습니다.

public enum Currency
{
    UsDollars,
    PoundsSterling,
    Euros
}

HasConversion<string>을 사용하여 이 열거형의 값을 문자열 “UsDollars”, “PoundsStirling”, “Euros”로 저장하도록 EF Core를 구성할 수 있습니다. 예시:

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

EF Core 6.0부터 제네릭 형식은 ‘값 변환기 형식’을 지정할 수 있습니다. 이는 기본 제공 값 변환기 중 하나일 수 있습니다. 예를 들어 열거형 값을 16비트 숫자로 데이터베이스에 저장하려면 다음을 수행합니다.

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

또는 사용자 지정 값 변환기 형식일 수 있습니다. 예를 들어 열거형 값을 통화 기호로 저장하는 변환기를 살펴보겠습니다.

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

이제 제네릭 HasConversion 메서드를 사용하여 구성할 수 있습니다.

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

다 대 다 관계에 대한 구성 감소

GitHub 이슈: #21535.

두 엔터티 형식 간의 명확한 다 대 다 관계는 규칙에 따라 검색됩니다. 필요한 경우 또는 원하는 경우 탐색을 명시적으로 지정할 수 있습니다. 예시:

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

두 경우 모두 EF Core는 두 형식 간의 조인 엔터티 역할을 하도록 Dictionary<string, object>에 따라 형식이 지정된 공유 엔터티를 만듭니다. EF Core 6.0부터는 UsingEntity를 구성에 추가하여 추가 구성 없이도 이 형식만 변경할 수 있습니다. 예시:

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

또한 왼쪽 및 오른쪽 관계를 명시적으로 지정하지 않고도 조인 엔터티 형식을 추가로 구성할 수 있습니다. 예시:

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

마지막으로 전체 구성을 제공할 수도 있습니다. 예시:

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

값 변환기에서 null을 변환하도록 허용

GitHub 이슈: #13850.

Important

아래에 설명된 문제로 인해 null 변환을 허용하는 ValueConverter에 대한 생성자가 EF Core 6.0 릴리스에 대해 [EntityFrameworkInternal]로 표시되었습니다. 이제 이러한 생성자를 사용하면 빌드 경고가 생성됩니다.

일반적으로 값 변환기는 null을 다른 값으로 변환할 수 없습니다. nullable 형식과 nullable이 아닌 형식 모두에 동일한 값 변환기를 사용할 수 있기 때문입니다. 이는 FK는 nullable이고 PK는 nullable이 아닌 PK/FK 조합에 매우 유용합니다.

EF Core 6.0부터 null을 변환하는 값 변환기를 만들 수 있습니다. 그러나 이 기능의 유효성 검사를 통해 실제로 많은 문제가 있는 것으로 확인되었습니다. 예시:

이는 간단한 문제가 아니며 쿼리 문제의 경우 검색하기가 쉽지 않습니다. 따라서 EF Core 6.0의 경우 이 기능을 내부로 표시했습니다. 여전히 사용할 수 있지만 컴파일러 경고가 표시됩니다. 이 경고는 #pragma warning disable EF1001을 사용하여 사용하지 않도록 설정할 수 있습니다.

null 변환이 유용할 수 있는 한 가지 예는 데이터베이스에 null이 포함되어 있지만 엔터티 형식은 속성에 다른 기본값을 사용하려는 경우입니다. 예를 들어 기본값이 “Unknown”인 열거형을 살펴보겠습니다.

public enum Breed
{
    Unknown,
    Burmese,
    Tonkinese
}

그러나 데이터베이스에는 품종을 알 수 없는 경우에 null 값이 포함될 수 있습니다. EF Core 6.0에서는 값 변환기를 사용하여 처리할 수 있습니다.

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

품종이 “Unknown”인 고양이의 경우 데이터베이스에서 Breed 열이 null로 설정됩니다. 예시:

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

context.SaveChanges();

SQL Server에서 다음 insert 문을 생성합니다.

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

DbContext 팩터리 기능 향상

AddDbContextFactory는 DbContext도 직접 등록합니다.

GitHub 이슈: #25164.

경우에 따라 DbContext 형식‘과’ 해당 형식의 컨텍스트에 대한 팩터리 둘 다 애플리케이션 D.I.(종속성 주입) 컨테이너에 등록하면 도움이 됩니다. 예를 들어 요청 범위에서 DbContext의 범위가 지정된 인스턴스를 확인할 수 있지만 팩터리를 사용하면 필요할 때 여러 독립 인스턴스를 만들 수 있습니다.

이 기능을 지원하기 위해 AddDbContextFactory는 DbContext 형식을 범위가 지정된 서비스로도 등록합니다. 예를 들어 애플리케이션의 D.I 컨테이너에서 이 등록을 살펴보겠습니다.

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

이 등록을 사용하면 이전 버전과 마찬가지로 루트 D.I. 컨테이너에서 팩터리를 확인할 수 있습니다.

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

팩터리에서 만든 컨텍스트 인스턴스는 명시적으로 삭제해야 합니다.

또한 컨테이너 범위에서 직접 DbContext 인스턴스를 확인할 수 있습니다.

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

이 경우 컨테이너 인스턴스는 컨테이너 범위가 삭제될 때 삭제됩니다. 컨텍스트는 명시적으로 삭제하면 안 됩니다.

일반적으로 팩터리의 DbContext를 다른 D.I. 형식에 삽입할 수 있음을 의미합니다. 예시:

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

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

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

        var results1 = context1.Blogs.ToList();
        var results2 = context2.Blogs.ToList();
        
        // Contexts obtained from the factory must be explicitly disposed
    }
}

또는

private class MyController1
{
    private readonly SomeDbContext _context;

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

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

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

DbContextFactory에서 DbContext 매개 변수가 없는 생성자 무시

GitHub 이슈: #24124

이제 EF Core 6.0에서는 AddDbContextFactory를 통해 팩터리를 등록할 때 매개 변수가 없는 DbContext 생성자와 DbContextOptions를 사용하는 생성자를 동일한 컨텍스트 형식에서 사용할 수 있습니다. 예를 들어 위의 예제에 사용된 컨텍스트에는 두 생성자가 모두 포함되어 있습니다.

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

    public SomeDbContext(DbContextOptions<SomeDbContext> options)
        : base(options)
    {
    }
    
    public DbSet<Blog> Blogs { get; set; }
}

종속성 주입 없이 DbContext 풀링을 사용할 수 있음

GitHub 이슈: #24137

애플리케이션에 종속성 주입 컨테이너가 없어도 PooledDbContextFactory 형식을 public으로 설정하여 DbContext 인스턴스의 독립 실행형 풀로 사용할 수 있습니다. 풀은 컨텍스트 인스턴스를 만드는 데 사용되는 DbContextOptions의 인스턴스로 만듭니다.

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

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

그런 다음 팩터리를 사용하여 인스턴스를 만들고 풀링할 수 있습니다. 예시:

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

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

인스턴스는 삭제될 때 풀에 반환됩니다.

기타 개선 사항

마지막으로 EF Core는 위에 설명되지 않은 영역에서 향상된 몇 가지 기능을 제공합니다.

테이블을 만들 때 [ColumnAttribute.Order] 사용

GitHub 이슈: #10059.

이제 마이그레이션을 사용하여 테이블을 만들 때 ColumnAttributeOrder 속성을 사용하여 열을 정렬할 수 있습니다. 예를 들어 다음 모델을 살펴봅니다.

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

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

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

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

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

기본적으로 EF Core는 기본 키 열을 먼저 정렬하고 그 다음에 엔터티 형식과 소유된 형식의 속성을 정렬하고 마지막으로 기본 형식의 속성을 정렬합니다. 예를 들어 SQL Server에 다음 테이블이 생성됩니다.

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

EF Core 6.0에서는 ColumnAttribute를 사용하여 여러 열 순서를 지정할 수 있습니다. 예시:

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

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

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

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

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

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

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

    public Address Address { get; set; }
}

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

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

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

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

이제 SQL Server에서 생성되는 테이블은 다음과 같습니다.

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

이렇게 하면 FistNameLastName 열이 기본 형식으로 정의되어 있더라도 위쪽으로 이동됩니다. 열 순서 값에는 차이가 있을 수 있으며, 여러 파생 형식에서 사용되는 경우에도 항상 끝에 열을 배치하는 데 범위를 사용할 수 있습니다.

이 예제에서는 열 이름과 순서를 모두 지정하는 데 동일한 ColumnAttribute를 사용하는 방법도 보여 줍니다.

열 정렬은 OnModelCreatingModelBuilder API를 사용해서 구성할 수도 있습니다. 예시:

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

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

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

HasColumnOrder를 사용하는 모델 빌더의 정렬은 ColumnAttribute로 지정된 순서보다 우선 순위가 높습니다. 즉, 서로 다른 속성의 특성이 동일한 순서 번호를 지정하는 경우 충돌을 해결하는 것을 포함하여 특성으로 지정된 순서를 재정의하는 데 HasColumnOrder를 사용할 수 있습니다.

Important

일반적으로 대부분의 데이터베이스는 테이블을 만들 때 열 순서만 지원합니다. 즉 기존 테이블의 열 순서를 변경하는 데 열 순서 특성을 사용할 수 없습니다. 한 가지 주목할 만한 예외는 SQLite에서는 마이그레이션이 새 열 순서를 사용하여 전체 테이블을 다시 빌드한다는 것입니다.

EF Core 최소 API

GitHub 이슈: #25192.

.NET Core 6.0에는 .NET 애플리케이션에서 일반적으로 필요한 많은 상용구 코드가 제거되어 간소화된 “최소 API” 기능을 제공하는 업데이트된 템플릿이 포함되어 있습니다.

EF Core 6.0에는 DbContext 형식을 등록하고 데이터베이스 공급자에 대한 구성을 한 줄에 제공하는 새로운 확장 메서드가 포함되어 있습니다. 예시:

var builder = WebApplication.CreateBuilder(args);

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

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

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

이러한 항목은 다음과 정확히 동일합니다.

var builder = WebApplication.CreateBuilder(args);

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

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

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

참고 항목

EF Core 최소 API는 DbContext 및 공급자의 매우 기본적인 등록 및 구성만 지원합니다. AddDbContext, AddDbContextPool, AddDbContextFactory 등을 사용하여 EF Core에서 사용 가능한 모든 유형의 등록 및 구성에 액세스합니다.

최소 API에 대한 자세한 내용은 다음 리소스를 확인하세요.

SaveChangesAsync에서 동기화 컨텍스트 유지

GitHub 이슈: #23971.

5.0 릴리스의 EF Core 코드를 변경하여 await 비동기 코드가 있는 모든 위치에서 Task.ConfigureAwaitfalse로 설정했습니다. 이는 일반적으로 EF Core 사용에 더 적합합니다. 그러나 비동기 데이터베이스 작업이 완료된 후 EF Core가 생성된 값을 추적된 엔터티로 설정하므로 SaveChangesAsync는 특수한 경우입니다. 이러한 변경으로 인해, 예를 들어 U.I. 스레드에서 실행해야 하는 알림을 트리거할 수 있습니다. 따라서 SaveChangesAsync 메서드에 대해서만 EF Core 6.0에서 이 변경 내용을 되돌리는 중입니다.

메모리 내 데이터베이스: 필수 속성이 null이 아닌지 유효성 검사

GitHub 이슈: #10613. 이 기능은 @fagnercarvalho가 제공했습니다. 대단히 고맙습니다!

필수로 표시된 속성에 대해 null 값을 저장하려고 하면 EF Core 메모리 내 데이터베이스에서 예외가 throw됩니다. 예를 들어 필수 Username 속성이 있는 User 형식을 가정해 보겠습니다.

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

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

엔터티를 null로 저장하려고 하면 Username에서 다음과 같은 예외가 발생합니다.

Microsoft.EntityFrameworkCore.DbUpdateException: Required properties '{'Username'}' are missing for the instance of entity type 'User' with the key value '{Id: 1}'.(키 값이 ‘{Id: 1}’인 엔터티 형식 ‘User’의 인스턴스에서 필수 속성 ‘{'Username'}’이 누락되었습니다.)

필요한 경우 이 유효성 검사를 사용하지 않도록 설정할 수 있습니다. 예시:

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

진단 및 인터셉터에 대한 명령 소스 정보

GitHub 이슈: #23719. 이 기능은 @Giorgi가 제공했습니다. 대단히 고맙습니다!

이제 진단 소스 및 인터셉터에 제공된 CommandEventData에 명령 만들기를 담당하는 EF 파트를 나타내는 열거형 값이 포함됩니다. 이 값을 진단 또는 인터셉터에서 필터로 사용할 수 있습니다. 예를 들어 SaveChanges에서 생성되는 명령에만 적용되는 인터셉터가 필요할 수 있습니다.

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

        return result;
    }
}

마이그레이션과 쿼리도 생성하는 애플리케이션에서 사용될 때 인터셉터를 SaveChanges 이벤트로만 필터링합니다. 예시:

Saving changes for CustomersContext:

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

더 나은 임시 값 처리

GitHub 이슈: #24245.

EF Core는 엔터티 형식 인스턴스에서 임시 값을 노출하지 않습니다. 예를 들어 저장소 생성 키가 있는 Blog 엔터티 형식을 가정해 보겠습니다.

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

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

Id 키 속성은 Blog가 컨텍스트에 의해 추적되는 즉시 임시 값을 가져옵니다. 예를 들어 DbContext.Add를 호출하면 다음과 같습니다.

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

임시 값은 컨텍스트 변경 추적기에서 가져올 수 있지만 엔터티 인스턴스로 설정되지는 않습니다. 예를 들어 이 코드는 다음과 같습니다.

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

다음 출력이 생성됩니다.

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

실수에 의해 비임시 값으로 처리될 수 있는 애플리케이션 코드에 임시 값이 누출되지 않도록 하기 때문에 유용합니다. 그러나 임시 값을 직접 처리하는 것이 유용한 경우도 있습니다. 예를 들어 외래 키를 사용하여 관계를 형성하는 데 사용할 수 있도록 추적되기 전에 애플리케이션에서 엔터티 그래프에 대해 고유한 임시 값을 생성하려고 할 수 있습니다. 이렇게 하려면 값을 임시로 명시적으로 표시하면 됩니다. 예시:

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

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

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

EF Core 6.0에서 값은 임시로 표시된 경우에도 엔터티 인스턴스에 유지됩니다. 예를 들어 위의 코드는 다음과 같은 출력을 생성합니다.

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

마찬가지로 EF Core에서 생성된 임시 값은 엔터티 인스턴스로 명시적으로 설정하고 임시 값으로 표시할 수 있습니다. 임시 키 값을 사용하여 새 엔터티 간의 관계를 명시적으로 설정하는 데 사용할 수 있습니다. 예시:

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

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

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

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

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

결과는 다음과 같습니다.

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

C# nullable 참조 형식에 주석이 추가된 EF Core

GitHub 이슈: #19007.

이제 EF Core 코드베이스 전체에서 C# NRT(Nullable 참조 형식)가 사용됩니다. 즉, 직접 작성한 코드에서 EF Core 6.0을 사용할 경우 null 사용에 대해 올바른 컴파일러 표시를 받을 수 있습니다.

Microsoft.Data.Sqlite 6.0

GitHub에서 샘플 코드를 다운로드하여 아래에 표시된 모든 샘플을 실행하고 디버그할 수 있습니다.

연결 풀링

GitHub 이슈: #13837.

데이터베이스 연결을 최대한 적은 시간 동안 열어 두는 것이 일반적입니다. 이렇게 하면 연결 리소스에 대한 경합을 방지할 수 있습니다. 따라서 EF Core와 같은 라이브러리는 데이터베이스 작업을 수행하기 직전에 연결을 열고 직후에 다시 닫습니다. 예를 들어 다음 EF Core 코드를 고려하세요.

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

var users = context.Users.ToList();

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

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

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

        context.SaveChanges();

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

연결에 대한 로깅이 켜져 있는 이 코드의 출력은 다음과 같습니다.

Starting query...

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

Query finished.

Starting SaveChanges...

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

SaveChanges finished.

각 작업에 대해 연결이 빠르게 열리고 닫힙니다.

그러나 대부분의 데이터베이스 시스템에서 데이터베이스에 대한 물리적 연결을 여는 것은 비용이 많이 드는 작업입니다. 따라서 대부분의 ADO.NET 공급자는 물리적 연결 풀을 만들고 필요에 따라 DbConnection 인스턴스에 임대합니다.

SQLite는 데이터베이스 액세스가 일반적으로 파일에 액세스하는 것이기 때문에 약간 다릅니다. 즉, SQLite 데이터베이스에 대한 연결을 여는 것이 일반적으로 매우 빠릅니다. 하지만 항상 이렇게 되지는 않습니다. 예를 들어 암호화된 데이터베이스에 대한 연결을 여는 것은 매우 느릴 수 있습니다. 따라서 이제 Microsoft.Data.Sqlite 6.0을 사용할 때 SQLite 연결이 풀링됩니다.

DateOnly 및 TimeOnly 지원

GitHub 이슈: #24506.

Microsoft.Data.Sqlite 6.0은 .NET 6의 새로운 DateOnlyTimeOnly 형식을 지원합니다. 이러한 형식은 EF Core 6.0의 SQLite 공급자에서도 사용할 수 있습니다. SQLite와 마찬가지로 네이티브 형식 시스템은 이러한 형식의 값을 네 가지 지원되는 형식 중 하나로 저장해야 함을 의미합니다. Microsoft.Data.Sqlite는 이러한 형식을 TEXT로 저장합니다. 예를 들어 다음과 같은 형식을 사용하는 엔터티가 있습니다.

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

SQLite 데이터베이스의 다음 테이블에 매핑됩니다.

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

그러면 일반적인 방식으로 값을 저장하고, 쿼리하고, 업데이트할 수 있습니다. 예를 들어 다음과 같은 EF Core LINQ 쿼리가 있습니다.

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

SQLite에서 다음과 같이 변환됩니다.

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

1900 CE 이전 생일이 있는 사용만 반환합니다.

Found 'ajcvickers'
Found 'wendy'

저장점 API

GitHub 이슈: #20228.

ADO.NET 공급자의 저장점에 대한 공용 API를 표준화했습니다. 이제 Microsoft.Data.Sqlite는 다음을 포함하여 이 API를 지원합니다.

저장점을 사용하면 전체 트랜잭션을 롤백하지 않고 트랜잭션의 일부를 롤백할 수 있습니다. 예를 들어 아래 코드는 다음을 수행합니다.

  • 트랜잭션을 만듭니다.
  • 업데이트를 데이터베이스로 보냅니다.
  • 저장점을 만듭니다.
  • 또 다른 업데이트를 데이터베이스로 보냅니다.
  • 이전에 만든 저장점으로 롤백합니다.
  • 트랜잭션을 커밋합니다.
using var connection = new SqliteConnection("Command Timeout=60;DataSource=test.db");
connection.Open();

using var transaction = connection.BeginTransaction();

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

transaction.Save("MySavepoint");

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

transaction.Rollback("MySavepoint");

transaction.Commit();

그러면 첫 번째 업데이트는 데이터베이스에 커밋되고 두 번째 업데이트는 트랜잭션을 커밋하기 전 저장점이 롤백된 이후 커밋되지 않습니다.

연결 문자열의 명령 시간 제한

GitHub 이슈: #22505. 이 기능은 @nmichels가 제공했습니다. 대단히 고맙습니다!

ADO.NET 공급자는 두 가지 고유한 시간 제한을 지원합니다.

  • 연결 시간 제한 - 데이터베이스에 연결할 때까지 대기하는 최대 시간을 결정합니다.
  • 명령 시간 제한 - 명령 실행이 완료될 때까지 대기하는 최대 시간을 결정합니다.

명령 시간 제한은 코드에서 DbCommand.CommandTimeout을 사용하여 설정할 수 있습니다. 이제 많은 공급자가 이 명령 시간 제한을 연결 문자열에 노출합니다. Microsoft.Data.Sqlite는 Command Timeout 연결 문자열 키워드를 사용하여 이 추세를 따르고 있습니다. 예를 들어 "Command Timeout=60;DataSource=test.db"는 연결에서 만든 명령에 대해 60초를 기본 시간 제한으로 사용합니다.

Sqlite는 Default TimeoutCommand Timeout의 동의어로 처리하므로 원하는 경우 대신 사용할 수 있습니다.