上一篇讲了 AdminTable 的整体结构,这一篇专门讲"查询条件"这一块:从界面上的搜索框、列头筛选、高级搜索,到最终生成的 SQL,中间发生了什么。
一、四种查询入口
AdminTable 支持四类筛选,BootstrapBlazor 负责收集,框架负责翻译成 SQL:
| 入口 | 开启方式 | 收集到哪 |
|---|---|---|
| 模糊搜索 | ShowSearch + 列上 Searchable="true" |
QueryPageOptions.Searches |
| 高级搜索 | ShowAdvancedSearch |
QueryPageOptions.AdvanceSearches |
| 列头筛选 | 列上 Filterable="true" |
QueryPageOptions.Filters |
| 自定义搜索 | 页面调用 QueryAsync 时传 CustomerSearches |
QueryPageOptions.CustomerSearches |
排序信息在 SortList / SortName + SortOrder 里。
二、翻译层:QueryPageOptions → DynamicFilterInfo
public static DynamicFilterInfo ToDynamicFilter(this QueryPageOptions option, params string[] ignoreColumns)
{
var ret = new DynamicFilterInfo() { Filters = [] };
// 处理模糊搜索
if (option.Searches.Count > 0)
{
var filters = option.Searches.Select(i => i.ToDynamicFilter(ignoreColumns)).Where(i => i != null).ToList();
if (filters.Count > 0)
{
ret.Filters.Add(new()
{
Logic = DynamicFilterLogic.Or,
Filters = filters
});
}
}
// 处理自定义搜索
if (option.CustomerSearches.Count > 0)
{
ret.Filters.AddRange(option.CustomerSearches.Select(i => i.ToDynamicFilter(ignoreColumns)).Where(i => i != null));
}
// 处理高级搜索
if (option.AdvanceSearches.Count > 0)
{
ret.Filters.AddRange(option.AdvanceSearches.Select(i => i.ToDynamicFilter(ignoreColumns)).Where(i => i != null));
}
// 处理表格过滤条件
if (option.Filters.Count > 0)
{
ret.Filters.AddRange(option.Filters.Select(i => i.ToDynamicFilter(ignoreColumns)).Where(i => i != null));
}
ret.Filters = ret.Filters.Where(i => i != null).ToList();
return ret;
}
这里有一个关键语义差异:
- 模糊搜索是"或":
Searches之间用DynamicFilterLogic.Or组合。多个可搜索列之间是"任一匹配",符合搜索框的直觉。 - 高级搜索和列头筛选是追加条件:和外层条件是"与"的关系。
也就是说:
(搜索框:列A 匹配 OR 列B 匹配)
AND (高级搜索条件)
AND (列头筛选条件)
操作符映射
private static DynamicFilterOperator ToDynamicFilterOperator(this FilterAction action) => action switch
{
FilterAction.Equal => DynamicFilterOperator.Equal,
FilterAction.NotEqual => DynamicFilterOperator.NotEqual,
FilterAction.Contains => DynamicFilterOperator.Contains,
FilterAction.NotContains => DynamicFilterOperator.NotContains,
FilterAction.GreaterThan => DynamicFilterOperator.GreaterThan,
FilterAction.GreaterThanOrEqual => DynamicFilterOperator.GreaterThanOrEqual,
FilterAction.LessThan => DynamicFilterOperator.LessThan,
FilterAction.LessThanOrEqual => DynamicFilterOperator.LessThanOrEqual,
_ => throw new NotSupportedException()
};
界面上的 FilterAction 就这样一一映射到 FreeSql 的查询操作符,不需要你手写 Where。
三、ignoreColumns:为什么必须有
private void GetIgnoredPropertyNames()
{
if (IgnoreSearchColumns != null && _IgnoreSearchColumns.Count == 0)
{
IgnoreSearchColumns.ExtractPropertyNames(_IgnoreSearchColumns);
}
}
页面这样声明要忽略的列:
<AdminTable TItem="SysUser" TKey="long"
IgnoreSearchColumns="x => new { x.Roles, x.Org }"
... />
为什么必须忽略导航属性?因为导航集合不是数据库列。如果 Roles 被当作筛选字段传给 FreeSql,会直接抛"无法匹配 xxx 属性"的错误。
注意这段逻辑的时序——忽略列表必须在任何 await 之前提取完,否则首次渲染抢跑查询时会传空列表,异常就出现了。这也是第 04 篇里反复强调"回调要在 await 前赋值"的同类问题。
四、Flags 枚举:位运算筛选
// 遍历 DynamicFilterInfo,发现字段是 Flags Enum 的节点时,使用 bitwise where 并在返回的树中移除该节点
private static DynamicFilterInfo ProcessFlagsFilters<T>(ISelect<T> select, Type entityType, DynamicFilterInfo root)
{
...
if (enumType.IsEnum && Attribute.IsDefined(enumType, typeof(FlagsAttribute)))
{
if (node.Value != null)
{
try
{
var val = Convert.ToInt64(node.Value);
// 使用位运算判断是否包含该位
if (val > 0) select.Where($"(a.{columnName} & {val}) != 0");
return null; // 从树中移除该节点,避免 WhereDynamicFilter 重复处理
}
catch { }
}
}
...
}
实测意义:一个 [Flags] 的"文章类型"字段,值是"原创 + 转载",筛选"原创"时用等值比较是筛不出来的,必须用位运算。这段逻辑会自动处理,只对标记了 [Flags] 的枚举生效。
五、排序:不要覆盖页面写的默认排序
public static ISelect<T> ApplyOrder<T, TKey>(this ISelect<T> select, QueryPageOptions options) where T : class, IEntity<TKey>
{
// 优先使用 QueryPageOptions 中的排序配置(SortList 或 SortName),
// 如果两者都不存在,则保留传入的 select 原有 OrderBy(不覆盖)。
var hasSortList = options?.SortList != null && options.SortList.Count > 0;
var hasSortName = options != null && options.SortOrder != SortOrder.Unset && !string.IsNullOrEmpty(options.SortName);
if (hasSortList)
{
return select
.OrderBy(string.Join(",", options.SortList.ToArray()))
.OrderByPropertyNameIf(hasSortName, options.SortName, options.SortOrder == SortOrder.Asc);
}
if (hasSortName)
{
return select.OrderByPropertyNameIf(true, options.SortName, options.SortOrder == SortOrder.Asc);
}
// 无排序配置,直接返回传入的 select(保留其可能已设置的 OrderBy)
return select;
}
所以页面里可以放心写默认排序:
private void OnBeforeQuery(AdminQueryEventArgs<Article> e)
{
e.Select.OrderByDescending(a => a.PublishTime); // 用户没点排序时生效
}
用户点了列头排序后,SortList / SortName 会接管,不会出现"用户点了排序但结果没变"的问题。
六、分页与计数在同一条件上
public static async Task<QueryData<T>> GetPagedAsync<T, TKey>(this ISelect<T> select, QueryPageOptions options, params string[] ignoreColumns)
where T : class, IEntity<TKey>
{
var dynamicFilter = options.ToDynamicFilter(ignoreColumns);
dynamicFilter = ProcessFlagsFilters(select, typeof(T), dynamicFilter);
var query = select
.WhereDynamicFilter(dynamicFilter)
.ApplyOrder<T, TKey>(options)
.Count(out var count);
var items = options.IsPage ? await query.Page(options.PageIndex, options.PageItems).ToListAsync() : await query.ToListAsync();
return new QueryData<T>() { Items = items, TotalCount = Convert.ToInt32(count), IsFiltered = true, IsSearch = true };
}
Count(out var count) 和分页查的是同一个 ISelect,条件完全一致。这是"总条数"和"当前页数据"不打架的前提。
七、页面里怎么加自己的条件:OnBeforeQuery
OnBeforeQuery 拿到的是 ISelect<TItem> 和 QueryPageOptions,可以自由扩展:
private void OnBeforeQuery(AdminQueryEventArgs<Article> e)
{
// 1) 追加固定条件
e.Select.Where(a => a.IsAudit == true);
// 2) 加载关联数据(详情列展示用)
e.Select.Include(a => a.Classify);
}
AdminQueryEventArgs<TItem> 的定义很简洁:
public record AdminQueryEventArgs<TItem>(ISelect<TItem> Select, QueryPageOptions options)
{
/// <summary>
/// 是否导出场景。导出全量数据时应跳过 Include/IncludeMany 导航集合加载,避免上千行时极慢。
/// </summary>
public bool IsExport { get; set; }
}
那句注释是实战经验:导出上千行时带上 Include 会非常慢,所以框架在导出时会传 IsExport = true,页面据此跳过导航加载。
八、列头筛选:自定义筛选器
普通列只需要 Filterable="true",框架会用默认的筛选界面。需要"选实体""选多值"这类复杂筛选时,用 FilterTemplate + FilterProvider:
<TableColumn @bind-Field="context.ClassifyId" Filterable="true">
<Template Context="v">@v.Row.Classify?.ClassifyName</Template>
<FilterTemplate>
<FilterProvider>
<AdminSelectEntityFilter TItem="Classify" GetText="x => x.ClassifyName" />
</FilterProvider>
</FilterTemplate>
</TableColumn>
AdminSelectEntityFilter 的参数(源码):
[Parameter] public Expression<Func<TItem, bool>>? Where { get; set; }
[Parameter] public Func<TItem, string> GetText { get; set; } = default!;
关联字段的模糊搜索
想按"关联实体的名称"模糊搜索,用 AdminSelectEntityFilterGeneric:
<TableColumn @bind-Field="context.Title" Filterable="true" Searchable="true">
<FilterTemplate>
<FilterProvider>
<AdminSelectEntityFilterGeneric TItem="Classify" TKey="string"
FilterAction="FilterAction.Contains"
GetValue="a=>a.ClassifyName"
GetText="x => x.ClassifyName" />
</FilterProvider>
</FilterTemplate>
</TableColumn>
它的参数(源码):
[Parameter] public Expression<Func<TItem, bool>>? Where { get; set; }
[Parameter] public Func<TItem, string> GetText { get; set; } = default!;
[Parameter] public Func<TItem, string>? GetValue { get; set; }
[Parameter] public FilterAction FilterAction { get; set; } = FilterAction.Equal;
注意 TKey 是"生成筛选值的类型"(这里是字符串),GetValue 决定筛选条件里放什么值,FilterAction 决定操作符(Contains 就是模糊匹配)。
多值筛选
<TableColumn @bind-Field="context.ArticleType" ComponentType="typeof(MultiSelect<ArticleType>)" Filterable="true" Searchable="true">
<FilterTemplate>
<FilterProvider ShowMoreButton="false">
<AdminMultiSelectFilter TValue="ArticleType" />
</FilterProvider>
</FilterTemplate>
</TableColumn>
AdminMultiSelectFilter 的参数:
[Parameter] public IEnumerable<SelectedItem>? Items { get; set; }
九、性能:几个容易被忽略的点
- 导航集合必须放进
IgnoreSearchColumns,同时避免在查询里无条件Include。 - 导出时让框架走投影。
OnExportAllAsync会按可见列做动态投影;但如果你在OnBeforeQuery里强制Include且不判断IsExport,投影优化会被破坏。 - 模糊搜索的列要有索引。
Contains生成的LIKE '%x%'通常用不上索引,列多、数据大时要权衡哪些列可以被搜索。 Count也是查询。超大表上分页的总数统计本身有成本,必要时改成"不显示总数"或维护统计表。- 排序字段加索引。默认按主键排序没问题,按业务字段排序要考虑索引。
WhereDynamicFilter拼接的条件来自界面。字段名来自列的Field,所以不要在列上绑定非数据库属性(比如ApprovalStatusText这种IsIgnore的属性),否则会生成非法 SQL。
十、常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 报"无法匹配 xxx" | 导航属性被当成筛选字段 | 加入 IgnoreSearchColumns |
| 搜索框只匹配了一列 | 只有部分列标了 Searchable="true" |
需要参与模糊搜索的列都标上 |
| 用户点了排序没变化 | 排序字段不在可见列里,或字段名不对 | 检查 TableColumn 的 Field |
| Flags 枚举筛选结果为空 | 该枚举没标 [Flags] |
加上 [Flags] |
| 导出很慢 | Include 在导出时也在执行 |
用 IsExport 判断后跳过 |
| 高级搜索条件没生效 | 列没标 Filterable,或字段是 IsIgnore 属性 |
检查列定义 |
十一、小结
一次查询的完整链路:
BootstrapBlazor 表格收集条件(Searches / AdvanceSearches / Filters)
→ AdminTable.OnQueryDataAsync
→ OnBeforeQuery(页面追加 Where / Include / Join)
→ ApplyDataPermission(数据权限)
→ ToDynamicFilter(翻译成 DynamicFilterInfo,Searches 用 Or)
→ ProcessFlagsFilters(Flags 枚举转位运算)
→ ApplyOrder(SortList / SortName,保留默认排序)
→ Count + Page
理解了这条链路,你就能准确判断"这个条件为什么没生效":是界面没收集到、被忽略列表挡掉了、还是 SQL 条件被合并成了别的关系。
如果你正在用 .NET 10 + Blazor 做后台,查询筛选是每天都要碰的东西。EasyAdminBlazor 把条件收集、SQL 翻译、排序分页串成了一条清晰链路,也允许在 OnBeforeQuery 里自由扩展。