EF Core 11 が SQL Server 2025 で Contains を JSON_CONTAINS に翻訳
EF Core 11 は JSON コレクションに対する LINQ Contains を SQL Server 2025 の新しい JSON_CONTAINS 関数に自動翻訳し、JSON インデックスを利用できるパス指定・モード指定クエリ向けに EF.Functions.JsonContains を追加します。
SQL Server 2025 はネイティブの JSON_CONTAINS 関数を追加し、EF Core 11 はそれに繋ぎ込むリリースです。コレクションを JSON カラムとして格納している人にとって 2 つのことが変わります: JSON コレクションに対する Contains が古い OPENJSON join ではなく直接翻訳されるようになり、JSON パスや特定の検索モードが必要なケースのために新しい EF.Functions.JsonContains() が追加されました。この作業は EF Core 11 Preview 3 の一部です。
SQL Server 2025 の互換性レベルへのオプトイン
新しい翻訳は、プロバイダーが SQL Server 2025 と話していると分かったときだけ有効になります。プロバイダーオプションに UseCompatibilityLevel(170) を渡すことで設定します:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.UseSqlServer(
connectionString,
o => o.UseCompatibilityLevel(170));
互換性レベル 170 は SQL Server 2025 が報告する値です。それより低いレベルでは古い翻訳が使われ続けるので、実際にデータベースをアップグレードするまで省略しても安全です。
Contains は今どう見えるか
クラシックな「タグを JSON 配列として」の形を取りましょう:
public class Blog
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<string> Tags { get; set; } = new();
}
modelBuilder.Entity<Blog>()
.Property(b => b.Tags)
.HasColumnType("json"); // SQL Server 2025 native JSON type
EF Core 10、または古い SQL Server ターゲットでは、このクエリ:
var posts = await context.Blogs
.Where(b => b.Tags.Contains("ef-core"))
.ToListAsync();
は OPENJSON 翻訳を返し、相関サブクエリのように読めます:
WHERE N'ef-core' IN (
SELECT [t].[value]
FROM OPENJSON([b].[Tags]) WITH ([value] nvarchar(max) '$') AS [t]
)
互換性レベル 170 の EF Core 11 は代わりにこれを発行します:
WHERE JSON_CONTAINS([b].[Tags], 'ef-core') = 1
これが重要な理由は SQL の見た目だけではありません。JSON_CONTAINS は SQL Server 2025 で JSON インデックス を使える唯一の述語です。CREATE JSON INDEX IX_Tags ON Blogs(Tags) があるとき、OPENJSON パスは決してそれに触れませんが、EF 11 の翻訳は触れます。
リリースノートで指摘されている落とし穴が 1 つあります: JSON_CONTAINS は LINQ の Contains のようには NULL を扱わないため、EF は少なくとも片側が証明可能に non-nullable (null でない定数、または non-nullable なカラム) のときだけ新しい翻訳を選びます。両側が null になり得る場合、EF は OPENJSON にフォールバックし、既存の挙動を保ちます。
パスや検索モードが必要なとき
Contains は「このスカラーが配列に含まれるか」のケースをカバーします。それ以外には、EF Core 11 が EF.Functions.JsonContains(container, value, path?, mode?) を公開します。古典的な例は、構造化された JSON ドキュメント内の特定パスにある値を検索することです:
public class Blog
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string JsonData { get; set; } = "{}"; // { "Rating": 8, ... }
}
var ratedEights = await context.Blogs
.Where(b => EF.Functions.JsonContains(b.JsonData, 8, "$.Rating") == 1)
.ToListAsync();
これは次のように翻訳されます:
WHERE JSON_CONTAINS([b].[JsonData], 8, N'$.Rating') = 1
スカラーな string カラム、JSON にマップされた複合型、そして OwnsOne(... b.ToJson()) でマップされた owned 型と一緒に使えます。= 1 との比較が重要です: JSON_CONTAINS は bit を返し、EF はそれを保つので、WHERE ... AND JSON_CONTAINS(...) = 1 のような複合述語が JSON インデックスに対して SARGable のままになります。
これと EF.Functions.JsonPathExists を組み合わせて「プロパティが存在するか」のチェックをすれば、生 SQL に降りずに JSON カラムクエリの大部分をカバーできます。EF Core 11 の翻訳の変更点の完全なリストは What’s New ドキュメントにあります。