Skip to content

Commit

Permalink
8.0 breaking change note on parameterized collection perf regression (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
roji authored Nov 30, 2024
1 parent e578fcf commit 579baa9
Showing 1 changed file with 93 additions and 12 deletions.
105 changes: 93 additions & 12 deletions entity-framework/core/what-is-new/ef-core-8.0/breaking-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ EF Core 8 targets .NET 8. Applications targeting older .NET, .NET Core, and .NET
| **Breaking change** | **Impact** |
|:--------------------------------------------------------------------------------------------------------------|------------|
| [`Contains` in LINQ queries may stop working on older SQL Server versions](#sqlserver-contains-compatibility) | High |
| [Possible query performance regressions around `Contains` in LINQ queries](#contains-perf-regression) | High |
| [Enums in JSON are stored as ints instead of strings by default](#enums-as-ints) | High |
| [SQL Server `date` and `time` now scaffold to .NET `DateOnly` and `TimeOnly`](#sqlserver-date-time-only) | Medium |
| [Boolean columns with a database generated value are no longer scaffolded as nullable](#scaffold-bools) | Medium |
Expand All @@ -47,17 +48,79 @@ EF Core 8 targets .NET 8. Applications targeting older .NET, .NET Core, and .NET

#### Old behavior

Previously, when the `Contains` operator was used in LINQ queries with a parameterized value list, EF generated SQL that was inefficient but worked on all SQL Server versions.
EF had specialized support for LINQ queries using `Contains` operator over a parameterized value list:

```c#
var names = new[] { "Blog1", "Blog2" };

var blogs = await context.Blogs
.Where(b => names.Contains(b.Name))
.ToArrayAsync();
```

Before EF Core 8.0, EF inserted the parameterized values as constants into the SQL:

```sql
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] IN (N'Blog1', N'Blog2')
```

#### New behavior

Starting with EF Core 8.0, EF now generates SQL that is more efficient, but is unsupported on SQL Server 2014 and below.
Starting with EF Core 8.0, EF now generates SQL that is more efficient in many cases, but is unsupported on SQL Server 2014 and below:

```sql
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] IN (
SELECT [n].[value]
FROM OPENJSON(@__names_0) WITH ([value] nvarchar(max) '$') AS [n]
)
```

Note that newer SQL Server versions may be configured with an older [compatibility level](/sql/t-sql/statements/alter-database-transact-sql-compatibility-level), also making them incompatible with the new SQL. This can also occur with an Azure SQL database which was migrated from a previous on-premises SQL Server instance, carrying over the old compatibility level.

#### Why

The previous SQL generated by EF Core for `Contains` inserted the parameterized values as constants in the SQL. For example, the following LINQ query:
The insertion of constant values into the SQL creates many performance problems, defeating query plan caching and causing unneeded evictions of other queries. The new EF Core 8.0 translation uses the SQL Server [`OPENJSON`](/sql/t-sql/functions/openjson-transact-sql) function to instead transfer the values as a JSON array. This solves the performance issues inherent in the previous technique; however, the `OPENJSON` function is unavailable in SQL Server 2014 and below.

For more information about this change, [see this blog post](https://devblogs.microsoft.com/dotnet/announcing-ef8-preview-4/).

#### Mitigations

If your database is SQL Server 2016 (13.x) or newer, or if you're using Azure SQL, check the configured compatibility level of your database via the following command:

```sql
SELECT name, compatibility_level FROM sys.databases;
```

If the compatibility level is below 130 (SQL Server 2016), consider modifying it to a newer value ([documentation](/sql/t-sql/statements/alter-database-transact-sql-compatibility-level#best-practices-for-upgrading-database-compatibility-leve)).

Otherwise, if your database version really is older than SQL Server 2016, or is set to an old compatibility level which you cannot change for some reason, you can configure EF to revert to the older, pre-8.0 SQL. If you're using EF 9, you can use the newly-introduced <xref:Microsoft.EntityFrameworkCore.Infrastructure.RelationalDbContextOptionsBuilder%602.TranslateParameterizedCollectionsToConstants%2A>:

```c#
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseSqlServer("<CONNECTION STRING>", o => o.TranslateParameterizedCollectionsToConstants())
```

If you're using EF 8, you can achieve the same effect when using SQL Server by configuring EF's SQL compatibility level:

```c#
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseSqlServer(@"<CONNECTION STRING>", o => o.UseCompatibilityLevel(120));
```

<a name="contains-perf-regression"></a>

### Possible query performance regressions around `Contains` in LINQ queries

[Tracking Issue #32394](https://github.com/dotnet/efcore/issues/32394)

#### Old behavior

EF had specialized support for LINQ queries using `Contains` operator over a parameterized value list:

```c#
var names = new[] { "Blog1", "Blog2" };
Expand All @@ -67,36 +130,54 @@ var blogs = await context.Blogs
.ToArrayAsync();
```

... would be translated to the following SQL:
Before EF Core 8.0, EF inserted the parameterized values as constants into the SQL:

```sql
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] IN (N'Blog1', N'Blog2')
```

Such insertion of constant values into the SQL creates many performance problems, defeating query plan caching and causing unneeded evictions of other queries. The new EF Core 8.0 translation uses the SQL Server [`OPENJSON`](/sql/t-sql/functions/openjson-transact-sql) function to instead transfer the values as a JSON array. This solves the performance issues inherent in the previous technique; however, the `OPENJSON` function is unavailable in SQL Server 2014 and below.
#### New behavior

For more information about this change, [see this blog post](https://devblogs.microsoft.com/dotnet/announcing-ef8-preview-4/).
Starting with EF Core 8.0, EF now generates the following:

```sql
SELECT [b].[Id], [b].[Name]
FROM [Blogs] AS [b]
WHERE [b].[Name] IN (
SELECT [n].[value]
FROM OPENJSON(@__names_0) WITH ([value] nvarchar(max) '$') AS [n]
)
```

However, after the release of EF 8 it turned out that while the new SQL is more efficient for most cases, it can be dramatically less efficient in a minority of cases, even causing query timeouts in some cases

#### Mitigations

If your database is SQL Server 2016 (13.x) or newer, or if you're using Azure SQL, check the configured compatibility level of your database via the following command:
If you're using EF 9, you can use the newly-introduced <xref:Microsoft.EntityFrameworkCore.Infrastructure.RelationalDbContextOptionsBuilder%602.TranslateParameterizedCollectionsToConstants%2A> to revert the `Contains` translation for all queries back to the pre-8.0 behavior:

```sql
SELECT name, compatibility_level FROM sys.databases;
```c#
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseSqlServer("<CONNECTION STRING>", o => o.TranslateParameterizedCollectionsToConstants())
```

If the compatibility level is below 130 (SQL Server 2016), consider modifying it to a newer value ([documentation](/sql/t-sql/statements/alter-database-transact-sql-compatibility-level#best-practices-for-upgrading-database-compatibility-leve)).

Otherwise, if your database version really is older than SQL Server 2016, or is set to an old compatibility level which you cannot change for some reason, configure EF Core to revert to the older, less efficient SQL as follows:
If you're using EF 8, you can achieve the same effect when using SQL Server by configuring EF's SQL compatibility level:

```c#
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseSqlServer(@"<CONNECTION STRING>", o => o.UseCompatibilityLevel(120));
```

Finally, you can control the translation on a query-by-query basis using <xref:Microsoft.EntityFrameworkCore.EF.Constant%2A?displayProperty=nameWithType> as follows:

```c#
var blogs = await context.Blogs
.Where(b => EF.Constant(names).Contains(b.Name))
.ToArrayAsync();
```

<a name="enums-as-ints"></a>

### Enums in JSON are stored as ints instead of strings by default
Expand Down

0 comments on commit 579baa9

Please sign in to comment.