1 - Mapper

Core Domain
<dependency>
  <groupId>tw.com.softleader.data.jakarta</groupId>
  <artifactId>specification-mapper</artifactId>
  <version>${specification-mapper.version}</version>
</dependency>
requires specification.mapper;
requires jakarta.persistence;
logging:
  level:
    tw.com.softleader.data.jpa.spec: info

Maven Central 查看最新版本

specification-mapper 是一套 Specifications 的產生器, 它讀取了 Object 中的欄位, 配合欄位上 Annotation 的定義, 來動態的建立查詢條件!

另外 specification-mapper-starter 提供了 Spring Boot 的整合, 讓你可以零配置的在 Spring apps 中使用, 使用 Spring boot 的應用程式可以參考看看!

1.1 - SpecMapper

操作入口

SpecMapper 是所有 Spec 操作的 API 入口, 首先我們需要建構實例:

var mapepr = SpecMapper.builder().build();

接著我們定義封裝查詢條件的物件, 這是一個 POJO 即可, 如:

@Data
public class CustomerCriteria {

  @Spec(Like.class)
  String firstname;
}

這樣我們就可以做 Specification 的轉換了, 得到 Specification 後就可以依照原本的方式去資料庫查詢, 例如透過 Spring Data JPA 的 repository:

var criteria = new CustomerCriteria();
criteria.setFirstname("Hello")

var mapper = SpecMapper.builder().build();
var specification = mapper.toSpec(criteria);

customerRepository.findAll(specification);

執行 SQL 將會是:

... where x.firstname like '%Hello%'

Skipping Strategy

在 POJO 中的欄位, 只要符合以下任一條件, 在轉換的過程中都將會忽略:

  • 沒有掛任何 Spec Annotation
  • 值為 null
  • 若 Type 為 Iterable 且值為 empty
  • 若 Type 為 Optional 且值為 empty
  • 若 Type 為 CharSequence 且長度為 0
  • 若 Type 為 Array 且長度為 0
  • 若 Type 為 Map 且值為 empty

例如, 將以下 POJO 建構後, 不 set 任何值就直接轉換成 Specification 及查詢

@Data
public class CustomerCriteria {

  @Spec(Like.class)
  String firstname;
  
  String lastname = "Hello";
 
  @Spec
  String nickname = "";
     
  @Spec(GreaterThat.class)
  Optional<Integer> age = Optional.empty();
  
  @Spec(In.class)
  Collection<String> addresses = Arrays.asList();
}

var mapper = SpecMapper.builder().build();
customerRepository.findAll(mapper.toSpec(new CustomerCriteria()));

以上執行的 SQL 將不會有任何過濾條件!

若你想要客製化跳脫邏輯, 可以實作 SkippingStrategy, 並在建構 SpecMapper 時傳入:

var mapper = SpecMapper.builder()
      .defaultResolvers()
      .skippingStrategy(fieldValue -> {
        // 判斷是否要跳 field value, 回傳 boolean
      })
      .build();

1.2 - Simple Specs

基本查詢條件

在 POJO 中, 你可以在 Field 上使用 @Spec 來定義 Specification 的實作, 預設是 Equals:

@Spec // 同等於 @Spec(Equals.class)
String firstname;

對應的 entity path 預設會使用 field name, 你也可以設定 @Spec#path 來改變

@Spec(path = "...") // 有定義則優先使用
String firstname; // 預設使用欄位名稱

Built-in @Spec

以下是內建的 @Spec 的類型清單:

SpecSupported field typeSampleJPQL snippet
EqualsAny@Spec(Equals.class) String firstname;... where x.firstname = ?
NotEqualsAny@Spec(NotEquals.class) String firstname;... where x.firstname <> ?
BetweenIterable of Comparable
(Expected exact 2 elements in Iterable)
@Spec(Between.class) List<Integer> age;... where x.age between ? and ?
LessThanComparable@Spec(LessThan.class) Integer age;... where x.age < ?
LessThanEqualComparable@Spec(LessThanEqual.class) Integer age;... where x.age <= ?
GreaterThanComparable@Spec(GreaterThan.class) Integer age;... where x.age > ?
GreaterThanEqualComparable@Spec(GreaterThanEqual.class) Integer age;... where x.age >= ?
AfterComparable@Spec(After.class) LocalDate startDate;... where x.startDate > ?
BeforeComparable@Spec(Before.class) LocalDate startDate;... where x.startDate < ?
IsNullBoolean@Spec(IsNull.class) Boolean age;... where x.age is null (if true)
... where x.age not null (if false)
NotNullBoolean@Spec(NotNull .class) Boolean age;... where x.age not null (if true)
... where x.age is null (if false)
LikeString@Spec(Like.class) String firstname;... where x.firstname like %?%
NotLikeString@Spec(NotLike.class) String firstname;... where x.firstname not like %?%
StartingWithString@Spec(StartingWith.class) String firstname;... where x.firstname like ?%
EndingWithString@Spec(EndingWith.class) String firstname;... where x.firstname like %?
InIterable of Any@Spec(In.class) Set<String> firstname;... where x.firstname in (?, ?, ...)
NotInIterable of Any@Spec(NotIn.class) Set<String> firstname;... where x.firstname not in (?, ?, ...)
TrueBoolean@Spec(True.class) Boolean active;... where x.active = true (if true)
... where x.active = false (if false)
FalseBoolean@Spec(False.class) Boolean active;... where x.active = false (if true)
... where x.active = true (if false)
HasLengthBoolean@Spec(HasLength.class) Boolean firstname;... where x.firstname is not null and character_length(x.firstname)>0 (if true)
... where not(x.firstname is not null and character_length(x.firstname)>0) (if false)
HasTextBoolean@Spec(HasText.class) Boolean firstname;... where x.firstname is not null and character_length(trim(BOTH from x.firstname))>0 (if true)
... where not(where x.firstname is not null and character_length(trim(BOTH from x.firstname))>0) (if false)

為了方便已經熟悉 Spring Data JPA 的人使用, 以上名稱都是儘量跟著 Query Methods 一樣

Negates @Spec

你可以使用 @Spec#not 來判斷反向條件, 預設是 false, 設定成 true 就會將結果做反向轉換.

例如, 我想要用 Between不在區間內的資料, 則範例如下:

@Spec(value = Between.class, not = true)
Collection<Integer> age;

執行的 SQL 會類似:

... where x.age not between ? and ?

Extending @Spec

@Spec 是可以很容易擴充的, 只要實作了 SimpleSpecification<T> 並提供規定的 Constructor, 這些 class 就可以被定義在 @Spec

例如, 我有個 Customer Entity, 有以下欄位:

  • firstname (String) - 人名, 可重複
  • createdTime (LocalDateTime) - 建立時間, 不可重複

我希望可以找出每個人名中, 建立時間為最新的那筆資料! 且我打算撰寫一個 subquery 來完成這需求, 完整的範例如下:

首先我們實作 SimpleSpecification<T>, 並提供規定的 Constructor:

public class MaxCustomerCreatedTime extends SimpleSpecification<Customer> {

  // 這是規定必須提供的建構值, 修飾詞不限定: public, protected, default, private 都支援
  protected MaxCustomerCreatedTime(Context context, String path, Object value) {
    super(context, path, value);
  }

  @Override
  public Predicate toPredicate(Root<Customer> root,
    CriteriaQuery<?> query,
    CriteriaBuilder builder) {
    // 以下提供 subquery 的實作, 僅供參考
    var subquery = query.subquery(LocalDateTime.class);
    var subroot = subquery.from(Customer.class);
    subquery.select(
      builder.greatest(subroot.get("createdTime").as(LocalDateTime.class))
    ).where(builder.equal(root.get((String) value), subroot.get((String) value)));
    return builder.equal(root.get("createdTime"), subquery);
  }
}

上面完成的 MaxCustomerCreatedTime 就可以被應用在 @Spec 中了, 接著我們定義 POJO 及進行 Specification 的轉換:

@Data
public class CustomerCriteria {

  @Spec(MaxCustomerCreatedTime.class)
  String maxBy;
}

var criteria = new CustomerCriteria();
criteria.setMaxBy("firstname");

