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

Check the latest version on Maven Central

specification-mapper is a generator for Specifications. It reads the fields from an object and dynamically creates query conditions based on the definitions of the fields’ annotations.

In addition, specification-mapper-starter provides integration with Spring Boot, allowing you to use it effortlessly in Spring apps without any configuration. We highly recommend checking it out if you are using a Spring Boot application!

1.1 - SpecMapper

The entry point for specification operations

SpecMapper is the most important class and serves as the API entry point for all specification operations:

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

Next, we define a POJO (Plain Old Java Object) that encapsulates the query conditions, such as:

@Data
public class CustomerCriteria {

  @Spec(Like.class)
  String firstname;
}

With this, we can perform the conversion to a Specification. Once we have the Specification, we can query the database using the original approach, for example, through the repository of Spring Data JPA:

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

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

customerRepository.findAll(specification);

The executed SQL will be like:

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

Skipping Strategy

In the fields of the POJO, if any of the following conditions are met, they will be ignored during the conversion process:

  • No Spec Annotation is attached.
  • The value is null.
  • If the type is Iterable and the value is empty.
  • If the type is Optional and the value is empty.
  • If the type is CharSequence and the length of value is 0.
  • If the type is Array and the length of value is 0.
  • If the type is Map and the value is empty.

For example, after constructing the following POJO, if no values are set and it is directly converted into a Specification for querying:

@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()));

The executed SQL in the above example will not have any filtering conditions.

If you are using the Builder Pattern (e.g., Lombok’s @Builder), please pay special attention to the default values set in the builder.

If you want to customize the logic for skipping, you can implement a SkippingStrategy and pass it when constructing a SpecMapper:

var mapper = SpecMapper.builder()
      .defaultResolvers()
      .skippingStrategy(fieldValue -> {
        // Determine whether to skip the field value and return a boolean
      })
      .build();

1.2 - Simple Specs

Basic query conditions

You can use @Spec on fields to define the implementation of the Specification, Equals spec is the default:

@Spec // Equivalent to @Spec(Equals.class)
String firstname;

The corresponding entity path will default to the field name, but you can also set @Spec#path to change it:

@Spec(path = "...") // Takes precedence if defined
String firstname; // Defaults to the field name

Built-in @Spec

Here is a list of the built-in types for @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)

In order to facilitate the usage for those who are already familiar with Spring Data JPA, the specs are named as closely as possible with Query Methods

Negates @Spec

You can use @Spec#not to indicate the inverse condition. By default, it is set to false, but if you set it to true, the result will be inverted.

For example, if you want to use Between to find data outside of a certain range, the example would be as follows:

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

The executed SQL will be like:

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

Extending @Spec

@Spec can be easily extended by implementing SimpleSpecification<T> and providing the required constructor. These classes can then be used in @Spec annotations.

For example, let’s say we have a Customer entity with the following fields:

  • firstname (String): Name of the person (can be duplicated)
  • createdTime (LocalDateTime): Creation time (unique)

We want to retrieve the data for each unique name with the latest creation time. To achieve this, we plan to write a subquery. The complete example is as follows:

First, we implement SimpleSpecification<T> and provide the required constructor:

public class MaxCustomerCreatedTime extends SimpleSpecification<Customer> {

  // This is the required constructor, the modifier can be public, protected, default, or private
  protected MaxCustomerCreatedTime(Context context, String path, Object value) {
    super(context, path, value);
  }

  @Override
  public Predicate toPredicate(Root<Customer> root, CriteriaQuery<?> query, CriteriaBuilder builder) {
    // The following provides an example implementation for the 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);
  }
}

The MaxCustomerCreatedTime class we implemented above can now be used in @Spec. Next, we define the POJO and convert it to a 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);

The executed SQL will be like:

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

1.3 - Combine Specs

Combine multiple query conditions

You can use @And or @Or to combine multiple specifications within an object. The default combination is @And.

Combine on Classes

@And or @Or can be used at the class level , For example, if you want to change it to @Or, the code would be as follows:

@Or // Default is @And if not specified
@Data
public class CustomerCriteria {

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

The executed SQL will be like:

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

Combine on Fields

You can also use @And or @Or annotations on fields to control how an individual field is combined with other fields. Here’s an example:

@Data
public class CustomerCriteria {

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

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

The executed SQL will be like:

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

Note that the fields are combined in the order they are declared, and SQL has operator precedence. Please ensure that the combination and the result align with your expectations.

For example, if we adjust the field order in the above example:

@Data
public class CustomerCriteria {

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

The executed SQL will be like:

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

1.4 - Nested Specs

Handle query conditions for nested objects

You can use @NestedSpec on a field to instruct SpecMapper to combine specifications with the nested object. There is no level limitation, so you can keep going deeper!

For example, let’s say we have a shared AddressCriteria POJO, and we want to include it in other POJOs. The code would look like this:

@Data
public class CustomerCriteria {

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

@Or
@Data
public class AddressCriteria {

  @Spec
  String county;
  
