ARTICLE DETAIL

资讯详情

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

SqlSugar导航查询实战:从Includes到Mapper的完整指南

SqlSugar导航查询实战:从Includes到Mapper的完整指南 SqlSugar 的导航查询是我用这个 ORM 之后觉得最值得花时间摸透的功能。如果你只是拿 SqlSugar 做单表 CRUD可能感受不到它的威力一旦业务里出现订单、明细、商品、分类这种相互关联的数据导航查询能帮你省掉大量手工拼装的代码但也有一堆官方文档里没细写的细节等着你去踩。这篇东西是我基于 SqlSugar 6.x 实际项目整理出来的导航查询笔记从实体建模、Includes 写法、Mapper 映射到各种翻车现场尽量一次讲透。适合刚接触导航查询、或者已经被关联查询搞晕的人也适合想优化现有代码查询性能的同学。我会先讲清楚导航查询的原理和两条路线再给出可以直接抄的实体配置和查询代码最后把常见问题做成速查表。全程用订单、明细、商品这类业务场景举例保证你能照着跑通。1. 导航查询到底解决了什么问题1.1 没有导航查询时的痛苦做业务系统的人都知道数据落到数据库里是按表拆的但读出来的时候我们往往想要的是一个有层次的对象树。比如查一个订单除了订单本身还得带上订单明细明细里面还得知道商品叫什么名字。要是没有导航查询最常见的写法是这样的先查订单列表循环订单查明细再循环明细查商品。代码长不说每查一条明细就发一条 SQL订单一多数据库直接被打爆。又或者写一个多表 JOIN 视图把订单和明细一次性拉出来结果一条订单对应多条明细订单字段被重复填充后期还得自己在内存里做去重和分组。这条路我走过很多遍痛点非常集中手工拼装对象树容易漏字段JOIN 出来的扁平结果做分页容易数据错乱代码里到处都是foreach嵌套别人接手的时候根本不敢动。导航查询要解决的就是把“对象树”和“关系数据库”之间的转换成本降下来让你直接告诉 ORM订单下面有明细明细下面有商品剩下的由框架去处理。1.2 SqlSugar 6 的两条导航路线SqlSugar 的导航查询我把它分成两条路线理解清楚之后遇到任何业务场景都知道该选哪条。第一条是对象导航核心是Includes方法。你在实体类里用[Navigate]特性声明好表与表之间的关系查询的时候通过Includes(x x.xxx)直接把关联数据带出来。它的特点是“关系建模靠特性固化”一旦配好任何查询都能复用代码最简洁适合业务模型清晰、关系稳定的场景。第二条是 Mapper 映射核心是Mapper方法。查询时临时指定“这个字段要从哪个关联数据里取”不要求实体类提前配好外键和导航属性。它的特点是“灵活”适合复杂的 DTO 投影、报表查询以及那些不想为临时需求改动实体模型的场景。两条路线各有侧重我把它们的差异整理成一张表对比项对象导航 IncludesMapper 映射关系配置实体特性[Navigate]查询时临时指定代码简洁度高一行带出整颗对象树中需要写映射逻辑关联数量冗余由框架控制相对稳定如果写法不当容易产生 N1子表过滤条件不支持只能在主表加条件灵活可以按需过滤适合场景业务实体对象详情页、列表页DTO 投影、报表、临时关联新手友好度需要先理解特性配置更贴近手写 SQL 的思路我的习惯是业务核心对象之间的关联用对象导航一旦涉及跨模块 DTO 或者报表聚合果断切到 Mapper。两者不是替代关系是互补关系。2. 实体建模关系设计决定导航查询的上限2.1 一对一和一对多关系建模导航查询写得好不好实体建模要占七成功劳。SqlSugar 通过[Navigate]特性识别关系常见的三种关系一对一、一对多、多对多分别对应不同的配置方式。先看一对多这是订单和订单明细的关系[SugarTable(orders)] public class Order { [SugarColumn(IsPrimaryKey true, IsIdentity true)] public int Id { get; set; } public string OrderNo { get; set; } public int CustomerId { get; set; } [Navigate(NavigateType.OneToMany, nameof(OrderItem.OrderId))] public ListOrderItem Items { get; set; } }[SugarTable(order_items)] public class OrderItem { [SugarColumn(IsPrimaryKey true, IsIdentity true)] public int Id { get; set; } public int OrderId { get; set; } public int ProductId { get; set; } public int Quantity { get; set; } public decimal Price { get; set; } [Navigate(NavigateType.OneToOne, nameof(ProductId))] public Product Product { get; set; } }这里有三个关键点一是Order.Id要配上主键和自增[SugarColumn(IsPrimaryKey true, IsIdentity true)]缺一不可二是子表OrderItem里必须有外键字段OrderId三是[Navigate(NavigateType.OneToMany, nameof(OrderItem.OrderId))]的第二个参数一定要填子表外键的属性名不是表名。2.2 多对多关系建模多对多稍微绕一点比如商品和标签的关系。商品可以有多个标签一个标签也能挂在多个商品下面这就需要一个中间表[SugarTable(products)] public class Product { [SugarColumn(IsPrimaryKey true, IsIdentity true)] public int Id { get; set; } public string Name { get; set; } public int CategoryId { get; set; } [Navigate(NavigateType.ManyToMany, nameof(ProductTag.ProductId), nameof(ProductTag.TagId))] public ListTag Tags { get; set; } }[SugarTable(product_tags)] public class ProductTag { public int ProductId { get; set; } public int TagId { get; set; } }[SugarTable(tags)] public class Tag { [SugarColumn(IsPrimaryKey true, IsIdentity true)] public int Id { get; set; } public string Name { get; set; } }ManyToMany一共有三个参数第一个是中间表实体ProductTag第二个是中间表里指向“当前表”的外键ProductId第三个是中间表里指向“目标表”的外键TagId。很多新手把第二、第三个参数弄反结果查出来的标签要么全空要么全乱。提示多对多导航查询时中间表实体不需要额外加主键属性SqlSugar 会自动处理。但中间表的两个外键字段一定要和实际表字段对应上否则会引起查询异常。2.3 实体配置里容易忽略的三个坑第一个坑是导航属性上的特性问题。Items、Product、Tags这些导航属性不要加[SugarColumn]加了反而可能被当成表字段处理建表或插入时报“列不存在”。正确做法是只加[Navigate]或者干脆什么都不加。第二个坑是IsIgnore的使用场景。如果一个属性既不是表字段也不是导航属性只是业务临时使用比如public string Remark { get; set; }一定记得加[SugarColumn(IsIgnore true)]。否则在 CodeFirst 建表时SqlSugar 会尝试把它映射成数据库列一旦类型不兼容整个建表流程就挂了。第三个坑是导航属性初始化。有些同学习惯写public ListOrderItem Items { get; set; } new ListOrderItem();来避免空引用。但在 SqlSugar 导航查询里如果你给了初始值框架填充时可能因为集合已经被实例化而出现意外行为。我建议统一保持 null查询后用?.或者判空来处理。[SqlSugar.SugarColumn(IsIgnore true)] public string Remark { get; set; } string.Empty;实体关系建模一旦稳定导航查询就成功了一半。反过来如果实体关系本身就乱后面怎么写查询都会很别扭。3. 对象导航查询实操Includes 从入门到嵌套3.1 单层查询订单带明细对象导航查询的核心方法是Includes最基础的单层用法using (var db new SqlSugarClient(new ConnectionConfig { ConnectionString your connection string, DbType DbType.SqlServer, IsAutoCloseConnection true })) { var list db.QueryableOrder() .Includes(x x.Items) .ToList(); }这一个查询执行完之后返回的每个Order对象里Items集合已经被自动填充成该订单的明细数据。SqlSugar 生成的 SQL 不会是一条全表 JOIN而是先查订单主表再根据订单主键集合批量查明细表最后在内存里组装。这样做的好处是避免了 JOIN 造成的大量重复行分页也更好处理。提示如果你想看 SqlSugar 实际生成的 SQL可以在ConnectionConfig里配置Aop.OnLogExecuting把sql打到控制台。我排查问题第一步永远是看真实 SQL而不是猜。3.2 多级嵌套订单、明细、商品一起查业务往往不止一层。查订单的时候除了明细还要在明细里带上商品信息。这时用链式的Includes嵌套var list db.QueryableOrder() .Includes(x x.Items) .Includes(x x.Items.Select(y y.Product)) .ToList();注意第二行的写法Items是集合集合里的每一项又有一个Product对象所以要用Select(y y.Product)把下一级关系表达出来。这个嵌套理论上是无限的你还可以继续往下挂var list db.QueryableOrder() .Includes(x x.Items) .Includes(x x.Items.Select(y y.Product)) .Includes(x x.Items.Select(y y.Product.Select(z z.Category))) .ToList();每多一级SqlSugar 就会多生成一条子查询。虽然 SQL 条数变多但每条都是基于主键集合的批量查询性能完全可以接受。前提是不要在循环里调用这个查询逻辑否则再优化的 SQL 也扛不住。3.3 带过滤条件的对象导航对象导航查询同样支持Where、OrderBy、ToPageList等常规操作但它们的作用范围是“主表”var pageIndex 1; var pageSize 10; var total 0; var list db.QueryableOrder() .Where(o o.CustomerId 1001) .Where(o o.OrderNo.Contains(SO)) .OrderBy(o o.Id, OrderByType.Desc) .Includes(x x.Items) .ToPageList(pageIndex, pageSize, ref total);这里要特别注意一个限制Includes不支持给子表单独加过滤条件。比如“订单明细里只取数量大于 2 的明细”这种需求用Includes是做不到的。很多新手在这里卡住试图写.Includes(x x.Items.Where(i i.Quantity 2))结果发现编译都不通过甚至部分版本会静默忽略。正确的做法有两个一是先查符合条件的明细外键集合再去查订单主表二是改用 Mapper 映射在主表查询完成后按需填充子集合。两种做法我都在项目里用过简单业务选第一种复杂业务选第二种。3.4 分页与排序ToPageList 的注意事项ToPageList配合Includes时SqlSugar 会先对主表做分页拿到当前页的订单主键集合再去查这些订单下的明细最后组装对象树。所以分页结果是准确的不会出现一条订单被明细撑成多行导致页码错乱的问题。不过排序要小心。如果你对OrderItem里的字段排序比如按明细价格排序这个排序不会传导到主表分页结果上。正确的思路是排序字段只选主表字段子表字段的排序应该通过业务层或 Mapper 再做一次。var list db.QueryableOrder() .OrderBy(o o.CreateTime, OrderByType.Desc) .Includes(x x.Items) .ToPageList(1, 10, ref total);这段代码从语义上讲很直观最新的订单排前面每页十个同时带出每个订单的明细。实际跑下来SQL 数量大概是 1 条主查询加 1 条明细查询稳定可控。4. Mapper 映射导航更灵活也要更小心4.1 一对一或单字段映射对象导航适合把完整对象树查出来但很多时候我们只需要把某个关联字段填到 DTO 里。比如订单列表页要显示客户姓名不想把整个Customer对象查出来这时候用Mapper最合适var list db.QueryableOrder() .Select(o new OrderDto { Id o.Id, OrderNo o.OrderNo, CustomerId o.CustomerId }) .Mapper(it it.CustomerName, it it.Customer.Name) .ToList();Mapper的写法有点像“投影后再补字段”。它并不要求Order实体提前配好Customer导航属性只要表结构里存在CustomerId外键SqlSugar 就能根据表达式中it.Customer.Name的语义自动生成关联查询并完成填充。一对一映射最常见的场景是“查主表数据带上关联表的某个业务字段”比如订单带客户名称、商品带分类名称。比起手写 JOINMapper的方式更贴近对象思维代码可读性也更好。4.2 一对多集合映射真正体现 Mapper 价值的是“一对多”场景尤其是带条件的集合映射。比如订单列表要显示每个订单下的高价值明细条件就很难塞进Includes。这时可以先查主表再用Mapper填充var list db.QueryableOrder() .Where(o o.CustomerId 1001) .Mapper(it it.Items db.QueryableOrderItem() .Where(i i.OrderId it.Id i.Quantity 2) .ToList()) .ToList();这段代码逻辑上是能跑的但有个隐患每处理一个Order对象就会发一条查询明细的 SQL。订单 100 条就是 101 条 SQL虽然比循环里手动写Queryable省心但从数据库交互次数上看并不优雅。更高效的方式是用“批量映射”先把订单主表查出来收集所有订单 ID再一次性查明细最后通过映射逻辑把明细按OrderId分组挂到对应订单上。SqlSugar 的Mapper也支持传入集合条件进行批处理具体要求以你所用版本的支持为准。如果版本不支持或者你拿不准手动写三段式也完全可行var list db.QueryableOrder() .Where(o o.CustomerId 1001) .ToList(); var orderIds list.Select(o o.Id).ToList(); var items db.QueryableOrderItem() .Where(i orderIds.Contains(i.OrderId) i.Quantity 2) .ToList(); var itemGroups items.GroupBy(i i.OrderId).ToDictionary(g g.Key, g g.ToList()); foreach (var order in list) { order.Items itemGroups.TryGetValue(order.Id, out var value) ? value : new ListOrderItem(); }三段式的优势非常明显无论主表多少条SQL 永远只有两条。我在实际项目里经常用这个套路简单、稳定、可控。4.3 复杂条件导航先查再填的三段式有些业务的关联条件特别复杂比如“统计每个订单下已发货、金额大于 100 的明细数量”这时无论如何都不适合用Includes我会直接走三段式。第一步查主表订单数据第二步按复杂条件查明细并做好分组第三步把明细分组挂到订单上。这种方式看不懂吗不会它是纯内存操作每个人都能读明白。性能也好因为数据库只执行两条 SQL。除非明细数据量极大否则内存分组在绝大多数系统里都能轻松胜任。var orderList db.QueryableOrder() .Where(o o.CreateTime DateTime.Now.AddDays(-7)) .ToList(); var orderIds orderList.Select(o o.Id).ToList(); var validItems db.QueryableOrderItem() .Where(i orderIds.Contains(i.OrderId)) .Where(i i.IsShipped i.Price * i.Quantity 100) .Select(i new { i.OrderId, i.Id }) .ToList() .GroupBy(i i.OrderId) .ToDictionary(g g.Key, g g.Count()); foreach (var order in orderList) { order.ValidItemCount validItems.TryGetValue(order.Id, out var count) ? count : 0; }如果Order类里没有ValidItemCount这个属性就加一个[SugarColumn(IsIgnore true)]的只读属性。这样既不干扰数据库映射又能承载业务计算结果。4.4 性能红线不要在循环里写查询这是导航查询里最容易发生、也最致命的问题。我见过不少同事图省事直接在循环里调用db.QueryableOrderItem()结果页面一打开数据库连接池直接飙满接口响应时间从几十毫秒变成几秒。判断标准很简单如果你在foreach或for循环里看到了db.Queryable就要停下来想一想能否把这个查询提到循环外面用批量条件一次查出来。不管是用Contains、In还是临时字典缓存都要比循环里发 SQL 好上百倍。提示SqlSugar 没有懒加载机制。也就是说Includes查出来的数据是一次性加载的不会在你访问order.Items时偷偷发查询。这个特性其实是好事只要别在循环里手动触发查询整个请求的 SQL 数量就是可控的。5. 进阶场景无实体导航、动态表名与跨库查询5.1 没有实体也能做关联查询有些场景下表结构是动态的或者临时查询不想建实体类这时候能不能做导航查询我的答案很直接不建议硬做但可以用 SqlSugar 的动态 SQL 能力曲线实现。如果你连实体类都懒得建最直接的方式是写 SQL 视图然后用SqlQueryable或Ado.SqlQuery读取。视图的优点在于把关联关系固化在数据库层代码里只需要一个扁平的结果集var list db.Ado.SqlQuerydynamic( SELECT o.Id, o.OrderNo, i.ProductName, SUM(i.Amount) AS Amount FROM orders o LEFT JOIN order_items i ON o.Id i.OrderId WHERE o.CustomerId customerId GROUP BY o.Id, o.OrderNo, i.ProductName, new { customerId 1001 });这种写法没有导航查询那种“自动组装对象树”的体验但胜在简单通用适合临时报表、看板、导出等场景。如果字段固定建议定义对应的 DTO 类接收维护性更好。5.2 动态表名和分表场景的处理业务量大了之后订单表可能会按月份拆成orders_202501、orders_202502。SqlSugar 的实体默认映射到固定表名导航查询时Includes也会基于默认表名生成 SQL遇到分表就会出错。我的做法是把“动态表名”和“导航查询”解耦分表查询时只做主表查询通过手动改表名或 SqlSugar 的AS方法指定物理表拿到当前需要的订单数据后再用三段式Mapper去查明细而不是强制Includes走分表。var list db.QueryableOrder() .AS(orders_202501) .Where(o o.CustomerId 1001) .ToList();这样虽然少了一点“全自动导航”的感觉但规避了分表下 SQL 拼接错误的坑。等到需要跨多张分表统计时建议用数据库视图或专门的报表服务而不是在 ORM 层面硬碰硬。5.3 跨库查询的实践建议跨数据库查询是另一个容易翻车的点。如果你的用户表在user_db订单表在order_db同一个 SqlSugar 实例的Includes是搞不定的因为它生成的 SQL 走的是同一连接串。硬要支持得在实体上配置[SugarTable(dbname.dbo.orders)]但这样耦合太重我不推荐。实际项目里更稳妥的方案有两种一是数据库层面做同库视图把跨库数据统一到视图中二是业务层先查一个库的数据拿到 ID 集合再去另一个库批量查询然后内存组装。第二种方案说起来复杂其实就是前面讲的三段式SQL 数量可控逻辑清晰跨库跨得明明白白。6. 常见问题排查实录6.1 导航属性查出来全是 null这是最常遇到的问题通常是[Navigate]参数配置不正确导致的。排查顺序我建议这样先确认主表主键有没有配IsPrimaryKey true再确认子表外键字段名是否和[Navigate]第二个参数一致最后确认表名映射是否正确。如果是在两个数据库之间或者同名字段很多的情况下还要确认是否因为大小写或 Schema 差异导致映射失败。6.2 Select 投影后导航属性丢失Includes和Select一起用时容易踩到“投影后字段丢失”的问题。我举个例子var list db.QueryableOrder() .Select(o new OrderDto { Id o.Id, OrderNo o.OrderNo }) .Includes(x x.Items) .ToList();如果OrderDto里没有Items属性Includes的结果就没有地方可以放导航等于白写。正确做法是确保投影的目标类型里包含导航对应的属性或者先查实体再转 DTO。我在项目里更倾向于后者先拿实体对象树再用 AutoMapper 或手动映射转成对外 DTO这样逻辑更清晰。6.3 一对多 Includes 后数据翻倍如果你发现返回的订单列表里出现了重复订单第一反应别急着怀疑分页先看看是不是多对多关系配置出了问题。ManyToMany导航如果中间表外键顺序写反会导致关联结果错乱主表记录被重复匹配。另外如果你在同一个查询里多次Includes同一个集合也可能导致重复填充。检查一下是不是写了两个重复的.Includes(x x.Items)这种情况 SqlSugar 不会报错但会产生不必要的重复查询。6.4 循环查询导致的慢接口接口慢90% 是因为循环里发了 SQL。判断方法很简单打开 AOP 日志看 SQL 条数。正常的详情列表查询SQL 条数应该在个位数以内如果循环里有查询SQL 条数会随记录数线性增长。修复思路就是把循环内的查询改造成批量查询通常用Contains或In条件一次取出所有关联数据再在内存里组装。6.5 多数据库的类型映射差异SqlSugar 支持多种数据库但导航查询生成的 SQL 在不同数据库下会有差异。比如 SQL Server 的TOP、MySQL 的LIMIT、Oracle 的ROWNUM这些分页语法框架会处理但如果你在实体里用了数据库特有类型比如GUID或NVarChar查询条件就要小心。遇到导航查询生成 SQL 报错先看 AOP 日志里的真实 SQL再去和当前数据库语法对照通常很快能定位。6.6 问题排查速查表现象可能原因解决思路导航属性全为 null[Navigate] 外键参数错误或主键未配置核对实体映射先跑单表查询确认字段投影后导航丢失投影目标类型没有对应属性先查实体再转 DTO一对多分页数据错乱子表 JOIN 导致主表重复用 Includes 或三段式代替 JOIN 扁平结果接口响应极慢循环内部发了 SQL改成批量查询SQL 控制在个位数跨库关联报错不同连接串的库无法通过 Includes 联查用视图或先查后组装多表字段冲突JOIN 时同名字段被覆盖尽量用对象导航或别名做 DTO 投影排查导航查询问题我的口诀就一句话先看 SQL再看实体映射最后再怀疑框架。最后再分享一点个人习惯。我现在写查询之前会先把实体关系图画一遍一对多、多对多都标清楚再决定用Includes还是Mapper。遇到复杂报表绝不硬套导航直接写视图或 SQL 反而省事。如果哪天返回的数据不对我会第一时间打开Aop.OnLogExecuting把实际 SQL 打出来绝大多数问题盯着真实 SQL 看一遍就明白了。SqlSugar 的导航查询是一个值得花时间熟悉的功能用顺了之后你会发现业务代码里最啰嗦的关联查询部分真的能被压缩成几行。
返回列表