SqlSugar 5联表查询实战:从Lambda别名到DTO投影与分页
2026/9/17 5:00:23 网站建设 项目流程

在 .NET 里用 ORM 开发,单表查询大家都很熟,一旦到了“订单列表要带客户名称”“报表要关联区域字段”,很多人就开始纠结:是回去手写 SQL,还是让 SqlSugar 5 的联表查询来接这活。我的答案是后者,但前提是你要懂它的 lambda 签名规则,否则写出来的 join 完全可能和预期 SQL 对不上。

这篇文章不打算抄文档。我会用一个电商后台最常见的“订单—客户—区域”模型,把联表查询从最开始的 Inner Join 写到三表关联,再讲分页、DTO 投影和 MergeTable 二次过滤,最后把我实际项目里踩过的三个问题直接摊开说。手边有项目正在用 SqlSugar 5.x,或者刚准备从 DbContext 切过来的朋友,建议直接照着敲一遍,比只看不练强得多。

1. 先理解 SqlSugar 联表查询的等价物:lambda 里的每个参数都是一张表的别名

1.1 为什么 join 之后的 lambda 参数突然变多了

SqlSugar 5 的单表查询,写法大家都很熟:

var orders = db.Queryable<Order>().Where(o => o.Status == 1).ToList();

这里o就是Order表的一个别名,但这种说法和 SQL 里的别名不完全一样。它不是一个真实的对象,只是表达式树里的一个“表别名符号”。一旦你开始联表,这个符号体系会直接决定你能不能把查询写对。

比如:

var list = db.Queryable<Order>() .InnerJoin<Customer>((o, c) => o.CustomerId == c.Id) .Where((o, c) => o.Status == 1) .Select((o, c) => new { OrderId = o.Id, CustomerName = c.Name }) .ToList();

关键点在于:InnerJoin<Customer>后面紧跟的 lambda 里出现了两个参数oc。这两个参数的顺序不是随便写的,第一个对应Queryable<Order>的主表,第二个对应刚 join 进来的Customer表。后面WhereSelectOrderBy里的 lambda 参数数量,也会跟着已关联表的数量一起变。

很多新手卡住的原因就在这里:where 条件里想用c.Name,但 lambda 还是只写了一个参数o => ...,然后编译器直接报错。这不是 SqlSugar 设计得反人类,而是它想帮你把“哪张表的哪个字段”这种信息放在编译期就固定住。

1.2 先学会把查询翻译成 SQL 再继续推进

我在实际项目里调试联表查询,几乎不会直接写完就ToList()。SqlSugar 提供了两个非常有用的调试入口:

var sql = db.Queryable<Order>() .LeftJoin<Customer>((o, c) => o.CustomerId == c.Id) .ToSqlString();

ToSqlString()会把当前查询对象生成好的 SQL 直接打印成字符串,不会真的去数据库执行。部分版本也支持ToSql(),返回带参数对象的键值对,具体看手里版本的方法签名。这个操作成本几乎为零,但能帮你提前看到:

  • join 条件是不是写反了
  • 表名、字段名有没有被转成下划线风格
  • where 条件是不是落在正确的位置
  • 分页有没有真的生成分页语句

我自己调联表查询的标准流程是:先写查询对象,然后.ToSqlString()看一次 SQL,确认 join 顺序和 on 条件无误后,再补过滤条件,最后再加分页。这一套下来,出问题的概率低非常多。

1.3 它为什么不像手写 SQL 那样“直给”

手写 SQL 的FROM a JOIN b ON a.id = b.aid很直观,但字符串无法在编译期做任何检查。SqlSugar 用泛型和表达式树,把表名、字段名、别名全封闭在一个强类型上下文里,你不需要关心表名的真实物理名字,也不用担心重构实体类后 SQL 忘记改。

代价就是:初看不如 SQL 直观,比如Select((o, c, r) => ...)这种一口气三个参数的写法,在刚开始时确实有点不习惯。但只要养成“一个参数就是一张表”的联想习惯,后面写四表五表 join 的效率比手写 SQL 高很多,毕竟手写 SQL 时我还得反复去对表别名和字段前缀,表达式树把这些事都包了。

2. 从零写第一组联表:订单表和客户表的 Inner Join 实操

