跳至主要内容

JPA 查詢工具模組

Package: io.leandev.appfuse.jpa.*, io.leandev.appfuse.search.*

AppFuse Server 提供一系列 JPA 查詢建構工具,簡化複雜查詢的撰寫,支援動態過濾、分頁和投影查詢。

核心特色

1. 動態查詢建構

2. 工具類別總覽

類別用途
Filter結構化查詢條件建構
PredicateBuilder將 Filter 轉換為 JPA Predicate
SpecificationBuilder將 Filter 轉換為 Spring Data Specification
CriteriaQueryBuilderFluent API 建構 Criteria 查詢
TupleQueryBuilder建構投影查詢(選取部分欄位)
TupleConstructor將 Tuple 結果轉換為物件
QueryRunner執行查詢並支援分頁
PageableBuilder建構分頁參數並轉換排序欄位

Filter 查詢條件

Filter 是核心的查詢條件建構工具,提供流暢的 API 建構複雜查詢。

基本用法

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.lt("price", 1000);
Filter filter = Filter.ge("quantity", 10); // 大於等於
Filter filter = Filter.le("quantity", 100); // 小於等於

// 模糊比對(LIKE)
Filter filter = Filter.like("name", "Apple"); // %Apple%
Filter filter = Filter.startsWith("code", "PRD"); // PRD%

// 空值檢查
Filter filter = Filter.isNull("deletedAt");
Filter filter = Filter.isNotNull("assignee");

// IN / NOT IN
Filter filter = Filter.in("status", List.of("ACTIVE", "PENDING"));
Filter filter = Filter.notIn("category", List.of("ARCHIVED", "DELETED"));

// 區間查詢
Filter filter = Filter.between("createdAt", startDate, endDate);

組合條件

// 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))
);

支援的運算子

運算子方法說明
==eq()等於
!=neq()不等於
>gt()大於
>=ge()大於等於
<lt()小於
<=le()小於等於
=in=in()包含於集合
=out=notIn()不包含於集合
=has=has()集合包含元素
=is=is(), isNull()是(含 null 檢查)
=isnt=isNot(), isNotNull()不是(含 null 檢查)
=between=between()區間

搭配 Spring Data JPA

SpecificationBuilder

最簡單的整合方式是使用 SpecificationBuilder 搭配 JpaSpecificationExecutor

import io.leandev.appfuse.jpa.SpecificationBuilder;
import io.leandev.appfuse.search.Filter;
import org.springframework.data.jpa.domain.Specification;

// Repository 需要繼承 JpaSpecificationExecutor
public interface ProductRepository extends
JpaRepository<Product, Long>,
JpaSpecificationExecutor<Product> {
}

// Service 中使用
@Service
public class ProductService {

private final ProductRepository productRepository;

public Page<Product> findProducts(ProductSearchCriteria criteria, Pageable pageable) {
// 建構 Filter
Filter filter = Filter.empty()
.and(Filter.eq("status", criteria.getStatus()))
.and(Filter.like("name", criteria.getName()))
.and(Filter.ge("price", criteria.getMinPrice()))
.and(Filter.le("price", criteria.getMaxPrice()));

// 轉換為 Specification
Specification<Product> spec = SpecificationBuilder.build(filter);

// 執行查詢
return productRepository.findAll(spec, pageable);
}
}

關聯查詢

Filter 支援巢狀屬性查詢:

// 查詢 product.category.name = "Electronics"
Filter filter = Filter.eq("category.name", "Electronics");

// 查詢 order.customer.email 包含 "gmail"
Filter filter = Filter.like("customer.email", "gmail");

CriteriaQueryBuilder

對於更複雜的查詢,使用 CriteriaQueryBuilder 提供完整控制:

基本查詢

import io.leandev.appfuse.jpa.CriteriaQueryBuilder;

@PersistenceContext
private EntityManager entityManager;

public List<Product> findActiveProducts() {
Filter filter = Filter.eq("status", "ACTIVE");

CriteriaQuery<Product> query = CriteriaQueryBuilder
.<Product, Product>of(entityManager)
.from(Product.class)
.where(filter)
.orderBy(Sort.by("name"))
.build();

return entityManager.createQuery(query).getResultList();
}

JOIN 查詢

public List<Product> findProductsWithCategory(String categoryName) {
Filter filter = Filter.eq("category.name", categoryName);

CriteriaQuery<Product> query = CriteriaQueryBuilder
.<Product, Product>of(entityManager)
.from(Product.class)
.join("category", JoinType.INNER) // INNER JOIN category
.where(filter)
.build();

return entityManager.createQuery(query).getResultList();
}

