Blog Logo

10 Feb 2023 ~ 8 min read

Spring Data REST searching with tanstack router and react query (part 5)


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

build.gradle.kts
// 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 dont 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.

@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)).

@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

Terminal window
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:

Terminal window
http ":8080/api/clubs?clubName=6"

produces

{
"_embedded": {
"clubs": [
{
"_links": { ... },
"clubName": "club6",
"id": "01GRXW291WQXRJ5PTNRT0ZW23T",
"managerEmail": "amanager@club6.com"
}
]
},
...
}

which is exactly what we expected.

Since this is a case insensitive query

Terminal window
http ":8080/api/clubs?clubName=Club6"

also produces club6.

Searching in the managerEmail field works as expected

Terminal window
http ":8080/api/clubs?managerEmail=club2"

produces

{
"_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.

Terminal window
http ":8080/api/clubs?id=GRX"

produces an empty result

{
"_embedded": {
"clubs": []
}
}

but an exact search works as expected:

Terminal window
http ":8080/api/clubs?id=01GRXW291WQXRJ5PTNRT0ZW23T"

produces

{
"_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

src/api/api.ts
// 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", { sort, filter, pageNum, pageSize }],
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.

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 }
}
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' }}
/>
<Column
field="managerEmail"
header="Manager Email"
sortable={true}
filter
filterPlaceholder="Search by email"
showFilterMatchModes={false}
style={{ minWidth: '12rem' }}
/>
</DataTable>
</>
</div>
)
}

Puh, there is a lot to unpack here. 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)).

Now we can pass the 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.

Filter UI

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.


Headshot of Rainer Burgstaller

Hi, I'm Rainer. I'm a software engineer based in Salzburg. You can follow me on Twitter, see some of my work on GitHub, or read more about me on my website.