Spring Data Specifications czyli sposób na dynamiczne budowanie zapytań

Spring Data Specifications czyli sposób na dynamiczne budowanie zapytań

Artykuł ten bazuje na artykule https://attacomsian.com/blog/spring-data-jpa-specifications i jest jego polskojęzycznym opracowaniem. Specyfikacje JPA pozwalają na tworzenie dynamicznych zapytań do bazy danych z użyciem Criteria API. Technicznie specyfikacja JPA to interfejs z jedną metodą toPredicate() do zaimplementowania:

public interface Specification<T> {
  Predicate toPredicate(Root<T> root, CriteriaQuery query, CriteriaBuilder cb);
}

Po co stosować specyfikacje JPA? Jaki jest tego cel? Klasyczny interfejs reprezentujący repozytorium JPA wygląda następująco:

public interface DogRepository extends JpaRepository<Dog, Long> {
    Dog findByName(String name);
    List<Dog> findByColor(String color, Sort sort);
    Page<Dog> findByAgeGreaterThan(int age, Pageable pageable)
}

Wadą tego rozwiązania jest to, że:

  1. Wraz z rozwojem aplikacji ilość metod wzrasta co sprawia, że aplikacja staje się trudna w rozwoju na poziomie warstwy utrwalania.
  2. Każda z metod repozytorium ma stałą ilość parametrów które należy wykonać. Ilość tych parametrów nie może być zmieniona na etapie działania aplikacji w tzw. runtime.

Rozwiązaniem są właśnie tytułowe specyfikacje JPA! Tworzymy nowy projekt Spring Boot. Plik pom.xml niezbędne zależności:

<dependencies>
	<dependency>
		<groupId>org.projectlombok</groupId>
		<artifactId>lombok</artifactId>
		<version>1.18.16</version>
		<scope>provided</scope>
	</dependency>
	<dependency>
		<groupId>com.h2database</groupId>
		<artifactId>h2</artifactId>
		<scope>runtime</scope>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-data-jpa</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter</artifactId>
	</dependency>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-test</artifactId>
		<scope>test</scope>
	</dependency>
</dependencies>

Konfiguracja bazy danych H2:

spring.datasource.url             = jdbc:h2:mem:testdb
spring.datasource.driverClassName = org.h2.Driver
spring.datasource.username        = sa
spring.datasource.password        = password
spring.jpa.database-platform      = org.hibernate.dialect.H2Dialect

Klasa encji:

@Entity
@NoArgsConstructor
@Getter
@Setter
@ToString
public class Movie implements Serializable {
 
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
 
    public Movie(String title, String genre, double rating, double watchTime, int releaseYear) {
        this.title = title;
        this.genre = genre;
        this.rating = rating;
        this.watchTime = watchTime;
        this.releaseYear = releaseYear;
    }
 
    private String title;
    private String genre;
    private double rating;
    private double watchTime;
    private int releaseYear;
}

Interfejs repozytorium:

public interface MovieRepository extends CrudRepository<Movie, Long>, JpaSpecificationExecutor<Movie> {
}

JpaSpecificationExecutor – metody pozwalający na wykonywanie zapytań do bazy danych z użyciem JPA Criteria API.

Utwórzmy wyliczenie reprezentujące zestaw dostępnych operacji do wykonania:

public enum SearchOperation {
    GREATER_THAN,
    LESS_THAN,
    GREATER_THAN_EQUAL,
    LESS_THAN_EQUAL,
    NOT_EQUAL,
    EQUAL,
    MATCH,
    MATCH_START,
    MATCH_END,
    IN,
    NOT_IN
}

oraz klasę reprezentująca kryteria wyszukiwania:

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class SearchCriteria {
    private String key;
    private Object value;
    private SearchOperation operation;
}

gdzie:

  • key to pole encji,
  • value to wartość dla wskazanego pola encji,
  • operation reprezentuje operacje do wykonania/wyszukania.

Utwórzmy klasę implementującą interfejs Specification dostarczając implementację dla metody toPredicate(). Klasa ta pozwoli na wykonywanie (generowanie) dynamicznych zapytań do bazy danych (w naszym przypadku będzie to baza plikowa H2):

public class MovieSpecification implements Specification<Movie> {
 
    private List<SearchCriteria> searchCriteriaList;
 
    public MovieSpecification() {
        this.searchCriteriaList = new ArrayList<>();
    }
 
    public void add(SearchCriteria criteria) {
        searchCriteriaList.add(criteria);
    }
 