DISTINCT 查詢

CriteriaQuery<Product> query = CriteriaQueryBuilder
.<Product, Product>of(entityManager)
.from(Product.class)
.distinct() // SELECT DISTINCT
.where(filter)
.build();

TupleQueryBuilder 投影查詢

當只需要部分欄位時,使用 TupleQueryBuilder 建構投影查詢:

選取部分欄位

import io.leandev.appfuse.jpa.TupleQueryBuilder;
import jakarta.persistence.Tuple;

public List<Tuple> findProductSummaries() {
CriteriaQuery<Tuple> query = TupleQueryBuilder
.<Product>of(entityManager)
.from(Product.class)
.select("id", "name", "price") // 只選取這些欄位
.where(Filter.eq("status", "ACTIVE"))
.orderBy(Sort.by("name"))
.build();

return entityManager.createQuery(query).getResultList();
}

聚合函數

// COUNT
CriteriaQuery<Tuple> query = TupleQueryBuilder
.<Product>of(entityManager)
.from(Product.class)
.select("category.name")
.countAs("id", "productCount") // COUNT(id) AS productCount
.groupBy("category.name")
.build();

// SUM, AVG, MAX, MIN
CriteriaQuery<Tuple> query = TupleQueryBuilder
.<Order>of(entityManager)
.from(Order.class)
.select("customer.id")
.sum("totalAmount", "totalSpent")
.avg("totalAmount", "avgOrder")
.max("totalAmount", "maxOrder")
.min("totalAmount", "minOrder")
.groupBy("customer.id")
.build();

轉換為物件

使用 TupleConstructor 將 Tuple 轉換為 DTO:

import io.leandev.appfuse.jpa.TupleConstructor;

// DTO 需要無參數建構子和對應的 setter
public class ProductSummaryDto {
private Long id;
private String name;
private BigDecimal price;
// getters and setters
}

public List<ProductSummaryDto> findProductSummaries() {
CriteriaQuery<Tuple> query = TupleQueryBuilder
.<Product>of(entityManager)
.from(Product.class)
.select("id", "name", "price")
.build();

TupleConstructor<ProductSummaryDto> constructor =
new TupleConstructor<>(ProductSummaryDto.class);

return entityManager.createQuery(query)
.getResultList()
.stream()
.map(constructor::construct)
.toList();
}

QueryRunner 分頁查詢

QueryRunner 簡化分頁查詢的執行:

import io.leandev.appfuse.jpa.QueryRunner;
import org.springframework.data.domain.Page;

public Page<Product> findProducts(Filter filter, Pageable pageable) {
QueryRunner<Product> runner = new QueryRunner<>(entityManager);

// 建構 data query 和 count query
CriteriaQueryBuilder<Product, Product> builder = CriteriaQueryBuilder
.<Product, Product>of(entityManager)
.from(Product.class)
.where(filter);

CriteriaQuery<Product> dataQuery = builder.build();
CriteriaQuery<Long> countQuery = builder.buildCountQuery();

return runner.findAll(dataQuery, countQuery, pageable);
}

單一結果查詢

QueryRunner<Product> runner = new QueryRunner<>(entityManager);

// 回傳 Optional
Optional<Product> product = runner.findOne(query);

// 回傳物件或 null
Product product = runner.get(query);

// 回傳 List
List<Product> products = runner.findAll(query);

PageableBuilder 分頁建構

PageableBuilder 協助處理分頁參數,特別是排序欄位的轉換:

欄位名稱轉換

前端傳來的排序欄位名稱可能與資料庫欄位不同:

import io.leandev.appfuse.jpa.PageableBuilder;

@GetMapping("/products")
public Page<Product> getProducts(Pageable pageable) {
// 將前端欄位名稱轉換為 Entity 欄位名稱
Pageable convertedPageable = PageableBuilder.of(pageable)
.sort(pageable.getSort(), fieldName -> switch (fieldName) {
case "categoryName" -> "category.name";
case "supplierName" -> "supplier.companyName";
default -> fieldName;
})
.build();

return productService.findAll(convertedPageable);
}

預設排序

Pageable pageable = PageableBuilder.of(requestPageable)
.sort(requestPageable.getSort(),
this::convertFieldName,
Sort.by("createdAt").descending()) // 預設排序
.build();

多租戶支援

JPA 模組提供多租戶數據隔離的基礎設施。

TenantAwareEntity

繼承 TenantAwareEntity 自動獲得租戶隔離:

import io.leandev.appfuse.jpa.tenant.TenantAwareEntity;