var spec = mapper.toSpec(criteria, Customer.class);
repository.findAll(spec);

執行的 SQL 會類似:

... where customer0_.created_time=(
  select max(customer1_.created_time) from customer customer1_ 
  where customer0_.firstname=customer1_.firstname
)

1.3 - Combine Specs

組合多個查詢條件

在 POJO 中, 可以使用 @And@Or 來組合一個物件中的多個 Specification, 組合的預設是 @And.

Combine on Classes

@And@Or 可以宣告在 Class 上, 例如:

@Or // 若沒定義預設就是 @And
@Data
public class CustomerCriteria {

  @Spec(Like.class)
  String firstname;
  
  @Spec(Like.class)
  String lastname;
}

執行的 SQL 會類似:

... where x.firstname like %?% or x.lastname like %?% 

Combine on Fields

你也可以將 @And@Or 註記在欄位上來控制單獨一個欄位要怎麼跟其他欄位組合. 舉個例子如下:

@Data
public class CustomerCriteria {

  @Spec(Like.class)
  String firstname;
  
  @Spec(Like.class)
  String lastname;

  @Or
  @Spec(value = After.class, not = true)
  LocalDate birthday;
}

執行的 SQL 會類似:

... where (x.firstname like ?) and (x.lastname like ?) or x.birthday<=?

例如, 將上面的例子調整欄位順序:

@Data
public class CustomerCriteria {

  @Spec(Like.class)
  String firstname;
  
  @Or
  @Spec(value = After.class, not = true)
  LocalDate birthday;
  
  @Spec(Like.class)
  String lastname;
}

執行的 SQL 會類似:

... where (x.firstname like ? or x.birthday<=?) and (x.lastname like ?)

1.4 - Nested Specs

處理巢狀物件的查詢條件

在 POJO 中, 你可以在 Field 上使用 @NestedSpec 來告知 SpecMapper 要往下一層物件 (Nested Object) 去組合 Specification, 這是沒有層級限制的, 可以一直往下找!

例如我有一個共用的 AddressCriteria POJO, 我就可以將它掛載到其他的 POJO 中, 程式碼範例如下:

@Data
public class CustomerCriteria {

  @Spec(Like.class)
  String firstname;
  
  @NestedSpec
  AddressCriteria address;
}

@Or
@Data
public class AddressCriteria {

  @Spec
  String county;
  
  @Spec
  String city;
}

執行的 SQL 會類似:

... where x.firstname like %?% and ( x.county=? or x.city=? )

Combine Nested Specs

你也可以在 Nested Object 的欄位上宣告 @And@Or 來控制結果要怎麼跟其他的欄位組合, 詳細的說明請參考 Combining on Fields.

舉個例子如下:

@Data
public class CustomerCriteria {

  @Spec(Like.class)
  String firstname;
  
  @Or
  @NestedSpec
  AddressCriteria address;
}

@Data
public class AddressCriteria {

  @Spec
  String county;
  
  @Spec
  String city;
}

執行的 SQL 會類似:

... where (x.firstname like ?) or x.county=? and x.city=?

1.5 - Join

關聯查詢的過濾條件

在 POJO 中, 你可以在 Field 或 Class 上使用 @Join 來過濾關聯的 Entity

Single Join

要使用 @Join, 在 Entity 中需要先定義好關聯, 例如, 有個客戶 Entity, 會一對多的關聯訂單 Entity:

@Entity
class Customer {

  @OneToMany(cascade = ALL, fetch = LAZY)
  @JoinColumn(name = "order_id")
  private Collection<Order> orders;
}

@Entity
class Order {

  private String itemName;
}

如果你想要查詢買了指定東西的客戶, 則可以定義 POJO 如下:

@Data
public class CustomerOrderCriteria {

  @Join(path = "orders", alias = "o")
  @Spec(path = "o.itemName", value = In.class)
  Collection<String> items;
}

執行的 SQL 會類似:

select distinct customer0_.* from customer customer0_ 
inner join orders orders1_ on customer0_.id=orders1_.order_id 
where orders1_.item_name in (? , ?)

