In diesem Artikel werden wir versuchen einen REST-Service zu entwickeln der mit Hilfe von Spring und JPA-Specification verschiedene Ergebnisse liefert, die unter anderem gefiltert werden können.
Um diese Aufgabe zu bewältigen habe ich mich dazu entschieden, ein Gradle-basiertes Projekt mit Spring-Boot zu erstellen um mir die Aufgabe zu erleichtern.
Was benötigen wir alles?
Beginnen wir mit unseren Entitäten:
Das Beispiel soll eine vereinfachte Struktur eines “üblichen” Tracking-Systems für die Softwareentwicklung darstellen, wie es unter anderem auch in YouTrack, Jira, etc. vorzufinden ist.
Beginnen wir bei unserem User:
User.java
package at.ciit.dynamic_query.entities; import at.ciit.dynamic_query.constants.Role; import lombok.Data; import javax.persistence.*; import java.util.Set; @Data @Entity @Table(name = "USER") public class User { @Id @GeneratedValue @Column(name = "ID") private Long id; @Column(name = "FIRSTNAME") private String firstName; @Column(name = "LASTNAME") private String lastName; @Column(name = "AGE") private Integer age; @ElementCollection(targetClass = Role.class) @CollectionTable(name = "USER_ROLE", joinColumns = @JoinColumn(name = "USER_ID")) @Column(name = "ROLE") @Enumerated(value = EnumType.STRING) private Set<Role> role; }
Um unseren User auch einfach von der DB abfragen zu können benötigen wir dementsprechend ein Repository für unseren User:
UserRepo.java
package at.ciit.dynamic_query.repositories; import at.ciit.dynamic_query.entities.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; public interface UserRepo extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> { }
In unserem Fall ist es wichtig, dass wir unser UserRepository mit JpaSpecificationExecutor erweitern. Um die Funktionalitäten des benötigten Specification-APIs zu erhalten.
Um ein gewünschtes Ergebnis definieren zu können, benötigen wir einmal ein DTO welches alle notwendigen Informationen für die Anfrage enthält.
SearchDto.java
package at.ciit.dynamic_query.dto; import at.ciit.dynamic_query.constants.search.SearchResults; import lombok.Data; import java.util.List; @Data public class SearchDto { private SearchResults resultType; private List<SearchCriteriaDto> filters; }
SearchCriteraDto.java
package at.ciit.dynamic_query.dto; import at.ciit.dynamic_query.constants.search.CriteriaOperation; import at.ciit.dynamic_query.constants.search.FieldJunction; import lombok.Data; @Data public class SearchCriteriaDto { private String fieldName; private CriteriaOperation operation; private FieldJunction fieldJunction = FieldJunction.AND; private String filterValue; }
Im SearchDto definieren wir zuerst einmal den Ergebnistyp. In unserem Fall sind das unsere User.
Im SearchCriteriaDto definieren wir den Filter für unsere Ergebnisliste.
Hier eine kurze Erklärung zu den einzelnen Feldern:
Um nach mehreren Kriterien suchen zu können, enthält unser SearchDto mehrere SearchCriteriaDto.
Kommen wir zu der eigentlichen Herausforderung, welche wir mithilfe der Specifications lösen wollen.
Um die Implementierung nicht jedesmal für die jeweiligen Ergebnistypen wiederholen zu müssen, können wir eine abstrakte Klasse erstellen. Mithilfe dieser können wir uns dann später typen-sichere “FilterSearch-Komponenten” erstellen, die uns unter anderem die notwendige Specification liefern.
Sehen wir uns diese einmal im Detail an:
AbstractFilterSearch.java
package at.ciit.dynamic_query.services.filter; import at.ciit.dynamic_query.constants.search.FieldJunction; import at.ciit.dynamic_query.dto.SearchCriteriaDto; import at.ciit.dynamic_query.dto.SearchDto; import org.springframework.data.jpa.domain.Specification; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import javax.persistence.criteria.CriteriaBuilder; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; public abstract class AbstractFilterSearch<T> { //region CREATE DYN FILTER SPEC //region PUBLIC public Specification<T> createDynSpecification(SearchDto searchDto) { Specification<T> spec = Specification.where(null); if (!CollectionUtils.isEmpty(searchDto.getFilters())) { for (SearchCriteriaDto searchCriteria : searchDto.getFilters()) { if (!StringUtils.isEmpty(searchCriteria.getFilterValue())) { spec = createSingleDynFilter(spec, searchCriteria); } } } return spec; } //endregion PUBLIC //region PRIVATE private Specification<T> createSingleDynFilter(Specification<T> spec, SearchCriteriaDto searchCriteria) { if (searchCriteria != null && searchCriteria.getFieldName() != null) { switch (searchCriteria.getOperation()) { case EQUALS: return addToSpec(spec, specEqual(searchCriteria.getFieldName(), searchCriteria.getFilterValue()), searchCriteria.getFieldJunction()); case LIKE: return addToSpec(spec, specLike(searchCriteria.getFieldName(), searchCriteria.getFilterValue()), searchCriteria.getFieldJunction()); case GREATER: return addToSpec(spec, specGreater(searchCriteria.getFieldName(), searchCriteria.getFilterValue()), searchCriteria.getFieldJunction()); case GREATER_OR_EQUALS: return addToSpec(spec, specGreaterOrEquals(searchCriteria.getFieldName(), searchCriteria.getFilterValue()), searchCriteria.getFieldJunction()); case LESS: return addToSpec(spec, specLess(searchCriteria.getFieldName(), searchCriteria.getFilterValue()), searchCriteria.getFieldJunction()); case LESS_OR_EQUALS: return addToSpec(spec, specLessOrEquals(searchCriteria.getFieldName(), searchCriteria.getFilterValue()), searchCriteria.getFieldJunction()); default: throw new IllegalArgumentException(String.format("Filter operation '%s' is not supported yet.", searchCriteria.getOperation())); } } return spec; } private Specification<T> addToSpec(Specification<T> spec, Specification<T> filterCondition, FieldJunction condition) { if (spec == null) return Specification.where(filterCondition); if (filterCondition == null) return spec; switch (condition) { case OR: return spec.or(filterCondition); case AND: default: return spec.and(filterCondition); } } //endregion //endregion //region Specifications protected Specification<T> specLike(String field, String filterValue) { if (field == null || filterValue == null) return Specification.where(null); return (root, query, criteriaBuilder) -> toPredicateLike(root, criteriaBuilder, field, filterValue); } protected Specification<T> specEqual(String field, String filterValue) { if (field == null || filterValue == null) return Specification.where(null); return (root, query, criteriaBuilder) -> toPredicateEqual(root, criteriaBuilder, field, filterValue); } //Further definitions for Specification should be here. Looks similar to specLike, specEqual. //endregion //region Predicates Predicate toPredicateLike(Root<T> root, CriteriaBuilder criteriaBuilder, String fieldName, String filterValue) { return criteriaBuilder.like(root.get(fieldName), "%" + filterValue + "%"); } Predicate toPredicateEqual(Root<T> root, CriteriaBuilder criteriaBuilder, String fieldName, String filterValue) { return criteriaBuilder.equal(root.get(fieldName), filterValue); } //Further definitions for Predicate should be here. Looks similar to toPredicateLike, toPredicateEqual. //endregion }
Wie zu erkennen ist müssen wir hier unsere unterstützen CriteriaOption implementieren. Wenn wir das einmal haben, können wir sehen, dass unsere eigentliche Methode zum erstellen unserer Specification schon weit einfacher und übersichtlicher ist (createDynSpecification).
Nun können wir uns einfach unseren User spezifischen FilterSearch erstellen.
UserFilterSearch.java
package at.ciit.dynamic_query.services.filter; import at.ciit.dynamic_query.entities.User; import org.springframework.stereotype.Component; @Component public class UserFilterSearch extends AbstractFilterSearch<User> { }
Nun haben wir es fast geschafft. Jetzt benötigen wir nur mehr unseren eigentlich “Hauptservice”, der unsere unterschiedlichen Ergebnistypen zusammenfasst.
DynamicSearchService.java
package at.ciit.dynamic_query.services; import at.ciit.dynamic_query.dto.SearchDto; import at.ciit.dynamic_query.repositories.FeatureRepo; import at.ciit.dynamic_query.repositories.ProjectRepo; import at.ciit.dynamic_query.repositories.TaskRepo; import at.ciit.dynamic_query.repositories.UserRepo; import at.ciit.dynamic_query.services.filter.FeatureFilterSearch; import at.ciit.dynamic_query.services.filter.ProjectFilterSearch; import at.ciit.dynamic_query.services.filter.TaskFilterSearch; import at.ciit.dynamic_query.services.filter.UserFilterSearch; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.util.List; @Service public class DynamicSearchService { @Autowired private UserRepo userRepo; @Autowired private UserFilterSearch userFilterSearch; @Autowired private FeatureRepo featureRepo; @Autowired private FeatureFilterSearch featureFilterSearch; @Autowired private TaskRepo taskRepo; @Autowired private TaskFilterSearch taskFilterSearch; @Autowired private ProjectRepo projectRepo; @Autowired private ProjectFilterSearch projectFilterSearch; public List<?> search(SearchDto searchDto) { switch (searchDto.getResultType()) { case FEATURE: return featureRepo.findAll(featureFilterSearch.createDynSpecification(searchDto)); case PROJECT: return projectRepo.findAll(projectFilterSearch.createDynSpecification(searchDto)); case TASK: return taskRepo.findAll(taskFilterSearch.createDynSpecification(searchDto)); case USER: return userRepo.findAll(userFilterSearch.createDynSpecification(searchDto)); default: throw new IllegalArgumentException("ResultType is not supported"); } } }
Um diese Funktionalität nun auch über REST verwenden zu können müssen wir noch unseren RestController definieren.
DynamicSearchRestController.java
package at.ciit.dynamic_query.rest; import at.ciit.dynamic_query.dto.SearchDto; import at.ciit.dynamic_query.services.DynamicSearchService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController() @RequestMapping("/rest/search") public class DynamicSearchRestController { @Autowired private DynamicSearchService dynamicSearchService; @PostMapping() public ResponseEntity<?> search(@RequestBody SearchDto searchDto) { List<?> search = dynamicSearchService.search(searchDto); return ResponseEntity.ok(search); } }
Wenn wir unsere Applikation starten und unsere DB mit Daten befüllt haben können wir nun unsere erste Abfragen machen.
Suchen wir zuerst alle User die ein “a” im firstName oder lastName haben.
###GET ALL USERS with 'a' POST http://localhost:8080/rest/search Content-Type: application/json { "resultType": "USER", "filters": [ { "fieldName": "firstName", "operation": "LIKE", "fieldJunction": "OR", "filterValue": "a" }, { "fieldName": "lastName", "operation": "LIKE", "fieldJunction": "OR", "filterValue": "a" } ] }
Unser Ergebnis:
[ { "id": 1, "firstName": "Stewart", "lastName": "Barry", "age": 32, "role": [ "PRODUCT_OWNER", "LEADER" ] }, { "id": 3, "firstName": "Sandra", "lastName": "Parker", "age": 29, "role": [ "TESTER", "DEVELOPER" ] }, { "id": 5, "firstName": "Ryana", "lastName": "Hunter", "age": 33, "role": [ "STAKEHOLDER" ] } ]
Nun wollen wir alle User die jünger als 30 sind.
###GET ALL USERS younger than 30 POST http://localhost:8080/rest/search Content-Type: application/json { "resultType": "USER", "filters": [ { "fieldName": "age", "operation": "LESS", "filterValue": "30" } ] }
Unser Ergebnis: [ { "id": 2, "firstName": "Morris", "lastName": "Bobby", "age": 26, "role": [ "TESTER", "DEVELOPER" ] }, { "id": 3, "firstName": "Sandra", "lastName": "Parker", "age": 29, "role": [ "TESTER", "DEVELOPER" ] } ]
Wir haben unter anderem auch die Möglichkeit unsere Tasks abfragen zu können. Schauen wir mal welche noch in Arbeit sind.
###GET ALL TASKS in state 'progress' POST http://localhost:8080/rest/search Content-Type: application/json { "resultType": "TASK", "filters": [ { "fieldName": "state", "operation": "LIKE", "filterValue": "PROGRESS" } ] }
Ergebnis:
[ { "id": 1, "state": "PROGRESS", "title": "First Single Request over HTTP", "description": "Create a Search-service for dynamic content for single resultTypes. Support a REST-Controller for using this service", "assignee": { "id": 2, "firstName": "Morris", "lastName": "Bobby", "age": 26, "role": [ "DEVELOPER", "TESTER" ] }, "feature": { "id": 1, "state": "PROGRESS", "description": "Search-service for dynamic content for single resultType", "project": { "id": 1, "name": "DYNAMIC_FILTER_BOARD", "description": "Search for dynamic content", "leader": { "id": 1, "firstName": "Stewart", "lastName": "Barry", "age": 32, "role": [ "LEADER", "PRODUCT_OWNER" ] } } } } ]
Sind wir nicht schon fertig? Sollten wir alles vielleicht zum Testen freigeben ;)?
Natürlich ist die Implementierung weitgehend einfach gehalten und kann jederzeit erweitert und verbessert werden. Ich denke man kann aber gut erkennen, wie viel Aufwand es ist eine von außen einfache “dynamische-Query” bereitzustellen. Diese Test-Implementierung kann eine gute Grundlage für komplexere Anwendungsgebiete sein.
Es gibt noch viele Themen, die in diesem Artikel nicht behandelt wurden und diverse Funktionalitäten die eine “dynamische Query” verbessern.
Hier vielleicht noch ein Paar Themen:
Das vollständige Test-Projekt finden Sie auch auf GitHub. In diesem Projekt finden Sie unter anderem auch die Testdaten die beim Starten der Applikation automatisch in die H2-DB eingespielt werden. Die Test-Abfragen sind in den <*.http> Files.
Marcel Anibas
30.08.2020