ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SqlSugar 5联表查询实战:从Lambda别名到DTO投影与分页

SqlSugar 5联表查询实战:从Lambda别名到DTO投影与分页 在 .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.QueryableOrder().Where(o o.Status 1).ToList();这里o就是Order表的一个别名但这种说法和 SQL 里的别名不完全一样。它不是一个真实的对象只是表达式树里的一个“表别名符号”。一旦你开始联表这个符号体系会直接决定你能不能把查询写对。比如var list db.QueryableOrder() .InnerJoinCustomer((o, c) o.CustomerId c.Id) .Where((o, c) o.Status 1) .Select((o, c) new { OrderId o.Id, CustomerName c.Name }) .ToList();关键点在于InnerJoinCustomer后面紧跟的 lambda 里出现了两个参数o和c。这两个参数的顺序不是随便写的第一个对应QueryableOrder的主表第二个对应刚 join 进来的Customer表。后面Where、Select、OrderBy里的 lambda 参数数量也会跟着已关联表的数量一起变。很多新手卡住的原因就在这里where 条件里想用c.Name但 lambda 还是只写了一个参数o ...然后编译器直接报错。这不是 SqlSugar 设计得反人类而是它想帮你把“哪张表的哪个字段”这种信息放在编译期就固定住。1.2 先学会把查询翻译成 SQL 再继续推进我在实际项目里调试联表查询几乎不会直接写完就ToList()。SqlSugar 提供了两个非常有用的调试入口var sql db.QueryableOrder() .LeftJoinCustomer((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 serverlocalhost;databaseshop;uidsa;pwd123456;, DbType DbType.SqlServer, IsAutoCloseConnection true, InitKeyType InitKeyType.Attribute });如果是 MySQL 或 PostgreSQL把DbType换成对应的枚举即可后面的写法和 SQL 生成逻辑几乎不用改。2.2 写第一条联表查询需求很简单查订单列表并且把客户的名称带出来。这个场景在后台管理端天天出现订单表只存CustomerId页面要显示客户姓名必须 join。var list db.QueryableOrder() .InnerJoinCustomer((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了得换LeftJoinvar list db.QueryableOrder() .LeftJoinCustomer((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.QueryableOrder() .InnerJoinCustomer((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 列表不会把Order和Customer的所有字段一股脑全查出来。对列表页这种高频率查询来说少传几十个没有用的字段性能和网络开销都会有明显改善。3. 三表关联的常见编排连接条件、筛选条件、排序分页怎么放3.1 订单、客户、区域三个表怎么连需求升级订单列表要显示客户名还要显示区域名。区域表是独立的订单表存了RegionId所以结构上是两个 LeftJoin 或 InnerJoin 叠在一起var query db.QueryableOrder() .InnerJoinCustomer((o, c) o.CustomerId c.Id) .LeftJoinRegion((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 参数会累积。第一个InnerJoinCustomer用(o, c)第二个LeftJoinRegion就要用(o, c, r)。后面Where和OrderBy如果想引用任何一张表里的字段lambda 参数都得保持三个即使你当前的条件只用到了o.Status。3.2 条件到底放 on 还是放 where决定 LeftJoin 是否变质这是联表查询里最值得单独拎出来讲的一点。假设你要查“订单列表只显示区域为华东的订单”你当然会写var list db.QueryableOrder() .LeftJoinRegion((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是 NULLNULL 华东 不成立。也就是说LeftJoin 硬生生变成了 InnerJoin 的效果。如果业务上想表达“区域表里匹配到华东才显示但匹配不上就保留订单、区域名显示为空”需要把区域的条件放到 join 条件里var list db.QueryableOrder() .LeftJoinRegion((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.QueryableOrder() .InnerJoinCustomer((o, c) o.CustomerId c.Id) .LeftJoinRegion((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.QueryableOrder() .Where(o SqlFunc.SubqueryableOrderItem() .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实体直接抛给前端因为实体里可能有不该暴露的字段也可能少了一些前端想要的展示字段比如CustomerName、RegionName这些需要联表才能得到的信息。所以我在项目里定了一个规则联表查询的返回类型一律用 DTO。这样Select就会变成一个“字段裁剪工厂”只把需要的列带出去。4.2 MergeTable联表结果当临时表继续过滤有一个场景很多人会碰到三张表 join 完之后Select 已经选出了 DTO但还想再根据 DTO 里的字段做过滤和排序。直接写Where(dto dto.Amount 100)不一定桥接到 SQL 列因为前面的表达式树已经把结果投影成一个匿名或 DTO 结构。SqlSugar 提供了一个很有用的方法MergeTable()。它的作用是把当前查询的结果“拍平成一张临时结果集”后续可以继续用这张结果集的字段名做 where、order、分页。var query db.QueryableOrder() .InnerJoinCustomer((o, c) o.CustomerId c.Id) .SelectOrderListDto((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 是聚合了多个表的原始 joinMergeTable()之后SqlSugar 会把这个查询作为子查询包一层后面的过滤条件都是基于子查询结果列来执行的。它适合的场景是联表复杂度已经很高后面还有多个动态条件要根据结果字段继续过滤。如果不做 MergeTable你可能要把Where写成长长的((o, c) ... ... ...)一串看着累也容易出问题。MergeTable 之后查询就变成“先得到一张扁平的结果集再在结果集上做筛选”心智负担小很多。4.3 分组统计里的一个易错点联表查询不只是为了取列还经常配合分组做统计。比如统计每个客户的订单数量var stats db.QueryableOrder() .InnerJoinCustomer((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.Id和c.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 字段名不一致上实体里叫NameDTO 里叫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 层面的细节没处理干净。这套路数在老项目里救了我很多次值得你也试试。
返回列表