2

I'm trying to implement properly search functionality for database table. I tried this approach:

Controller:

    @GetMapping
    public Page<TransactionDTO> find(TransactionFilterDTO filter, Pageable page) {
        return searchRepository
                .findTransactionsByFilter(mapper.toFilter(filter), page)
                .map(mapper::toDTO);
    }

Filer DTO:

public class TransactionFilterDTO {

    private String name;

    private Integer id;

    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
    private LocalDateTime from;

    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
    private LocalDateTime to;
    ... // getters and setter
}

Search implementation:

@Repository
public class TransactionSearchRepositoryImpl implements TransactionSearchRepository {

    @Autowired
    private TransactionRepository transactionRepository;

    @Autowired
    private TransactionSpecification specification;

    @Override
    public Page<Transaction> findTransactionsByFilter(TransactionFilter filter, @Nullable Pageable page) {


        List<Transaction> transactions = transactionRepository
                .findAll(specification.getFilter(filter));

        int totalCount = transactions.size();

        if(page != null) {
           transactions = transactions
                   .stream()
                   .skip(page.getOffset())
                   .limit(page.getPageSize())
                   .collect(Collectors.toList());
        }

        return new PageImpl<>(transactions, page, totalCount);
    }
}

Repository:

public interface TransactionSearchRepository {

    Page<Transaction> findTransactionsByFilter(TransactionFilter filter, Pageable page);
}

Is there some better way to implement a search functionality? This solution is very ugly in my view.

Peter Penzov
  • 1,126
  • 134
  • 430
  • 808
  • 2
    @PeterPenzov to implement any business logic you must be clear your use cases that you need to implement.. here you only defined what you have implemented.. Just think about different use cases and try to build `Query` using `Criteria Builder` class.. This might help u out.. – Vikrant Kashyap Apr 16 '19 at 12:21
  • 1
    in my case I have TransactionFilterDTO with several attributes. I want to search using any one of the attributes. – Peter Penzov Apr 16 '19 at 12:25
  • 2
    Well beauty is in the eye of the beholder so I do not think anyone can help you on `This solution is very ugly in my view.` – Koray Tugay Apr 18 '19 at 11:35
  • [findAll(Specification, Pageable)](https://docs.spring.io/spring-data/jpa/docs/current/api/org/springframework/data/jpa/repository/JpaSpecificationExecutor.html#findAll-org.springframework.data.jpa.domain.Specification-org.springframework.data.domain.Pageable-) on `JpaSpecificationExecutor` would be more efficient for large result sets. – df778899 Apr 19 '19 at 08:25
  • Can you show me code example, please how to implement it? – Peter Penzov Apr 19 '19 at 08:55
  • may be useful : https://github.com/vijjayy81/spring-boot-jpa-rest-demo-filter-paging-sorting – corroborator Apr 19 '19 at 11:49
  • What's wrong with the code in the question? I understand that Java might be quite verbose sometimes, but I don't see it as some kind of fix/patch to do whatever the framework doesn't let you do. Also you're using Java 8 syntax already.. It seems you're already doing it the Spring way and Java 8 way! – Aritz Apr 19 '19 at 11:53

3 Answers3

11

I can recommend several approaches to solve such a task:

1) Simple but not flexible: just use request parameters in your controller according to your filter properties (name, from, to) and prepare the corresponding query in your repo, for example:

Controller:

@GetMapping("/q")
public List<ResponseDto> getAllByQuery(
    @RequestParam(value = "name", required = false) String name,
    @RequestParam(value = "from", required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate from,
    @RequestParam(value = "to", required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate to,
    Pageable pageable
) {
    return service.getByQuery(name, from, to, pageable);
}

Service:

public Page<ResponseDto> getByQuery(String name, LocalDate from, LocalDate to, Pageable pageable) {
    return repo.getByQuery(name, from, to, pageable).map(mapper::toResponseDto);
}

Repository:

@Query("select m from MyEntity m where " +
       "(?1 is null or upper(m.name) like concat('%', upper(?1), '%')) " +
       "and (?2 is null or m.createdAt >= ?2) " +
       "and (?3 is null or m.createdAt <= ?3)")
Page<MyEntity> getByQuery(String name, final LocalDate from, final LocalDate to, final Pageable pageable);

Then perform a request:

GET http://localhost:8080/q?name=john&from=2019-04-19&to=2019-04-19

2) Using QueryDsl. You should add it to your project (you can find details here), extend your repo from QuerydslPredicateExecutor and QuerydslBinderCustomizer, add some 'tuning' to it:

public interface MyEntityRepo extends JpaRepository<MyEntity, Integer>, QuerydslPredicateExecutor<MyEntity>, QuerydslBinderCustomizer<QMyEntity> {
    @Override
    default void customize(@NonNull QuerydslBindings bindings, @NonNull QMyEntity entity) {

        // Make case-insensitive 'like' filter for all string properties
        bindings.bind(String.class).first((SingleValueBinding<StringPath, String>) StringExpression::containsIgnoreCase);

        // Add 'between' and 'greater or equal' filter date property
        bindings.bind(entity.createdAt).all((path, value) -> {
            Iterator<? extends LocalDate> it = value.iterator();
            LocalDate from = it.next();
            if (value.size() >= 2) {
                LocalDate to = it.next();
                return Optional.of(path.between(from, to)); // between
            } else {
                return Optional.of(path.goe(from)); // greater than or equal
            }
        });
    }

Add service method:

public Page<ResponseDto> getAllByQueryDsl(Predicate predicate, Pageable pageable) {
    return repo.findAll(predicate, pageable).map(mapper::toResponseDto);
}

Add controller method:

@GetMapping("/query-dsl")
public Page<ResponseDto> getAllByQueryDsl(
        @QuerydslPredicate(root = MyEntity.class, bindings = MyEntityRepo.class) Predicate predicate,
        Pageable pageable
) {
    return service.getAllByQueryDsl(predicate, pageable);
}

And add @DateTimeFormat annotation to 'Date' properties of your entity:

@Entity
public class MyEntity {
    // ...
    @DateTimeFormat(iso = ISO.DATE) private LocalDate createdAt;
}

Then you can perform such a request:

GET http://localhost:8080/query-dsl?name=john&createdAt=2019-04-15&createdAt=2019-04-19

Where the first date is 'from' parameter and the second date is 'to' parameter. If you use only one date - it will be 'from' parameter (greater than or equal).

3) Using specification-arg-resolver library. Add it to your project (see instruction: 1 and 2), then extend your repo from JpaSpecificationExecutor:

public interface MyEntityRepo extends JpaRepository<MyEntity, Integer>, JpaSpecificationExecutor<MyEntity> {}

Add such a method to your controller:

@GetMapping("/specification")
public Page<ResponseDto> getAllBySpecification(
        @And({
                @Spec(path = "name", spec = LikeIgnoreCase.class),
                @Spec(path = "createdAt", params = "from", spec = GreaterThanOrEqual.class),
                @Spec(path = "createdAt", params = "to", spec = LessThanOrEqual.class)
        }) Specification<MyEntity> specification,
        Pageable pageable
) {
    return service.getAllBySpecification(specification, pageable);
}

Update your service:

public Page<ResponseDto> getAllBySpecification(final Specification<MyEntity> specification, final Pageable pageable) {
    return repo.findAll(specification, pageable).map(mapper::toResponseDto);
}

And then request your data:

GET http://localhost:8080/specification?name=john&from=2019-04-10&to=2019-04-19

4) Building a Specification manualy:

Create a filter class:

@Data
public class MyFilter implements Specification<MyEntity> {
    private String name;
    @DateTimeFormat(iso = ISO.DATE) private LocalDate from;
    @DateTimeFormat(iso = ISO.DATE) private LocalDate to;

    @Override
    public Predicate toPredicate(Root<MyEntity> root, CriteriaQuery<?> query, CriteriaBuilder builder) {
        List<Predicate> predicates = new ArrayList<>();
        if (name != null) predicates.add(builder.like(builder.upper(root.get("name")), "%" + name.toUpperCase() + "%"));
        if (from != null) predicates.add(builder.greaterThanOrEqualTo(root.get("createdAt"), from));
        if (to != null) predicates.add(builder.lessThanOrEqualTo(root.get("createdAt"), to));
        return builder.and(predicates.toArray(new Predicate[0]));
    }
}