@Join 也可以用在 class 層級, 在同一個物件內的欄位就都可以使用 alias 來對 Join 的對象增加條件, 例如:

@Data
@Join(path = "orders", alias = "o")
public class CustomerOrderCriteria {

  @Spec(path = "o.itemName", value = In.class)
  Collection<String> items;

  @Spec(path = "o.orderNo", value = StartingWith.class)
  String orderNo;
}

Join Behavior

為了比較符合大部分的使用情境, 以下是預設的行為:

  • Join type 預設為 INNER
  • 將結果排除重複 (distinct)

透過設定 @Join#joinType@Join#distinct 可以改變預設行為, 如:

@Join(joinType = JoinType.RIGHT, distinct = false)

Multi Joins

你可以使用 @Joins 來定義多層級的 Join, 例如, 在剛剛的訂單 Entity 中, 還會多對多的關聯到類別 Entity:

@Entity
class Customer {

  @OneToMany(cascade = ALL, fetch = LAZY)
  @JoinColumn(name = "order_id")
  private Set<Order> orders;
}

@Entity
class Order {
    
  @ManyToMany(cascade = ALL, fetch = LAZY)
  private Set<Tag> tags;
}

@Entity
class Tag {

  private String name;
}

如果你想要查詢買了指定所屬類別的東西的客戶, 則可以定義 POJO 如下:

@Data
class CustomerOrderTagCriteria {

  @Joins({
    @Join(path = "orders", alias = "o"),
    @Join(path = "o.tags", alias = "t")
  })
  @Spec(path = "t.name", value = In.class)
  Collection<String> tags;
}

執行的 SQL 會類似:

select distinct customer0_.* from customer customer0_ 
inner join orders orders1_ on customer0_.id=orders1_.order_id 
inner join orders_tags tags2_ on orders1_.id=tags2_.order_id 
inner join tag tag3_ on tags2_.tags_id=tag3_.id 
where tag3_.name in (?)

@Joins 也是可以用在 class 層級, 在同一個物件內的欄位就都可以使用 alias 來對 Join 的對象增加條件, 例如:

@Data
@Joins({
  @Join(path = "orders", alias = "o"),
  @Join(path = "o.tags", alias = "t")
})
public class CustomerOrderCriteria {

  @Spec(path = "o.itemName", value = In.class)
  Collection<String> items;

  @Spec(path = "t.name", value = In.class)
  Collection<String> tags;
}

Joins Order

Annotation 的處理是有順序性的, 因此必須依照 Join 的順序去定義 @Joins

例如依照上面的情境, 下列的定義順序是錯誤的:

@Data
class CustomerOrderTagCriteria {

  @Joins({
    @Join(path = "o.tags", alias = "t"), // "o" alias will be not exist during processing this @Join
    @Join(path = "orders", alias = "o")
  })
  @Spec(path = "t.name", value = In.class)
  Collection<String> tagNames;
}

Alias

@Join#alias 的使用規則如下:

  • 在同個 POJO 中是共用的
  • 在同的 POJO 中不可重複宣告
  • 若沒提供, 預設使用 @Join#path
  • 若包含了 . 會以 _ 取代之

例如:

@Joins({
  @Join(path = "orders"), // alias 預設為 orders
  @Join(path = "orders.tags") // alias 預設為 orders_tags
})
@Spec(path = "orders_tags.name", value = In.class)

1.6 - Join Fetch

關聯查詢的過濾條件, 並同時選取指定欄位

在 POJO 中, 你可以在 Field 或 Class 上使用 @JoinFetch 來過濾關聯的 Entity, 跟 @Join 的差別是, 這可以一次撈出所有 Lazy 的關聯資料

Single Join Fetch

例如, 有個客戶 Entity, 會一對多的關聯訂單 Entity:

@Entity
class Customer {

  @OneToMany(fetch = LAZY, cascade = ALL)
  @JoinColumn(name = "order_id")
  private Collection<Order> orders;
}

@Entity
class Order {
  
  private String itemName;
}

如果你想在取得 Customer 時就順便取得 Order, 則:

@Data
@JoinFetch(path = "orders")
class CustomerOrderCriteria {

  @Spec
  String name;
}

