搜尋過濾模組
Package:
io.leandev.appfuse.search.*
AppFuse Server 提供結構化的搜尋過濾工具,支援 RSQL 查詢語法解析,讓前端可以透過 URL 參數傳遞複雜的查詢條件。
核心特色
1. RSQL 查詢語法
RSQL (RESTful Service Query Language) 是一種適合 REST API 的查詢語法:
# 簡單條件
status==ACTIVE
# 組合條件(AND 使用分號)
status==ACTIVE;price>=100
# OR 條件(使用逗號)
status==ACTIVE,status==PENDING
# 複雜組合
(category=="Electronics");(price>=100;price<=1000)
2. 架構圖
3. 工具類別
| 類別 | 用途 |
|---|---|
| Filter | 結構化查詢條件(可程式化建構) |
| FilterParser | 解析 RSQL 字串為 Filter |
| ComparisonOperator | 比較運算子列舉 |
| LogicalOperator | 邏輯運算子(AND、OR) |
RSQL 語法
比較運算子
| 運算子 | 說明 | 範例 |
|---|---|---|
== | 等於 | status==ACTIVE |
!= | 不等於 | status!=DELETED |
=gt= 或 > | 大於 | price=gt=100 |
=ge= 或 >= | 大於等於 | price=ge=100 |
=lt= 或 < | 小於 | price=lt=1000 |
=le= 或 <= | 小於等於 | price=le=1000 |
=in= | 包含於集合 | status=in=(ACTIVE,PENDING) |
=out= | 不包含於集合 | status=out=(DELETED,ARCHIVED) |
=is= | 是(含 null) | deletedAt=is=null |
=isnt= | 不是(含 null) | assignee=isnt=null |
=has= | 集合包含 | tags=has=featured |
=between= | 區間 | price=between=(100,500) |
邏輯運算子
| 運算子 | 說明 | 範例 |
|---|---|---|
; | AND | status==ACTIVE;price>=100 |
, | OR | status==ACTIVE,status==PENDING |
() | 群組 | (status==ACTIVE);(price>=100,featured==true) |
萬用字元
# 模糊比對(LIKE)
name==*Apple* # 包含 "Apple"
name==Apple* # 以 "Apple" 開頭
name==*Phone # 以 "Phone" 結尾
巢狀屬性
# 關聯查詢
category.name==Electronics
customer.address.city==Taipei
FilterParser 解析器
FilterParser 將 RSQL 字串解析為 Filter 物件。
基本用法
import io.leandev.appfuse.search.FilterParser;
import io.leandev.appfuse.search.Filter;
FilterParser parser = new FilterParser();
// 解析簡單條件
Filter filter = parser.parse("status==ACTIVE");
// 解析組合條件
Filter filter = parser.parse("status==ACTIVE;price>=100;price<=1000");
// 解析 OR 條件
Filter filter = parser.parse("status==ACTIVE,status==PENDING");
// 解析複雜條件
Filter filter = parser.parse("(category==\"Electronics\");(price>=100;price<=1000)");
自動類型轉換
FilterParser 會自動識別並轉換參數類型:
| 輸入格式 | 轉換類型 | 範例 |
|---|---|---|
| 整數 | Long | 100, -50, +20 |
| 浮點數 | BigDecimal | 3.14, -2.5, .5 |
| 布林值 | Boolean | true, false |
| ISO 日期 | Date | 2024-01-15T10:30:00.000+08:00 |
null | null | null |
| 其他 | String | ACTIVE, Apple |
// 整數自動轉換
Filter filter = parser.parse("quantity>=10");
// quantity >= 10L
// 日期自動轉換
Filter filter = parser.parse("createdAt>=2024-01-01T00:00:00.000+08:00");
// createdAt >= Date
// null 值
Filter filter = parser.parse("deletedAt=is=null");
// deletedAt IS NULL
注意事項
// 前導零的數字會被視為字串
parser.parse("code==01"); // code == "01" (字串)
parser.parse("code==1"); // code == 1L (Long)
// 含空格的值需要引號
parser.parse("name==\"Boxed Flower\"");
// null 必須小寫
parser.parse("field=is=null"); // 正確
parser.parse("field=is=NULL"); // 也可以(會轉換為 null)
Filter 程式化建構
除了解析 RSQL 字串,也可以直接用程式碼建構 Filter。
基本用法
import io.leandev.appfuse.search.Filter;
// 等於
Filter filter = Filter.eq("status", "ACTIVE");
// 不等於
Filter filter = Filter.neq("status", "DELETED");
// 比較運算
Filter filter = Filter.gt("price", 100); // >
Filter filter = Filter.ge("price", 100); // >=
Filter filter = Filter.lt("price", 1000); // <
Filter filter = Filter.le("price", 1000); // <=
// 模糊比對
Filter filter = Filter.like("name", "Apple"); // %Apple%
Filter filter = Filter.startsWith("code", "PRD"); // PRD%
// 集合運算
Filter filter = Filter.in("status", List.of("ACTIVE", "PENDING"));
Filter filter = Filter.notIn("category", List.of("ARCHIVED"));
// 空值檢查
Filter filter = Filter.isNull("deletedAt");
Filter filter = Filter.isNotNull("assignee");
// 區間
Filter filter = Filter.between("price", 100, 1000);
組合條件
// AND 組合
Filter filter = Filter.eq("status", "ACTIVE")
.and(Filter.ge("price", 100))
.and(Filter.le("price", 1000));
// OR 組合
Filter filter = Filter.eq("status", "ACTIVE")
.or(Filter.eq("status", "PENDING"));
// 複雜組合
Filter filter = Filter.eq("category", "Electronics")
.and(
Filter.ge("price", 100)
.or(Filter.eq("featured", true))
);
動態建構
public Filter buildFilter(ProductSearchCriteria criteria) {
Filter filter = Filter.empty();
// 只有非空值才加入條件
if (criteria.getStatus() != null) {
filter = filter.and(Filter.eq("status", criteria.getStatus()));
}
if (criteria.getName() != null) {
filter = filter.and(Filter.like("name", criteria.getName()));
}
if (criteria.getMinPrice() != null) {
filter = filter.and(Filter.ge("price", criteria.getMinPrice()));
}
if (criteria.getMaxPrice() != null) {
filter = filter.and(Filter.le("price", criteria.getMaxPrice()));
}
return filter;
}
Filter.of 自動忽略空值
Filter.of() 方法在值為 null 或空字串時會自動返回空 Filter:
// criteria.getStatus() 為 null 時,返回 Filter.empty()
Filter filter = Filter.of("status", ComparisonOperator.EQUAL, criteria.getStatus());
// 等同於
Filter filter = criteria.getStatus() != null
? Filter.eq("status", criteria.getStatus())
: Filter.empty();
整合 Controller
接收 RSQL 查詢參數
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
private final FilterParser filterParser = new FilterParser();
@GetMapping
public Page<ProductDto> getProducts(
@RequestParam(required = false) String filter,
Pageable pageable) {
// 解析 RSQL 查詢字串
Filter searchFilter = filterParser.parse(filter);
return productService.findAll(searchFilter, pageable);
}
}
前端呼叫範例
// 簡單查詢
GET /api/products?filter=status==ACTIVE
// 組合查詢
GET /api/products?filter=status==ACTIVE;price>=100;price<=1000
// URL 編碼
GET /api/products?filter=name%3D%3D*Apple*
// 分頁 + 排序 + 過濾
GET /api/products?filter=category==Electronics&page=0&size=20&sort=price,desc
整合 JPA
使用 SpecificationBuilder
import io.leandev.appfuse.jpa.SpecificationBuilder;
@Service
public class ProductService {
private final ProductRepository productRepository;
public Page<Product> findAll(Filter filter, Pageable pageable) {
Specification<Product> spec = SpecificationBuilder.build(filter);
return productRepository.findAll(spec, pageable);
}
}
使用 PredicateBuilder
import io.leandev.appfuse.jpa.PredicateBuilder;
@Repository
public class ProductRepositoryImpl {
@PersistenceContext
private EntityManager entityManager;
public List<Product> findByFilter(Filter filter) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Product> query = cb.createQuery(Product.class);
Root<Product> root = query.from(Product.class);
Predicate predicate = PredicateBuilder.<Product>of(cb)
.from(root)
.where(filter)
.build();
if (predicate != null) {
query.where(predicate);
}
return entityManager.createQuery(query).getResultList();
}
}
完整範例
產品搜尋 API
// Controller
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
private final ProductService productService;
private final FilterParser filterParser = new FilterParser();
@GetMapping
public ResponseEntity<Page<ProductDto>> searchProducts(
@RequestParam(required = false) String filter,
@RequestParam(required = false) String category,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam(required = false) BigDecimal maxPrice,
Pageable pageable) {
// 方式 1:使用 RSQL 字串
Filter rsqlFilter = filterParser.parse(filter);
// 方式 2:合併個別參數
Filter paramFilter = Filter.empty()
.and(Filter.eq("category.name", category))
.and(Filter.ge("price", minPrice))
.and(Filter.le("price", maxPrice));
// 合併兩種來源的條件
Filter combinedFilter = rsqlFilter.and(paramFilter);
Page<Product> products = productService.search(combinedFilter, pageable);
Page<ProductDto> dtos = products.map(this::toDto);
return ResponseEntity.ok(dtos);
}
}
// Service
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository productRepository;
public Page<Product> search(Filter filter, Pageable pageable) {
// 加入基本條件(只查詢未刪除的商品)
Filter baseFilter = Filter.isNull("deletedAt");
Filter finalFilter = baseFilter.and(filter);
Specification<Product> spec = SpecificationBuilder.build(finalFilter);
return productRepository.findAll(spec, pageable);
}
}
欄位名稱映射
前端欄位名稱可能與 Entity 欄位不同,可以使用 Filter.map() 轉換:
public Filter mapFieldNames(Filter filter) {
return filter.map(node -> {
String selector = node.selector();
String mappedSelector = switch (selector) {
case "categoryName" -> "category.name";
case "supplierName" -> "supplier.companyName";
case "createDate" -> "createdAt";
default -> selector;
};
return new ComparisonNode(
node.operator(),
mappedSelector,
node.expectation()
);
});
}
權限過濾
根據用戶權限自動加入過濾條件:
@Service
public class SecureProductService {
public Page<Product> search(Filter userFilter, Pageable pageable) {
// 加入租戶過濾
String tenantId = TenantContext.getCurrentTenantId();
Filter tenantFilter = Filter.eq("tenantId", tenantId);
// 加入狀態過濾(只顯示上架商品給一般用戶)
Filter statusFilter = Filter.empty();
if (!SecurityUtils.isAdmin()) {
statusFilter = Filter.eq("status", "PUBLISHED");
}
// 合併所有條件
Filter finalFilter = tenantFilter
.and(statusFilter)
.and(userFilter);
Specification<Product> spec = SpecificationBuilder.build(finalFilter);
return productRepository.findAll(spec, pageable);
}
}
節點結構
Filter 內部使用樹狀結構表示查詢條件:
Filter
└── Node
├── ComparisonNode (比較條件)
│ ├── selector: "price"
│ ├── operator: GREATER_THAN
│ └── expectation: 100
├── AndNode (AND 組合)
│ └── children: [Node, Node, ...]
├── OrNode (OR 組合)
│ └── children: [Node, Node, ...]
└── EmptyNode (空條件)
檢查 Filter 狀態
Filter filter = Filter.eq("status", "ACTIVE");
// 檢查是否為空
if (filter.isEmpty()) {
// 無條件
}
if (filter.isNotEmpty()) {
// 有條件
}
// 取得節點資訊(單一比較條件)
String selector = filter.selector(); // "status"
Object expectation = filter.expectation(); // "ACTIVE"
Object operator = filter.operator(); // ComparisonOperator.EQUAL
// 取得子條件(邏輯組合)
List<Filter> children = filter.children();
最佳實踐
1. 驗證輸入
@GetMapping
public Page<ProductDto> search(@RequestParam(required = false) String filter) {
try {
Filter parsedFilter = filterParser.parse(filter);
// ...
} catch (RSQLParserException e) {
throw new BadRequestException("Invalid filter syntax: " + e.getMessage());
}
}
2. 限制可查詢欄位
private static final Set<String> ALLOWED_FIELDS = Set.of(
"name", "status", "price", "category.name", "createdAt"
);
public Filter validateFilter(Filter filter) {
return filter.map(node -> {
if (!ALLOWED_FIELDS.contains(node.selector())) {
throw new BadRequestException("Field not allowed: " + node.selector());
}
return node;
});
}
3. 預設條件
public Filter applyDefaults(Filter userFilter) {
// 永遠排除已刪除
Filter baseFilter = Filter.isNull("deletedAt");
// 如果用戶沒指定狀態,預設只顯示啟用的
if (!hasField(userFilter, "status")) {
baseFilter = baseFilter.and(Filter.eq("status", "ACTIVE"));
}
return baseFilter.and(userFilter);
}
private boolean hasField(Filter filter, String field) {
// 檢查 filter 是否包含特定欄位的條件
// ...
}
下一步
- JPA 模組 - 搭配 JPA 執行資料庫查詢
- Security 模組 - 權限控管
- Tenant 模組 - 多租戶數據隔離