spring-data-search-report

Spring Data Search Reports

License

License

Categories

Categories

Data Search Business Logic Libraries
GroupId

GroupId

com.weedow
ArtifactId

ArtifactId

spring-data-search-report
Last Version

Last Version

0.0.1
Release Date

Release Date

Type

Type

pom
Description

Description

spring-data-search-report
Spring Data Search Reports

Download spring-data-search-report

How to add to project

<!-- https://jarcasting.com/artifacts/com.weedow/spring-data-search-report/ -->
<dependency>
    <groupId>com.weedow</groupId>
    <artifactId>spring-data-search-report</artifactId>
    <version>0.0.1</version>
    <type>pom</type>
</dependency>
// https://jarcasting.com/artifacts/com.weedow/spring-data-search-report/
implementation 'com.weedow:spring-data-search-report:0.0.1'
// https://jarcasting.com/artifacts/com.weedow/spring-data-search-report/
implementation ("com.weedow:spring-data-search-report:0.0.1")
'com.weedow:spring-data-search-report:pom:0.0.1'
<dependency org="com.weedow" name="spring-data-search-report" rev="0.0.1">
  <artifact name="spring-data-search-report" type="pom" />
</dependency>
@Grapes(
@Grab(group='com.weedow', module='spring-data-search-report', version='0.0.1')
)
libraryDependencies += "com.weedow" % "spring-data-search-report" % "0.0.1"
[com.weedow/spring-data-search-report "0.0.1"]

Dependencies

compile (3)

Group / Artifact Type Version
com.weedow : spring-data-search-core jar 0.0.1
org.jetbrains.kotlin : kotlin-reflect jar
org.jetbrains.kotlin : kotlin-stdlib-jdk8 jar

test (1)

Group / Artifact Type Version
org.springframework.boot : spring-boot-starter-test jar 2.2.6.RELEASE

Project Modules

There are no modules declared in this project.

Spring Data Search

Spring Data Search

About

Spring Data Search allows to automatically expose endpoints in order to search for data related to JPA entities.

Spring Data Search provides an advanced search engine that does not require the creation of JPA repositories with custom methods needed to search on different fields of JPA entities.

We can search on any field, combine multiple criteria to refine the search, and even search on nested fields.

Query GIF

Why use Spring Data Search?

Spring Data Rest builds on top of the Spring Data repositories and automatically exports those as REST resources.

However, when we want to search for JPA entities according to different criteria, we need to define several methods in the Repositories to perform different searches.

Moreover, by default REST endpoints return JPA Entities content directly to the client, without mapping with a dedicated DTO class.
We can use Projections on Repositories, but this means that from the architecture level, we strongly associate the infrastructure layer with the application layer.

Spring Data Search allows to easily expose an endpoint for a JPA entity and thus be able to search on any fields of this entity, to combine several criteria and even search on fields belonging to sub-entities.

Let's say you manage Persons associated with Addresses, Vehicles and a Job.
You want to allow customers to search for them, regardless of the search criteria:

  • Search for Persons whose first name is "John" or "Jane"
  • Search for Persons whose company where they work is "Acme", and own a car or a motorbike
  • Search for Persons who live in London

You could create a Repository with custom methods to perform all these searches, and you could add new custom methods according to the needs.

Alternatively, you can use Spring Data Search which allows you to perform all these searches with a minimum configuration, without the need of a custom Repository. If you want to do other different searches, you do not need to add new methods to do that.

Build

GitHub repo size GitHub code size in bytes

Build Libraries.io dependency status for GitHub repo

Code Coverage Sonar Quality Gate Sonar Tech Debt Sonar Violations

Built with:

Getting Started

Prerequisites

  • JDK 8 or more.
  • Spring Boot

Installation

GitHub release (latest by date including pre-releases) Downloads Maven Central

  • You can download the latest release.
  • If you have a Maven project, you can add the following dependency in your pom.xml file:
    <dependency>
        <groupId>com.weedow</groupId>
        <artifactId>spring-data-search</artifactId>
        <version>1.0.1</version>
    </dependency>
  • If you have a Gradle project, you can add the following dependency in your build.gradle file:
    implementation "com.weedow:spring-data-search:1.0.1"

