REST Endpunkte mit Filtern (2)

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

Schreibe einen Kommentar