執行的 SQL 會類似:

select distinct 
  customer0_.* ...,
  orders1_.* ...
from customer customer0_ 
inner outer join orders orders1_ on customer0_.id=orders1_.order_id 
where customer0_.name=?

你可以看到 orders1_.* 也被放入了 select 項目內

Join Behavior

為了比較符合大部分的使用情境, 以下是預設的行為:

  • Join type 預設為 INNER
  • 將結果排除重複 (distinct)

透過設定 @JoinFetch#joinType@JoinFetch#distinct 可以改變預設行為, 如:

@JoinFetch(joinType = JoinType.RIGHT, distinct = false)

With Clause

@JoinFetch 也可以讓你過濾資料, 例如, 我想要過濾客戶名稱及訂單名稱, 則可以定義 POJO 如下:

@Data
@JoinFetch(path = "orders", alias = "o")
public class CustomerOrderCriteria {

  @Spec
  String name;

  @Spec(path = "o.itemName", value = In.class)
  Collection<String> items;
}

執行的 SQL 會類似:

select distinct 
  customer0_.* ...,
  orders1_.* ...
from customer customer0_ 
inner outer join orders orders1_ on customer0_.id=orders1_.order_id 
where customer0_.name=? and orders1_.item_name in (?)

@JoinFetch 也可以用在 Field 層級, 例如:

@Data
public class CustomerOrderCriteria {

  @Spec
  String name;

  @JoinFetch(path = "orders", alias = "o")
  @Spec(path = "o.itemName", value = In.class)
  Collection<String> items;
}

Multi Join Fetches

你可以使用 @JoinFetches 來定義多層級的 Fetch, 例如, 在剛剛的訂單 Entity 中, 還會多對多的關聯到類別 Entity:

@Entity
class Customer {

  @OneToMany(cascade = ALL, fetch = LAZY)
  @JoinColumn(name = "order_id")
  private Set<Order> orders;
}

@Entity
class Order {
    
  @ManyToMany(cascade = ALL, fetch = LAZY)
  private Set<Tag> tags;
}

@Entity
class Tag {

  private String name;
}

如果你想要查詢買了指定所屬類別的東西的客戶, 則可以定義 POJO 如下:

@Data
class CustomerOrderTagCriteria {

  @JoinFetches({
    @JoinFetch(path = "orders", alias = "o"),
    @JoinFetch(path = "o.tags", alias = "t")
  })
  @Spec(path = "t.name", value = In.class)
  Collection<String> tags;
}

執行的 SQL 會類似:

select distinct 
  customer0_.* ...,
  orders1_.* ...,
  tag3_.* ...
from customer customer0_ 
inner join orders orders1_ on customer0_.id=orders1_.order_id 
inner join orders_tags tags2_ on orders1_.id=tags2_.order_id 
inner join tag tag3_ on tags2_.tags_id=tag3_.id 
where tag3_.name in (?)

@JoinFetches 也是可以用在 class 層級, 在同一個物件內的欄位就都可以使用 alias 來對 Join 的對象增加條件, 例如:

@Data
@JoinFetches({
  @JoinFetch(path = "orders", alias = "o"),
  @JoinFetch(path = "o.tags", alias = "t")
})
public class CustomerOrderCriteria {

  @Spec(path = "o.itemName", value = In.class)
  Collection<String> items;

  @Spec(path = "t.name", value = In.class)
  Collection<String> tags;
}

Fetches Order

Annotation 的處理是有順序性的, 因此必須依照 Join 的順序去定義 @JoinFetches

例如依照上面的情境, 下列的定義順序是錯誤的:

@Data
class CustomerOrderTagCriteria {

  @JoinFetches({
    @JoinFetch(path = "o.tags", alias = "t"), // "o" alias will be not exist during processing this @Join
    @JoinFetch(path = "orders", alias = "o")
  })
  @Spec(path = "t.name", value = In.class)
  Collection<String> tagNames;
}

Alias

@JoinFetch#alias 的使用規則如下:

  • 在同個 POJO 中是共用的
  • 在同的 POJO 中不可重複宣告
  • 若沒提供, 預設使用 @JoinFetch#path
  • 若包含了 . 會以 _ 取代之