Getting Started in 5 minutes

  • Go to https://start.spring.io/
  • Generate a new Java project sample-app-java with the following dependencies:
    • Spring Web
    • Spring Data JPA
    • H2 Database start.spring.io
  • Update the generated project by adding the dependency of Spring Data Search:
    • For Maven project, add the dependency in the pom.xml file:
    <dependency>
      <groupId>com.weedow</groupId>
      <artifactId>spring-data-search</artifactId>
      <version>1.0.1</version>
    </dependency>
    • For Gradle project, add the dependency in the build.gradle file:
    implementation "com.weedow:spring-data-search:1.0.1"
  • Create a new file Person.java to add a new JPA Entity Person with the following content:
    import javax.persistence.*;
    import java.time.LocalDateTime;
    import java.util.Set;
    
    @Entity
    public class Person {
    
        @Id
        @GeneratedValue
        private Long id;
    
        @Column(nullable = false)
        private String firstName;
    
        @Column(nullable = false)
        private String lastName;
    
        @Column(unique = true, length = 100)
        private String email;
    
        @Column
        private LocalDateTime birthday;
    
        @Column
        private Double height;
    
        @Column
        private Double weight;
    
        @ElementCollection(fetch = FetchType.EAGER)
        private Set<String> nickNames;
    
        @ElementCollection
        @CollectionTable(name = "person_phone_numbers", joinColumns = {@JoinColumn(name = "person_id")})
        @Column(name = "phone_number")
        private Set<String> phoneNumbers;
    
        public Long getId() {
            return id;
        }
    
        public Person setId(Long id) {
            this.id = id;
            return this;
        }
    
        public String getFirstName() {
            return firstName;
        }
    
        public String getLastName() {
            return lastName;
        }
    
        public String getEmail() {
            return email;
        }
    
        public LocalDateTime getBirthday() {
            return birthday;
        }
    
        public Double getHeight() {
            return height;
        }
    
        public Double getWeight() {
            return weight;
        }
    
        public Set<String> getNickNames() {
            return nickNames;
        }
    
        public Person setNickNames(Set<String> nickNames) {
            this.nickNames = nickNames;
            return this;
        }
    
        public Set<String> getPhoneNumbers() {
            return phoneNumbers;
        }
    
        public Person setPhoneNumbers(Set<String> phoneNumbers) {
            this.phoneNumbers = phoneNumbers;
            return this;
        }
    
        public boolean equals(Object object) {
            if (this == object) {
                return true;
            }
            if (object == null || getClass() != object.getClass()) {
                return false;
            }
            if (!super.equals(object)) {
                return false;
            }
    
            Person person = (Person) object;
    
            if (!firstName.equals(person.firstName)) {
                return false;
            }
            if (!lastName.equals(person.lastName)) {
                return false;
            }
    
            return true;
        }
    
        public int hashCode() {
            int result = super.hashCode();
            result = 31 * result + firstName.hashCode();
            result = 31 * result + lastName.hashCode();
            return result;
        }
    }
  • Add the following Configuration class to add a new SearchDescriptor:
    import com.example.sampleappjava.entity.Person;
    import com.weedow.spring.data.search.config.SearchConfigurer;
    import com.weedow.spring.data.search.descriptor.SearchDescriptor;
    import com.weedow.spring.data.search.descriptor.SearchDescriptorBuilder;
    import com.weedow.spring.data.search.descriptor.SearchDescriptorRegistry;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class SampleAppJavaConfiguration implements SearchConfigurer {
    
        @Override
        public void addSearchDescriptors(SearchDescriptorRegistry registry) {
            registry.addSearchDescriptor(personSearchDescriptor());
        }
    
        private SearchDescriptor<Person> personSearchDescriptor() {
            return new SearchDescriptorBuilder<Person>(Person.class).build();
        }
    }
  • Create a new file data.sql in /src/main/resources, and add the following content:
    INSERT INTO PERSON (id, first_name, last_name, email, birthday, height, weight)
        VALUES (1, 'John', 'Doe', '[email protected]', '1981-03-12 10:36:00', 174.0, 70.5);
    INSERT INTO PERSON (id, first_name, last_name, email, birthday, height, weight)
        VALUES (2, 'Jane', 'Doe', '[email protected]', '1981-11-26 12:30:00', 165.0, 68.0);
    
    INSERT INTO PERSON_PHONE_NUMBERS (person_id, phone_number) VALUES (1, '+33612345678');
    INSERT INTO PERSON_PHONE_NUMBERS (person_id, phone_number) VALUES (2, '+33687654321');
    
    INSERT INTO PERSON_NICK_NAMES (person_id, nick_names) VALUES (1, 'Johnny');
    INSERT INTO PERSON_NICK_NAMES (person_id, nick_names) VALUES (1, 'Joe');
  • Run the application:
    • For Maven Project: ./mvnw spring-boot:run
    • For Gradle Project: ./gradlew bootRun
    • From your IDE: Run the Main Class com.example.sampleappjava.SampleAppJavaApplication
  • Open your browser and go to the URL http://localhost:8080/search/person find-all-persons
  • You can filter the results by adding query parameters representing the JPA Entity fields:
    Here is an example where the results are filtered by the first name: find-person-by-firstname

Usage

The examples in this section are based on the following entity model:

The Person.java Entity has relationships with the Address.java Entity, the Job.java Entity and the Vehicle.java Entity. Here are the entities:

