EF Core 9의 새로운 기능

EF Core 9(EF9)는 EF Core 8 이후의 다음 릴리스이며 2024년 11월에 출시될 예정입니다. 자세한 내용은 Entity Framework Core 9 계획을 참조하세요.

EF9는 모든 최신 EF9 기능 및 API 조정을 포함하는 매일 빌드로 사용할 수 있습니다. 여기서 샘플은 이러한 일상적인 빌드를 사용합니다.

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

EF9는 .NET 8을 대상으로 하므로 .NET 8(LTS) 또는 .NET 9 미리 보기와 함께 사용할 수 있습니다.

새로운 기능 문서는 미리 보기마다 업데이트됩니다. 모든 샘플은 EF9 일별 빌드를 사용하도록 설정되어 있으며 일반적으로 최신 미리 보기에 비해 완료 작업이 몇 주 더 걸립니다. 새로운 기능을 테스트할 때 부실한 비트에 대해 테스트를 수행하지 않도록 일별 빌드를 사용하는 것이 좋습니다.

LINQ 및 SQL 변환

팀에서는 JSON 매핑 및 문서 데이터베이스에 대한 지속적인 개선의 일환으로 EF Core 9의 쿼리 파이프라인에 대한 몇 가지 중요한 아키텍처 변경 작업을 진행하고 있습니다. 즉, 사용자와 같은 사람들이 이러한 새로운 내부 요소에서 사용자의 코드를 실행할 수 있도록 해야 합니다. (릴리스의 이 시점에서 "새로운 기능" 문서를 읽고 계시다면 사용자는 커뮤니티에 적극적으로 참여하고 계신 것입니다. 감사합니다!) 120,000개가 넘는 테스트가 있지만 충분하지 않습니다. 문제를 찾고 믿음직한 릴리스를 제공하기 위해 비트에서 여러분이 실제 코드를 실행해 주셔야 합니다.

OPENJSON의 WITH 절에 전달된 열 정리

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

EF9는 OPENJSON WITH를 호출할 때 불필요한 열을 제거합니다. 예를 들어, 조건자를 사용하여 JSON 컬렉션에서 개수를 가져오는 쿼리를 생각해 보세요.

var postsUpdatedOn = await context.Posts
    .Where(p => p.Metadata!.Updates.Count(e => e.UpdatedOn >= date) == 1)
    .ToListAsync();

EF8에서 이 쿼리는 Azure SQL Database 공급자를 사용할 때 다음 SQL을 생성합니다.

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE (
    SELECT COUNT(*)
    FROM OPENJSON([p].[Metadata], '$.Updates') WITH (
        [PostedFrom] nvarchar(45) '$.PostedFrom',
        [UpdatedBy] nvarchar(max) '$.UpdatedBy',
        [UpdatedOn] date '$.UpdatedOn',
        [Commits] nvarchar(max) '$.Commits' AS JSON
    ) AS [u]
    WHERE [u].[UpdatedOn] >= @__date_0) = 1

이 쿼리에는 UpdatedByCommits가 필요하지 않습니다. EF9부터 다음 열은 이제 정리됩니다.

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE (
    SELECT COUNT(*)
    FROM OPENJSON([p].[Metadata], '$.Updates') WITH (
        [PostedFrom] nvarchar(45) '$.PostedFrom',
        [UpdatedOn] date '$.UpdatedOn'
    ) AS [u]
    WHERE [u].[UpdatedOn] >= @__date_0) = 1

일부 시나리오에서는 이로 인해 WITH 절이 완전히 제거됩니다. 예시:

var tagsWithCount = await context.Tags.Where(p => p.Text.Length == 1).ToListAsync();

EF8에서 이 쿼리는 다음 SQL로 변환됩니다.

SELECT [t].[Id], [t].[Text]
FROM [Tags] AS [t]
WHERE (
    SELECT COUNT(*)
    FROM OPENJSON([t].[Text]) WITH ([value] nvarchar(max) '$') AS [t0]) = 1

