Skip to content
bitzorcas
中EN

Reference

标准列表查询内核与 QueryShape

深度剖析 BitzOrcas.Modern 标准列表查询内核,揭秘 QueryShape 编译期执行计划生成、多表联查别名自动解耦、手写条件扩展与双 ORM 投影下推。

Last updated

在企业级管理系统(尤其是律所案件、跨国合规等高要求领域)中,80% 以上的高频请求都是列表检索与多维筛选。传统企业架构往往在此处陷入两类典型的工程反模式:

  1. “大宽表内存反序列化”的反模式:在 Application 层直接注入仓储拉取完整聚合根(包含大文本、富文本与 JSON 审计快照),在内存中通过 AutoMapper 转换为 DTO,带来惊人的 GC 压力与数据库网络 I/O 浪费;
  2. “字符串别名硬绑定”的脆弱反模式:在多表联查场景中,上层 DTO 标注硬编码物理表别名(如 t.CaseNo、t4.OppositeName),一旦底层的 JOIN 顺序微调或插入新表,整个查询管道瞬间因别名错位而在运行时抛出 SQL 解析异常。

BitzOrcas.Modern 通过 ADR 0303(标准列表查询内核与 QueryShape) 建立了新一代声明式查询范式:由声明式元数据在 Roslyn 编译期生成强类型执行计划,实现 100% 类型安全、AOT 零运行时反射、多表物理别名彻底解耦、手写业务条件安全扩展,以及双 ORM(SqlSugar / EF Core)对齐的极致投影下推。

1. 核心架构:QueryShape 的设计哲学

1.1 为什么严禁在 Application 层手写 LINQ 链式调用?

这种写法在复杂企业级架构下存在三大致命硬伤:

  • 破坏投影下推(Projection Pushdown):手写仓储极易拉取未投影的整个实体,导致宽列(如大型合同正文、批注流)全部落入内存;
  • 破坏双 ORM 行为一致性:SqlSugar 与 EF Core 对某些复杂 LINQ 表达式(如特定的空值传播、字符串方法)的翻译细节不同,手写 LINQ 会引入跨 ORM 行为漂移;
  • 混淆读写语义:仓储的 FindAsync 属于写侧聚合根恢复通道(附带并发版本与状态跟踪),而高频列表查询属于无状态的只读操作,二者物理通道必须隔离。

1.2 QueryShape 运行全景图

Roslyn 增量生成器无缝注入 basePredicate自动对齐物理别名与投影

HTTP Request
(Fields + Paging + Sort)

Application ListQuery
(组合信封值对象)

ICaseMatterReadModelStore
(业务只读窄端口)

*ListInput : IDeclareQueryShape
(声明式元数据 DTO)

QueryDescriptor
(强类型 Lambda Accessor)

QueryShapeExpressionBuilder
(构建类型化 AST 表达式树)

IQueryShapeExecutor
(SqlSugar / EF Core 执行器)

数据库引擎 (SQL Server / PostgreSQL)

PagedResult<TDto>
(纯标量行结果集)

QueryShape(查询形状) 是一个编译期元数据契约,它精确定义了:

  • 数据源(Source / Root / Joins):主聚合根实体与关联从表的类型归属;
  • 目标行(ReadModel Row):数据库层直接构造的扁平标量投影模型;
  • 可查询字段(QueryFields):受白名单保护的筛选字段、操作符与源路径;
  • 排序列(Sorts):公开排序列与底层的兜底稳定排序(Tie-breaker)。

2. 编译期元数据生成机制

在 src/Framework/BitzOrcas.QueryShape.Generator 中,Roslyn 增量源生成器会扫描所有标注了 [ReadModelQuery] 的输入类:

