„Den Fortschritt verdanken wir den Nörglern. Zufriedene Menschen wünschen keine Veränderung.“
Herbert George Wells
Im vorherigen Beitrag wurde ein einfache Lösung für REST Endpunkte mit Filtern vorgestellt. Ein Kritikpunkt an der vorgestellten Implementierung ist die starke Kopplung zwischen dem JPA Repository und dem Filterable
.
Die Übersetzung der Filter
Instanzen in Specification
Instanzen erfolgte direkt am Repository
. Dies führt bei der Nutzung vieler Repository
Interfaces zu redundanten Code. Außerdem muss das Mapping zwischen Filter
und der typspezifischen Specification
explizit vorgenommen werden.
Die Entkopplung wird hier durch die Klasse FilterableSpecificationManager
vorgenommen. Sie bestimmt die Typen der jeweiligen Filterattribute und wählt die korrekte Art der Specification
aus. Möglich ist ihr dies durch die Verwendung des JPA EntityManagers und seines Metamodels.
public class FilterableSpecificationManager { private final EntityManager entityManager; public <E> Specification<E> byFilterable(Class<E> entityClass, Filterable filterable) { return new Builder<E>(entityClass, entityManager).byFilterable(filterable); } @RequiredArgsConstructor private static class Builder<T> { private final Class<T> entityClass; private final EntityManager entityManager; private static final Map<Class<?>, Function<Filter, Specification<?>>> functions = Map.ofEntries( Map.entry(Long.class, Builder::getLongSpecification), Map.entry(long.class, Builder::getLongSpecification), Map.entry(Integer.class, Builder::getIntegerSpecification), Map.entry(int.class, Builder::getIntegerSpecification), Map.entry(String.class, Builder::getStringSpecification)); private Specification<T> byFilterable(Filterable filterable) { return filterable.getFilters().stream().map(this::getSpecification).collect(JpaCollectors.toSpecification()); } private Specification<T> getSpecification(Filter filter) { Attribute<? super T, ?> attribute = entityManager.getMetamodel().entity(entityClass) .getAttribute(filter.getProperty()); PersistentAttributeType persistentAttributeType = attribute.getPersistentAttributeType(); if (persistentAttributeType != PersistentAttributeType.BASIC) { throw new IllegalArgumentException("No basic attribute type: " + filter.getProperty()); } Class<?> javaType = attribute.getJavaType(); if (javaType.isEnum()) { return getEnumSpecification(javaType, filter); } Function<Filter, Specification<?>> filterSpecificationFunction = functions.get(javaType); if (filterSpecificationFunction == null) { throw new IllegalArgumentException("Unsupported attribute type: " + filter.getProperty()); } return (Specification<T>) filterSpecificationFunction.apply(filter); } // some more code... } }
Die byFilterable
Methode des FilterableSpecificationManager
erhält zum Filterable zusätzlich die Klasse der aktuellen JPA Entity und kann damit die Filterattribute gegen die Attribute des Entity prüfen. Existiert ein Filterattribut nicht im Entity oder ist kein einfaches Attribut, dann wird eine IllegalArgumentException
geworfen.
Ansonsten wird je nach Typ eine passende getXXXSpecification
Methode aufgerufen. In dieser wird der String Wert aus dem Filter in den entsprechenden Typen der Specification
umgewandelt und dann die Specification
erzeugt.
private static <S> Specification<S> getLongSpecification(Filter filter) { long value = Long.parseLong(filter.getValue()); return (Root<S> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) -> criteriaBuilder.equal(root.get(filter.getProperty()), value); }
Für einfache Typen wie Long
, long
, Integer
, .etc. wird der Wert mit der equals
Methode des CriteriaBuilders
verglichen.
private static <S> Specification<S> getEnumSpecification(Class<?> javaType, Filter filter) { Object value = Arrays.stream(javaType.getEnumConstants()).filter(c -> c.toString().equals(filter.getValue())) .findFirst().orElseThrow(); return (Root<S> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) -> criteriaBuilder.equal(root.get(filter.getProperty()), value); }
Für Enums muss aus dem Namen auf die Enum-Konstante rückgeschlossen werden. Dafür vergleicht die getEnumSpecification
Methode den Filterwert mit der String
Darstellung der Enum-Konstanten.
private static <S> Specification<S> getStringSpecification(Filter filter) { String value = filter.getValue(); if (filter.isCaseSensitive() || isCaseSensitive(filter.getValue())) { return (Root<S> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) -> criteriaBuilder.like(root.get(filter.getProperty()), value); } return (Root<S> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) -> criteriaBuilder.like(criteriaBuilder.upper(root.get(filter.getProperty())), value.toUpperCase()); } private static boolean isCaseSensitive(String value) { return value.chars().anyMatch(c -> Character.isLowerCase(c) || Character.isUpperCase(c)); }
Etwas komplizierter ist die getStringSpecification
Methode, weil sie zwischen case-sensitive und nicht case-sensitive Vergleich unterscheidet. Für den nicht case-sensitive Vergleich werden die Werte in Großbuchstaben miteinander verglichen. Für den case-sensitive Vergleich gibt es mit der isCaseSensitive
Methode noch ein weitere Einschränkung bzw.. Optimierung. Wenn der zu filternde Wert keine Zeichen enthält, die der Groß- und Kleinschreibung unterliegen, dann wird der nicht case-sensitive Vergleich angewendet.
public Page<AncestorDto> getAll(Pageable pageable, Filterable filterable) { return ancestorRepository.findAll(manager.byFilterable(Ancestor.class, filterable), pageable).map(AncestorMapper::mapToDto); }
Zu guter Letzt muss nur noch der FilterableSpecificationManager
mit seiner byFilterable
im AncestorService
verwendet werden. Damit wird nun beim Filtern nur noch auf Specification
zurückgegriffen, die in der Ancestor
Entität existieren und die unterstützten Typen besitzen.