EF9에서는 다음과 같이 개선되었습니다.

SELECT [t].[Id], [t].[Text]
FROM [Tags] AS [t]
WHERE (
    SELECT COUNT(*)
    FROM OPENJSON([t].[Text]) AS [t0]) = 1

GREATEST/LEAST 관련 번역

여기에 표시된 코드는 LeastGreatestSample.cs에서 가져온 것입니다.

GREATESTLEAST SQL 함수를 사용하는 몇 가지 새로운 변환이 도입되었습니다.

Important

GREATESTLEAST 함수는 2022 버전의 SQL Server/Azure SQL Database에 도입되었습니다. Visual Studio 2022는 기본적으로 SQL Server 2019를 설치합니다. EF9에서 이러한 새로운 번역을 시험해 보려면 SQL Server Developer Edition 2022를 설치하는 것이 좋습니다.

예를 들어, Math.Max 또는 Math.Min을 사용하는 쿼리는 이제 각각 GREATESTLEAST를 사용하는 Azure SQL을 위해 번역됩니다. 예시:

var walksUsingMin = await context.Walks
    .Where(e => Math.Min(e.DaysVisited.Count, e.ClosestPub.Beers.Length) > 4)
    .ToListAsync();

이 쿼리는 SQL Server 2022에 대해 실행되는 EF9를 사용할 때 다음 SQL로 변환됩니다.

SELECT [w].[Id], [w].[ClosestPubId], [w].[DaysVisited], [w].[Name], [w].[Terrain]
FROM [Walks] AS [w]
INNER JOIN [Pubs] AS [p] ON [w].[ClosestPubId] = [p].[Id]
WHERE LEAST((
    SELECT COUNT(*)
    FROM OPENJSON([w].[DaysVisited]) AS [d]), (
    SELECT COUNT(*)
    FROM OPENJSON([p].[Beers]) AS [b])) >

Math.MinMath.Max는 기본 컬렉션의 값에도 사용될 수 있습니다. 예시:

var pubsInlineMax = await context.Pubs
    .SelectMany(e => e.Counts)
    .Where(e => Math.Max(e, threshold) > top)
    .ToListAsync();

이 쿼리는 SQL Server 2022에 대해 실행되는 EF9를 사용할 때 다음 SQL로 변환됩니다.

SELECT [c].[value]
FROM [Pubs] AS [p]
CROSS APPLY OPENJSON([p].[Counts]) WITH ([value] int '$') AS [c]
WHERE GREATEST([c].[value], @__threshold_0) > @__top_1

마지막으로 SQL에서 RelationalDbFunctionsExtensions.LeastRelationalDbFunctionsExtensions.Greatest를 사용하여 Least 또는 Greatest 함수를 직접 호출할 수 있습니다. 예시:

var leastCount = await context.Pubs
    .Select(e => EF.Functions.Least(e.Counts.Length, e.DaysVisited.Count, e.Beers.Length))
    .ToListAsync();

이 쿼리는 SQL Server 2022에 대해 실행되는 EF9를 사용할 때 다음 SQL로 변환됩니다.

SELECT LEAST((
    SELECT COUNT(*)
    FROM OPENJSON([p].[Counts]) AS [c]), (
    SELECT COUNT(*)
    FROM OPENJSON([p].[DaysVisited]) AS [d]), (
    SELECT COUNT(*)
    FROM OPENJSON([p].[Beers]) AS [b]))
FROM [Pubs] AS [p]

쿼리 매개 변수화 적용 또는 방지

여기에 표시된 코드는 QuerySample.cs에서 가져온 것입니다.

일부 특별한 경우를 제외하고 EF Core는 LINQ 쿼리에 사용되는 변수를 매개 변수화하지만 생성된 SQL에 상수를 포함합니다. 예를 들어, 다음 쿼리 방법을 살펴보세요.