@Entity
public class Person {

    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false)
    private String firstName;

    @Column(nullable = false)
    private String lastName;

    @Column
    private LocalDateTime birthday;

    @Column
    private Double height;

    @Column
    private Double weight;

    @ElementCollection(fetch = FetchType.EAGER)
    private Set<String> nickNames;

    @ElementCollection
    @CollectionTable(name = "person_phone_numbers", joinColumns = {@JoinColumn(name = "person_id")})
    @Column(name = "phone_number")
    private Set<String> phoneNumbers;

    @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    @JoinTable(name = "person_address", joinColumns = {JoinColumn(name = "personId")}, inverseJoinColumns = {JoinColumn(name = "addressId")})
    @JsonIgnoreProperties("persons")
    private Set<Address> addressEntities;

    @OneToOne(mappedBy = "person", cascade = CascadeType.ALL, orphanRemoval = true)
    private Job jobEntity;

    @OneToMany(mappedBy = "person", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<Vehicle> vehicles;

    @ElementCollection
    @CollectionTable(
            name = "characteristic_mapping",
            joinColumns = {@JoinColumn(name = "person_id", referencedColumnName = "id")})
    @MapKeyColumn(name = "characteristic_name")
    @Column(name = "value")
    private Map<String, String> characteristics;

    // Getters/Setters
}

@Entity
public class Address {

    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false)
    private String street;

    @Column(nullable = false)
    private String city;

    @ManyToOne(optional = false)
    private String zipCode;

    @Enumerated(EnumType.STRING)
    private CountryCode country;

    @ManyToMany(mappedBy = "addressEntities")
    @JsonIgnoreProperties("addressEntities")
    private Set<Person> persons;

    // Getters/Setters
}


@Entity
public class Job {

    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false)
    private String title;
    
    @Column(nullable = false)
    private String company;
    
    @Column(nullable = false)
    private Integer salary;
    
    @Column(nullable = false)
    private OffsetDateTime hireDate;
    
    @OneToOne(optional = false)
    private Person person;

    // Getters/Setters

}

@Entity
public class Vehicle {

    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false)
    @Enumerated(EnumType.STRING)
    private VehicleType vehicleType;

    @Column(nullable = false)
    private String brand;

    @Column(nullable = false)
    private String model;

    @ManyToOne(optional = false)
    private String person;

    @OneToMany(cascade = {CascadeType.ALL})
    @JoinTable(name = "feature_mapping",
            joinColumns = {@JoinColumn(name = "vehicle_id", referencedColumnName = "id")},
            inverseJoinColumns = {@JoinColumn(name = "feature_id", referencedColumnName = "id")})
    @MapKey(name = "name") // Feature name
    private Map<String, Feature> features;

    // Getters/Setters
}

@Entity
public class Feature {

    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false, unique = true)
    private String name;

    @Column(nullable = false)
    private String description;

    @ElementCollection
    @CollectionTable(
            name = "metadata_mapping",
            joinColumns = {@JoinColumn(referencedColumnName = "id", name = "feature_id")}
    )
    @MapKeyColumn(name = "metadata_name")
    @Column(name = "value")
    private Map<String, String> metadata;

    // Getters/Setters
}

public enum VehicleType {
    CAR, MOTORBIKE, SCOOTER, VAN, TRUCK
}

Standard Query

You can search for entities by adding query parameters representing entity fields to the search URL.

To search on nested fields, you must concatenate the deep fields separated by the dot '.'.
Example: The Person Entity contains a property of the Address Entity that is named addressEntities, and we search for Persons who live in 'Paris':
/search?addressEntities.city=Paris

To search on fields with a Map type, you have to use the special keys key or value to query the keys or values respectively. Example: The Person Entity contains a property of type Map that is named characteristics, and we search for Persons who have 'blue eyes':
/search?characteristics.key=eyes&characteristics.value=blue

This mode is limited to the use of the AND operator between each field criteria.
Each field criteria is limited to the use of the EQUALS operator and the IN operator.

What you want to query Example
Persons with the firstName is 'John' /search?firstName=John
Persons with the firstName is 'John' or 'Jane'
This will be result from a query with an IN operator
/search?firstName=John&firstName=Jane
Persons with the firstName is 'John' and lastName is 'Doe' /search?firstName=John&lastName=Doe
Persons whose the vehicle brand is 'Renault' /search/person?vehicles.brand=Renault
Persons whose the vehicle brand is 'Renault' and the job company is 'Acme' /search/person?vehicles.brand=Renault&jobEntity.company=Acme
Persons with the firstName is 'John' or 'Jane', and the vehicle brand is 'Renault' and the job company is 'Acme' /search?firstName=John&firstName=Jane&vehicles.brand=Renault&jobEntity.company=Acme
Persons who have a vehicle with 'GPS'
This will be result from a query on the feature field of type Map
/search?vehicles.features.value.name=gps
Persons with the birthday is 'null' /search?birthday=null
Persons who don't have jobs /search?jobEntity=null
Persons who have a vehicle without defined feature in database /search?vehicles.features=null

Advanced Query

You can search for entities by using the query string query.

query supports a powerful query language to perform advanced searches for the JPA Entities.

You can combine logical operators and operators to create complex queries.

The value types are the following:

  • String: must be surrounded by single quotes or double quotes.
    Example: firstName='John', firstName="John"
  • Number: could be an integer or a decimal number.
    Example: height=174, height=175.2
  • Boolean: could be true or false and is case-insensitive Example: active=true, active=FALSE