2.1 先把实体准备齐

为了后面几个部分都能复用,我先定义三个实体,模拟非常典型的电商订单场景:

public class Order { [SugarColumn(IsPrimaryKey = true)] public int Id { get; set; } public string OrderNo { get; set; } public int CustomerId { get; set; } public int RegionId { get; set; } public decimal Amount { get; set; } public int Status { get; set; } public DateTime CreateTime { get; set; } } public class Customer { [SugarColumn(IsPrimaryKey = true)] public int Id { get; set; } public string Name { get; set; } public bool IsDelete { get; set; } } public class Region { [SugarColumn(IsPrimaryKey = true)] public int Id { get; set; } public string Name { get; set; } }

业务语义很简单:一个订单属于一个客户,也属于一个区域。在真实系统里,Region可能是省市区表,这里只保留关键字段。接着初始化 Db 对象:

var db = new SqlSugarClient(new ConnectionConfig { ConnectionString = "server=localhost;database=shop;uid=sa;pwd=123456;", DbType = DbType.SqlServer, IsAutoCloseConnection = true, InitKeyType = InitKeyType.Attribute });

如果是 MySQL 或 PostgreSQL,把DbType换成对应的枚举即可,后面的写法和 SQL 生成逻辑几乎不用改。

2.2 写第一条联表查询

需求很简单:查订单列表,并且把客户的名称带出来。这个场景在后台管理端天天出现,订单表只存CustomerId,页面要显示客户姓名,必须 join。

var list = db.Queryable<Order>() .InnerJoin<Customer>((o, c) => o.CustomerId == c.Id) .Where((o, c) => o.Status == 1) .Select((o, c) => new { OrderId = o.Id, OrderNo = o.OrderNo, CustomerName = c.Name, Amount = o.Amount }) .ToList();

这段代码翻译成 SQL 大概是:

SELECT o.Id AS OrderId, o.OrderNo AS OrderNo, c.Name AS CustomerName, o.Amount AS Amount FROM [Order] o INNER JOIN Customer c ON o.CustomerId = c.Id WHERE o.Status = 1

注意这里有个很实用的细节:你想取哪些列,完全由Select里的初始化器决定。哪怕实体类里有 20 个字段,只要没被投影进匿名对象,就不会进最终 select 列表,也不会给没用的字段做内存分配。

2.3 Inner Join 和 Left Join 的结果差异,最好用代码说话

同样一段查询,如果需求改成“订单列表里哪怕客户已删除,也要显示订单,客户名不存在的给空”,就不能用InnerJoin了,得换LeftJoin