async Task<List<Post>> GetPosts(int id)
    => await context.Posts
        .Where(
            e => e.Title == ".NET Blog" && e.Id == id)
        .ToListAsync();

이는 Azure SQL을 사용할 때 다음 SQL 및 매개 변수로 변환됩니다.

info: 2/5/2024 15:43:13.789 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (1ms) [Parameters=[@__id_0='1'], CommandType='Text', CommandTimeout='30']
      SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
      FROM [Posts] AS [p]
      WHERE [p].[Title] = N'.NET Blog' AND [p].[Id] = @__id_0

EF는 ".NET 블로그"에 대한 SQL에서 상수를 만들었습니다. 이 값은 쿼리마다 변경되지 않기 때문입니다. 상수를 사용하면 쿼리 계획을 만들 때 데이터베이스 엔진에서 이 값을 검사할 수 있으므로 잠재적으로 쿼리가 더 효율적이 됩니다.

반면, 동일한 쿼리가 id에 대해 여러 다른 값으로 실행될 수 있으므로 id의 값은 매개 변수화됩니다. 이 경우 상수를 만들면 매개 변수 값만 다른 많은 쿼리로 인해 쿼리 캐시가 오염됩니다. 이는 데이터베이스의 전반적인 성능에 매우 좋지 않습니다.

일반적으로 이러한 기본값은 변경하면 안 됩니다. 그러나 EF Core 8.0.2에는 매개 변수가 기본적으로 사용되는 경우에도 EF가 상수를 사용하도록 하는 EF.Constant 메서드가 도입되었습니다. 예시:

async Task<List<Post>> GetPostsForceConstant(int id)
    => await context.Posts
        .Where(
            e => e.Title == ".NET Blog" && e.Id == EF.Constant(id))
        .ToListAsync();

이제 번역에는 id 값에 대한 상수가 포함됩니다.

info: 2/5/2024 15:43:13.812 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
      FROM [Posts] AS [p]
      WHERE [p].[Title] = N'.NET Blog' AND [p].[Id] = 1

반대로 수행되도록 EF9에 EF.Parameter 메서드가 도입되었습니다. 즉, 값이 코드에서 상수인 경우에도 EF가 매개 변수를 사용하도록 합니다. 예시:

async Task<List<Post>> GetPostsForceParameter(int id)
    => await context.Posts
        .Where(
            e => e.Title == EF.Parameter(".NET Blog") && e.Id == id)
        .ToListAsync();

이제 번역에는 ".NET Blog" 문자열에 대한 매개 변수가 포함됩니다.

info: 2/5/2024 15:43:13.803 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (1ms) [Parameters=[@__p_0='.NET Blog' (Size = 4000), @__id_1='1'], CommandType='Text', CommandTimeout='30']
      SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
      FROM [Posts] AS [p]
      WHERE [p].[Title] = @__p_0 AND [p].[Id] = @__id_1

인라인 상관되지 않은 하위 쿼리

여기에 표시된 코드는 QuerySample.cs에서 가져온 것입니다.

EF8에서는 다른 쿼리에서 참조되는 IQueryable이 별도의 데이터베이스 왕복으로 실행될 수 있습니다. 예를 들어 다음 LINQ 쿼리를 생각해 봅니다.

var dotnetPosts = context
    .Posts
    .Where(p => p.Title.Contains(".NET"));

var results = dotnetPosts
    .Where(p => p.Id > 2)
    .Select(p => new { Post = p, TotalCount = dotnetPosts.Count() })
    .Skip(2).Take(10)
    .ToArray();

EF8에서는 dotnetPosts에 대한 쿼리가 1회 왕복으로 실행된 후 최종 결과가 두 번째 쿼리로 실행됩니다. 예를 들어 SQL Server에서는 다음과 같이 표시됩니다.