Note: The examples use the unencoded 'query' parameter, where firstName = 'John' is encoded as firstName+%3d+%27John%27.

Remember to manage this encoding when making requests from your code.

  1. Equals operator =
What you want to query Example
Persons with the first name 'John' /person?query=firstName='John'
Persons with the birthday equals to the given LocalDateTime /person?query=birthday='1981-03-12T10:36:00'
Persons with the hire date equals to the given OffsetDateTime /person?query=job.hireDate='2019-09-01T09:00:00Z'
Persons who own a car (VehicleType is an Enum) /person?query=vehicle.vehicleType='CAR'
Persons who are 1,74 m tall /person?query=height=174
Persons who are actively employed /person?query=job.active=true
Persons who have brown hair
It uses a field of Map type
/person?query=characteristics.key=hair AND characteristics.value=brown
  1. Not Equals operator !=
What you want to query Example
Persons who are not named 'John' /person?query=firstName!='John'
Persons with the birthday not equals to the given LocalDateTime /person?query=birthday!='1981-03-12T10:36:00'
Persons with the hire date not equals to the given OffsetDateTime /person?query=job.hireDate!='2019-09-01T09:00:00Z'
Persons who don't own a car (VehicleType is an Enum) /person?query=vehicle.vehicleType!='CAR'
Persons who are not 1,74 m tall /person?query=height!=174
Persons who are not actively employed /person?query=job.active!=true
  1. Less than operator <
What you want to query Example
Persons who were born before the given LocalDateTime /person?query=birthday<'1981-03-12T10:36:00'
Persons who are hired before the given OffsetDateTime /person?query=job.hireDate<'2019-09-01T09:00:00Z'
Persons who are smaller than 1,74 m /person?query=height<174
  1. Less than or equals operator <=
What you want to query Example
Persons who were born before or on the given LocalDateTime /person?query=birthday<='1981-03-12T10:36:00'
Persons who are hired before or on the given OffsetDateTime /person?query=job.hireDate<='2019-09-01T09:00:00Z'
Persons who are smaller than or equal to 1,74 m /person?query=height<=174
  1. Greater than operator >
What you want to query Example
Persons who were born after the given LocalDateTime /person?query=birthday>'1981-03-12T10:36:00'
Persons who are hired after the given OffsetDateTime /person?query=job.hireDate>'2019-09-01T09:00:00Z'
Persons who are taller than 1,74 m /person?query=height>174
  1. Greater than or equals operator >=
What you want to query Example
Persons who were born after or on the given LocalDateTime /person?query=birthday>='1981-03-12T10:36:00'
Persons who are hired after or on the given OffsetDateTime /person?query=job.hireDate>='2019-09-01T09:00:00Z'
Persons who are taller than or equal to 1,74 m /person?query=height>=174
  1. Matches operator MATCHES

Use the wildcard character * to match any string with zero or more characters.

What you want to query Example
Persons with the first name starting with 'Jo' /person?query=firstName MATCHES 'Jo*'
Persons with the first name ending with 'hn' /person?query=firstName MATCHES '*hn'
Persons with the first name containing 'oh' /person?query=firstName MATCHES '*oh*'
Persons with the first name that does not start with 'Jo' /person?query=firstName NOT MATCHES 'Jo*'
  1. Case-insensitive matches operator IMATCHES

This operator has the same behaviour as 'MATCHES' except that it is not case-sensitive.

Use the wildcard character * to match any string with zero or more characters.

What you want to query Example
Persons with the first name starting with 'JO', ignoring case-sensitive /person?query=firstName IMATCHES 'JO*'
Persons with the first name ending with 'HN', ignoring case-sensitive /person?query=firstName IMATCHES '*HN'
Persons with the first name containing 'OH', ignoring case-sensitive /person?query=firstName IMATCHES '*OH*'
Persons with the first name that does not start with 'JO', ignoring case-sensitive /person?query=firstName NOT IMATCHES 'JO*'
  1. ÌN operator
What you want to query Example
Persons who are named 'John' or 'Jane' /person?query=firstName IN ('John', 'Jane')
Persons with the height is one the given values /person?query=height IN (168, 174, 185)
Persons who own one of the given vehicle types (VehicleType is an Enum) /person?query=vehicle.vehicleType IN ('CAR', 'MOTORBIKE', 'TRUCK')
Persons who are not named 'John' or 'Jane' /person?query=firstName NOT IN ('John', 'Jane')
  1. NULL comparison
What you want to query Example
Persons with the birthday is 'null' /person?query=birthday=null
/person?query=birthday IS NULL
Persons with the birthday is not 'null' /person?query=birthday!=null
/person?query=birthday IS NOT NULL
Persons who don't have jobs /person?query=job=null
/person?query=job IS NULL
Persons who have jobs /person?query=job!=null
/person?query=job IS NOT NULL
  1. AND logical operator
