Skip to content

Commit 3c93d4e

Browse files
committed
feat: Filtering for auto grid
1 parent e4ee82e commit 3c93d4e

37 files changed

+600
-64
lines changed

packages/java/endpoint/pom.xml

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<project xmlns="http://maven.apache.org/POM/4.0.0"
3-
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
45
<modelVersion>4.0.0</modelVersion>
56
<parent>
67
<groupId>dev.hilla</groupId>
@@ -201,7 +202,12 @@
201202
</exclusion>
202203
</exclusions>
203204
</dependency>
204-
205+
<dependency>
206+
<groupId>jakarta.persistence</groupId>
207+
<artifactId>jakarta.persistence-api</artifactId>
208+
<version>3.1.0</version>
209+
<scope>provided</scope>
210+
</dependency>
205211
<dependency>
206212
<groupId>dev.hilla</groupId>
207213
<artifactId>parser-jvm-utils</artifactId>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package dev.hilla.crud;
2+
3+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
4+
import org.springframework.context.annotation.Bean;
5+
import org.springframework.context.annotation.Configuration;
6+
import org.springframework.data.jpa.domain.Specification;
7+
8+
@Configuration
9+
public class CrudConfiguration {
10+
11+
@Bean
12+
@ConditionalOnClass(Specification.class)
13+
JpaFilterConverter jpaFilterConverter() {
14+
return new JpaFilterConverter();
15+
}
16+
17+
}

packages/java/endpoint/src/main/java/dev/hilla/crud/CrudRepositoryService.java

+22-4
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@
33
import java.util.List;
44

55
import dev.hilla.EndpointExposed;
6+
import dev.hilla.Nullable;
7+
import dev.hilla.crud.filter.Filter;
8+
import org.springframework.beans.factory.annotation.Autowired;
69
import org.springframework.data.domain.Pageable;
10+
import org.springframework.data.jpa.domain.Specification;
711
import org.springframework.data.jpa.repository.JpaRepository;
12+
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
813

