EF Core 8 SQL Server Contains语法错误避坑

2026-06-09阅读 0热度 0
Contain

不少团队迁移到 .NET 8 与 EF Core 8 后,碰到了一个令人困惑的查询异常:原先正常运行的 Where(x => ids.Contains(x.id)) 突然抛出错误,日志显示:“关键字 'WITH' 附近有语法错误。如果此语句是公用表表达式,那么前一个语句必须以分号结尾。”——本文从现象拆解、根因追溯、修复方案到防坑策略,全链路一次讲透。

EF Core 8 + SQL Server:Contains() 突然报

1. 先看症状

假设你有一段极为常见的代码:

var ids = new List { 1, 2, 3, 5, 8 };
var users = await _db.sys_admin
    .Where(x => ids.Contains(x.id))
    .ToListAsync();

在 EF Core 6/7 上一切正常。升级到 EF Core 8 后,相同代码报错:

Microsoft.Data.SqlClient.SqlException (0x80131904):
关键字 'WITH' 附近有语法错误。
如果此语句是公用表表达式、xmlnamespaces 子句或者更改跟踪上下文子句,
那么前一个语句必须以分号结尾。

要害词语:WITH分号公用表表达式(CTE)。SQL Server 错误码为 156。

2. 根因:EF Core 8 对 Contains 的翻译方式变了

这不是 Bug,而是 EF Core 8 一个有意的 Breaking Change(官方文档明确标为 High Impact)。

2.1 旧行为(EF Core 6/7)

EF 将参数化列表的值内联为 SQL 常量

-- EF Core 7 生成的 SQL
SELECT [s].[id], [s].[username], ...
FROM [sys_admin] AS [s]
WHERE [s].[id] IN (1, 2, 3, 5, 8)

简洁直接,无 CTE,问题归零——直到你开始关注查询计划缓存。

2.2 新行为(EF Core 8)

EF Core 8 不再内联常量,而是改用 OPENJSONCTE(公用表表达式) 来传递参数化集合。简化后的生成逻辑如下:

简单值列表(string/int 常量)→ OPENJSON 方式
复杂查询 / 多次 Contains → CTE(WITH ... AS)方式

对于 ids.Contains(x.id) 这类场景,EF 可能生成类似 SQL:

-- EF Core 8 可能生成的 SQL(简化版)
;WITH [t] AS (
    SELECT [v].[value] FROM OPENJSON(@__ids_0) ...
)
SELECT [s].[id], ...
FROM [sys_admin] AS [s]
WHERE [s].[id] IN (SELECT [t].[value] FROM [t])

症结所在:WITH 前面必须有一个完整语句的分号 ;。如果当前 SQL 批处理中 EF 没有在前方补全分号,SQL Server 就会抛出错误 156。

2.3 官方文档怎么说

微软在 EF Core 8 Breaking Changes 中明确记录了此项(Tracking Issue #13617):

Contains in LINQ queries may stop working on older SQL Server versions
Impact: High

链接:https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-8.0/breaking-changes#contains-in-linq-queries-may-stop-working-on-older-sql-server-versions

3. 什么时候会触发?

并非所有 Contains 都会崩溃,但它可能在你想不到的地方突然爆发。触发条件包括但不限于:

场景风险
ids.Contains(x.id)idsList? 高
ids.Contains(x.id)idsList? 高(不少项目已踩过)
stringList.Contains(x.name)? 中(可能走 OPENJSON)
同一查询中有多个 Contains? 高
.Contains() 嵌套在复杂 Where 表达式中? 中
查询中同时有其他关联(Join / Include)? 高

最关键的信号:一旦错误信息中出现 WITH分号,99% 就是此问题。

4. 解决方案(5 种,从优到差)

方案一:参数化 Raw SQL(推荐 ⭐)

直接绕过 EF 翻译,采用 FromSqlRaw + SqlParameter,性能最优,零陷阱:

var paramNames = ids.Select((_, i) => $"@p{i}").ToArray();
var parameters = ids.Select((id, i) => 
    new Microsoft.Data.SqlClient.SqlParameter($"@p{i}", id)).ToArray();
var sql = $"SELECT * FROM sys_admin WHERE id IN ({string.Join(",", paramNames)})";

var users = await _db.sys_admin
    .FromSqlRaw(sql, parameters)
    .ToListAsync();
  • ✅ 生成的 SQL 即为简单的 WHERE id IN (@p0, @p1, ...)
  • Microsoft.Data.SqlClient 随 EF Core SQL Server 包自动引入,无需额外安装
  • ✅ 完全避免 SQL 注入
  • ⚠️ 需要知晓表名(不过 DbContext 中已有定义)

适用:批量删除、批量更新、批量查询等「已知 ID 列表查实体」场景。

方案二:FindAsync 逐个查询(小数据量 ⭐)

若 ID 列表极短(例如页面批量操作选 10-20 条),直接用主键查询:

var users = new List();
foreach (var id in ids)
{
    var user = await _db.sys_admin.FindAsync(id);
    if (user != null) users.Add(user);
}
  • FindAsync 走主键索引直查,不会生成 CTE
  • ✅ 简单可靠
  • ❌ N+1 查询,ID 数量多时性能堪忧

适用:后台管理的批量操作(用户勾选几条记录删除/启用等),ID 数量通常不超过几十个。

方案三:全量拉到内存过滤(小表 ⭐)

var all = await _db.sys_admin.ToListAsync();
var users = all.Where(x => ids.Contains(x.id)).ToList();
  • ✅ 零 SQL 风险
  • ✅ 一行代码解决问题
  • ❌ 全表拉取至内存,表量大时成为灾难
  • Contains 在内存中走 LINQ to Objects,无 SQL 问题

适用:字典表、配置表等行数极少(<1000)的表。

方案四:ToArray()(碰运气)

有时将 List 换成 int[] 后,EF 生成的 SQL 会发生变化:

var idArray = ids.ToArray();
var users = await _db.sys_admin
    .Where(x => idArray.Contains(x.id))
    .ToListAsync();
  • ⚠️ 不能保证有效,取决于具体的 EF Core 8.x 小版本与查询复杂度
  • ⚠️ 同一套代码在不同环境表现可能迥异
  • ❌ 不建议作为可靠方案

方案五(EF 9 专属):TranslateParameterizedCollectionsToConstants

若你已升级到 EF Core 9,可通过新增配置项恢复旧行为:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseSqlServer(connectionString)
        .TranslateParameterizedCollectionsToConstants();
}