What you want to query Example
Persons with the first name 'John', with blue eyes, with a height greater than 1,60 m, the birthday is the given LocalDateTime and who are actively employed /person?query=firstName='John' AND characteristics.key='eyes' AND characteristics.value='blue' AND height > 160 and birthday='1981-03-12T10:36:00' AND job.active=true
  1. OR logical operator
What you want to query Example
Persons who are named 'John' or 'Jane' /person?query=firstName='John' OR firstName='Jane'
Persons with the height is 1,68 m, 1,74 m or 1,85 m /person?query=height=168 OR height=174 OR height=185
Persons who own a car or a motorbike (VehicleType is an Enum) /person?query=vehicle.vehicleType='CAR' OR vehicle.vehicleType='MOTORBIKE'
  1. NOT operator
What you want to query Example
Persons with the first name is not 'John' or 'Jane' /person?query=NOT (firstName='John' OR firstName='Jane')
Persons who don't live in France and is not actively employed /person?query=NOT (address.country='FR' AND job.active=true
Persons who don't own a car (VehicleType is an Enum) /person?query=NOT vehicle.vehicleType='CAR'
  1. Parentheses

The precedence of operators determines the order of evaluation of terms in an expression.

AND operator has precedence over the OR operator.

To override this order and group terms explicitly, you can use parentheses.

What you want to query Example
Persons who are named 'John' or 'Jane', and own a car or a motorbike /person?query=(firstName='John' OR firstName='Jane') AND (vehicle.vehicleType='CAR' OR vehicle.vehicleType='MOTORBIKE')
  1. Nested fields

To search on nested fields, you must concatenate the deep fields separated by the dot '.'.
Example: The Person Entity contains a property of the Address Entity that is named addressEntities, and we search for Persons who live in 'Paris':
/search?addressEntities.city='Paris'

What you want to query Example
Persons who own a car /person?query=vehicle.vehicleType='CAR'
Persons who live in 'France' or in Italy /person?query=address.country='FR' OR address.country='IT'
Persons who work job company is Acme and are actively employed /person?query=job.company='Acme' AND job.active=true

Features

Javadoc

javadoc

Search Descriptor

The Search Descriptors allow exposing automatically search endpoints for JPA Entities.
The new endpoints are mapped to /search/{searchDescriptorId} where searchDescriptorId is the ID defined for the SearchDescriptor.

Note: You can change the default base path /search. See Changing the Base Path.

The easiest way to create a Search Descriptor is to use the com.weedow.spring.data.search.descriptor.SearchDescriptorBuilder which provides every options available to configure a SearchDescriptor.

Configure a new Search Descriptor

You have to add the SearchDescriptors to the Spring Data Search Configuration to expose the JPA Entity endpoint:

  • Implement the com.weedow.spring.data.search.config.SearchConfigurer interface and override the addSearchDescriptors method:

    @Configuration
    public class SearchDescriptorConfiguration implements SearchConfigurer {
    
        @Override
        public void addSearchDescriptors(SearchDescriptorRegistry registry) {
            SearchDescriptor searchDescriptor = new SearchDescriptorBuilder<Person>(Person.class).build();
            registry.addSearchDescriptor(searchDescriptor);
        }
    }
  • Another solution is to add a new @Bean. This solution is useful when you want to create a SearchDescriptor which depends on other Beans:

    @Configuration
    public class SearchDescriptorConfiguration {
        @Bean
        SearchDescriptor<Person> personSearchDescriptor(PersonRepository personRepository) {
            return new SearchDescriptorBuilder<Person>(Person.class)
                       .jpaSpecificationExecutor(personRepository)
                       .build();
        }
    }
  • If the SearchDescriptor Bean is declared without a specific JpaSpecificationExecutor, an exception may be thrown if the SearchDescriptor Bean is initialized before JpaSpecificationExecutorFactory. In this case, @DependsOn must be used to prevent the exception:

    @Configuration
    public class SearchDescriptorConfiguration {
        @Bean
        @DependsOn("jpaSpecificationExecutorFactory")
        SearchDescriptor<Person> personSearchDescriptor() {
            return new SearchDescriptorBuilder<Person>(Person.class).build();
        }
    }

Search Descriptor options

Search Descriptor ID

This is the Search Descriptor Identifier. Each identifier must be unique.
Spring Data Search uses this identifier in the search endpoint URL which is mapped to /search/{searchDescriptorId}: searchDescriptorId is the Search Descriptor Identifier.

If the Search Descriptor ID is not set, Spring Data Search uses the JPA Entity Name in lowercase as Search Descriptor ID.
If the Entity is Person.java, the Search Descriptor ID is person

Example with a custom Search Descriptor ID:

@Configuration
public class SearchDescriptorConfiguration implements SearchConfigurer {

    @Override
    public void addSearchDescriptors(SearchDescriptorRegistry registry) {
        registry.addSearchDescriptor(personSearchDescriptor());
    }
    