声明式 QueryShape 示例
// 1. 声明数据源与行投影,锁定全局唯一 QueryShape 标识
[ReadModelQuery<CaseMatter, CaseMatterListRow>("Case.MatterList")]
// 2. 声明默认多级稳定排序规则
[ReadModelSort(nameof(SubmittedOn), Descending = true, Priority = 0)]
[ReadModelSort(nameof(MatterId), Descending = true, Priority = 1)]
public sealed partial class CaseMatterListInput : IDeclareQueryShape
{
// 3. 声明模糊搜索字段,映射为 SQL Contains 谓词
[QueryField(SourcePath = nameof(CaseMatter.Title), DefaultOperator = FilterOperator.Contains)]
public string? SearchText { get; init; }
// 4. 声明精确匹配状态字段
[QueryField(SourcePath = nameof(CaseMatter.Status), DefaultOperator = FilterOperator.Equal)]
public MatterStatus? Status { get; init; }
// 5. 标准分页参数
public int PageIndex { get; init; }
public int PageSize { get; init; }
}

2.1 编译期生成的四大核心保障

  1. 自动实现 IDeclareQueryShape: 生成器产出静态的 QueryDescriptor。将字段属性映射转化为强类型 Lambda 表达式(如 item => item.Title),运行时零反射查找属性。
  2. 表达式树单 Parameter 规约: 在组合多个 Where 筛选条件时,框架通过参数替换访问器(ParameterReplacementVisitor)将所有子条件统一重写到同一个根 ParameterExpression,输出纯净的 AndAlso / OrElse 节点,杜绝 ORM 无法翻译的 Expression.Invoke 节点。
  3. 全局租户与软删除基线自动下推: 对于实现 ITenantEntity 的实体,ORM 底层全局过滤器(SqlSugar AddTableFilter 与 EF Core HasQueryFilter)会自动注入租户隔离与软删除条件。业务开发者在声明 QueryShape 时严禁手写 !item.IsDeleted 或 item.TenantId == tenantId。
  4. 编译期安全边界保护: 未被 [QueryField] 显式标注的属性不会进入白名单,防止恶意请求探测或注入未授权字段。

3. 多表联查与物理表别名彻底解耦

3.1 为什么 DTO 标注完全不需要物理表前缀?

在老一代动态查询中,由于底层依赖弱类型字符串拼接(如 SqlSugar 的 ConditionalModel),DTO 必须硬编码物理表别名:

[反模式] 硬编码物理表别名(严禁使用)
// 危险:强耦合当前查询特定的 Join 顺序与别名 t4
[AdvancedWhere(FieldNames = new[] { "t4", nameof(CaseMatterRelatedParty.OppositeName) })]
public string? OppositeName { get; set; }

这一模式存在致命脆弱性:一旦业务需求变更,服务层在第 2 个位置插入了一个新的关联表(如 Office),原来的关联方表从第 4 张表变成了第 5 张表,但 DTO 上的 t4 没改,线上查询就会立刻崩溃或查错字段。

3.2 新架构的解耦机制:从实体路径到自适应 AST

在 BitzOrcas.Modern 中,DTO 标注完全面向领域实体,严禁、也完全不需要写任何 t.、t1.、t4. 等前缀:

[推荐实践] 纯净实体路径声明
// 声明属于主表或关联实体属性,无需关心任何物理 SQL 别名
[QueryField(SourcePath = nameof(CaseMatter.ClientName))]
public string? ClientName { get; init; }
[QueryField(SourcePath = nameof(CaseMatterRelatedParty.OppositeName))]
public string? OppositeName { get; init; }

底层工作原理解析:

  1. 编译期:源生成器为字段生成面向实体类型的单源 Accessor:
    // Accessor 只与实体类型绑定,完全独立于具体查询的 Join 位置
    new QueryFieldAccessor("matter", "ClientName", (Expression<Func<CaseMatter, string>>)(m => m.ClientName))
  2. 执行期组装(QueryShapeExpressionBuilder): 执行器在构建双源查询时,创建类型化的形参 root = Expression.Parameter(typeof(TRoot)) 和 join = Expression.Parameter(typeof(TJoin)),根据字段的源归属精准编织为强类型双参数 Lambda:Expression<Func<TRoot, TJoin, bool>>。
  3. ORM 自动映射: 以 SqlSugar 为例,SqlSugarJoinQueryShapeExecutor 将该 Expression 直接传入 ISugarQueryable<TRoot, TJoin>.Where(...)。SqlSugar 表达式解析引擎根据 形参类型与位置索引 自动解析主从表,并生成合法的物理表别名([t]、[t1])。
  4. 绝对解耦:无论后续如何调整 Join 顺序或增加表,DTO 和 AST 均按类型自适应映射,彻底根除了历史别名错位的严重痛点。

