This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

Mapper

Core Domain
    <dependency>
      <groupId>tw.com.softleader.data.jakarta</groupId>
      <artifactId>specification-mapper</artifactId>
      <version>${specification-mapper.version}</version>
    </dependency>
    

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

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

    Getting Started

    我們需要的是建立 SpecMapper 實例, 這是最重要的 Class 也是所有 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 將不會有任何過濾條件!

    如果你有使用 Builder Pattern, (e.g. Lombok’s @Builder), 請特別注意 Builder 的 Default Value!

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

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

    Simple Specifications

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

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

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

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

    Built-in Simple @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 the @Spec

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

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

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

    執行的 SQL 將會是:

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

    Extending Simple @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
    )
    

    Combining Specs

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

    例如我想要改成 @Or, 程式碼範例如下:

    @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 %?% 
    

    Specify Combining Type on Field

    你也可以將 @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<=?
    

    特別注意, 欄位是依照宣告的順序組合的, 而 SQL 也是有運算子優先順序的, 需注意兩者的配合的結果是否符合你的期望

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

    @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 ?)
    

    Nested Specs

    你可以在 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=? )
    

    Specify Combining Type on Nested Object

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

    舉個例子如下:

    @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=?
    

    Join

    你可以在 Field 上使用 @Join 來過濾關聯 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 distinc ... from customer customer0_ 
    inner join orders orders1_ on customer0_.id=orders1_.order_id 
    where orders1_.item_name in (? , ?)
    

    為了比較符合大部分的使用情境, 預設的 Join type 是 INNER, 也會將結果排除重複 (distinct), 你可以設定 @Join#joinType@Join#distinct 來改變, 如:

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

    Multi Level Joins

    你可以使用 @Joins 來定義多層級的 Join, 例如:

    @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 ... 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 (?)
    

    特別注意, 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;
    }
    

    Join Fetch

    你可以在 class 層級上使用 @JoinFetch 可以一次撈出所有 Lazy 的關聯資料, 例如:

    @Entity
    class Customer {
    
      @OneToMany(fetch = LAZY, cascade = ALL)
      @JoinColumn(name = "order_id")
      private Collection<Order> orders;
    }
    
    @Entity
    class Order {
      
      private String itemName;
    }
    

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

    @JoinFetch(paths = "orders")
    @Data
    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=?
    

    為了比較符合大部分的使用情境, 預設的 Join type 是 INNER, 也會將結果排除重複 (distinct), 你可以設定 @FetchJoin#joinType@FetchJoin#distinct 來改變, 如:

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

    Multi Level Fetch Joins

    你可以使用 @FetchJoins 來定義多層級的 Fetch Join, 例如:

    @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;
    }
    

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

    @JoinFetches({
      @JoinFetch(paths = "orders"),
      @JoinFetch(paths = "orders.tags")
    })
    @Data
    class CustomerOrderTagCriteria {
    
      @Spec
      String name;
    }
    

    執行的 SQL 將會是:

    select distinct 
      customer0_.* ...,
      orders1_.* ...,
      tags3_.* ...
    from customer customer0_ 
    left outer join orders orders1_ on customer0_.id=orders1_.order_id 
    inner join orders orders2_ on customer0_.id=orders2_.order_id 
    left outer join orders_tags tags3_ on orders2_.id=tags3_.order_id 
    left outer join tag tag4_ on tags3_.tags_id=tag4_.id 
    where customer0_.name=?
    

    Customize Spec Annotation

    延續 Extending Simple @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
    )
    

    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]]]]]
    

    如果你喜歡根據轉換的物件來控制和設定 Logging, 我們提供了另一種策略, 通過設定 ASTWriterFactory, 可以改成使用目標物件的 Logger 來輸出:

    var mapper = SpecMapper.builder()
          .defaultResolvers()
          .astWriterFactory(ASTWriterFactory.impersonation())
          .build();
    

    Limitation

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