    SearchDescriptor<Person> personSearchDescriptor() {
        return new SearchDescriptorBuilder<Person>(Person.class)
                        .id("people")
                        .build();
    }
}
Entity Class

This is the Class of the Entity to be searched.
When you use com.weedow.spring.data.search.descriptor.SearchDescriptorBuilder, the Entity Class is added during instantiation:

  • In a Java project: new SearchDescriptorBuilder<>(Person.class)
  • In a Kotlin project: SearchDescriptorBuilder.builder<Person>().build() or SearchDescriptorBuilder(Address::class.java).build()
DTO Mapper

This option allows to convert the Entity to a specific DTO before returning the HTTP response.
This can be useful when you don't want to return all data of the entity.

To do this, you need to create a class which implements the com.weedow.spring.data.search.dto.DtoMapper interface:

public class PersonDtoMapper implements DtoMapper<Person, PersonDto> {
    @Override
    public PersonDto map(Person source) {
        return PersonDto.Builder()
                .firstName(source.firstName)
                .lastName(source.lastName)
                .email(source.email)
                .nickNames(source.nickNames)
                .phoneNumbers(source.phoneNumbers)
                .build();
    }
}

Then you add this DTO Mapper to the SearchDescriptor:

@Configuration
public class SearchDescriptorConfiguration implements SearchConfigurer {

    @Override
    public void addSearchDescriptors(SearchDescriptorRegistry registry) {
        registry.addSearchDescriptor(personSearchDescriptor());
    }
    
    SearchDescriptor<Person> personSearchDescriptor() {
        return new SearchDescriptorBuilder<Person>(Person.class)
                        .dtoMapper(new PersonDtoMapper())
                        .build();
    }
}

If this option is not set, the entity is not converted and the HTTP response returns it directly.

Validators

Spring Data Search provides a validation service to validate the Field Expressions.

A Field Expression is a representation of a query parameter which evaluates an Entity field.
Example: /search/person?job.company=Acme : the query parameter job.company=Acme is converted to a Field Expression where the company field from the Job Entity must be equals to Acme.

Note: The validation service does not validate the Type of the query parameter values. This is already supported when Spring Data Search converts the query parameter values from String to the correct type expected by the related field. (See Converters)

The validators is used to validate whether:

  • A value matches a specific Regular Expression,
  • A number is between a minimum and maximum value
  • There is at least one query parameter in the request
  • A query parameter for a specific field is present or absent in the request
  • ...

To do this, you need to create a new class which implements the com.weedow.spring.data.search.validation.DataSearchValidator interface:

public class EmailValidator implements DataSearchValidator {

    private static final String EMAIL_REGEX = "(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21\\x23-\\x5b\\x5d-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x21-\\x5a\\x53-\\x7f]|\\\\[\\x01-\\x09\\x0b\\x0c\\x0e-\\x7f])+)\\])";

    @Override
    public void validate(Collection<? extends FieldExpression> fieldExpressions, DataSearchErrors errors) {
        fieldExpressions
                .stream()
                .filter(fieldExpression -> "email".equals(fieldExpression.getFieldInfo().getField().getName()))
                .forEach(fieldExpression -> {
                    final Object value = fieldExpression.getValue();
                    if (value instanceof String) {
                        if (!value.toString().matches(EMAIL_REGEX)) {
                            errors.reject("email", "Invalid email value");
                        }
                    }
                });
    }
}

Then you need to add the validators to a Search Descriptor:

@Configuration
public class SearchDescriptorConfiguration implements SearchConfigurer {

    @Override
    public void addSearchDescriptors(SearchDescriptorRegistry registry) {
        registry.addSearchDescriptor(personSearchDescriptor());
    }
    
    SearchDescriptor<Person> personSearchDescriptor() {
        return new SearchDescriptorBuilder<Person>(Person.class)
                        .validators(new NotEmptyValidator(), new EmailValidator("email"))
                        .build();
    }
}

Spring Data Search provides the following DataSearchValidator implementations:

  • com.weedow.spring.data.search.validation.validator.NotEmptyValidator: Checks if there is at least one field expression.
  • com.weedow.spring.data.search.validation.validator.NotNullValidator: Checks if the field expression value is not null.
  • com.weedow.spring.data.search.validation.validator.RequiredValidator: Checks if all specified required fieldPaths are present. The validator iterates over the field expressions and compare the related fieldPath with the required fieldPaths.
  • com.weedow.spring.data.search.validation.validator.PatternValidator: Checks if the field expression value matches the specified pattern.
  • com.weedow.spring.data.search.validation.validator.UrlValidator: Checks if the field expression value matches a valid URL.
  • com.weedow.spring.data.search.validation.validator.EmailValidator: Checks if the field expression value matches the email format.
  • com.weedow.spring.data.search.validation.validator.MaxValidator: Checks if the field expression value is less or equals to the specified maxValue.
  • com.weedow.spring.data.search.validation.validator.MinValidator: Checks if the field expression value is greater or equals to the specified minValue.
  • com.weedow.spring.data.search.validation.validator.RangeValidator: Checks if the field expression value is between the specified minValue and maxValue.