或:

builder.Services.AddDbContext(options =>
    options.UseSqlServer(connectionString,
        sqlOptions => sqlOptions.TranslateParameterizedCollectionsToConstants()));
  • ✅ 全局生效,一行配置解决问题
  • ✅ 恢复 EF Core 7 的 Contains 翻译方式
  • ❌ 回归旧行为:查询计划缓存问题依然存在(微软当时改它的初衷)
  • ❌ 需要 EF Core 9

5. 推荐策略

你的 SQL Server 版本 >= 2016 且兼容级别 >= 130?
└─ 是 → 方案一(FromSqlRaw)或方案二(FindAsync),保留 EF8 新行为
└─ 否 → 考虑升级 SQL Server 或调整兼容级别

你的项目仍在 .NET 8 / EF Core 8?
└─ 批量操作(已知 IDs)→ 方案一 或 方案二
└─ 动态查询(用户输入过滤)→ 直接使用 EF8 的 OPENJSON 方式通常不会触发 CTE 问题
└─ 小表兜底 → 方案三

你的项目已升级到 EF Core 9?
└─ 考虑方案五,但需理解其代价(查询计划缓存退化)

6. 实战案例:MiePcb 项目经验

在 MiePcb 管理后台项目中先后两次踩中这个坑:

踩坑 1:GetPageList 的部门查询

// 错误写法
query = query.Where(x => deptIds.Contains(x.dept_id));

报错WITH 附近语法错误。
修复:内存中全量拉取部门表,再通过 Join 过滤。

var depts = await _db.sys_dept.Select(d => new { d.id, d.name }).ToListAsync();
// 后续在内存中关联

踩坑 2:BatchDelete 的批量删除

// 错误写法
var users = await _db.sys_admin.Where(x => ids.Contains(x.id)).ToListAsync();

报错:同上。
修复:方案一(FromSqlRaw + 参数化),一次查询干净利落。

经验总结

  1. EF Core 8 项目中的所有 Contains 都应在心里打个问号 — 写代码时就要提前想好万一报错,用哪个方案兜底
  2. ListList 更易触发 — 可空类型会使 EF 生成的 SQL 更复杂,更倾向于 CTE
  3. SQL Server 错误 156 若看到 WITH,就直接排查 Contains — 方向比错误信息本身更关键
  4. 记入 MEMORY.md — 团队其他成员可能不知道这个坑,需要文档沉淀

一句话总结:EF Core 8 将 Contains 翻译从“内联常量”改为“CTE / OPENJSON”,CTE 要求前置分号但 EF 未补齐,导致 SQL Server 报错。修复起来简单——用 FromSqlRaw 参数化查询、FindAsync、或内存过滤。核心原则:不再盲目信任 EF 的 Contains 翻译,批量操作优先 Raw SQL。

免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

相关阅读

更多
欢迎回来 登录或注册后,可保存提示词和历史记录
登录后可同步收藏、历史记录和常用模板
注册即表示同意服务条款与隐私政策