  @Spec
  String city;
}

The executed SQL will be like:

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

Combine Nested Specs

You can also declare @And or @Or on fields within the nested object to control how the result is combined with other fields. For detailed information, please refer to Specify Combining Type on Field.

Here’s an example:

@Data
public class CustomerCriteria {

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

@Data
public class AddressCriteria {

  @Spec
  String county;
  
  @Spec
  String city;
}

The executed SQL will be like:

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

1.5 - Join

Filter related entities

In a POJO, you can use @Join on a field or class to filter related entities.

Single Join

To use @Join, you first need to define the relationship in your entity. For example, a Customer entity has a one-to-many relationship with an Order entity:

@Entity
class Customer {

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

@Entity
class Order {

  private String itemName;
}

If you want to query customers who bought specific items, you can define a POJO like this:

@Data
public class CustomerOrderCriteria {

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

The executed SQL will be like:

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

@Join can also be used at the class level. In this case, all fields within the same object can use the alias to apply additional conditions to the joined entity. For example:

@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

To better align with most usage scenarios, the default behavior is as follows:

  • The default join type is INNER.
  • Duplicate results are removed (distinct).

You can modify the default behavior by configuring @Join#joinType or @Join#distinct, for example:

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

Multi Joins

You can use @Joins to define multi-level joins. For example, if the Order entity has a many-to-many relationship with a Tag 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;
}

If you want to query customers who bought items belonging to specific categories, you can define a POJO like this:

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

The executed SQL will be like:

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 can also be used at the class level, allowing all fields within the same object to use the alias for additional conditions on the joined entity, for example:

@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

Annotations are processed in order, so you must define @Joins in the correct sequence.

For example, the following definition is incorrect:

@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

The usage rules for @Join#alias are as follows:

  • It is shared within the same POJO
  • It cannot be declared multiple times within the same POJO
  • If not provided, the default alias is @Join#path
  • If it contains ., it will be replaced with _

For example:

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

1.6 - Join Fetch

Filter related entities while selecting specific columns

In a POJO, you can use @JoinFetch on a field or class to filter associated entities. The difference from @Join is that this allows fetching all lazy-related data at once.

Single Join Fetch

For example, consider a Customer entity that has a one-to-many relationship with an Order entity:

@Entity
class Customer {

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

@Entity
class Order {
  
  private String itemName;
}

If you want to fetch Order data when retrieving Customer, you can do:

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

  @Spec
  String name;
}

The executed SQL will be like:

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

You can see that orders1_.* is also included in the select fields.

Join Behavior

To better align with most usage scenarios, the default behavior is as follows:

  • The default join type is INNER.
  • Duplicate results are removed (distinct).

You can modify the default behavior by configuring @JoinFetch#joinType or @JoinFetch#distinct, for example:

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

With Clause

@JoinFetch also allows filtering data. For example, if you want to filter both customer names and order names, define a POJO as follows:

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

  @Spec
  String name;

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

The executed SQL will be like:

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 can also be used at the field level, for example:

@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

You can use @JoinFetches to define multi-level fetches. For example, if the Order entity has a many-to-many relationship with a Tag 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;
}

If you want to query customers who purchased items belonging to specific categories, define a POJO as follows:

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

The executed SQL will be like:

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 can also be used at the class level, making the aliases available across fields in the same object. For example:

@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

Annotations are processed in order, so @JoinFetches must be defined in the correct sequence.

For example, in the previous scenario, the following order is incorrect:

@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

The usage rules for @JoinFetch#alias are as follows:

  • It is shared within the same POJO
  • It cannot be declared multiple times within the same POJO
  • If not provided, the default alias is @JoinFetch#path
  • If it contains ., it will be replaced with _

For example:

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

1.7 - Customize Specs

Extend and customize query conditions

Continuing from the example in the section Extending @Spec, let’s take it a step further. Now, we want to make the entity class configurable so that it can be used for entities other than Customer.

To fulfill this requirement, we need to define additional parameters in the annotation. As a result, the Simple @Spec approach is no longer suitable. Instead, we need to define a new annotation. Here’s the complete code:

First, we define the annotation:

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

  Class<?> from();
}

Next, we need to implement the logic responsible for handling @MaxCreatedTime. We can extend it by implementing the SpecificationResolver interface:

public class MaxCreatedTimeSpecificationResolver implements SpecificationResolver {