JPA Specification Executor

Spring Data Search uses the Spring Data JPA Specifications to aggregate all expressions in query parameters and query the JPA Entities in the Database.

The base interface to use the Spring Data JPA Specifications is JpaSpecificationExecutor.

Spring Data Search uses the following method of this interface:

public interface JpaSpecificationExecutor<T> {
    //...//
    List<T> findAll(Specification<T> spec);
    //...//
}

If this option is not set, Spring Data Search instantiates a default implementation of JpaSpecificationExecutor according to the JPA Entity.
This is normally sufficient for the majority of needs, but you can set this option with your own JpaSpecificationExecutor implementation if you need a specific implementation.

Entity Join Handlers

It is sometimes useful to optimize the number of SQL queries by specifying the data that you want to fetch during the first SQL query with the criteria.

This option allows to add EntityJoinHandler implementations to specify join types for any fields having join annotation.

The join annotations detected by Spring Data Search are the following:

  • javax.persistence.OneToOne
  • javax.persistence.OneToMany
  • javax.persistence.ManyToMany
  • javax.persistence.ElementCollection
  • javax.persistence.ManyToOne

You can add several EntityJoinHandler implementations. The first implementation that matches from the support(...) method will be used to specify the join type for the given field.

Spring Data Search provides the following default implementations:

  • FetchingAllEntityJoinHandler: This implementation allows to query an entity by fetching all data related to this entity, i.e. all fields related to another Entity recursively.
    Example:
    A has a relationship with B and B has a relationship with C.
    When we search for A, we retrieve A with data from B and C.
  • FetchingEagerEntityJoinHandler: This implementation allows to query an entity by fetching all fields having a Join Annotation with the Fetch type defined as EAGER.
    Example:
    A has a relationship with B using @OneToMany annotation and FetchType.EAGER, and A has a relationship with C using @OneToMany annotation and FetchType.LAZY.
    When we search for A, we retrieve A with just data from B, but not C.

You can create your own implementation to fetch the additional data you require.
Just implement the com.weedow.spring.data.search.join.handler.EntityJoinHandler interface:

/**
 * Fetch all fields annotated with @ElementCollection
 **/
public class MyEntityJoinHandler implements com.weedow.spring.data.search.join.handler.EntityJoinHandler {
    @Override
    public boolean supports(Class<?> entityClass, Class<?> fieldClass, String fieldName, Annotation joinAnnotation) {
        return joinAnnotation instanceof ElementCollection;
    }

    @Override
    public JoinInfo handle(Class<?> entityClass, Class<?> fieldClass, String fieldName, Annotation joinAnnotation) {
        return new JoinInfo(JoinType.LEFT, true);
    }
}

If this option is not set, the default Spring Data Search behavior is to create LEFT JOIN if needed.

For more details about joins handling, please read the following explanations.


If the result contains the root Entity with the related Entities, there will be multiple SQL queries:

  • One SQL query with your criteria
  • One query by joined Entity to retrieve the related data

Let's say you want to have an endpoint to search any Persons.
The endpoint response returns the Persons found with their Jobs and Vehicles.

The Person.java Entity has relationships with the Job.java Entity and the Vehicle.java Entity. Here are the entities :

@Entity
public class Person {
    //...//
    @OneToOne(mappedBy = "person", cascade = CascadeType.ALL, orphanRemoval = true)
    private Job jobEntity;

    @OneToMany(mappedBy = "person", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<Vehicle> vehicles;
    //...//
}

@Entity
public class Vehicle {

    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false)
    private String brand;

    @Column(nullable = false)
    private String model;

    @ManyToOne(optional = false)
    private String person;

    // Getters/Setters
}

@Entity
public class Job {

    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false)
    private String title;
    
    @Column(nullable = false)
    private String company;
    
    @Column(nullable = false)
    private Integer salary;
    
    @Column(nullable = false)
    private OffsetDateTime hireDate;
    
    @OneToOne(optional = false)
    private Person person;

    // Getters/Setters
}

You want to search for the persons whose the vehicle brand is Renault, and the job company is Acme:

/search/person?vehicles.brand=Renault&jobEntity.company=Acme

If you have any persons who match your query, you should get an HTTP response that looks like the following:

[
    {
        "firstName": "John",
        "lastName": "Doe",
        "email": "[email protected]",
        "birthday": "1981-03-12T10:36:00",
        "jobEntity": {
            "title": "Lab Technician",
            "company": "Acme",
            "salary": 50000,
            "hireDate": "2019-09-01T11:00:00+02:00",
            "id": 1,
            "createdOn": "2020-03-12T11:36:00+01:00",
            "updatedOn": "2020-04-17T14:00:00+02:00"
        },
        "vehicles": [
            {
                "vehicleType": "CAR",
                "brand": "Renault",
                "model": "Clio",
                "id": 1,
                "createdOn": "2020-03-12T11:36:00+01:00",
                "updatedOn": "2020-04-17T14:00:00+02:00"
            }
        ],
        "id": 1,
        "createdOn": "2020-03-12T11:36:00+01:00",
        "updatedOn": "2020-04-17T14:00:00+02:00"
    }
]