4. 手写复杂条件(basePredicate)与外挂逻辑支持

自动化 QueryShape 覆盖了 90% 的标准化列表筛选,但在实际企业级业务中,经常会遇到特殊的业务边界:

  • 当前用户数据隔离(如只看分配给当前登录律师的案件);
  • 复杂跨实体冲突排查(如对方当事人与历史禁用名单的特定外挂排查);
  • 动态状态机组合(如排除草稿与归档案件)。

BitzOrcas.Modern 原生支持在执行期向 QueryShape 注入强类型的 basePredicate。

4.1 单源与双源 basePredicate 注入范例

在 Store 中无缝注入强类型手写条件
public async Task<Result<PagedResult<CaseMatterSummaryDto>>> SearchMattersAsync(
CaseMatterListFields fields,
PaginationParams paging,
string currentLawyerId,
bool includeRestrictedArchive,
CancellationToken cancellationToken = default)
{
var input = CaseMatterListInput.From(fields, paging);
// 针对单源查询:注入强类型单表谓词
return await input.ExecutePageAsync(
executorFactory,
static row => new CaseMatterSummaryDto(row.MatterId, row.Title, row.ClientName, row.Status),
basePredicate: matter =>
// 1. 用户所属案件范围隔离
matter.LeadLawyerId == currentLawyerId
// 2. 状态机动态逻辑(按权限排除归档案件)
&& (includeRestrictedArchive || matter.Status != MatterStatus.Archived),
cancellationToken: cancellationToken).ConfigureAwait(false);
}
双源联查场景下的手写条件注入
// 针对双源联查:直接使用双参数 Lambda,享受完全的类型安全
return await joinInput.ExecutePageAsync<CaseMatter, CaseMatterRelatedParty, CaseMatterSummaryDto>(
joinExecutorFactory,
static (matter, party) => new CaseMatterSummaryDto(matter.Id, matter.Title, party.OppositeName),
// 手写跨表业务条件:完全强类型,无需任何 t / t1 别名
basePredicate: (matter, party) =>
matter.OfficeId == currentOfficeId
&& (party.IsActive || party.RiskLevel < RiskLevel.High),
cancellationToken: cancellationToken).ConfigureAwait(false);

4.2 为什么手写 basePredicate 依然完全安全?

  1. 类型安全与重构感知:使用 C# 强类型 Lambda,实体属性重命名或类型变更会在编译期直接报错,绝无运行时隐患;
  2. 双 ORM 完全对齐:
    • SqlSugar:直接作为 ISugarQueryable<TRoot, TJoin>.Where(basePredicate) 下推;
    • EF Core:通过 JoinRowParameterVisitor 自动重绑定为 row.Root.xxx 与 row.Joined.xxx 下推;
  3. 与 DTO 筛选条件自动合并:执行器会自动将 DTO 自动生成的动态条件与 basePredicate 通过 Expression.AndAlso 编译为单一纯净的 SQL WHERE 子句。

5. 企业级实战金样板:律所案件立案检索

以下展示一个包含 HTTP 组合信封、业务窄端口、QueryShape 声明、手写隔离谓词与标量投影 的完整高价值企业级切片:

5.1 Application 层:组合信封与校验规则