    @Override
    public Predicate toPredicate(Root<Movie> root, CriteriaQuery<?> query, CriteriaBuilder builder) {
 
        List<Predicate> predicates = new ArrayList<>();
 
        for (SearchCriteria criteria : searchCriteriaList) {
            if (criteria.getOperation().equals(SearchOperation.GREATER_THAN)) {
                predicates.add(
                        builder.greaterThan(
                                root.get(criteria.getKey()),
                                criteria.getValue().toString())
                );
            } else if (criteria.getOperation().equals(SearchOperation.LESS_THAN)) {
                predicates.add(
                        builder.lessThan(
                                root.get(criteria.getKey()),
                                criteria.getValue().toString())
                );
            } else if (criteria.getOperation().equals(SearchOperation.GREATER_THAN_EQUAL)) {
                predicates.add(
                        builder.greaterThanOrEqualTo(
                                root.get(criteria.getKey()),
                                criteria.getValue().toString()
                        )
                );
            } else if (criteria.getOperation().equals(SearchOperation.LESS_THAN_EQUAL)) {
                predicates.add(
                        builder.lessThanOrEqualTo(
                                root.get(criteria.getKey()),
                                criteria.getValue().toString()
                        ));
            } else if (criteria.getOperation().equals(SearchOperation.NOT_EQUAL)) {
                predicates.add(
                        builder.notEqual(root.get(
                                criteria.getKey()), criteria.getValue())
                );
            } else if (criteria.getOperation().equals(SearchOperation.EQUAL)) {
                predicates.add(
                        builder.equal(root.get(
                                criteria.getKey()), criteria.getValue()));
            } else if (criteria.getOperation().equals(SearchOperation.MATCH)) {
                predicates.add(
                        builder.like(
                                builder.lower(root.get(criteria.getKey()))
                                , "%" + criteria.getValue().toString().toLowerCase() + "%")
                );
            } else if (criteria.getOperation().equals(SearchOperation.MATCH_END)) {
                predicates.add(
                        builder.like(
                                builder.lower(root.get(criteria.getKey())),
                                criteria.getValue().toString().toLowerCase() + "%")
                );
            } else if (criteria.getOperation().equals(SearchOperation.MATCH_START)) {
                predicates.add
                        (builder.like(
                                builder.lower(root.get(criteria.getKey())),
                                "%" + criteria.getValue().toString().toLowerCase())
                        );
            } else if (criteria.getOperation().equals(SearchOperation.IN)) {
                predicates.add(
                        builder.in(
                                root.get(criteria.getKey())
                        ).value(criteria.getValue())
                );
            } else if (criteria.getOperation().equals(SearchOperation.NOT_IN)) {
                predicates.add(
                        builder.not(
                                root.get(criteria.getKey())
                        ).in(criteria.getValue()));
            }
        }
 
        return builder.and(predicates.toArray(new Predicate[0]));
    }
}

Przetestujemy aplikację:

@SpringBootApplication
public class JpaSpecificationsApplication {
 
	public static void main(String[] args) {
		SpringApplication.run(JpaSpecificationsApplication.class, args);
	}
 
	@Bean
	public CommandLineRunner specificationsDemo(MovieRepository movieRepository) {
		return args -> {
 
			// create new movies
			movieRepository.saveAll(Arrays.asList(
					new Movie("Troy", "Drama", 7.2, 196, 2004),
					new Movie("The Godfather", "Crime", 9.2, 178, 1972),
					new Movie("Invictus", "Sport", 7.3, 135, 2009),
					new Movie("Black Panther", "Action", 7.3, 135, 2018),
					new Movie("Joker", "Drama", 8.9, 122, 2018),
					new Movie("Iron Man", "Action", 8.9, 126, 2008)
			));
 
			// search movies by `genre`
			MovieSpecification msGenre = new MovieSpecification();
			msGenre.add(new SearchCriteria("genre", "Action", SearchOperation.EQUAL));
			List<Movie> msGenreList = movieRepository.findAll(msGenre);
			msGenreList.forEach(System.out::println);
 
			System.out.println("******************************************************************");
 
			// search movies by `title` and `rating` > 7
			MovieSpecification msTitleRating = new MovieSpecification();
			msTitleRating.add(new SearchCriteria("title", "black", SearchOperation.MATCH));
			msTitleRating.add(new SearchCriteria("rating", 7, SearchOperation.GREATER_THAN));
			List<Movie> msTitleRatingList = movieRepository.findAll(msTitleRating);
			msTitleRatingList.forEach(System.out::println);
 
			System.out.println("******************************************************************");
 
			// search movies by release year < 2010 and rating > 8
			MovieSpecification msYearRating = new MovieSpecification();
			msYearRating.add(new SearchCriteria("releaseYear", 2010, SearchOperation.LESS_THAN));
			msYearRating.add(new SearchCriteria("rating", 8, SearchOperation.GREATER_THAN));
			List<Movie> msYearRatingList = movieRepository.findAll(msYearRating);
			msYearRatingList.forEach(System.out::println);
 
			System.out.println("******************************************************************");
 
			// search movies by watch time >= 150 and sort by `title`
			MovieSpecification msWatchTime = new MovieSpecification();
			msWatchTime.add(new SearchCriteria("watchTime", 150, SearchOperation.GREATER_THAN_EQUAL));
			List<Movie> msWatchTimeList = movieRepository.findAll(msWatchTime, Sort.by("title"));
			msWatchTimeList.forEach(System.out::println);
 
			System.out.println("******************************************************************");
 
			// search movies by `title` <> 'white' and paginate results
			MovieSpecification msTitle = new MovieSpecification();
			msTitle.add(new SearchCriteria("title", "white", SearchOperation.NOT_EQUAL));
 
			System.out.println("******************************************************************");
 
			Pageable pageable = PageRequest.of(0, 3, Sort.by("releaseYear").descending());
			Page<Movie> msTitleList = movieRepository.findAll(msTitle, pageable);
 
			msTitleList.forEach(System.out::println);
		};
	}
 
}