To get this result, there were several SQL queries:

  • The SQL query with your criteria:
    select
        distinct p.id,
        p.created_on,
        p.updated_on,
        p.birthday,
        p.email,
        p.first_name,
        p.height,
        p.last_name,
        p.weight
    from person p 
    left outer join vehicle v on p.id=v.person_id 
    left outer join job j on p.id=j.person_id 
    where
        j.company='Acme' 
        and v.brand='Renault';
  • The following SQL query executed for each Person returned by the first SQL query:
    select j.*, p.*
    from job j 
    inner join person p on j.person_id=p.id 
    where
        j.person_id={PERSON_ID};
    These SQL queries occur because the field jobEntity present on the Person Entity is annotated with the @OneToOne annotation whose the default fetch type is EAGER.
    • The following SQL query executed for each Person returned by the first SQL query:
      select v.*
      from vehicle v 
      where
          v.person_id={PERSON_ID}
      The vehicles field present on the Person Entity is annotated with the @OneToMany annotation (default fetch type is LAZY).
      However, these SQL queries occur because vehicle information must be returned in the HTTP response.

It is therefore sometimes useful to optimize the number of SQL queries by specifying the data that you want to fetch during the first SQL query with the criteria.

To do this, you can use the EntityJoinHandlers to specify the join type for each Entity field having a relationship with another Entity.

Aliases

Spring Data Search provides an alias management to replace any field name with another name in queries.

This can be useful when the name of a field is too technical or too long or simply to allow several possible names.

Let's say you manage Persons with following Entity:

@Entity
public class Person {
    //...//
    @OneToOne(mappedBy = "person", cascade = CascadeType.ALL, orphanRemoval = true)
    private Job jobEntity;
    //...//
}

You want to search for persons with their job company is Acme. The request looks like: /search/person?jobEntity.company=Acme
However you don't want use the jobEntity string but job into the URL: /search/person?job.company=Acme

To do this, you need to create a AliasResolver implementation:

/**
 * Create an alias for all fields ending with 'Entity'.
 **/
class MyAliasResolver implements AliasResolver {
    private static final String SUFFIX = "Entity";

    @Override
    public Boolean supports(Class<?> entityClass, Field field) {
        return field.name.endsWith(SUFFIX);
    }

    @Override
    List<String> resolve(Class<?> entityClass, Field field) {
        return Arrays.asList(StringUtils.substringBefore(fieldName, SUFFIX));        
    }
}

You must then register it in the Alias Resolver Registry:

@Configuration
public class SampleAppJavaConfiguration implements SearchConfigurer {

    @Override
    public void addAliasResolvers(AliasResolverRegistry registry) {
        registry.addAliasResolver(new MyAliasResolver());
    }
}

Another solution is to declare your AliasResolver as @Bean. This solution is useful when you want to create a AliasResolver which depends on other Beans.

By default, Spring Data Search registers the following Alias Resolvers:

  • DataSearchDefaultAliasConfigurerAutoConfiguration: Creates an alias for all fields ending with the suffixes Entity or Entities.

Converters

Spring Data Search converts the query parameter values from String to the correct type expected by the related field.

Spring Data Search uses the Spring Converter Service.
Spring Converter Service provides several converter implementations in the core.convert.support package.

To create your own converter, implement the Converter interface and parameterize S as the java.lang.String type and T as the type you are converting to.

public class MyConverter implements Converter<String, MyObject> {

    @Override
    public MyObject convert(String s) {
        return MyObject.of(s);
    }

} 

You must then register it in the Converter registry:

@Configuration
public class SampleAppJavaConfiguration implements SearchConfigurer {

    @Override
    public void addConverters(ConverterRegistry registry) {
        registry.addConverter(new MyConverter());
    }
}

Another solution is to declare your Converter as @Bean. This solution is useful when you want to create a Converter which depends on other Beans.

Changing the Base Path

By default, Spring Data Search defines the Base Path as /search and add the Search Descriptor ID. Example: /search/person

You can do change the Base Path by setting a single property in application.properties, as follows:

spring.data.search.base-path=/api

This changes the Base Path to /api. Example: /api/person


Issues

Issues

Contributing

Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are greatly appreciated.

  1. Fork the Project
  2. Create your Feature Branch (git checkout -b feature/AmazingFeature)
  3. Commit your Changes (git commit -m 'Add some AmazingFeature')
  4. Push to the Branch (git push origin feature/AmazingFeature)
  5. Open a Pull Request

Contact

Nicolas Dos Santos - @Kobee1203

Project Link: https://github.com/Kobee1203/spring-data-search

Social Networks

Tweets

GitHub forks GitHub stars GitHub watchers

License

MIT License
Copyright (c) 2020 Nicolas Dos Santos and other contributors

Versions

Version
0.0.1