☰
SqlSugar导航查询实战:从Includes到Mapper的完整指南
2026/10/2 9:06:52 网站建设 项目流程

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]查询时临时指定
代码简洁度高,一行带出整颗对象树中,需要写映射逻辑
关联数量冗余由框架控制,相对稳定如果写法不当容易产生 N+1
子表过滤条件不支持,只能在主表加条件灵活,可以按需过滤
适合场景业务实体对象,详情页、列表页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 List<OrderItem> 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 List<Tag> 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 List<OrderItem> Items { get; set; } = new List<OrderItem>();来避免空引用。但在 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.Queryable<Order>() .Includes(x => x.Items) .ToList(); }

这一个查询执行完之后,返回的每个Order对象里,Items集合已经被自动填充成该订单的明细数据。SqlSugar 生成的 SQL 不会是一条全表 JOIN,而是先查订单主表,再根据订单主键集合批量查明细表,最后在内存里组装。这样做的好处是避免了 JOIN 造成的大量重复行,分页也更好处理。

提示:如果你想看 SqlSugar 实际生成的 SQL,可以在ConnectionConfig里配置Aop.OnLogExecuting,把sql打到控制台。我排查问题第一步永远是看真实 SQL,而不是猜。

3.2 多级嵌套:订单、明细、商品一起查

业务往往不止一层。查订单的时候,除了明细,还要在明细里带上商品信息。这时用链式的Includes嵌套:

var list = db.Queryable<Order>() .Includes(x => x.Items) .Includes(x => x.Items.Select(y => y.Product)) .ToList();

注意第二行的写法:Items是集合,集合里的每一项又有一个Product对象,所以要用Select(y => y.Product)把下一级关系表达出来。这个嵌套理论上是无限的,你还可以继续往下挂:

var list = db.Queryable<Order>() .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.Queryable<Order>() .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.Queryable<Order>() .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.Queryable<Order>() .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的语义,自动生成关联查询并完成填充。

一对一映射最常见的场景是“查主表数据,带上关联表的某个业务字段”,比如订单带客户名称、商品带分类名称。比起手写 JOIN,Mapper的方式更贴近对象思维,代码可读性也更好。

4.2 一对多集合映射

真正体现 Mapper 价值的是“一对多”场景,尤其是带条件的集合映射。比如订单列表要显示每个订单下的高价值明细,条件就很难塞进Includes。这时可以先查主表,再用Mapper填充:

var list = db.Queryable<Order>() .Where(o => o.CustomerId == 1001) .Mapper(it => it.Items = db.Queryable<OrderItem>() .Where(i => i.OrderId == it.Id && i.Quantity > 2) .ToList()) .ToList();

这段代码逻辑上是能跑的,但有个隐患:每处理一个Order对象,就会发一条查询明细的 SQL。订单 100 条就是 101 条 SQL,虽然比循环里手动写Queryable省心,但从数据库交互次数上看并不优雅。

更高效的方式是用“批量映射”:先把订单主表查出来,收集所有订单 ID,再一次性查明细,最后通过映射逻辑把明细按OrderId分组挂到对应订单上。SqlSugar 的Mapper也支持传入集合条件进行批处理,具体要求以你所用版本的支持为准。如果版本不支持或者你拿不准,手动写三段式也完全可行:

var list = db.Queryable<Order>() .Where(o => o.CustomerId == 1001) .ToList(); var orderIds = list.Select(o => o.Id).ToList(); var items = db.Queryable<OrderItem>() .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 List<OrderItem>(); }

三段式的优势非常明显:无论主表多少条,SQL 永远只有两条。我在实际项目里经常用这个套路,简单、稳定、可控。

4.3 复杂条件导航:先查再填的三段式

有些业务的关联条件特别复杂,比如“统计每个订单下已发货、金额大于 100 的明细数量”,这时无论如何都不适合用Includes,我会直接走三段式。

第一步,查主表订单数据;第二步,按复杂条件查明细,并做好分组;第三步,把明细分组挂到订单上。这种方式看不懂吗?不会,它是纯内存操作,每个人都能读明白。性能也好,因为数据库只执行两条 SQL。除非明细数据量极大,否则内存分组在绝大多数系统里都能轻松胜任。

var orderList = db.Queryable<Order>() .Where(o => o.CreateTime > DateTime.Now.AddDays(-7)) .ToList(); var orderIds = orderList.Select(o => o.Id).ToList(); var validItems = db.Queryable<OrderItem>() .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.Queryable<OrderItem>(),结果页面一打开,数据库连接池直接飙满,接口响应时间从几十毫秒变成几秒。

判断标准很简单:如果你在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.SqlQuery<dynamic>(@" 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.Queryable<Order>() .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.Queryable<Order>() .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 的导航查询是一个值得花时间熟悉的功能,用顺了之后,你会发现业务代码里最啰嗦的关联查询部分,真的能被压缩成几行。

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

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

立即咨询