例如:

@JoinFetches({
  @JoinFetch(path = "orders"), // alias 預設為 orders
  @JoinFetch(path = "orders.tags") // alias 預設為 orders_tags
})
@Spec(path = "orders_tags.name", value = In.class)

1.7 - Customize Specs

客製化與擴充查詢條件

延續 Extending @Spec 章節範例, 進階一點現在我們希望可以將 Entity Class 設計成可以配置, 這樣才能在 Customer 以外的 Entity 都可以使用!

要完成這需求我們需要在 Annotation 中定義更多參數, 因此 Simple @Spec 不適用了, 我們需要的是定義新的 Annotation, 完整的程式碼如下:

首先我們定義 Annotation:

@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD })
public @interface MaxCreatedTime {

  Class<?> from();
}

接著我們要撰寫負責處理 @MaxCreatedTime 的邏輯, 透過實作 SpecificationResolver 來擴充:

public class MaxCreatedTimeSpecificationResolver implements SpecificationResolver {

  @Override
  public boolean supports(Databind databind) { 
    // 這邊告訴 SpecMapper 什麼時候要使用此 resolver
    return databind.getField().isAnnotationPresent(MaxCreatedTime.class);
  }

  @Override
  public Specification<Object> buildSpecification(Context context, Databind databind) {
    var def = databind.getField().getAnnotation(MaxCreatedTime.class);
    return databind.getFieldValue()
        .map(value -> subquery(def.from(), value.toString()))
        .orElse(null);
  }

  Specification<Object> subquery(Class<?> entityClass, String by) {
    // 以下提供 subquery 的實作, 僅供參考
    return (root, query, builder) -> {
      var subquery = query.subquery(LocalDateTime.class);
      var subroot = subquery.from(entityClass);
      subquery.select(
        builder.greatest(subroot.get("createdTime").as(LocalDateTime.class))
      ).where(builder.equal(root.get(by), subroot.get(by)));
      return builder.equal(root.get("createdTime"), subquery);
    };
  }
}

接著我們在 SpecMapper 建構時加入此 resolver:

var mapper = SpecMapper.builder()
      .defaultResolvers()
      .resolver(new MaxCreatedTimeSpecificationResolver())
      .build();

最後我們定義 POJO 及進行 Specification 的轉換:

@Data
public class CustomerCriteria {

  @MaxCreatedTime(from = Customer.class)
  String maxBy;
}

var criteria = new CustomerCriteria();
criteria.setMaxBy("firstname");

var spec = mapper.toSpec(criteria, Customer.class);
repository.findAll(spec);

執行的 SQL 會類似:

... where customer0_.created_time=(
  select max(customer1_.created_time) from customer customer1_ 
  where customer0_.firstname=customer1_.firstname
)

1.8 - Logging

物件轉換過程的日誌輸出

在 Logging 等級中設定 tw.com.softleader.data.jpa.spec.SpecMapper=debug, 會在物件轉換成 Spec 的過程中印出更多資訊, 可以有效的幫助查找問題, 輸出類似如:

DEBUG 20297 --- [           main] t.c.softleader.data.jpa.spec.SpecMapper  : --- Spec AST ---
+-[CustomerCriteria]: my.package.CustomerCriteria
|  +-[CustomerCriteria.firstname]: @Spec(value=Equals, path=, not=false) -> Equals[path=name, value=matt]
|  +-[CustomerCriteria.address]: my.package.AddressCriteria (NestedSpecificationResolver)
|  |  +-[AddressCriteria.county]: @Spec(value=Equals, path=, not=false) -> null
|  |  +-[AddressCriteria.city]: @Spec(value=Equals, path=, not=false) -> Equals[path=name, value=Taipei]
|  \-[CustomerCriteria.address]: Conjunction[specs=[Equals[path=city, value=Taipei]]]
\-[CustomerCriteria]: Conjunction[specs=[Equals[path=name, value=matt], Conjunction[specs=[Equals[path=city, value=Taipei]]]]]

Logger Name