Podczas wyszukiwania dnaych korzystamy z utworzonego wcześniej repozytorium za każdym razem wykonując tą samą metodę findAll gdzie przekazywane są okreslone kryteria wyszukiwania. Pozwala to na etapie wykonywania aplikacji przekazać dowolne kryteria wyszukiwania np. z formularza wypełnianego przez użytkownika. Pamiętajmy, że użytkownik nie zawsze może wypełnić wszystkie pola formularza dlatego elastyczny mechanizm wyszukiwania rekordów z bazy danych jest niezbędny.

Wynik działa aplikacji:

Movie(id=4, title=Black Panther, genre=Action, rating=7.3, watchTime=135.0, releaseYear=2018)
Movie(id=6, title=Iron Man, genre=Action, rating=8.9, watchTime=126.0, releaseYear=2008)
******************************************************************
Movie(id=4, title=Black Panther, genre=Action, rating=7.3, watchTime=135.0, releaseYear=2018)
******************************************************************
Movie(id=2, title=The Godfather, genre=Crime, rating=9.2, watchTime=178.0, releaseYear=1972)
Movie(id=6, title=Iron Man, genre=Action, rating=8.9, watchTime=126.0, releaseYear=2008)
******************************************************************
Movie(id=2, title=The Godfather, genre=Crime, rating=9.2, watchTime=178.0, releaseYear=1972)
Movie(id=1, title=Troy, genre=Drama, rating=7.2, watchTime=196.0, releaseYear=2004)
******************************************************************
******************************************************************
Movie(id=5, title=Joker, genre=Drama, rating=8.9, watchTime=122.0, releaseYear=2018)
Movie(id=4, title=Black Panther, genre=Action, rating=7.3, watchTime=135.0, releaseYear=2018)
Movie(id=3, title=Invictus, genre=Sport, rating=7.3, watchTime=135.0, releaseYear=2009)

Możliwe jest także łączenie specyfikacji w jedno zapytanie do bazy danych:

// combine using `AND` operator
System.out.println("[AND] ******************************************************************");
List<Movie> moviesCombineAndOperator = movieRepository.findAll(Specification.where(msTitle).and(msYearRating));
System.out.println(moviesCombineAndOperator);
System.out.println("[AND] ******************************************************************");
 
// combine using `OR` operator
System.out.println("[OR] ******************************************************************");
List<Movie> moviesCombineOrOperator = movieRepository.findAll(Specification.where(msTitle).or(msYearRating));
System.out.println(moviesCombineOrOperator);
System.out.println("[OR] ******************************************************************");

wynik:

[AND] ******************************************************************
[Movie(id=2, title=The Godfather, genre=Crime, rating=9.2, watchTime=178.0, releaseYear=1972),
 Movie(id=6, title=Iron Man, genre=Action, rating=8.9, watchTime=126.0, releaseYear=2008)]
[AND] ******************************************************************
[OR] ******************************************************************
 [Movie(id=1, title=Troy, genre=Drama, rating=7.2, watchTime=196.0, releaseYear=2004), 
 Movie(id=2, title=The Godfather, genre=Crime, rating=9.2, watchTime=178.0, releaseYear=1972), 
 Movie(id=3, title=Invictus, genre=Sport, rating=7.3, watchTime=135.0, releaseYear=2009), 
 Movie(id=4, title=Black Panther, genre=Action, rating=7.3, watchTime=135.0, releaseYear=2018), 
 Movie(id=5, title=Joker, genre=Drama, rating=8.9, watchTime=122.0, releaseYear=2018), 
 Movie(id=6, title=Iron Man, genre=Action, rating=8.9, watchTime=126.0, releaseYear=2008)]
[OR] ******************************************************************

Zobacz kod na GitHubie i zapisz się na bezpłatny newsletter!

.

Leave a comment

Your email address will not be published.


*