914
/**
1015
* A browser-callable service that delegates crud operations to a JPA
@@ -13,21 +18,34 @@
1318
@EndpointExposed
1419
public class CrudRepositoryService<T, ID> implements CrudService<T> {
1520

16-
private JpaRepository<T, ID> repository;
21+
@Autowired
22+
private JpaFilterConverter jpaFilterConverter;
23+
24+
private JpaSpecificationExecutor<T> repository;
25+
private Class<T> entityClass;
1726

1827
/**
1928
* Creates the service using the given repository.
2029
*
2130
* @param repository
2231
* the JPA repository
2332
*/
24-
public CrudRepositoryService(JpaRepository<T, ID> repository) {
33+
public <R extends JpaRepository<T, ID> & JpaSpecificationExecutor<T>> CrudRepositoryService(
34+
Class<T> entityClass, R repository) {
2535
this.repository = repository;
36+
this.entityClass = entityClass;
37+
}
38+
39+
protected JpaSpecificationExecutor<T> getRepository() {
40+
return repository;
2641
}
2742

2843
@Override
29-
public List<T> list(Pageable pageable) {
30-
return repository.findAll(pageable).getContent();
44+
public List<T> list(Pageable pageable, @Nullable Filter filter) {
45+
Specification<T> spec = jpaFilterConverter.toSpec(filter, entityClass);
46+
return ((JpaSpecificationExecutor<T>) repository)
47+
.findAll(spec, pageable).getContent();
48+
3149
}
3250

3351
}

packages/java/endpoint/src/main/java/dev/hilla/crud/CrudService.java

+5-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import java.util.List;
44

5+
import dev.hilla.Nullable;
6+
import dev.hilla.crud.filter.Filter;
57
import org.springframework.data.domain.Pageable;
68

79
/**
@@ -18,8 +20,10 @@ public interface CrudService<T> {
1820
*
1921
* @param pageable
2022
* contains information about paging and sorting
23+
* @param filter
24+
* the filter to apply or {@code null} to not filter
2125
* @return a list of objects or an empty list if no objects were found
2226
*/
23-
List<T> list(Pageable pageable);
27+
List<T> list(Pageable pageable, @Nullable Filter filter);
2428

2529
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package dev.hilla.crud;
2+
3+
import jakarta.persistence.EntityManager;
4+
5+
import dev.hilla.crud.filter.AndFilter;
6+
import dev.hilla.crud.filter.Filter;
7+
import dev.hilla.crud.filter.OrFilter;
8+
import dev.hilla.crud.filter.PropertyStringFilter;
9+
import org.springframework.beans.factory.annotation.Autowired;
10+
import org.springframework.data.jpa.domain.Specification;
11+
import org.springframework.stereotype.Component;
12+
13+
@Component
14+
public class JpaFilterConverter {
15+
16+
@Autowired
17+
private EntityManager em;
18+
19+
/**
20+
* Converts the given Hilla filter specification into a JPA filter
21+
* specification.
22+
*
23+
* @param <T>
24+
* the type of the entity
25+
* @param rawFilter
26+
* the filter to convert
27+
* @param entity
28+
* the entity class
29+
* @return a JPA filter specification for the given filter
30+
*/
31+
public <T> Specification<T> toSpec(Filter rawFilter, Class<T> entity) {
32+
if (rawFilter instanceof AndFilter filter) {
33+
return Specification.allOf(filter.getChildren().stream()
34+
.map(f -> toSpec(f, entity)).toList());
35+
} else if (rawFilter instanceof OrFilter filter) {
36+
return Specification.anyOf(filter.getChildren().stream()
37+
.map(f -> toSpec(f, entity)).toList());
38+
} else if (rawFilter instanceof PropertyStringFilter filter) {
39+
Class<?> javaType = em.getMetamodel().entity(entity)
40+
.getAttribute(filter.getPropertyId()).getJavaType();
41+
42+
return new PropertyStringFilterSpecification<>(filter, entity,
43+
javaType);
44+
45+
} else {
46+
if (rawFilter != null) {
47+
throw new IllegalArgumentException("Unknown filter type "
48+
+ rawFilter.getClass().getName());
49+
}
50+
return Specification.anyOf();
51+
}
52+
}
53+
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package dev.hilla.crud;
2+
3+
import jakarta.persistence.criteria.CriteriaBuilder;
4+
import jakarta.persistence.criteria.CriteriaQuery;
5+
import jakarta.persistence.criteria.Expression;
6+
import jakarta.persistence.criteria.Path;
7+
import jakarta.persistence.criteria.Predicate;
8+
import jakarta.persistence.criteria.Root;
9+
10+
import java.time.LocalDate;
11+
import java.time.LocalDateTime;
12+
import java.time.LocalTime;
13+
import java.time.format.DateTimeFormatter;
14+
import java.time.temporal.TemporalAccessor;
15+
16+
import dev.hilla.crud.filter.PropertyStringFilter;
17+
import org.springframework.data.jpa.domain.Specification;
18+
19+
public class PropertyStringFilterSpecification<T> implements Specification<T> {
20+
21+
private PropertyStringFilter filter;
22+
private Class<T> entity;
23+
private Class<?> javaType;
24+
25+
public PropertyStringFilterSpecification(PropertyStringFilter filter,
26+
Class<T> entity, Class<?> javaType) {
27+
this.filter = filter;
28+
this.entity = entity;
29+
this.javaType = javaType;
30+
}
31+
32+
@Override
33+
public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query,
34+
CriteriaBuilder criteriaBuilder) {
35+
String value = filter.getFilterValue();
36+
Path<String> propertyPath = root.get(filter.getPropertyId());
37+
if (javaType == String.class) {
38+
Expression<String> expr = criteriaBuilder.lower(propertyPath);
39+
switch (filter.getMatcher()) {
40+
case EQUALS:
41+
return criteriaBuilder.equal(expr, value.toLowerCase());
42+
case CONTAINS:
43+
return criteriaBuilder.like(expr,
44+
"%" + value.toLowerCase() + "%");
45+
}
46+
47+
throw new IllegalArgumentException(
48+
"Matcher of type " + filter.getMatcher() + " is unknown");
49+
} else if (javaType == LocalDate.class) {
50+
TemporalAccessor date = DateTimeFormatter.ISO_DATE.parse(value);
51+
return criteriaBuilder.equal(propertyPath, LocalDate.from(date));
52+
} else if (javaType == LocalTime.class) {
53+
TemporalAccessor date = DateTimeFormatter.ISO_TIME.parse(value);
54+
return criteriaBuilder.equal(propertyPath, LocalDate.from(date));
55+
} else if (javaType == LocalDateTime.class) {
56+
TemporalAccessor date = DateTimeFormatter.ISO_DATE_TIME
57+
.parse(value);
58+
return criteriaBuilder.equal(propertyPath, LocalDate.from(date));
59+
} else {
60+
return criteriaBuilder.equal(propertyPath, value.toLowerCase());
61+
}
62+
}
63+
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package dev.hilla.crud.filter;
2+
3+
import java.util.List;
4+
5+
/**
6+
* A filter that requires all children to pass.
7+
*/
8+
public class AndFilter implements Filter {
9+
private List<Filter> children;
10+
11+
public List<Filter> getChildren() {
12+
return children;
13+
}
14+
15+
public void setChildren(List<Filter> children) {
16+
this.children = children;
17+
}
18+
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package dev.hilla.crud.filter;
2+
3+
import com.fasterxml.jackson.annotation.JsonSubTypes;
4+
import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
5+
import com.fasterxml.jackson.annotation.JsonTypeInfo;
6+
7+
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "t")
8+
@JsonSubTypes({ @Type(value = OrFilter.class, name = "o"),
9+
@Type(value = AndFilter.class, name = "a"),
10+
@Type(value = PropertyStringFilter.class, name = "p") })
11+
public interface Filter {
12+
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package dev.hilla.crud.filter;
2+
3+
import java.util.List;
4+
5+
/**
6+
* A filter that requires at least on of its children to pass.
7+
*/
8+
public class OrFilter implements Filter {
9+
10+
private List<Filter> children;
11+
12+
public List<Filter> getChildren() {
13+
return children;
14+
}
15+
16+
public void setChildren(List<Filter> children) {
17+
this.children = children;
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package dev.hilla.crud.filter;
2+
3+
/**
4+
* A filter for a given property that matches a string value using the given
5+
* matcher.
6+
*/
7+
public class PropertyStringFilter implements Filter {
8+
public enum Matcher {
9+
EQUALS, CONTAINS;
10+
}
11+
12+
private String propertyId;
13+
private String filterValue;
14+
private Matcher matcher;
15+
16+
public String getPropertyId() {
17+
return propertyId;
18+
}
19+
20+
public void setPropertyId(String propertyId) {
21+
this.propertyId = propertyId;
22+
}
23+
24+
public String getFilterValue() {
25+
return filterValue;
26+
}
27+
28+
public void setFilterValue(String filterValue) {
29+
this.filterValue = filterValue;
30+
}
31+
32+
public Matcher getMatcher() {
33+
return matcher;
34+
}
35+
36+
public void setMatcher(Matcher type) {
37+
this.matcher = type;
38+
}
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@org.springframework.lang.NonNullApi
2+
package dev.hilla.crud.filter;
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
dev.hilla.EndpointController
22
dev.hilla.push.PushConfigurer
33
dev.hilla.internal.hotswap.HotSwapConfiguration
4+
dev.hilla.crud.CrudConfiguration

packages/java/tests/spring/react-grid-test/frontend/routes.tsx

+10
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { createBrowserRouter } from 'react-router-dom';
22
import MainLayout from './MainLayout';
33
import { ReadOnlyGrid } from './views/ReadOnlyGrid';
4+
import { ReadOnlyGridSinglePropertyFilter } from './views/ReadOnlyGridSinglePropertyFilter';
5+
import { ReadOnlyGridOrFilter } from './views/ReadOnlyGridOrFilter';
46

57
export const routes = [
68
{
@@ -11,6 +13,14 @@ export const routes = [
1113
path: '/readonly-grid',
1214
element: <ReadOnlyGrid />,
1315
},
16+
{
17+
path: '/readonly-grid-single-property-filter',
18+
element: <ReadOnlyGridSinglePropertyFilter />,
19+
},
20+
{
21+
path: '/readonly-grid-or-filter',
22+
element: <ReadOnlyGridOrFilter />,
23+
},
1424
],
1525
},
1626
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { TextField } from '@hilla/react-components/TextField.js';
2+
import { AutoGrid } from '@hilla/react-grid';
3+
import Filter from 'Frontend/generated/dev/hilla/crud/filter/Filter';
4+
import PersonModel from 'Frontend/generated/dev/hilla/test/reactgrid/PersonModel';
5+
import { PersonService } from 'Frontend/generated/endpoints';
6+
import { useState } from 'react';
7+
8+
export function ReadOnlyGridOrFilter() {
9+
const [filter, setFilter] = useState<Filter | undefined>(undefined);
10+
11+
return (
12+
<div>
13+
<TextField
14+
id="filter"
15+
style={{ width: '20em' }}
16+
label="Search for first or last name"
17+
onValueChanged={(e) => {
18+
const firstNameFilter: any = {
19+
t: 'p',
20+
propertyId: 'firstName',
21+
matcher: 'CONTAINS',
22+
filterValue: e.detail.value,
23+
};
24+
const lasttNameFilter: any = {
25+
t: 'p',
26+
propertyId: 'lastName',
27+
matcher: 'CONTAINS',
28+
filterValue: e.detail.value,
29+
};
30+
setFilter({ t: 'o', children: [firstNameFilter, lasttNameFilter] });
31+
}}
32+
></TextField>
33+
<AutoGrid pageSize={10} service={PersonService} model={PersonModel} filter={filter} />
34+
</div>
35+
/* page size is defined only to make testing easier */
36+
);
37+
}

0 commit comments

Comments
 (0)