Zaawansowane i dynamiczne wyszukiwanie za pomocą Spring Data JPA

Często jestem proszony o opracowanie zaawansowanych usług wyszukiwania. Przez zaawansowane wyszukiwanie rozumiem wyszukiwanie, w którym możliwe jest zastosowanie wielu filtrów do wszystkich (lub prawie wszystkich) pól, takich jak like, between, in, greater than itp.

Proszę więc wyobrazić sobie konieczność zbudowania usługi opartej na jednej lub kilku jednostkach zdolnych do oferowania punktu końcowego, który można nazwać w ten sposób (proszę zacząć zwracać uwagę na specjalne sufiksy <propertyName><_suffix>):

curl - request GET \
 - url 'http://www.myexampledomain.com/persons?
firstName=Biagio
&lastName_startsWith=Toz
&birthDate_gte=19910101
&country_in=IT,FR,DE
&company.name_in=Microsoft,Apple
&company.employees_between=500,5000'

Lub

curl --request GET \
--url 'http://www.myexampledomain.com/persons?
firstName_endsWith=gio
&lastName_in=Tozzi,Totti
&birthDate_lt=19980101
&_offset=0
&_limit=100
&birthDate_sort=ASC'

Jeśli używają Państwo JPA w projekcie Spring Boot, mogą Państwo teraz opracować tę usługę wyszukiwania za pomocą zaledwie kilku wierszy kodu, dzięki aplikacji JPA Search Helper! Proszę pozwolić mi wyjaśnić, co to jest.

JPA Search Helper

Pierwszy krok: Adnotacja @Searchable

Proszę zacząć od zastosowania @Searchable adnotacji do pól w Państwa DTO lub alternatywnie w Państwa encji JPA, które mają być dostępne do wyszukiwania.

@Data
public class Person {

    @Searchable
    private String firstName;

    @Searchable
    private String lastName;

    @Searchable(entityFieldKey = "dateOfBirth")
    private Date birthDate;

    @Searchable
    private String country;
    
    private Company company;

    @Data
    public static class Company {
        
        @Searchable(entityFieldKey=companyEntity.name)
        private String name;
              
        @Searchable(entityFieldKey=companyEntity.employeesCount)
        private int employees;

    }

}

Adnotacja umożliwia określenie:

  • Podstawowe właściwości
    • entityFieldKey: Nazwa pola zdefiniowanego w encji bean (nie należy podawać, jeśli używana jest adnotacja w encji bean). Jeśli nie określono, kluczem będzie nazwa pola.
    • targetType: Typ obiektu zarządzanego przez podmiot. Jeśli nie zostanie określony, biblioteka spróbuje uzyskać go na podstawie typu pola (np. Integer pole bez definicji typu docelowego będzie INTEGER). Jeśli nie ma typu zgodnego z tymi zarządzanymi, będzie on zarządzany jako ciąg znaków. Typy zarządzane: STRING, INTEGER, DOUBLE, FLOAT, LONG, BIGDECIMAL, BOOLEAN, DATE, LOCALDATE, LOCALDATETIME, LOCALTIME, OFFSETDATETIME, OFFSETTIME.
  • Właściwości walidacji
    • datePattern: Tylko dla DATA typ docelowy. Określa wzorzec daty do użycia.
    • maxSize, minSize: Maksymalna/minimalna długość wartości.
    • maxDigits, minDigits: Tylko dla typów numerycznych. Maksymalna/minimalna liczba cyfr.
    • regexPattern: Regex pattern.
    • decimalFormat: Tylko dla dziesiętnych typów liczbowych. Domyślnie #.##.

Kontynuując przykład, nasze klasy encji:

@Entity
@Data
public class PersonEntity {

    @Id
    private Long id;

    @Column(name = "FIRST_NAME")
    private String firstName;

    @Column(name = "LAST_NAME")
    private String lastName;

    @Column(name = "BIRTH_DATE")
    private Date dateOfBirth;

    @Column(name = "COUNTRY")
    private String country;
        
    @OneToOne
    private CompanyEntity companyEntity;

}

@Entity
@Data
public class CompanyEntity {

    @Id
    private Long id;

    @Column(name = "NAME")
    private String name;

    @Column(name = "COUNT")
    private Integer employeesCount;

}

Drugi i ostatni krok: JPASearchRepository<?>

Państwa repozytorium Spring JPA musi rozszerzać JPASearchRepository<?>:

@Repository
public interface PersonRepository extends JpaRepository<PersonEntity, Long>, JPASearchRepository<PersonEntity> {

}

Cóż, zbudujmy filtry i prześlijmy je do repozytorium:

// ...

Map<String, String> filters = new HashMap<>();
filters.put("firstName_eq", "Biagio");
filters.put("lastName_startsWith", "Toz");
filters.put("birthDate_gte", "19910101"); 
filters.put("country_in", "IT,FR,DE");
filters.put("company.name_in", "Microsoft,Apple");
filters.put("company.employees_between", "500,5000");

// Without pagination
List<PersonEntity> fullSearch = personRepository.findAll(filters, Person.class);

filters.put("birthDate_sort" : "ASC");
filters.put("_limit", "10");
filters.put("_offset", "0");

// With pagination
Page<PersonEntity> sortedAndPaginatedSearch = personRepository.findAllWithPaginationAndSorting(filters, Person.class);

// ...

Zasadniczo wystarczy zdefiniować mapę, której klucz składa się z <fieldName><_suffix> i wartość wyszukiwania. Pełna lista sufiksów, tj. dostępnych filtrów, to tutaj.

Uwaga 1: Jeśli nie określono przyrostka, wyszukiwanie odbywa się w równych (_eq)

Uwaga 2: W przykładzie zastosowałem adnotację @Searchable do pól DTO. Alternatywnie można zastosować je bezpośrednio na encji.

Pseudo-rzeczywista implementacja w projekcie Spring Boot

Service/Manager bean:

@Service 
public class PersonManager {     
        
    @Autowired         
    private PersonRepository personRepository;
                     
    public List<Person> find(Map<String, String> filters) {
      return personRepository.findAllWithPaginationAndSorting(filters, Person.class).stream().map(this::toDTO).toList(); 
    } 

    private static Person toDTO(PersonEntity personEntity) {
        // ...
    }

}

Kontroler:

@RestController
public class MyController {
    
    @Autowired         
    private PersonManager personManager;
         
    @GetMapping(path="/persons", produces = MediaType.APPLICATION_JSON_VALUE)  
    public List<Person> findPersons(@RequestParam Map<String, String> requestParams) {  
        return personManager.find(requestParams);  
    }
}

..et voilà les jeux sont faits

Extra

Biblioteka umożliwia wymuszenie dołączenia do pobierania.

A “fetch” join umożliwia inicjalizację asocjacji lub kolekcji wartości wraz z ich obiektami nadrzędnymi za pomocą pojedynczego wyboru.

W ten sposób:

// ...

Map<String, JoinFetch> fetches = Map.of("companyEntity", JoinFetch.LEFT);
personRepository.findAll(filters, Person.class, fetches);

// ...

To wszystko… na razie!