Create a controller method:

@GetMapping("/filter")
public Page<ResponseDto> getAllByMyFilter(MyFilter filter, Pageable pageable) {
    return service.getAllBySpecification(filter, pageable);
}

Then run the request:

GET http://localhost:8080/filter?name=john&from=2019-04-10&to=2019-04-19
Cepr0
  • 28,144
  • 8
  • 75
  • 101
  • 2
    Very good answer! Which solution will be more easy to extend into the future and easy to support in your view? – Peter Penzov Apr 19 '19 at 12:22
  • @PeterPenzov I've updated 3rd and added 4th options. I think it depends on your requirements. 1st option is very simple but bad-extendable; 2nd - extendable but complicated; 3rd - very flexible and extendable; 4th - flexible, extendable, **controllable** but requires manual coding. – Cepr0 Apr 19 '19 at 15:07
  • I tried 3) But I get `The method findAll(Example, Pageable) in the type QueryByExampleExecutor is not applicable for the arguments (Specification, Pageable)` Any idea how to fix this? – Peter Penzov Apr 19 '19 at 21:18
  • @Cepr0, very nice answer! We tried out the JpaSpecification and the `@Query(...)` solution. In the end we are now using the `@Query(...)` annotation in combination with the native query option. We are passing the search parameters as objects and with the help of `SpEL` we are able to access the values within the query. This provides us huge flexibility, but you have to be careful, because `SpEL` has some troubles dealing with longer queries... And the flexibility comes with the cost of writing every single search criteria as a SQL `WHERE` or `HAVING` ... – git-flo Apr 20 '19 at 10:35
  • @Cepr0 Can I ask additional question how to implement search with conditions, please? https://stackoverflow.com/questions/55918502/implement-search-with-params – Peter Penzov Apr 30 '19 at 10:01
  • For example in example 3 can I combine sub conditions? – Peter Penzov Apr 30 '19 at 11:50
  • @PeterPenzov What do you mean 'combine sub conditions'? (I think it's better to contact the author of the 'specification-arg-resolver' library with this question) – Cepr0 May 01 '19 at 07:11
  • @Cepr0 Just wanna say THANKS for this! You saved me N number of hours reading the docs and discovering all approaches :) Example number 4 works best for me! – Nikola Zivkovic Sep 18 '19 at 15:18
  • 1
    @NikolaZivkovic Glad to help )) Check also [this](https://stackoverflow.com/a/55978161/5380322) post... – Cepr0 Sep 18 '19 at 15:39
2

Take a look at query by example. You can get rid of some the code simply by using

transactionRepository.findAll(Example.of(transaction));

Also it supports pagination and for more advanced API check ExampleMatcher class.

lealceldeiro
  • 14,342
  • 6
  • 49
  • 80
Adrian
  • 2,984
  • 15
  • 27
0

There are several approaches to resolve this . You can implement the following solution too : Ex. In your Repository :

@Query(value="FROM Students AS student "
            + " LEFT JOIN FETCH student.school AS school"
            .......................
            ...................
            + " WHERE  LOWER(student.name) LIKE LOWER(CONCAT('%',:criteria, '%')) OR"
            + " LOWER(student.code) LIKE LOWER(CONCAT('%',:criteria, '%')) OR"
            .........................
            .........................
            .........................
            + " LOWER(school.name) LIKE LOWER(CONCAT('%',:criteria, '%')) "         
            + " ORDER BY student.name ASC",
            countQuery=" SELECT COUNT(student) from Students AS student ")
    public Page<Students> getAllStudentsBySearchCriteria(@Param("criteria")String search, Pageable pageable);

In your Service:

public Page<Students> getAll(Page  pagable) {
    return studentRepository.getAllStudentsBySearchCriteria(searchString, pageable);

}
DEBENDRA DHINDA
  • 1,163
  • 5
  • 14