Spring Data REST searching with tanstack router and react query (part 5)
August 29, 2023
by
Rainer Burgstaller
In the previous article
we have enabled paging and sorting in our Spring Boot backend and
extended our React UI to make use of these features.
So the obvious next feature is searching.
Extending the backend
In contrast to paging and sorting which was already automatically
supported by Spring Data REST we need to do a bit of legwork
in order to add the support for search.
We will be using QueryDsl which has a very
nice integration with Spring Data REST.
Adding the QueryDSL dependencies
To get started we need to add the necessary dependencies in build.gradle.kts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// retrieve the querydsl version (1)
var queryDslVersion = dependencyManagement.importedProperties["querydsl.version"]
dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-data-rest")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("com.github.f4b6a3:ulid-creator:5.1.0")
// import the dependencies (2)
implementation("com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta")
implementation("com.querydsl:querydsl-core:${queryDslVersion}")
implementation("jakarta.persistence:jakarta.persistence-api")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
annotationProcessor("org.projectlombok:lombok")
// add the annotation processor (3)
annotationProcessor("com.querydsl:querydsl-apt:${queryDslVersion}:general")
compileOnly("org.projectlombok:lombok")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
|
First (1)
, we define a variable queryDslVersion
so that we don’t need to repeat
the QueryDsl version number in every dependency.
We use the version provided by the
Spring Boot dependency management plugin.
While Spring Boot defines the correct version already, we still need to refer
to the specific version in a few dependencies since we need the
com.querydsl:querydsl-jpa:5.0.0:jakarta
variant which supports
Jakarta EE out of the box (see (2)
). We also need querydsl-core
and
jakarta.persistence:jakarta.persistence-api
.
In addition to the implementation
dependencies we also need an annotation
processor (see (3)
), which will generate the QueryDsl specific types
of our Club
entity (QClub
) which allows type-safe access to our
entity properties.
Enabling QueryDsl
First we need to mark the Club
entity to be processed by the QueryDsl
annotation processor. This is done by adding a QueryEntity
annotation.
1
2
3
|
@Entity
@QueryEntity // (4)
@Table
|
As a next step we either need to compile our application using gradle
or
ensure that annotation processing is enabled in your IDE. For IntelliJ it is
enough to check the checkbox in the Annotation Procesors
configuration
(see the IntelliJ documentation).
Now we can extend our ClubRepository
.
The only thing we really need to do is to have our interface extend
from QuerydslPredicateExecutor
as well as from QuerydslBinderCustomizer
which allows us to customize how filters are interpreted (see (5)
).
1
2
3
4
5
6
7
8
9
10
11
12
13
|
@RepositoryRestResource
public interface ClubRepository extends JpaRepository<Club, String>,
QuerydslPredicateExecutor<Club>, // (5)
QuerydslBinderCustomizer<QClub> {
@Override
default void customize(
QuerydslBindings bindings, QClub root) {
bindings.bind(root.clubName) // (6)
.first(StringExpression::containsIgnoreCase);
bindings.bind(root.managerEmail)
.first(StringExpression::containsIgnoreCase)
}
}
|
This alone would already be enough to enable searching within our REST
API, however, we also want to be able to use a case-insensitive substring
search.
Therefore, we need to customize the default QuerydslBindings
accordingly (see (6)
).
Here, we simply define a case-insensitive search for both clubName
and managerEmail
.
Note that for the id
a substring search would not make any sense, therefore,
we leave it at that.
In order to have enough test data we again add a few rows into our table
1
2
3
4
|
http POST :8080/api/clubs clubName=club3 managerEmail=manager@club3.com
http POST :8080/api/clubs clubName=club4 managerEmail=manager@club4.com
http POST :8080/api/clubs clubName=club5 managerEmail=manager@club5.com
http POST :8080/api/clubs clubName=club6 managerEmail=amanager@club6.com
|
Now we are ready to test the searching:
1
|
http ":8080/api/clubs?clubName=6"
|
produces
1
2
3
4
5
6
7
8
9
10
11
12
13
|
{
"_embedded": {
"clubs": [
{
"_links": { ... },
"clubName": "club6",
"id": "01GRXW291WQXRJ5PTNRT0ZW23T",
"managerEmail": "amanager@club6.com"
}
]
},
...
}
|
which is exactly what we expected.
Since this is a case insensitive query
1
|
http ":8080/api/clubs?clubName=Club6"
|
also produces club6
.
Searching in the managerEmail
field works as expected
1
|
http ":8080/api/clubs?managerEmail=club2"
|
produces
1
2
3
4
5
6
7
8
9
10
11
12
|
{
"_embedded": {
"clubs": [
{
"clubName": "club2",
"id": "01GRXW1HE72BHW6GN4ZE7ZMVKA",
"managerEmail": "manager@club2.com"
...
}
]
},
...
|
However, when searching in the ID field we have to provide the exact
string, e.g.
1
|
http ":8080/api/clubs?id=GRX"
|
produces an empty result
1
2
3
4
5
|
{
"_embedded": {
"clubs": []
},
}
|
but an exact search works as expected:
1
|
http ":8080/api/clubs?id=01GRXW291WQXRJ5PTNRT0ZW23T"
|
produces
1
2
3
4
5
6
7
8
9
10
11
12
|
{
"_embedded": {
"clubs": [
{
"id": "01GRXW291WQXRJ5PTNRT0ZW23T",
"clubName": "club6",
"managerEmail": "amanager@club6.com",
"_links": { ... }
}
]
}
}
|
Extending the frontend
With that the backend part is done and we can focus on how to add
this to the React UI.
Update the fetch method
First we need to extend our fetch method to add support for filters in src/api/api.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
// define the search schema (7)
export const ClubFilterSchema = z.object({
clubName: z.string().nullish().default(null),
managerEmail: z.string().nullish().default(null),
})
export type ClubFilter = z.infer<typeof ClubFilterSchema>
export function useClubsQuery(
pageNum: number,
pageSize: number,
sort?: SortCriterium<ClubSort>,
// add filter param (8)
filter?: ClubFilter
) {
return useQuery({
// filter param needs to be in the query key, too (9)
queryKey: ['clubs', { pageNum, pageSize, sort, filter }],
queryFn: () => fetchClubs(pageNum, pageSize, sort, filter),
})
}
export async function fetchClubs(
pageNum: number,
pageSize: number,
sort?: SortCriterium<ClubSort>,
filter?: ClubFilter
) {
const queryParams = new URLSearchParams()
queryParams.append('size', `${pageSize}`)
queryParams.append('page', `${pageNum}`)
if (sort) {
queryParams.append('sort', `${sort.column},${sort.dir}`)
}
// add all set filters (10)
let key: keyof ClubFilter
for (key in filter) {
if (filter?.[key]) {
queryParams.append(key, filter?.[key] ?? '')
}
}
const result = await fetch(`/api/clubs?${queryParams.toString()}`)
return await extractJsonOrError<ClubsResponse>(result)
}
|
First we define a new zod schema for
the search parameters which we
will use for both the parameter type (see (7)
and (8)
) as well as for the
query parameter parsing (later).
The method useClubsQuery
is basically the same except for the addition of
the new filter
parameter which we well also need to pass to the queryKey
(see (9)
) in order to automatically re-query whenever a filter parameter changes.
In the fetchClubs
method we simply take all filter parameters (see (10)
) and
add them (if present) to the URLSearchParams
to pass them directly to the server.
Add searching to the UI
Now we can update the UI to be able to support search.
Since PrimeReact Datatable already has a very nice filter feature, there is not a lot we have
to do on the UI side, however, getting everything together is still quite involved:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
export const ClubPageSearchParams = z.object({
pageNum: z.number().optional().default(0),
pageSize: z.number().optional().default(5),
sort: z
.object({
column: ClubSchema.keyof(),
dir: z.enum(['asc', 'desc']),
})
.optional()
.default({ column: 'clubName', dir: 'asc' }),
// extend the search parameter schema for the page (11)
filter: ClubFilterSchema.optional(),
})
// convert the search parameters to the
// filter structure of primereact table (12)
function convertToDatatableFilter(filter?: ClubFilter): DataTableFilterMeta {
const filterResult: DataTableFilterMeta = {}
if (!filter) {
return filterResult
}
let key: keyof typeof filter
for (key in filter) {
filterResult[key] = { value: filter[key] } as DataTableFilterMetaData
}
return filterResult
}
// convert the primereact datatable filters to
// our search parameters format (13)
function convertDatatableFilterToSearchParams(
filters: DataTableFilterMeta
): ClubFilter {
const clubNameFilter = (filters.clubName as DataTableFilterMetaData)?.value
const managerEmailFilter = (filters.managerEmail as DataTableFilterMetaData)
?.value
return { managerEmail: managerEmailFilter, clubName: clubNameFilter }
}
|
First, we extend the ClubPageSearchParams
to include our previously defined ClubFilterSchema
(see (11)
).
Then, we need converter functions between the filter format of our
ClubFilterSchema
and the DataTable filter structure
(in both directions, see (12)
and (13)
).
With those tools in place we can adapt the ClubListPage
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
|
export const ClubListPage: FC = () => {
const search = useSearch({ from: clubsRoute.id, strict: true })
const { data, error, isLoading } = useClubsQuery(
search.pageNum,
search.pageSize,
search.sort,
// pass the filter to the query (14)
search.filter
)
...
return (
<div>
<>
...
<DataTable
lazy={true}
...
dataKey="id"
// show an inline filter row (15)
filterDisplay={'row'}
// pass the current filters to the table (16)
filters={convertToDatatableFilter(search.filter)}
onFilter={e => {
console.log('filter changed', e)
// update the search params on the current page (17)
navigate({
to: clubsRoute.id,
search: {
...search,
filter: convertDatatableFilterToSearchParams(e.filters),
},
}).catch(console.error)
}}
>
<Column field="id" header="ID" />
<Column
field="clubName"
header="Club Name"
sortable={true}
// make columns filterable (18)
filter
filterPlaceholder="Search by name"
showFilterMatchModes={false}
style={{ minWidth: '12rem' }}
/>
...
</DataTable>
</>
</div>
)
}
|
We can now pass the previously prepared search.filter
to our useClubsQuery
method (14)
.
Once all of this is in place we need to activate filtering on the DataTable
by setting filterDisplay
to row
(15)
. This makes inline filter boxes appear
underneath the table headers.
Further, we need to pass in the current search.filter
as the currently selected
filters
(16)
. This ensures that even if you change the filter in the URL
you will still see the current filter parameters in the Table filters.
Finally, we need to react to onFilter
changes (17)
and update the
current search
params whenever a filter changes. This will trigger an automatic
re-fetching of the data with the new filters.
The only remaining thing to do is to mark the clubName
and managerEmail
columns
as filterable (18)
and we are finally done.
In the screenshot you can see the DataTable
with active filtering.
Any change in one of the filter fields will automatically cause a re-fetching
of the data from the server.
The data is filtered, sorted and paged on the server via Spring Data REST.
The UI state is fully kept in the browser URL and the code allows a fully
type-safe handling of paging, sorting and searching aspects.
This concludes part 5 of the tutorial series. Thanks a lot for hanging in there.
You can find the finished source code on
GitHub.