SELECT COUNT(*)
FROM [Posts] AS [p]
WHERE [p].[Title] LIKE N'%.NET%'

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE [p].[Title] LIKE N'%.NET%' AND [p].[Id] > 2
ORDER BY (SELECT 1)
OFFSET @__p_1 ROWS FETCH NEXT @__p_2 ROWS ONLY

EF9에서는 dotnetPostsIQueryable이 인라인되어 단일 왕복이 발생합니다.

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata], (
    SELECT COUNT(*)
    FROM [Posts] AS [p0]
    WHERE [p0].[Title] LIKE N'%.NET%')
FROM [Posts] AS [p]
WHERE [p].[Title] LIKE N'%.NET%' AND [p].[Id] > 2
ORDER BY (SELECT 1)
OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY

ToHashSetAsync<T> 메서드

여기에 표시된 코드는 QuerySample.cs에서 가져온 것입니다.

Enumerable.ToHashSet 메서드는 .NET Core 2.0부터 존재했습니다. EF9에는 동등한 비동기 메서드가 추가되었습니다. 예시:

var set1 = await context.Posts
    .Where(p => p.Tags.Count > 3)
    .ToHashSetAsync();

var set2 = await context.Posts
    .Where(p => p.Tags.Count > 3)
    .ToHashSetAsync(ReferenceEqualityComparer.Instance);

이 개선 사항은 @wertzui가 제공했습니다. 대단히 고맙습니다!

ExecuteUpdate 및 ExecuteDelete

ExecuteUpdate에 복합 형식 인스턴스 전달 허용

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

추적이나 SaveChanges 없이 데이터베이스에 대한 즉각적이고 직접적인 업데이트를 수행하기 위해 ExecuteUpdate API가 EF7에 도입되었습니다. 예시:

await context.Stores
    .Where(e => e.Region == "Germany")
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Region, "Deutschland"));

이 코드를 실행하면 다음 쿼리가 실행되어 Region을 "Deutschland"로 업데이트합니다.

UPDATE [s]
SET [s].[Region] = N'Deutschland'
FROM [Stores] AS [s]
WHERE [s].[Region] = N'Germany'

EF8에서는 ExecuteUpdate를 사용하여 복합 형식 속성의 값을 업데이트할 수도 있습니다. 그러나 복합 형식의 각 멤버는 명시적으로 지정되어야 합니다. 예시:

var newAddress = new Address("Gressenhall Farm Shop", null, "Beetley", "Norfolk", "NR20 4DR");

await context.Stores
    .Where(e => e.Region == "Deutschland")
    .ExecuteUpdateAsync(
        s => s.SetProperty(b => b.StoreAddress.Line1, newAddress.Line1)
            .SetProperty(b => b.StoreAddress.Line2, newAddress.Line2)
            .SetProperty(b => b.StoreAddress.City, newAddress.City)
            .SetProperty(b => b.StoreAddress.Country, newAddress.Country)
            .SetProperty(b => b.StoreAddress.PostCode, newAddress.PostCode));

이 코드를 실행하면 다음 쿼리가 실행됩니다.

UPDATE [s]
SET [s].[StoreAddress_PostCode] = @__newAddress_PostCode_4,
    [s].[StoreAddress_Country] = @__newAddress_Country_3,
    [s].[StoreAddress_City] = @__newAddress_City_2,
    [s].[StoreAddress_Line2] = NULL,
    [s].[StoreAddress_Line1] = @__newAddress_Line1_0
FROM [Stores] AS [s]
WHERE [s].[Region] = N'Deutschland'

EF9에서는 복합 형식 인스턴스 자체를 전달하여 동일한 업데이트를 수행할 수 있습니다. 즉, 각 멤버를 명시적으로 지정할 필요가 없습니다. 예시:

var newAddress = new Address("Gressenhall Farm Shop", null, "Beetley", "Norfolk", "NR20 4DR");

await context.Stores
    .Where(e => e.Region == "Germany")
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.StoreAddress, newAddress));