var list = db.Queryable<Order>() .LeftJoin<Customer>((o, c) => o.CustomerId == c.Id) .Select((o, c) => new { OrderId = o.Id, CustomerName = c.Name // c 不存在时为 null }) .ToList();

在 SqlSugar 里,选择InnerJoin还是LeftJoin,本质上是业务语义的选择。如果业务上必须保证只有存在匹配客户才展示订单,就用 InnerJoin;如果订单是主体,客户只是附加信息,就用 LeftJoin。前者会把匹配不上的行过滤掉,后者会把右表字段置空,保留主表所有行。

实际项目里,我最常踩的并不是选错了 join 类型,而是后面Where条件一加,LeftJoin 悄悄变成了 InnerJoin 的效果,这一点后面专门讲。

2.4 匿名对象、实体还是 DTO,先不要凭感觉选

Select投影时可以用匿名对象,也可以指定具体类型。匿名对象适合临时查一次、不想新建类的场景,但如果你要把 list 返回给 Service 层或做分页结果包装,匿名对象很快就会成为类型地狱。我个人的习惯是:只在同一个方法内部自产自销的时候用匿名对象,一旦要跨方法传递,直接定义一个 DTO,这一步能省掉后面很多的类型转换麻烦。

public class OrderListDto { public int OrderId { get; set; } public string OrderNo { get; set; } public string CustomerName { get; set; } public decimal Amount { get; set; } }

然后:

var list = db.Queryable<Order>() .InnerJoin<Customer>((o, c) => o.CustomerId == c.Id) .Select((o, c) => new OrderListDto { OrderId = o.Id, OrderNo = o.OrderNo, CustomerName = c.Name, Amount = o.Amount }) .ToList();

这样做的好处是:SqlSugar 生成 SQL 时,只会把 DTO 中用到的列放进 select 列表,不会把OrderCustomer的所有字段一股脑全查出来。对列表页这种高频率查询来说,少传几十个没有用的字段,性能和网络开销都会有明显改善。

3. 三表关联的常见编排:连接条件、筛选条件、排序分页怎么放

3.1 订单、客户、区域三个表怎么连

需求升级:订单列表要显示客户名,还要显示区域名。区域表是独立的,订单表存了RegionId,所以结构上是两个 LeftJoin 或 InnerJoin 叠在一起:

var query = db.Queryable<Order>() .InnerJoin<Customer>((o, c) => o.CustomerId == c.Id) .LeftJoin<Region>((o, c, r) => o.RegionId == r.Id) .Where((o, c, r) => o.Status == 1) .OrderBy((o, c, r) => o.CreateTime, OrderByType.Desc) .Select((o, c, r) => new OrderListDto { OrderId = o.Id, OrderNo = o.OrderNo, CustomerName = c.Name, RegionName = r.Name, Amount = o.Amount });

这里最需要记住的是:第三个连接开始,lambda 参数会累积。第一个InnerJoin<Customer>(o, c),第二个LeftJoin<Region>就要用(o, c, r)。后面WhereOrderBy如果想引用任何一张表里的字段,lambda 参数都得保持三个,即使你当前的条件只用到了o.Status

3.2 条件到底放 on 还是放 where,决定 LeftJoin 是否变质

这是联表查询里最值得单独拎出来讲的一点。假设你要查“订单列表,只显示区域为华东的订单”,你当然会写:

var list = db.Queryable<Order>() .LeftJoin<Region>((o, r) => o.RegionId == r.Id) .Where((o, r) => r.Name == "华东") .ToList();

这段代码的 SQL 是:

SELECT ... FROM [Order] o LEFT JOIN Region r ON o.RegionId = r.Id WHERE r.Name = '华东'

问题来了:WHERE r.Name = '华东'会把那些RegionId没有匹配到区域的订单直接过滤掉,因为那些行的r.Name是 NULL,NULL = '华东' 不成立。也就是说,LeftJoin 硬生生变成了 InnerJoin 的效果。

如果业务上想表达“区域表里匹配到华东才显示,但匹配不上就保留订单、区域名显示为空”,需要把区域的条件放到 join 条件里:

var list = db.Queryable<Order>() .LeftJoin<Region>((o, r) => o.RegionId == r.Id && r.Name == "华东") .Select((o, r) => new { OrderId = o.Id, RegionName = r.Name }) .ToList();

生成的 SQL 是:

SELECT ... FROM [Order] o LEFT JOIN Region r ON o.RegionId = r.Id AND r.Name = '华东'

这样右表匹配不上的订单仍然保留,RegionName显示为 NULL。理解这个区别之后,你才算真正掌握了联表筛选的核心。凡是取右表字段做 where 过滤,都要先确认自己是想要“过滤后删除不匹配行”还是“保留主表所有行但右表条件只做连接约束”。

3.3 排序和分页是最后戴上的帽子

联表查询加上排序分页,把握一个原则:先写完整查询条件,再排序,最后分页。以三表查询为例:

int total = 0; var pageList = db.Queryable<Order>() .InnerJoin<Customer>((o, c) => o.CustomerId == c.Id) .LeftJoin<Region>((o, c, r) => o.RegionId == r.Id) .Where((o, c, r) => o.Status == 1) .OrderBy((o, c, r) => o.Amount, OrderByType.Desc) .ThenBy((o, c, r) => o.CreateTime, OrderByType.Desc) .Select((o, c, r) => new OrderListDto { OrderId = o.Id, OrderNo = o.OrderNo, CustomerName = c.Name, RegionName = r.Name, Amount = o.Amount }) .ToPageList(1, 20, ref total);

ToPageList会帮我们自动生成两条 SQL:一条查询当前页数据,一条统计总数。total是引用传递,调用结束后会拿到所有匹配条件下的总记录数。.ThenBy是次要排序,SQL 里会体现成多个ORDER BY字段。

这里有个容易被忽略的点:如果联表后存在一对多关系,比如一个订单有多条明细,那么ToPageList统计出的total是 join 后的行数,不是订单主表真实条数。这个问题我放到第 5 部分专门讲,因为它影响了不止一个项目。

3.4 有些过滤没必要真 join,子查询更划算

联表查询不是唯一手段。当你要判断“这个订单是否存在某条明细”时,如果直接 join 明细表,很可能会出现重复行。更稳的做法是用子查询:

var list = db.Queryable<Order>() .Where(o => SqlFunc.Subqueryable<OrderItem>() .Where(i => i.OrderId == o.Id && i.Status == 1) .Any()) .Select(o => new { OrderId = o.Id, OrderNo = o.OrderNo }) .ToList();

这个写法不会产生重复行,也不再需要Distinct。SqlSugar 的SqlFunc.Subqueryable能在表达式树里生成关联子查询,非常适合“右侧表只要存在匹配数据就算命中”的场景。我一般在“满足某条件的订单列表”这种需求里优先用子查询,而不是一上来就 join,既避免了重复数据,又让查询意图更明确。

4. 联表结果的二次加工:DTO 投影、MergeTable 过滤和分组统计

4.1 把联表结果转成 DTO 是默认选择

联表查询最忌讳的就是把整张Order实体直接抛给前端,因为实体里可能有不该暴露的字段,也可能少了一些前端想要的展示字段,比如CustomerNameRegionName这些需要联表才能得到的信息。

所以我在项目里定了一个规则:联表查询的返回类型,一律用 DTO。这样Select就会变成一个“字段裁剪工厂”,只把需要的列带出去。

4.2 MergeTable:联表结果当临时表继续过滤

有一个场景很多人会碰到:三张表 join 完之后,Select 已经选出了 DTO,但还想再根据 DTO 里的字段做过滤和排序。直接写Where(dto => dto.Amount > 100)不一定桥接到 SQL 列,因为前面的表达式树已经把结果投影成一个匿名或 DTO 结构。

SqlSugar 提供了一个很有用的方法:MergeTable()。它的作用是把当前查询的结果“拍平成一张临时结果集”,后续可以继续用这张结果集的字段名做 where、order、分页。

var query = db.Queryable<Order>() .InnerJoin<Customer>((o, c) => o.CustomerId == c.Id) .Select<OrderListDto>((o, c) => new OrderListDto { OrderId = o.Id, OrderNo = o.OrderNo, CustomerName = c.Name, Amount = o.Amount }) .MergeTable(); var result = await query .Where(d => d.CustomerName.Contains("张") && d.Amount >= 100) .OrderBy(d => d.Amount, OrderByType.Desc) .ToPageListAsync(1, 20, ref total);

这段代码非常典型。MergeTable()之前,SQL 是聚合了多个表的原始 join;MergeTable()之后,SqlSugar 会把这个查询作为子查询包一层,后面的过滤条件都是基于子查询结果列来执行的。

它适合的场景是:联表复杂度已经很高,后面还有多个动态条件要根据结果字段继续过滤。如果不做 MergeTable,你可能要把Where写成长长的((o, c) => ... && ... && ...)一串,看着累,也容易出问题。MergeTable 之后,查询就变成“先得到一张扁平的结果集,再在结果集上做筛选”,心智负担小很多。

4.3 分组统计里的一个易错点

联表查询不只是为了取列,还经常配合分组做统计。比如统计每个客户的订单数量:

var stats = db.Queryable<Order>() .InnerJoin<Customer>((o, c) => o.CustomerId == c.Id) .GroupBy((o, c) => new { c.Id, c.Name }) .Select((o, c) => new CustomerOrderStat { CustomerName = c.Name, OrderCount = SqlFunc.AggregateCount(o.Id) }) .ToList();

注意GroupBy里必须同时包含c.Idc.Name。如果你只按c.Id分组,却在Select里取c.Name,数据库会直接报错或者拿到的Name没有确定性依据。这是因为 SQL 分组规则要求:所有非聚合列,必须出现在GROUP BY里,否则这条 SQL 不合法。

这个坑在联表分组里特别容易犯,因为写代码时看着c.Name就在眼前,不觉得有问题。多表 join 后字段来源更复杂,分组条件稍一偷懒,线上就是一条 500 或者一次全表扫描。

5. 用 SqlSugar 5 做联表查询,我踩过的三个最典型的坑

5.1 LeftJoin 的“假左连”问题

前面已经说过:LeftJoin 之后把右表字段放进Where,会过滤掉左表保留的行。这个坑我在真实项目里出现过两次,一次是筛选“有客户订单”,另一次是筛选“有区域订单”,都因为沿用 InnerJoin 时代的习惯,直接Where(c.Name != null),结果发现列表行数对不上。

排查方式也不复杂:ToSqlString()看生成的 SQL,只要看到 where 条件里出现右表的列,就心里有数。如果要保留左表全量数据,一对多过滤就换成子查询;如果确实要过滤掉没有右表匹配的行,就接受它变成 InnerJoin 的事实,或者直接写InnerJoin,让意图更清晰。

5.2 join 明细表后,分页 total 数错

这是个业务数据特别容易失常的场景。订单主表一对多关联明细表,假设订单 100 条,明细 300 条,join 之后变成 300 行。如果直接ToPageList(1, 10, ref total),这个total会返回 300,不是 100。页面显示“共 300 条记录”,用户一看就知道不对。

要解决这个问题,就要回到业务口径:你到底要分页展示订单,还是展示明细行。如果是展示订单,就应该避免对明细表做 join,改用子查询来判断“是否存在满足条件的明细”;如果确实要显示明细行,那 total 为 300 就是正常的。

这种问题不会报错,只会在数据上“看起来有点怪”,所以我一般建议在联表前先明确“查询主体是哪张表”。主表是订单,就不能让明细表的一对多关系把行数撑大。

5.3 DTO 字段映射的静默失败

有一次我把联表结果Select到 DTO,前端拿到的CustomerName一直是 null,但数据库里明明有值。排查到最后,问题出在实体属性名和 DTO 字段名不一致上:实体里叫Name,DTO 里叫CustomerName,我在初始化器里的赋值表达式又漏了一行,结果 SQL 里根本没查出这个字段,DTO 自然就是默认值。

SqlSugar 的Select在投影到 DTO 时,如果没有显式给某字段赋值,它不会自动去猜你要把哪个实体字段塞进来,结果就是生成 SQL 时不会 select 那一列,或者按约定映射规则找了个空值。避免办法只有一个:项目里定好 DTO 的字段名,始终保持初始化器显式赋值,不要依赖自动映射。写完查询后,如果某些字段出现异常,先ToSqlString()看 select 列表里有没有该字段,比在内存里断点调试高效得多。

5.4 性能层面要留意的一个基本功

联表查询性能出问题,大多数不是 SqlSugar 的锅,而是 SQL 本身就没写好。常见的有:join 的表没有走索引、筛选条件用了函数包裹导致索引失效、select 了多余字段导致网络开销大。SqlSugar 生成的 SQL 是可以通过ToSqlString()拿出来放到数据库执行计划里分析的。

我一般遇到慢查询,优先做三件事:看执行计划里有没有 Index Seek;确认 join 条件两边字段类型一致,避免隐式转换;最后才是考虑加缓存。SqlSugar 5 也提供了简单好用的查询缓存,但对联表查询我很少一上来就开缓存,因为缓存失效策略一旦没设计好,数据一致性容易出问题,不如先把 SQL 本身打磨好。

一点实操体会

前前后后用 SqlSugar 5 维护了几个带复杂报表的 .NET 项目,我的体感是:联表查询本身并不复杂,真正耗时间的往往是对“查询主体”的把握。写 join 之前先问自己一句:这个页面主体是谁,一对多会不会放大行数,LeftJoin 的条件到底该放 on 还是放 where。三个问题想清楚,代码基本一遍过,生成的 SQL 也不用反复调。

最后再分享一个小习惯:我会在项目里封装一个基于ToSqlString()的调试方法,开发环境里在关键联表查询后面打一行 SQL 日志,页面出问题直接复制 SQL 去数据库工具里面跑,很多“看起来像是 ORM 的锅”最后都证明是自己 SQL 层面的细节没处理干净。这套路数在老项目里救了我很多次,值得你也试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询