src/Modules/Case/Application/Queries/ListCaseMattersQuery.cs
using BitzOrcas.Application.Abstractions;
using BitzOrcas.Application.Authorization;
using BitzOrcas.Case.Contracts;
using BitzOrcas.Domain.Abstractions.Queries;
using BitzOrcas.Domain.Results;
using BitzOrcas.Endpoint.Attributes;
using Mediator;
namespace BitzOrcas.Case.Application.Queries;
/// <summary>
/// 案件列表分页检索查询信封
/// </summary>
[GenerateEndpoint(
HttpRoute.Query,
"/api/cases/matters",
Tag = "CaseMatter",
QueryShapeName = "Case.MatterList")]
public sealed record ListCaseMattersQuery(
CaseMatterListFields Fields,
PaginationParams Paging,
SortRequest? Sort = null) :
IQuery<Result<PagedResult<CaseMatterSummaryDto>>>,
IAuthorizedRequest,
IPaginatedRequest
{
// 权限元数据绑定
public ResourceDescriptor Resource { get; } = new(CasePermissions.Module, CasePermissions.MatterResource);
public AuthorizationAction Action { get; } = AuthorizationAction.Read;
}
/// <summary>
/// 列表入参业务规则校验器 (Tier 1 基础验证)
/// </summary>
public sealed class ListCaseMattersQueryRule : IRequestRule<ListCaseMattersQuery>
{
public ValueTask<Result> ValidateAsync(ListCaseMattersQuery request, CancellationToken cancellationToken)
{
// 校验搜索关键字长度,防止超长文本导致慢查询
if (request.Fields.SearchText is { Length: > CaseMatterListFields.SearchTextMaxLength })
{
return ValueTask.FromResult(Result.Failure(CaseErrors.SearchTextTooLong));
}
// 校验分页页码必须大于等于 1
if (request.Paging.PageIndex < 1)
{
return ValueTask.FromResult(Result.Failure(CaseErrors.InvalidPageIndex));
}
return ValueTask.FromResult(Result.Success());
}
}
/// <summary>
/// 案件列表查询处理器
/// </summary>
public sealed class ListCaseMattersQueryHandler(ICaseMatterReadModelStore store)
: IQueryHandler<ListCaseMattersQuery, Result<PagedResult<CaseMatterSummaryDto>>>
{
public async ValueTask<Result<PagedResult<CaseMatterSummaryDto>>> Handle(
ListCaseMattersQuery request,
CancellationToken cancellationToken)
{
// 委托给只读窄端口执行,业务 Handler 不碰任何 ORM
return await store.SearchAsync(
request.Fields,
request.Paging,
request.Sort,
cancellationToken).ConfigureAwait(false);
}
}

5.2 Contracts 层:只读窄端口与业务筛选模型

src/Modules/Case/Contracts/ICaseMatterReadModelStore.cs
using BitzOrcas.Domain.Abstractions.Queries;
using BitzOrcas.Domain.Results;
namespace BitzOrcas.Case.Contracts;
/// <summary>
/// 案件只读检索窄端口(禁止暴露宽仓储或 IQueryable)
/// </summary>
public interface ICaseMatterReadModelStore
{
Task<Result<PagedResult<CaseMatterSummaryDto>>> SearchAsync(
CaseMatterListFields fields,
PaginationParams paging,
SortRequest? sort,
CancellationToken cancellationToken = default);
}
/// <summary>
/// 案件筛选字段业务包
/// </summary>
public sealed record CaseMatterListFields(
string? SearchText = null,
string? ClientName = null,
MatterStatus? Status = null,
DateRange? SubmittedRange = null)
{
public const int SearchTextMaxLength = 200;
}
/// <summary>
/// 案件列表只读 DTO
/// </summary>
public sealed record CaseMatterSummaryDto(
string MatterId,
string SerialId,
string Title,
string ClientName,
MatterStatus Status,
DateTimeOffset SubmittedOn);

5.3 Infrastructure 层:QueryShape 与 ReadModelStore 实现