이 코드를 실행하면 이전 예와 동일한 쿼리가 실행됩니다.

UPDATE [s]
SET [s].[StoreAddress_City] = @__complex_type_newAddress_0_City,
    [s].[StoreAddress_Country] = @__complex_type_newAddress_0_Country,
    [s].[StoreAddress_Line1] = @__complex_type_newAddress_0_Line1,
    [s].[StoreAddress_Line2] = NULL,
    [s].[StoreAddress_PostCode] = @__complex_type_newAddress_0_PostCode
FROM [Stores] AS [s]
WHERE [s].[Region] = N'Germany'

복합 형식 속성과 단순 속성 모두에 대한 여러 업데이트를 ExecuteUpdate에 대한 단일 호출로 결합할 수 있습니다. 예시:

await context.Customers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(
        s => s.SetProperty(
                b => b.CustomerInfo.WorkAddress, new Address("Gressenhall Workhouse", null, "Beetley", "Norfolk", "NR20 4DR"))
            .SetProperty(b => b.CustomerInfo.HomeAddress, new Address("Gressenhall Farm", null, "Beetley", "Norfolk", "NR20 4DR"))
            .SetProperty(b => b.CustomerInfo.Tag, "Tog"));

이 코드를 실행하면 이전 예와 동일한 쿼리가 실행됩니다.

UPDATE [c]
SET [c].[CustomerInfo_Tag] = N'Tog',
    [c].[CustomerInfo_HomeAddress_City] = N'Beetley',
    [c].[CustomerInfo_HomeAddress_Country] = N'Norfolk',
    [c].[CustomerInfo_HomeAddress_Line1] = N'Gressenhall Farm',
    [c].[CustomerInfo_HomeAddress_Line2] = NULL,
    [c].[CustomerInfo_HomeAddress_PostCode] = N'NR20 4DR',
    [c].[CustomerInfo_WorkAddress_City] = N'Beetley',
    [c].[CustomerInfo_WorkAddress_Country] = N'Norfolk',
    [c].[CustomerInfo_WorkAddress_Line1] = N'Gressenhall Workhouse',
    [c].[CustomerInfo_WorkAddress_Line2] = NULL,
    [c].[CustomerInfo_WorkAddress_PostCode] = N'NR20 4DR'
FROM [Customers] AS [c]
WHERE [c].[Name] = @__name_0

마이그레이션

개선된 임시 테이블 마이그레이션