@Entity
@Table(name = "products")
public class Product extends TenantAwareEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String name;
private BigDecimal price;

// tenantId 自動從 TenantAwareEntity 繼承
}

自動功能

  • @PrePersist:自動注入當前租戶 ID
  • @PreUpdate:驗證不跨租戶更新
  • Hibernate Filter:自動過濾查詢結果

啟用租戶過濾

使用 AOP 或 Interceptor 啟用 Hibernate Filter:

import io.leandev.appfuse.jpa.tenant.TenantFilterSupport;

@Aspect
@Component
public class TenantFilterAspect {

@PersistenceContext
private EntityManager entityManager;

@Before("@within(org.springframework.transaction.annotation.Transactional)")
public void enableTenantFilter() {
if (TenantContext.hasTenantContext()) {
String tenantId = TenantContext.getCurrentTenantId();
TenantFilterSupport.enableFilter(entityManager, tenantId);
}
}
}

加入審計功能

建立應用層基類加入審計欄位:

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableTenantEntity extends TenantAwareEntity {

@CreatedBy
@Column(name = "created_by", updatable = false)
private String createdBy;

@CreatedDate
@Column(name = "created_date", updatable = false)
private Instant createdDate;

@LastModifiedBy
@Column(name = "last_modified_by")
private String lastModifiedBy;

@LastModifiedDate
@Column(name = "last_modified_date")
private Instant lastModifiedDate;
}

完整範例

Service 層整合

@Service
@Transactional(readOnly = true)
public class ProductService {

private final ProductRepository productRepository;
private final EntityManager entityManager;

// 方式 1:使用 SpecificationBuilder(簡單場景)
public Page<Product> findBySpec(ProductSearchCriteria criteria, Pageable pageable) {
Filter filter = buildFilter(criteria);
Specification<Product> spec = SpecificationBuilder.build(filter);
return productRepository.findAll(spec, pageable);
}

// 方式 2:使用 CriteriaQueryBuilder(複雜場景)
public Page<Product> findByCriteria(ProductSearchCriteria criteria, Pageable pageable) {
Filter filter = buildFilter(criteria);
QueryRunner<Product> runner = new QueryRunner<>(entityManager);

CriteriaQueryBuilder<Product, Product> builder = CriteriaQueryBuilder
.<Product, Product>of(entityManager)
.from(Product.class)
.join("category")
.where(filter);

return runner.findAll(builder.build(), builder.buildCountQuery(), pageable);
}

// 方式 3:投影查詢
public List<ProductSummaryDto> findSummaries(ProductSearchCriteria criteria) {
Filter filter = buildFilter(criteria);
TupleConstructor<ProductSummaryDto> constructor =
new TupleConstructor<>(ProductSummaryDto.class);

CriteriaQuery<Tuple> query = TupleQueryBuilder
.<Product>of(entityManager)
.from(Product.class)
.select("id", "name", "price", "category.name")
.selectAs("category.name", "categoryName")
.where(filter)
.build();

return entityManager.createQuery(query)
.getResultList()
.stream()
.map(constructor::construct)
.toList();
}

private Filter buildFilter(ProductSearchCriteria criteria) {
return Filter.empty()
.and(Filter.eq("status", criteria.getStatus()))
.and(Filter.like("name", criteria.getName()))
.and(Filter.eq("category.id", criteria.getCategoryId()))
.and(Filter.ge("price", criteria.getMinPrice()))
.and(Filter.le("price", criteria.getMaxPrice()));
}
}

最佳實踐

1. 選擇適當的工具

場景推薦工具
簡單 CRUDSpring Data JPA + SpecificationBuilder
複雜 JOINCriteriaQueryBuilder
部分欄位投影TupleQueryBuilder
聚合統計TupleQueryBuilder + groupBy

2. Filter 建構技巧

// 使用 Filter.of 處理可能為 null 的條件
// 當 value 為 null 或空字串時,會自動回傳空 Filter
Filter filter = Filter.of("status", ComparisonOperator.EQUAL, criteria.getStatus());

// 等同於
if (criteria.getStatus() != null && !criteria.getStatus().isEmpty()) {
filter = filter.and(Filter.eq("status", criteria.getStatus()));
}

3. 避免 N+1 問題

// 使用 JOIN FETCH 預載關聯
CriteriaQueryBuilder<Product, Product> builder = CriteriaQueryBuilder
.<Product, Product>of(entityManager)
.from(Product.class)
.join("category", JoinType.LEFT) // 預載 category
.where(filter);

下一步