  @Override
  public boolean supports(Databind databind) { 
    // Here, we tell the SpecMapper when to use this 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) {
    // Here, we provide an example implementation for the 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);
    };
  }
}

Next, we add this resolver to the SpecMapper during its construction:

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

Finally, we define the POJO and convert it to a 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);

The executed SQL will be like:

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

1.8 - Logging

Object-to-spec conversion process logs

Setting logging level tw.com.softleader.data.jpa.spec.SpecMapper=debug will print more details during the object-to-Spec conversion process, which can be very helpful for troubleshooting. The output will look like this:

To set tw.com.softleader.data.jpa.spec.SpecMapper to logging level debug, which prints more information during the object-to-spec conversion process:

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

From the first line of the example above, you can see that the default Logger Name is SpecMapper. This allows for centralized logging configuration. However, if you prefer to control and configure logging based on the converted object, we provide an alternative strategy: using the target object’s Logger for output.

You can adjust this strategy by configuring ASTWriterFactory, for example:

var mapper = SpecMapper.builder()
      .defaultResolvers()
      // Default is ASTWriterFactory.domain()
      .astWriterFactory(ASTWriterFactory.impersonation())
      .build();

The output will then look like this:

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

1.9 - Limitation

Constraints and considerations

When SpecMapper searches for fields in a POJO, it only looks for local fields within the current class and does not traverse the hierarchy of classes to find fields. If you have shared fields that you want to use in multiple POJOs, consider using the Nested Specs approach.

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

Check the latest version on Maven Central

The specification-mapper-starter integrates specification-mapper with Spring Data JPA and provides a way to query by specifications.

Query by Spec (QBS) is a user-friendly querying approach that allows you to dynamically build query conditions using specifications. With the QBS interface, you can execute query statements easily.

Getting Started

By adding the dependency in your pom.xml file, the specification-mapper-starter will automatically configure everything during the Spring Boot startup process, allowing you to start using it without any additional configuration. The starter includes the following features:

The auto-configuration is enabled by default, and you can control it through the spec.mapper.enabled property in your application’s properties file. To disable the auto-configuration, you can use the following configuration:

spec:
  mapper:
    enabled: false

2.1 - Query by Spec

Introduction and usage of Query by Spec (QBS)

Query by Spec (QBS) provides the QueryBySpecExecutor<T> interface, which includes several query methods:

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.
}

To use these methods, you simply need to extend QueryBySpecExecutor<T> in your existing repository interface:

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

By inheriting QueryBySpecExecutor<T>, you can directly use the query methods in your repository interface, making it easy to perform queries using specifications.

Customize Base Repository

During the configuration process, QBS automatically configures the Spring Data JPA Base Repository. The default implementation is DefaultQueryBySpecExecutor.

However, since Java only supports single inheritance, and to allow your application to retain its original parent Base Repository, QBS provides an extension point called QueryBySpecExecutorAdapter.

Depending on your application’s needs, you can choose to either extend DefaultQueryBySpecExecutor or implement QueryBySpecExecutorAdapter to customize the Base Repository. For example:

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 be 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
  }
}

You can configure your custom Base Repository by setting the spec.mapper.repository-base-class property in your application’s properties file, specifying the full package name of your custom base repository, like this:

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

2.2 - Config SpecMapper

Auto-configuration of SpecMapper

During application startup, the starter automatically configures a Default SpecMapper and registers it as a Spring @Bean, allowing you to retrieve it via Autowired.

@Autowired SpecMapper specMapper;

For example, if you want to enhance the specifications before performing the query, you can use the SpecMapper as follows:

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

In the above example, the SpecMapper is injected into the PersonService, allowing you to convert the criteria into a specification using specMapper.toSpec(). You can then modify the spec as needed before passing it to the personRepository for querying.

Configuration

The starter provides multiple ways to adjust the configuration of the Default SpecMapper.

SpecificationResolver

SpecificationResolver allows you to add custom Spec annotations. Simply register your custom implementation as a Spring @Bean, and it will be automatically detected and configured during application startup.

@Configuration
class MyConfig {

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

If your SpecificationResolver needs access to the SpecMapper itself, you can wrap it in a SpecificationResolverCodecBuilder. This way, the SpecCodec, which is the interface of SpecMapper, will be passed in when constructing the resolver. Here’s an example:

@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 be used.
    this.codec = codec;
  }
  
  // implementation goes here
}

In the above example, the MySpecificationResolver is constructed with the SpecCodec provided by the SpecMapper. This allows you to access and utilize the SpecMapper functionality within your custom resolver.

SkippingStrategy

SkippingStrategy defines rules for skipping specific fields. By registering your custom implementation as a Spring @Bean, it will be automatically detected and added to the Default SpecMapper during application startup.

Example:

@Configuration
class MyConfig {

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

ASTWriterFactory

As described in Logging, different Logger Name strategies are available. You can enable impersonation mode using the spec.mapper.impersonate-logger property. By default, this setting is disabled. To enable it:

spec:
  mapper:
    # Whether to impersonate the logger of the actual object being processed, off by default
    impersonate-logger: true

For full customization, register your own ASTWriterFactory implementation as a Spring @Bean, and it will be automatically detected and added to the Default SpecMapper.

Example:

@Configuration
class MyConfig {

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

Customize SpecMapper

You can also fully customize SpecMapper by registering your own implementation as a Spring @Bean, which will take precedence over the default configuration.

Example:

@Configuration
class MyConfig {

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

3 - Compatibility

Versions that have been tested for compatibility.

Java

  • 17
  • 21

Spring

The following shows versions with compatible Spring Boot releases.

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