Searchy is a Spring-based library that allows to automatically expose endpoints in order to search for data related to Entities, whatever the database used.
Searchy provides an advanced search engine that does not require the creation of Repositories with custom methods needed to search on different fields of Entities.
We can search on any field, combine multiple criteria to refine the search, and even search on nested fields.
It supports the following data access layers:
Spring Data Rest builds on top of the Spring Data repositories and automatically exports those as REST resources.
- Each time we need to search with different criteria, we will have to add new methods (findByFirstName, findByFirstAndLastName, ...).
- If we need to make more complex queries or handle specific fetch joins, we use the
@Query
annotation which takes as attribute a String representing the query to be executed. This String is written in the language supported by the data access layer (JPQL, SQL, Mongo JSON ...).
Here is an JPA example:
@RepositoryRestResource
public interface PersonRepository extends Repository<Person, Long> {
List<Person> findAll();
List<Person> findByLastName(@Param("name") String name);
@Query("SELECT p FROM Person " +
"LEFT JOIN FETCH p.addressEntities a " +
"WHERE p.lastName='Doe' AND a.city='Paris'")
List<Person> findPersonsWithAddresses();
}
We realize that we cannot be exhaustive in order to search for Person entities whatever the search criteria: a single field, nested fields, multiple fields, AND/OR conjunctions...
We also realize that each time we use @Query
, we add a dependency to the data access layer since we write the query string in the language of the database we are querying. This can sometimes make it tedious to migrate to another type of database.
All this requires adding more code, releasing new versions ...
Searchy allows to easily expose an endpoint for an Entity and thus be able to search on any fields of this entity, 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
Searchy 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 code to do that.
The library provides a query language that allows to create queries on any field of the entity and sub-entities, and agnostic queries regarding the database used.
- JDK 11 or more.
- Spring Boot
- You can download the latest release.
- If you have a Maven project, you can add the following dependency in your
pom.xml
file:- JPA:
<dependency> <groupId>com.weedow</groupId> <artifactId>weedow-searchy-jpa</artifactId> <version>0.1.0</version> </dependency>
- MongoDB:
<dependency> <groupId>com.weedow</groupId> <artifactId>weedow-searchy-mongodb</artifactId> <version>0.1.0</version> </dependency>
- If you have a Gradle project, you can add the following dependency in your
build.gradle
file:- JPA:
implementation "com.weedow:weedow-searchy-jpa:0.1.0"
- MongoDB:
implementation "com.weedow:weedow-searchy-mongodb:0.1.0"
- Go to https://start.spring.io/
- Generate a new Java project
sample-app-java
with the following dependencies: - Update the generated project by adding the dependency of Searchy:
- For Maven project, add the dependency in the
pom.xml
file:
<dependency> <groupId>com.weedow</groupId> <artifactId>weedow-searchy-jpa</artifactId> <version>0.1.0</version> </dependency>
- For Gradle project, add the dependency in the
build.gradle
file:
implementation "com.weedow:weedow-searchy-jpa:0.1.0"
- For Maven project, add the dependency in the
- Create a new file
Person.java
to add a new JPA EntityPerson
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
SearchyDescriptor
:import com.example.sampleappjava.entity.Person; import com.weedow.searchy.config.SearchyConfigurer; import com.weedow.searchy.descriptor.SearchyDescriptor; import com.weedow.searchy.descriptor.SearchyDescriptorBuilder; import com.weedow.searchy.descriptor.SearchyDescriptorRegistry; import org.springframework.context.annotation.Configuration; @Configuration public class SampleAppJavaConfiguration implements SearchyConfigurer { @Override public void addSearchyDescriptors(SearchyDescriptorRegistry registry) { registry.addSearchyDescriptor(personSearchyDescriptor()); } private SearchyDescriptor<Person> personSearchyDescriptor() { return new SearchyDescriptorBuilder<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
- For Maven Project:
- Open your browser and go to the URL
http://localhost:8080/search/person
- You can filter the results by adding query parameters representing the Entity fields:
Here is an example where the results are filtered by the first name:
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;
@ElementCollection
@CollectionTable(
name = "person_tasks",
joinColumns = {@JoinColumn(name = "person_id", referencedColumnName = "id")})
@MapKeyJoinColumn(name = "task_id")
@Column(name = "task_date")
private Map<Task, LocalDateTime> tasks;
// 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 Task {
@Id
@GeneratedValue
private Long id;
@Column(nullable = false)
private String name;
@Column
private String description;
// Getters/Setters
// Override toString() method to get a JSON representation used by the HTTP response
@Override
public String toString() {
return ToStringBuilder.reflectionToString(this, ToStringStyle.JSON_STYLE);
}
}
@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
}
There are two methods to search for entities: Standard Query and Advanced Query
All search methods use the same operation described below.
To search for all the Database rows of an entity type, you must concatenate the Base Path (default is /search
) and the Search Descriptor ID (default is the Entity name in lower case).
Example: Search all Person
Entities
/search/person
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/person?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 1: The Person
Entity contains a property of type Map
that is named characteristics
, and we search for Persons who have 'blue eyes':
/search/person?characteristics.key=eyes&characteristics.value=blue
Example 2: The Person
Entity contains a property of type Map
that is named tasks
, and we search for Persons who have a task whose name is 'shopping':
/search/person?tasks.key.name=shopping
Example 3: The Vehicle
Entity contains a property of type Map
that is named features
, and we search for Persons who have vehicles with the 'GPS' feature:
/search/person?vehicles.features.value.name=gps
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 |
Persons who were born at current date | /search?birthday=CURRENT_DATE |
Persons who were born at current time | /search?birthday=CURRENT_TIME |
Persons who were born at current datetime | /search?birthday=CURRENT_DATE_TIME |
You can search for entities by using the query string query
.
query
supports a powerful query language to perform advanced searches for the 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
Date
: must be surrounded by single quotes or double quotes, or use special keywordsCURRENT_DATE
,CURRENT_TIME
,CURRENT_DATE_TIME
.
Example:birthday='1981-03-12T10:36:00'
,job.hireDate='2019-09-01T09:00:00Z'
,birthday=CURRENT_DATE_TIME
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.
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 with the birthday equals to the current LocalDateTime |
/person?query=birthday=CURRENT_DATE_TIME |
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 |
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 birthday not equals to the current LocalDateTime |
/person?query=birthday=CURRENT_DATE_TIME |
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 |
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 hired before current datetime | /person?query=job.hireDate<CURRENT_DATE_TIME |
Persons who are smaller than 1,74 m | /person?query=height<174 |
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 hired before or on current datetime | /person?query=job.hireDate<=CURRENT_DATE_TIME |
Persons who are smaller than or equal to 1,74 m | /person?query=height<=174 |
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 hired after the current datetime | /person?query=job.hireDate>CURRENT_DATE_TIME |
Persons who are taller than 1,74 m | /person?query=height>174 |
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 hired after or on current datetime | /person?query=job.hireDate>=CURRENT_DATE_TIME |
Persons who are taller than or equal to 1,74 m | /person?query=height>=174 |
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*' |
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*' |
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') |
The fields representing a date, time, or datetime can be compared with a string having a valid format according to the type of the field.
What you want to query | Example |
---|---|
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' |
Also, the fields representing a date, time, or datetime can be compared with the following keywords:
CURRENT_DATE
: keyword representing the current dateCURRENT_TIME
: keyword representing the current timeCURRENT_DATE_TIME
: keyword representing the current date and time
What you want to query | Example |
---|---|
Persons with the birthday is at current datetime | /person?query=birthday=CURRENT_DATE_TIME |
Persons with the birthday is not at current datetime | /person?query=birthday!=CURRENT_DATE_TIME |
Persons with the birthday is after current datetime | /person?query=birthday>CURRENT_DATE_TIME |
Persons with the birthday is after or at current datetime | /person?query=birthday>=CURRENT_DATE_TIME |
Persons with the birthday is before current datetime | /person?query=birthday<CURRENT_DATE_TIME |
Persons with the birthday is before or at current datetime | /person?query=birthday<=CURRENT_DATE_TIME |
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 |
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 |
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' |
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' |
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') |
- 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 |
Module | Javadoc |
---|---|
Core | |
JPA | |
MongoDB |
The Search Descriptors allow exposing automatically search endpoints for Entities.
The new endpoints are mapped to /search/{searchyDescriptorId}
where searchyDescriptorId
is the ID defined for the SearchyDescriptor
.
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.searchy.descriptor.SearchyDescriptorBuilder
which provides every options available to configure a SearchyDescriptor
.
You have to add the SearchyDescriptor
s to the Searchy Configuration to expose the Entity endpoint:
-
Implement the
com.weedow.searchy.config.SearchyConfigurer
interface and override theaddSearchyDescriptors
method:@Configuration public class SearchyDescriptorConfiguration implements SearchyConfigurer { @Override public void addSearchyDescriptors(SearchyDescriptorRegistry registry) { SearchyDescriptor searchyDescriptor = new SearchyDescriptorBuilder<Person>(Person.class).build(); registry.addSearchyDescriptor(searchyDescriptor); } }
-
Another solution is to add a new
@Bean
. This solution is useful when you want to create aSearchyDescriptor
which depends on other Beans:@Configuration public class SearchyDescriptorConfiguration { @Bean SearchyDescriptor<Person> personSearchyDescriptor(PersonRepository personRepository) { return new SearchyDescriptorBuilder<Person>(Person.class) .specificationExecutor(personRepository) .build(); } }
This is the Search Descriptor Identifier. Each identifier must be unique.
Searchy uses this identifier in the search endpoint URL which is mapped to /search/{searchyDescriptorId}
: searchyDescriptorId
is the Search Descriptor Identifier.
If the Search Descriptor ID is not set, Searchy uses the 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 SearchyDescriptorConfiguration implements SearchyConfigurer {
@Override
public void addSearchyDescriptors(SearchyDescriptorRegistry registry) {
registry.addSearchyDescriptor(personSearchyDescriptor());
}
SearchyDescriptor<Person> personSearchyDescriptor() {
return new SearchyDescriptorBuilder<Person>(Person.class)
.id("people")
.build();
}
}
This is the Class of the Entity to be searched.
When you use com.weedow.searchy.descriptor.SearchyDescriptorBuilder
, the Entity Class is added during instantiation:
- In a Java project:
new SearchyDescriptorBuilder<>(Person.class)
- In a Kotlin project:
SearchyDescriptorBuilder.builder<Person>().build()
orSearchyDescriptorBuilder(Address::class.java).build()
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.searchy.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 SearchyDescriptor
:
@Configuration
public class SearchyDescriptorConfiguration implements SearchyConfigurer {
@Override
public void addSearchyDescriptors(SearchyDescriptorRegistry registry) {
registry.addSearchyDescriptor(personSearchyDescriptor());
}
SearchyDescriptor<Person> personSearchyDescriptor() {
return new SearchyDescriptorBuilder<Person>(Person.class)
.dtoMapper(new PersonDtoMapper())
.build();
}
}
If this option is not set, a default DTO Mapper is used. This default DTO Mapper may be different according to the database implementation used.
The Core
provides a default DTO Mapper com.weedow.searchy.dto.DefaultDtoMapper
that does not convert the entity, and the HTTP response returns it directly.
Searchy 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 Searchy 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.searchy.validation.SearchyValidator
interface:
public class EmailValidator implements SearchyValidator {
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, SearchyErrors 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 SearchyDescriptorConfiguration implements SearchyConfigurer {
@Override
public void addSearchyDescriptors(SearchyDescriptorRegistry registry) {
registry.addSearchyDescriptor(personSearchyDescriptor());
}
SearchyDescriptor<Person> personSearchyDescriptor() {
return new SearchyDescriptorBuilder<Person>(Person.class)
.validators(new NotEmptyValidator(), new EmailValidator("email"))
.build();
}
}
Searchy provides the following SearchyValidator
implementations:
com.weedow.searchy.validation.validator.NotEmptyValidator
: Checks if there is at least one field expression.com.weedow.searchy.validation.validator.NotNullValidator
: Checks if the field expression value is notnull
.com.weedow.searchy.validation.validator.RequiredValidator
: Checks if all specified requiredfieldPaths
are present. The validator iterates over the field expressions and compare the relatedfieldPath
with the requiredfieldPaths
.com.weedow.searchy.validation.validator.PatternValidator
: Checks if the field expression value matches the specifiedpattern
.com.weedow.searchy.validation.validator.UrlValidator
: Checks if the field expression value matches a validURL
.com.weedow.searchy.validation.validator.EmailValidator
: Checks if the field expression value matches the email format.com.weedow.searchy.validation.validator.MaxValidator
: Checks if the field expression value is less or equals to the specifiedmaxValue
.com.weedow.searchy.validation.validator.MinValidator
: Checks if the field expression value is greater or equals to the specifiedminValue
.com.weedow.searchy.validation.validator.RangeValidator
: Checks if the field expression value is between the specifiedminValue
andmaxValue
.
Searchy defines the class com.weedow.searchy.query.specification.Specification
, inspired by Spring Data JPA Specifications.
It is used to aggregate all expressions in query parameters and query the Entities in the Database.
Searchy defines the following interface to allow execution of Specifications
:
public interface SpecificationExecutor<T> {
//...//
List<T> findAll(Specification<T> spec);
//...//
}
This interface is already implemented for each Database implementation (JPA, MongoDB ...).
This is normally sufficient for the majority of needs, but you can set this option with your own SpecificationExecutor
implementation if you need a specific implementation.
To ease integration with Spring Repositories, there is the com.weedow.searchy.repository.SearchyBaseRepository
interface.
- Extending an annotated
@Repository
interface with theSearchyBaseRepository
interface@Repository public interface PersonRepository extends SearchyBaseRepository { }
- Set the
SearchyDescriptor
with the previous interface@Configuration public class SearchyDescriptorConfiguration { @Bean SearchyDescriptor<Person> personSearchyDescriptor(PersonRepository personRepository) { return new SearchyDescriptorBuilder<Person>(Person.class) .specificationExecutor(personRepository) .build(); } }
- If the annotated @Repository interface has a specific implementation, implement the
List<T> findAll(Specification<T> specification)
methodpublic class PersonRepositoryImpl implements PersonRepository { public List<Person> findAll(Specification<Person> specification) { // ... } }
- If the annotated @Repository interface does not have a specific implementation, it means that it uses a default Spring implementation that will not support the
List<T> findAll(Specification<T> specification)
method. It is therefore necessary to override this behavior by specifying another FactoryBean class to be used for each repository instance. For example, if it's a JPA Repository, you have to specify that therepositoryFactoryBeanClass
iscom.weedow.searchy.jpa.repository.JpaSearchyRepositoryFactoryBean
:@SpringBootApplication @EnableJpaRepositories(value = {"com.sample.repository"}, repositoryFactoryBeanClass = JpaSearchyRepositoryFactoryBean.class) public class SampleAppJavaApplication { public static void main(String[] args) { SpringApplication.run(SampleAppJavaApplication.class, args); } }
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 adding EntityJoinHandler
implementations to specify join types for any fields having join annotation.
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.
@Configuration
public class SearchyDescriptorConfiguration {
@Bean
public SearchyDescriptor<Person> personSearchyDescriptor(SearchyContext searchyContext) {
return new SearchyDescriptorBuilder<>(Person.class)
.entityJoinHandlers(new MyEntityJoinHandler(), new JpaFetchingEagerEntityJoinHandler(searchyContext))
.build();
}
}
Searchy 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 withB
andB
has a relationship withC
.
When we search forA
, we retrieveA
with data fromB
andC
.JpaFetchingEagerEntityJoinHandler
: This specific JPA implementation allows to query an entity by fetching all fields having a Join Annotation with the Fetch type defined asEAGER
.
Example:
A
has a relationship withB
using@OneToMany
annotation andFetchType.EAGER
, andA
has a relationship withC
using@OneToMany
annotation andFetchType.LAZY
.
When we search forA
, we retrieveA
with just data fromB
, but notC
.
You can create your own implementation to fetch the additional data you require.
Just implement the com.weedow.searchy.join.handler.EntityJoinHandler
interface:
/**
* Fetch all fields annotated with @ElementCollection
*/
public class MyEntityJoinHandler implements EntityJoinHandler {
@Override
public boolean supports(PropertyInfos propertyInfos) {
return propertyInfos.getAnnotations().stream().anyMatch(annotation -> annotation instanceof ElementCollection);
}
@Override
public JoinInfo handle(PropertyInfos propertyInfos) {
return new JoinInfo(JoinType.LEFTJOIN, true);
}
}
If this option is not set, the default Searchy 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 Person
s.
The endpoint response returns the Person
s found with their Job
s and Vehicle
s.
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 who's 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:
These SQL queries occur because the field
select j.*, p.* from job j inner join person p on j.person_id=p.id where j.person_id={PERSON_ID};
jobEntity
present on thePerson
Entity is annotated with the@OneToOne
annotation whose the default fetch type isEAGER
.- The following SQL query executed for each Person returned by the first SQL query:
The
select v.* from vehicle v where v.person_id={PERSON_ID}
vehicles
field present on thePerson
Entity is annotated with the@OneToMany
annotation (default fetch type isLAZY
).
However, these SQL queries occur because vehicle information must be returned in the HTTP response.
- The following SQL query executed for each Person returned by the first SQL query:
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.
Searchy 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 SearchyConfigurer {
@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, Searchy registers the following Alias Resolvers:
SearchyDefaultAliasConfigurerAutoConfiguration
: Creates an alias for all fields ending with the suffixesEntity
orEntities
.
Searchy converts the query parameter values from String to the correct type expected by the related field.
Searchy 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 SearchyConfigurer {
@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.
By default, Searchy 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:
weedow.searchy.base-path=/api
This changes the Base Path to /api
. Example: /api/person
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.
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature
) - Commit your Changes (
git commit -m 'Add some AmazingFeature'
) - Push to the Branch (
git push origin feature/AmazingFeature
) - Open a Pull Request
Page to describe the process of creating a new release: Make a new release
Page to describe the process of adding a new module: Add a new module
Nicolas Dos Santos - @Kobee1203
Project Link: https://github.com/Kobee1203/weedow-searchy
Copyright (c) 2020 Nicolas Dos Santos and other contributors