從上面範例的第一行可以看到, 預設的 Logger Name 是使用 SpecMapper, 這可以方便你統一的設定, 如果你喜歡根據轉換的物件來控制和設定 Logging, 我們提供了另一種策略, 將 Logger Name 改成使用目標物件的 Logger 來輸出

通過設定 ASTWriterFactory 來調整策略, 你可以調整例如:

var mapper = SpecMapper.builder()
      .defaultResolvers()
      // 預設為 ASTWriterFactory.domain()
      .astWriterFactory(ASTWriterFactory.impersonation()) 
      .build();

輸出會類似:

DEBUG 20297 --- [           main] my.package.CustomerCriteria  : --- Spec AST ---
...

1.9 - Limitation

限制與考量

SpecMapper 在找 POJO 的欄位時, 只會找當前 Class 的 Local Field, 而不去往上找 Hierarchy Classes 的 Field, 如果你共用的欄位想要用在多個 POJO, 請考慮使用 Nested Specs 方式

2 - Starter

Spring Starter
<dependency>
  <groupId>tw.com.softleader.data.jakarta</groupId>
  <artifactId>specification-mapper-starter</artifactId>
  <version>${specification-mapper.version}</version>
</dependency>
requires specification.mapper;
requires specification.mapper.starter;
requires jakarta.persistence;
logging:
  level:
    tw.com.softleader.data.jpa.spec.starter: info

Maven Central 查看最新版本

specification-mapper-starter 整合了 specification-mapperSpring Data JPA, 並提供了 Query by Spec 的查詢方式等

Query by Spec (QBS) 是一個 user-friendly 的查詢方式, 可以動態的建立查詢條件 (Specifications), 透過 QBS interface 就可以執行查詢語句!

Getting Started

只要在 pom.xml 中加入 dependency, 此 Starter 在 Spring Boot 啟動過程就會自動的配置一切, 讓你可以零配置的就開始使用, 包含了:

自動配置預設是啟用的, 你可以透過 properties 中的 spec.mapper.enabled 控制, 如要關閉則:

spec:
  mapper:
    enabled: false

2.1 - Query by Spec

介紹與使用 Query by Spec (QBS)

Query by Spec (QBS) 提供了 QueryBySpecExecutor<T> 包含了許多查詢方法:

public interface QueryBySpecExecutor<T> {

  List<T> findBySpec(Object spec);
  
  List<T> findBySpec(Object spec, Sort sort);
  
  Page<T> findBySpec(Object spec, Pageable pageable);
  
  // … more functionality omitted.
}

只要在原本的 repository interface 中去繼承 QueryBySpecExecutor<T> 就可以直接使用了:

public interface PersonRepository 
  extends JpaRepository<Person, Long>, QueryBySpecExecutor<Person> {
  ...
}

@Service
public class PersonService {

  @Autowired PersonRepository personRepository;

  public List<Person> findPeople(PersonCriteria criteria) {
    return personRepository.findBySpec(criteria);
  }
}

Customize Base Repository

在配置的過程中, QBS 會自動配置 Spring Data JPA 的 Base Repository, 預設的實作為 DefaultQueryBySpecExecutor

由於 Java 只能單一繼承, 為了應用程式可以保留原有的 Parent Base Repository, QBS 還多提供了 QueryBySpecExecutorAdapter 擴展點

你的應用程式可以視情況選擇繼承 DefaultQueryBySpecExecutor 或實作 QueryBySpecExecutorAdapter 去客製化 Base Repository, 如:

class MyRepositoryImpl<T, ID> extends SimpleJpaRepository<T, ID>
  implements QueryBySpecExecutorAdapter<T> {

  @Setter
  @Getter
  private SpecMapper specMapper;

  private final EntityManager entityManager;

  MyRepositoryImpl(JpaEntityInformation entityInformation,
                          EntityManager entityManager) {
    super(entityInformation, entityManager);

    // Keep the EntityManager around to used from the newly introduced methods.
    this.entityManager = entityManager;
  }

  @Override
  public Class<T> getDomainClass() {
    return super.getDomainClass();
  }
  
  @Transactional
  public <S extends T> S mySave(S entity) {
    // implementation goes here
  }
}