기존 테이블을 임시 테이블로 변경할 때 만들어지는 마이그레이션의 크기가 EF9에서 축소되었습니다. 예를 들어, EF8에서 단일 기존 테이블을 임시 테이블로 만들면 다음과 같은 마이그레이션이 발생합니다.

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AlterTable(
        name: "Blogs")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AlterColumn<string>(
        name: "SiteUri",
        table: "Blogs",
        type: "nvarchar(max)",
        nullable: false,
        oldClrType: typeof(string),
        oldType: "nvarchar(max)")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AlterColumn<string>(
        name: "Name",
        table: "Blogs",
        type: "nvarchar(max)",
        nullable: false,
        oldClrType: typeof(string),
        oldType: "nvarchar(max)")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AlterColumn<int>(
        name: "Id",
        table: "Blogs",
        type: "int",
        nullable: false,
        oldClrType: typeof(int),
        oldType: "int")
        .Annotation("SqlServer:Identity", "1, 1")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart")
        .OldAnnotation("SqlServer:Identity", "1, 1");

    migrationBuilder.AddColumn<DateTime>(
        name: "PeriodEnd",
        table: "Blogs",
        type: "datetime2",
        nullable: false,
        defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AddColumn<DateTime>(
        name: "PeriodStart",
        table: "Blogs",
        type: "datetime2",
        nullable: false,
        defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
}

EF9에서는 이제 동일한 작업으로 인해 마이그레이션이 훨씬 작아집니다.

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AlterTable(
        name: "Blogs")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AddColumn<DateTime>(
        name: "PeriodEnd",
        table: "Blogs",
        type: "datetime2",
        nullable: false,
        defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
        .Annotation("SqlServer:TemporalIsPeriodEndColumn", true);

    migrationBuilder.AddColumn<DateTime>(
        name: "PeriodStart",
        table: "Blogs",
        type: "datetime2",
        nullable: false,
        defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
        .Annotation("SqlServer:TemporalIsPeriodStartColumn", true);
}

모델 빌드

시퀀스에 대한 캐싱 지정

여기에 표시된 코드는 ModelBuildingSample.cs에서 가져온 것입니다.

EF9에서는 이를 지원하는 모든 관계형 데이터베이스 공급자에 대해 데이터베이스 시퀀스에 대한 캐싱 옵션을 설정할 수 있습니다. 예를 들어, UseCache를 사용하여 캐싱을 명시적으로 켜고 캐시 크기를 설정할 수 있습니다.

modelBuilder.HasSequence<int>("MyCachedSequence")
    .HasMin(10).HasMax(255000)
    .IsCyclic()
    .StartsAt(11).IncrementsBy(2)
    .UseCache(3);

SQL Server를 사용할 때 다음과 같은 시퀀스 정의가 생성됩니다.

CREATE SEQUENCE [MyCachedSequence] AS int START WITH 11 INCREMENT BY 2 MINVALUE 10 MAXVALUE 255000 CYCLE CACHE 3;

마찬가지로 UseNoCache는 캐싱을 명시적으로 끕니다.

modelBuilder.HasSequence<int>("MyUncachedSequence")
    .HasMin(10).HasMax(255000)
    .IsCyclic()
    .StartsAt(11).IncrementsBy(2)
    .UseNoCache();
CREATE SEQUENCE [MyUncachedSequence] AS int START WITH 11 INCREMENT BY 2 MINVALUE 10 MAXVALUE 255000 CYCLE NO CACHE;

UseCache 또는 UseNoCache가 모두 호출되지 않으면 캐싱이 지정되지 않으며 데이터베이스는 기본값을 사용합니다. 이는 데이터베이스마다 기본값이 다를 수 있습니다.

이 개선 사항은 @bikbov가 제공했습니다. 대단히 고맙습니다!

키와 인덱스의 채우기 비율 지정

여기에 표시된 코드는 ModelBuildingSample.cs에서 가져온 것입니다.

EF9는 EF Core 마이그레이션을 사용하여 키와 인덱스를 만들 때 SQL Server 채우기 비율 사양을 지원합니다. SQL Server 문서에서 "인덱스가 만들어지거나 다시 빌드될 때 채우기 비율 값은 데이터로 채워질 각 리프 수준 페이지의 공간 비율을 결정하고 각 페이지의 나머지 부분을 향후 성장을 위한 사용 가능한 공간으로 예약합니다. "

채우기 비율은 단일 또는 복합 기본 및 대체 키와 인덱스에 설정할 수 있습니다. 예시:

modelBuilder.Entity<User>()
    .HasKey(e => e.Id)
    .HasFillFactor(80);

modelBuilder.Entity<User>()
    .HasAlternateKey(e => new { e.Region, e.Ssn })
    .HasFillFactor(80);

modelBuilder.Entity<User>()
    .HasIndex(e => new { e.Name })
    .HasFillFactor(80);

modelBuilder.Entity<User>()
    .HasIndex(e => new { e.Region, e.Tag })
    .HasFillFactor(80);

기존 테이블에 적용하면 테이블이 제약 조건에 대한 채우기 비율로 변경됩니다.

ALTER TABLE [User] DROP CONSTRAINT [AK_User_Region_Ssn];
ALTER TABLE [User] DROP CONSTRAINT [PK_User];
DROP INDEX [IX_User_Name] ON [User];
DROP INDEX [IX_User_Region_Tag] ON [User];

ALTER TABLE [User] ADD CONSTRAINT [AK_User_Region_Ssn] UNIQUE ([Region], [Ssn]) WITH (FILLFACTOR = 80);
ALTER TABLE [User] ADD CONSTRAINT [PK_User] PRIMARY KEY ([Id]) WITH (FILLFACTOR = 80);
CREATE INDEX [IX_User_Name] ON [User] ([Name]) WITH (FILLFACTOR = 80);
CREATE INDEX [IX_User_Region_Tag] ON [User] ([Region], [Tag]) WITH (FILLFACTOR = 80);

참고 항목

현재 Preview 2에는 테이블을 처음 만들 때 채우기 비율이 포함되지 않는 버그가 있습니다. 이는 문제 #33269로 추적됩니다.

이 개선 사항은 @deano-hunter가 제공했습니다. 대단히 고맙습니다!

기존 모델 빌드 규칙을 더욱 확장할 수 있도록 만듭니다.

여기에 표시된 코드는 CustomConventionsSample.cs에서 가져온 것입니다.

애플리케이션에 대한 공용 모델 빌드 규칙은 EF7에서 도입되었습니다. EF9에서는 기존 규칙 중 일부를 더 쉽게 확장할 수 있도록 만들었습니다. 예를 들어, EF7에서 특성별로 속성을 매핑하는 코드는 다음과 같습니다.

public class AttributeBasedPropertyDiscoveryConvention : PropertyDiscoveryConvention
{
    public AttributeBasedPropertyDiscoveryConvention(ProviderConventionSetBuilderDependencies dependencies)
        : base(dependencies)
    {
    }

    public override void ProcessEntityTypeAdded(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionContext<IConventionEntityTypeBuilder> context)
        => Process(entityTypeBuilder);

    public override void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        if ((newBaseType == null
             || oldBaseType != null)
            && entityTypeBuilder.Metadata.BaseType == newBaseType)
        {
            Process(entityTypeBuilder);
        }
    }

    private void Process(IConventionEntityTypeBuilder entityTypeBuilder)
    {
        foreach (var memberInfo in GetRuntimeMembers())
        {
            if (Attribute.IsDefined(memberInfo, typeof(PersistAttribute), inherit: true))
            {
                entityTypeBuilder.Property(memberInfo);
            }
            else if (memberInfo is PropertyInfo propertyInfo
                     && Dependencies.TypeMappingSource.FindMapping(propertyInfo) != null)
            {
                entityTypeBuilder.Ignore(propertyInfo.Name);
            }
        }

        IEnumerable<MemberInfo> GetRuntimeMembers()
        {
            var clrType = entityTypeBuilder.Metadata.ClrType;

            foreach (var property in clrType.GetRuntimeProperties()
                         .Where(p => p.GetMethod != null && !p.GetMethod.IsStatic))
            {
                yield return property;
            }

            foreach (var property in clrType.GetRuntimeFields())
            {
                yield return property;
            }
        }
    }
}

EF9에서는 이를 다음과 같이 간소화할 수 있습니다.

public class AttributeBasedPropertyDiscoveryConvention(ProviderConventionSetBuilderDependencies dependencies)
    : PropertyDiscoveryConvention(dependencies)
{
    protected override bool IsCandidatePrimitiveProperty(
        MemberInfo memberInfo, IConventionTypeBase structuralType, out CoreTypeMapping? mapping)
    {
        if (base.IsCandidatePrimitiveProperty(memberInfo, structuralType, out mapping))
        {
            if (Attribute.IsDefined(memberInfo, typeof(PersistAttribute), inherit: true))
            {
                return true;
            }

            structuralType.Builder.Ignore(memberInfo.Name);
        }

        mapping = null;
        return false;
    }
}

public이 아닌 생성자를 호출하도록 ApplyConfigurationsFromAssembly를 업데이트합니다.

이전 버전의 EF Core에서 ApplyConfigurationsFromAssembly 메서드는 매개 변수가 없는 공용 생성자를 사용하여 구문 형식만 인스턴스화했습니다. EF9에서는 실패할 때 생성되는 오류 메시지를 개선했으며 public이 아닌 생성자에 의한 인스턴스화도 사용하도록 설정했습니다. 이는 애플리케이션 코드로 인스턴스화해서는 안 되는 전용 중첩 클래스에 구성을 함께 배치할 때 유용합니다. 예시:

public class Country
{
    public int Code { get; set; }
    public required string Name { get; set; }

    private class FooConfiguration : IEntityTypeConfiguration<Country>
    {
        private FooConfiguration()
        {
        }

        public void Configure(EntityTypeBuilder<Country> builder)
        {
            builder.HasKey(e => e.Code);
        }
    }
}

또한 이 패턴이 엔터티 형식을 구성에 연결하기 때문에 혐오스럽다고 생각하는 사람들도 있으며, 구성이 엔터티 형식과 같은 위치에 있기 때문에 매우 유용하다고 생각하는 사람들도 있습니다. 여기서는 이에 대해 논쟁하지 않기로 합니다. :-)