src/Modules/Case/Infrastructure/CaseMatterReadModelStore.cs
using BitzOrcas.Application.Abstractions.Tenancy;
using BitzOrcas.Case.Contracts;
using BitzOrcas.Case.Domain;
using BitzOrcas.DI.Attributes;
using BitzOrcas.Domain.Abstractions.Queries;
using BitzOrcas.Domain.Results;
using BitzOrcas.Infrastructure.Queries;
namespace BitzOrcas.Case.Infrastructure;
[RegisterPersistenceAdapter<ICaseMatterReadModelStore>]
public sealed class CaseMatterReadModelStore(
IQueryShapeExecutorFactory executorFactory,
ICurrentTenant currentTenant) : ICaseMatterReadModelStore
{
public async Task<Result<PagedResult<CaseMatterSummaryDto>>> SearchAsync(
CaseMatterListFields fields,
PaginationParams paging,
SortRequest? sort,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(fields);
ArgumentNullException.ThrowIfNull(paging);
// 验证当前租户上下文
if (!currentTenant.Tenant.IsAvailable)
{
return Result.Failure<PagedResult<CaseMatterSummaryDto>>(CaseErrors.TenantRequired);
}
// 1. 在 From 中完成唯一的单点分页归一化
var input = CaseMatterListInput.From(fields, paging);
// 2. 执行 QueryShape 投影下推查询,并注入业务基础谓词
return await input.ExecutePageAsync(
executorFactory,
static row => new CaseMatterSummaryDto(
row.MatterId,
row.SerialId,
row.Title,
row.ClientName,
row.Status,
row.SubmittedOn),
basePredicate: matter => matter.Status != MatterStatus.DeletedDraft,
sorts: sort.WithTieBreaker(nameof(CaseMatterListInput.MatterId)),
cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// 案件列表 QueryShape 声明模型
/// </summary>
[ReadModelQuery<CaseMatter, CaseMatterListRow>("Case.MatterList")]
[ReadModelSort(nameof(SubmittedOn), Descending = true, Priority = 0)]
[ReadModelSort(nameof(MatterId), Descending = true, Priority = 1)]
public sealed partial class CaseMatterListInput : IDeclareQueryShape
{
public static CaseMatterListInput From(CaseMatterListFields fields, PaginationParams paging)
{
// 核心规范:单点安全归一化
var normalized = paging.Normalize();
return new CaseMatterListInput
{
SearchText = string.IsNullOrWhiteSpace(fields.SearchText) ? null : fields.SearchText.Trim(),
ClientName = string.IsNullOrWhiteSpace(fields.ClientName) ? null : fields.ClientName.Trim(),
Status = fields.Status,
SubmittedFrom = fields.SubmittedRange?.From,
SubmittedTo = fields.SubmittedRange?.To,
PageIndex = normalized.PageIndex,
PageSize = normalized.PageSize,
};
}
[QueryField(SourcePath = nameof(CaseMatter.Title), Default = true, DefaultOperator = FilterOperator.Contains)]
public string? SearchText { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.ClientName), DefaultOperator = FilterOperator.Contains)]
public string? ClientName { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.Status), DefaultOperator = FilterOperator.Equal)]
public MatterStatus? Status { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.SubmittedOn), DefaultOperator = FilterOperator.GreaterThanOrEqual)]
public DateTimeOffset? SubmittedFrom { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.SubmittedOn), DefaultOperator = FilterOperator.LessThanOrEqual)]
public DateTimeOffset? SubmittedTo { get; init; }
[QueryField(Filterable = false, Visible = false)]
public DateTimeOffset SubmittedOn { get; init; }
[QueryField(SourcePath = nameof(CaseMatter.Id), Filterable = false, Visible = false)]
public string? MatterId { get; init; }
public int PageIndex { get; init; }
public int PageSize { get; init; }
}
/// <summary>
/// 纯标量投影行模型(按需生成 SELECT 列表,杜绝宽实体拉取)
/// </summary>
internal sealed record CaseMatterListRow
{
[ReadModelProjection(nameof(CaseMatter.Id))]
public string MatterId { get; init; } = string.Empty;
public string SerialId { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string ClientName { get; init; } = string.Empty;
public MatterStatus Status { get; init; }
public DateTimeOffset SubmittedOn { get; init; }
}

6. 配套单元测试与 HTTP 负载示例

6.1 单元测试金样板:验证 QueryShape 执行器与手写谓词

tests/BitzOrcas.Unit.Tests/Case/CaseMatterQueryShapeTests.cs
using System.Linq.Expressions;
using BitzOrcas.Case.Contracts;
using BitzOrcas.Case.Domain;
using BitzOrcas.Case.Infrastructure;
using BitzOrcas.Domain.Abstractions.Queries;
using BitzOrcas.Infrastructure.Queries;
using Shouldly;
using Xunit;
namespace BitzOrcas.Unit.Tests.Case;
public sealed class CaseMatterQueryShapeTests
{
[Fact]
public void CaseMatterListInput_Should_Declare_Accurate_QueryDescriptor_Without_Reflection()
{
// 1. 验证编译期生成的 IDeclareQueryShape 描述符
var descriptor = ((IDeclareQueryShape)new CaseMatterListInput()).QueryDescriptor;
descriptor.ShouldNotBeNull();
descriptor.Shape.Name.ShouldBe("Case.MatterList");
// 2. 验证字段白名单与操作符生成
var titleField = descriptor.Shape.Fields.FirstOrDefault(f => f.InputField == nameof(CaseMatterListInput.SearchText));
titleField.ShouldNotBeNull();
titleField.Allows(FilterOperator.Contains).ShouldBeTrue();
// 3. 验证强类型访问器直接可用
var accessor = descriptor.ResolveAccessor("item", nameof(CaseMatter.Title));
accessor.IsSuccess.ShouldBeTrue();
}
[Fact]
public void BasePredicate_Should_Correctly_Filter_Deleted_Draft_Matters()
{
// 验证手写条件 basePredicate 的表达式逻辑
Expression<Func<CaseMatter, bool>> basePredicate = matter => matter.Status != MatterStatus.DeletedDraft;
var compiled = basePredicate.Compile();
var activeMatter = new CaseMatter { Status = MatterStatus.Active };
var draftMatter = new CaseMatter { Status = MatterStatus.DeletedDraft };
// 断言业务过滤行为正确
compiled(activeMatter).ShouldBeTrue();
compiled(draftMatter).ShouldBeFalse();
}
}

6.2 HTTP 请求与响应 JSON 负载

HTTP POST /api/cases/matters (或 GET 带查询包)
{
"fields": {
"searchText": "知识产权诉讼",
"status": "Active",
"submittedRange": {
"from": "2026-01-01T00:00:00Z",
"to": "2026-12-31T23:59:59Z"
}
},
"paging": {
"pageIndex": 1,
"pageSize": 20
},
"sort": {
"field": "submittedOn",
"descending": true
}
}
HTTP 200 OK 响应报文
{
"isSuccess": true,
"data": {
"items": [
{
"matterId": "matter-01912a3b-4c5d-7e8f-9a0b-1c2d3e4f5a6b",
"serialId": "CASE-2026-0889",
"title": "跨国专利侵权知识产权诉讼一审",
"clientName": "某高新科技股份有限公司",
"status": "Active",
"submittedOn": "2026-08-20T14:30:00Z"
}
],
"totalCount": 1,
"pageIndex": 1,
"pageSize": 20,
"totalPages": 1,
"hasPreviousPage": false,
"hasNextPage": false
}
}

7. 分页限制与防御性单点归一化矩阵

在实际生产中,前端页面偶尔可能会传出非法的分页参数(如 pageIndex = 0 或 pageSize = 5000)。BitzOrcas.Modern 确立了防损优先(Defense in Depth) 的分页归一化契约:

PaginationParams 内部归一化逻辑
public sealed record PaginationParams(
int PageIndex = 1,
int PageSize = PagingLimits.DefaultPageSize)
{
// 执行安全截断与硬边界归一化
public PaginationParams Normalize()
=> new(
PagingLimits.NormalizePageIndex(PageIndex),
PagingLimits.NormalizePageSize(PageSize));
}

7.1 PagingLimits 规范矩阵

场景输入值归一化后结果设计原因
页码非正pageIndex <= 01页码从 1 开始计起,杜绝 SQL 偏移负数
页大小为空/非正pageSize <= 020 (DefaultPageSize)回退到全库统一默认值
正常页大小1 <= pageSize <= 1000原样保留正常业务范围
超大页大小pageSize > 10001000 (MaxPageSize)自动截断到 1000,绝对不报 400 错误!

8. 总结与最佳实践清单

  1. DTO 声明绝不带物理表前缀:严禁在 [QueryField] 中填写 t.、t1. 等物理表别名,纯粹面向聚合根实体成员;
  2. 读写分离与窄端口:查询 Handler 仅注入 I*ReadModelStore,严禁在 Application 层暴露 IRepository 或 IQueryable;
  3. 精准标量投影:使用 ReadModelProjection 行模型直接按列生成 SELECT 语句,彻底杜绝全宽表物化;
  4. 手写条件走强类型 Lambda:复杂业务与范围隔离通过 basePredicate 注入,享受 100% 编译期重构检查与双 ORM 自动适配。

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%