跳至主要内容

搜尋過濾模組

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)

邏輯運算子

運算子說明範例
;ANDstatus==ACTIVE;price>=100
,ORstatus==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 會自動識別並轉換參數類型:

輸入格式轉換類型範例
整數Long100, -50, +20
浮點數BigDecimal3.14, -2.5, .5
布林值Booleantrue, false
ISO 日期Date2024-01-15T10:30:00.000+08:00
nullnullnull
其他StringACTIVE, 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 是否包含特定欄位的條件
// ...
}

下一步