SQL Server HierarchyId

여기에 표시된 코드는 HierarchyIdSample.cs에서 가져온 것입니다.

HierarchyId 경로 생성을 위한 Sugar

SQL Server HierarchyId 형식에 대한 최고 수준의 지원이 EF8에 추가되었습니다. EF9에서는 트리 구조에서 새 자식 노드를 더 쉽게 만들 수 있도록 sugar 메서드가 추가되었습니다. 예를 들어, 다음 코드는 HierarchyId 속성이 있는 기존 엔터티를 쿼리합니다.

var daisy = await context.Halflings.SingleAsync(e => e.Name == "Daisy");

그러면 이 HierarchyId 속성을 사용하여 명시적인 문자열 조작 없이 자식 노드를 만들 수 있습니다. 예시:

var child1 = new Halfling(HierarchyId.Parse(daisy.PathFromPatriarch, 1), "Toast");
var child2 = new Halfling(HierarchyId.Parse(daisy.PathFromPatriarch, 2), "Wills");

daisy/4/1/3/1/HierarchyId가 있으면 child1HierarchyId "/4/1/3/1/1/"을 가져오고 child2HierarchyId "/4/1/3/1/2/"를 가져옵니다.

이 두 자식 사이에 노드를 만들려면 추가 자식 수준을 사용할 수 있습니다. 예시:

var child1b = new Halfling(HierarchyId.Parse(daisy.PathFromPatriarch, 1, 5), "Toast");

그러면 HierarchyId/4/1/3/1/1.5/인 노드가 만들어져 child1child2 사이에 배치됩니다.

이 개선 사항은 @Rezakazemi890이 제공했습니다. 대단히 고맙습니다!

도구

다시 빌드 횟수 감소

기본적으로 dotnet ef 명령줄 도구는 도구를 실행하기 전에 프로젝트를 빌드합니다. 도구를 실행하기 전에 다시 빌드하지 않는 것이 작동하지 않을 때 혼동을 일으키는 일반적인 원인이기 때문입니다. 숙련된 개발자는 --no-build 옵션을 사용하여 속도가 느려질 수 있는 이 빌드를 방지할 수 있습니다. 그러나 --no-build 옵션을 사용하더라도 다음에 EF 도구 외부에서 빌드할 때 프로젝트가 다시 빌드될 수 있습니다.

@Suchiman커뮤니티 기여로 이 문제가 해결되었다고 생각합니다. 그러나 MSBuild 동작에 대한 조정이 의도하지 않은 결과를 초래하는 경향이 있다는 사실도 알고 있으므로 여러분과 같은 분들에게 이 기능을 시도해보고 부정적인 환경이 있으면 다시 보고해 주시기를 요청하고 있습니다.