並且透過 properties 中的 spec.mapper.repository-base-class 設定成自定義的 base repository 的 fulll package name, 如:

spec:
  mapper:
    repository-base-class: com.example.MyRepositoryImpl

2.2 - Config SpecMapper

自動配置 SpecMapper

Starter 會在 App 啟動的過程中自動的配置一個 Default SpecMapper, 並註冊成 Spring @Bean 中, 你可以透過 Autowired 的方式跟 Spring 取得.

@Autowired SpecMapper specMapper;

例如, 我想要在轉換成 Specification 後, 先做一些加強再去查詢, 則範例如下:

class PersonService {

  @Autowired SpecMapper specMapper;
  @Autowired PersonRepository personRepository;

  List<Person> getPersonByCriteria(PersonCriteria criteria) {
    var spec = specMapper.toSpec(criteria);
    
    // Perform additional operations on the spec, ex:
    // spec = spec.and((root, query, criteriaBuilder) ->  {
    //     ...
    // });
    
    return personRepository.findAll(spec);
  }
}

在上述範例中, SpecMapper 被注入到了 PersonService, 開發人員就可以使用 specMapper.toSpec() 將 criteria 物件轉換成 specification, 接著就可以對 specification 做更多的調整, 最後才傳入 personRepository 查詢

Configuration

Starter 提供了許多方式讓開發人員調整 Default SpecMapper 的配置

SpecificationResolver

SpecificationResolver 是用來增加自定義的 Spec Annotation, 只要將你要增加的客製化實作註冊成 Spring @Bean, 在 App 啟動的過程中就會自動的偵測及配置

範例如下:

@Configuration
class MyConfig {

  @Bean
  SpecificationResolver myResolver() {
    return ...
  }
}

如果你的 SpecificationResolver 需要用到 SpecMapper 本身, 則你可以包裝成 SpecificationResolverCodecBuilder, 在建構 resolver 時就會把 SpecCodec, 即 SpecMapper 的 interface, 傳進去, 例如:

@Configuration
class MyConfig {

  @Bean
  SpecificationResolverCodecBuilder myResolver() {
    return MySpecificationResolver::new;
  }
}

class MySpecificationResolver implements SpecificationResolver {
  
  private final SpecCodec codec;
  
  MySpecificationResolver(SpecCodec codec) {
    // Keep the SpecCodec around to used.
    this.codec = codec;
  }
  
  // implementation goes here
}

SkippingStrategy

SkippingStrategy 用來提供欄位跳脫的策略, 只要將你自定義的實作註冊成 Spring @Bean, 在 App 啟動的過程中就會自動的偵測並加入到 Default SpecMapper 中!

配置範例如下:

@Configuration
class MyConfig {

  @Bean
  SkippingStrategy mySkippingStrategy() {
    return ...
  }
}

ASTWriterFactory

Logging 中提供了不同的 Logger Name 策略, 透過 properties 中的 spec.mapper.impersonate-logger, 可以設定是否要偽裝成實際處理的 object logger, 預設是關閉的, 若要開啟範例如下:

spec:
  mapper:
    # 是否要偽裝成實際處理的 object logger, 預設關閉
    impersonate-logger: true

若你需要完整的客製化, 只要將你自定義的 ASTWriterFactory 實作註冊成 Spring @Bean, 在 App 啟動的過程中就會自動的偵測並加入到 Default SpecMapper 中!

配置範例如下:

@Configuration
class MyConfig {

  @Bean
  ASTWriterFactory myASTWriterFactory() {
    return ...
  }
}

Customize SpecMapper

當然, 你也可以完全的客製化 SpecMapper, 只要將你的 SpecMapper 註冊成 Spring @Bean, 就會最優先的使用!

配置範例如下:

@Configuration
class MyConfig {

  @Bean
  SpecMapper mySpecMapper() {
    return SpecMapper.builder()
      . ...
      .build();
  }
}

3 - Compatibility

通過相容性測試的版本清單

Java

  • 17
  • 21

Spring

以下為相容的 Spring Boot 版本

  • 3.0.x
  • 3.1.x
  • 3.2.x
  • 3.3.x
  • 3.4.x