├── .gitignore ├── src ├── test │ ├── java │ │ └── org │ │ │ └── springframework │ │ │ └── data │ │ │ └── jpa │ │ │ └── datatables │ │ │ ├── repository │ │ │ ├── UserRepository.java │ │ │ ├── RelationshipsRepository.java │ │ │ ├── EmployeeRepository.java │ │ │ ├── OfficeRepository.java │ │ │ ├── User.java │ │ │ ├── EmployeeController.java │ │ │ ├── RepositoryTest.java │ │ │ ├── UserRepositoryTest.java │ │ │ ├── RelationshipsRepositoryTest.java │ │ │ ├── EmployeeControllerTest.java │ │ │ └── EmployeeRepositoryTest.java │ │ │ ├── TestApplication.java │ │ │ ├── qrepository │ │ │ ├── QRelationshipsRepository.java │ │ │ ├── QEmployeeRepository.java │ │ │ ├── QRelationshipsRepositoryTest.java │ │ │ └── QEmployeeRepositoryTest.java │ │ │ ├── model │ │ │ ├── EmployeeDto.java │ │ │ ├── D.java │ │ │ ├── B.java │ │ │ ├── C.java │ │ │ ├── Office.java │ │ │ ├── A.java │ │ │ └── Employee.java │ │ │ ├── QConfig.java │ │ │ ├── mapping │ │ │ └── DataTablesInputTest.java │ │ │ └── Config.java │ └── resources │ │ └── log4j.properties └── main │ └── java │ └── org │ └── springframework │ └── data │ └── jpa │ └── datatables │ ├── Filter.java │ ├── mapping │ ├── SearchPanes.java │ ├── Search.java │ ├── Order.java │ ├── Column.java │ ├── DataTablesOutput.java │ └── DataTablesInput.java │ ├── Node.java │ ├── qrepository │ ├── QDataTablesRepositoryFactoryBean.java │ ├── QDataTablesRepository.java │ └── QDataTablesRepositoryImpl.java │ ├── repository │ ├── DataTablesRepositoryFactoryBean.java │ ├── DataTablesRepository.java │ └── DataTablesRepositoryImpl.java │ ├── GlobalFilter.java │ ├── PredicateBuilder.java │ ├── ColumnFilter.java │ ├── SpecificationBuilder.java │ └── AbstractPredicateBuilder.java ├── compose.yaml ├── jquery.spring-friendly.min.js ├── jquery.spring-friendly.js ├── .github └── workflows │ └── ci.yml ├── HISTORY.md ├── pom.xml ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .classpath 2 | .project 3 | .springBeans 4 | .settings/ 5 | .idea 6 | *.iml 7 | build/ 8 | target/ -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/repository/UserRepository.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | public interface UserRepository extends DataTablesRepository { 4 | } 5 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/TestApplication.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables; 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication; 4 | 5 | @SpringBootApplication 6 | public class TestApplication { 7 | } 8 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/repository/RelationshipsRepository.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | import org.springframework.data.jpa.datatables.model.A; 4 | 5 | interface RelationshipsRepository extends DataTablesRepository { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/repository/EmployeeRepository.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | import org.springframework.data.jpa.datatables.model.Employee; 4 | 5 | interface EmployeeRepository extends DataTablesRepository { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/qrepository/QRelationshipsRepository.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.qrepository; 2 | 3 | import org.springframework.data.jpa.datatables.model.A; 4 | 5 | public interface QRelationshipsRepository extends QDataTablesRepository { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/qrepository/QEmployeeRepository.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.qrepository; 2 | 3 | import org.springframework.data.jpa.datatables.model.Employee; 4 | 5 | public interface QEmployeeRepository extends QDataTablesRepository { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/repository/OfficeRepository.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | import org.springframework.data.jpa.datatables.model.Office; 4 | import org.springframework.data.repository.CrudRepository; 5 | 6 | interface OfficeRepository extends CrudRepository { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/test/resources/log4j.properties: -------------------------------------------------------------------------------- 1 | log4j.rootCategory=WARN, console 2 | 3 | log4j.appender.console.layout=org.apache.log4j.PatternLayout 4 | log4j.appender.console.layout.ConversionPattern=%d{ABSOLUTE} %5p %40.40c:%4L - %m%n 5 | log4j.appender.console=org.apache.log4j.ConsoleAppender 6 | 7 | #log4j.logger.org.springframework=INFO 8 | #log4j.logger.org.hibernate.SQL=DEBUG 9 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/model/EmployeeDto.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.model; 2 | 3 | import lombok.Value; 4 | 5 | @Value 6 | public class EmployeeDto { 7 | private int id; 8 | private String firstName; 9 | private String lastName; 10 | 11 | public static EmployeeDto AIRI_SATOU = new EmployeeDto(5407, "Airi", "Satou"); 12 | 13 | } -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/model/D.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.model; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import jakarta.persistence.Embeddable; 9 | 10 | @Data 11 | @AllArgsConstructor 12 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 13 | @Embeddable 14 | class D { 15 | private String someValue; 16 | } -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | postgres: 3 | image: postgres:14 4 | ports: 5 | - 5432:5432 6 | environment: 7 | POSTGRES_PASSWORD: "postgres" 8 | 9 | sqlserver: 10 | image: mcr.microsoft.com/mssql/server:2022-preview-ubuntu-22.04 11 | ports: 12 | - 1433:1433 13 | environment: 14 | ACCEPT_EULA: Y 15 | MSSQL_SA_PASSWORD: "Changeit_123" 16 | 17 | mysql: 18 | image: mysql:5.7 19 | ports: 20 | - 3306:3306 21 | environment: 22 | MYSQL_DATABASE: test 23 | MYSQL_ROOT_PASSWORD: changeit 24 | -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/Filter.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables; 2 | 3 | import com.querydsl.core.types.dsl.PathBuilder; 4 | 5 | import jakarta.persistence.criteria.CriteriaBuilder; 6 | import jakarta.persistence.criteria.From; 7 | import jakarta.persistence.criteria.Predicate; 8 | 9 | interface Filter { 10 | 11 | Predicate createPredicate(From from, CriteriaBuilder criteriaBuilder, String attributeName); 12 | 13 | com.querydsl.core.types.Predicate createPredicate(PathBuilder pathBuilder, String attributeName); 14 | } -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/model/B.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.model; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import jakarta.persistence.Entity; 9 | import jakarta.persistence.Id; 10 | import jakarta.persistence.Table; 11 | 12 | @Data 13 | @AllArgsConstructor 14 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 15 | @Entity 16 | @Table(name = "b") 17 | class B { 18 | private @Id String name; 19 | private String someValue; 20 | } -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/mapping/SearchPanes.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.mapping; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | 6 | import java.util.List; 7 | import java.util.Map; 8 | 9 | @Data 10 | @AllArgsConstructor 11 | public class SearchPanes { 12 | private Map> options; 13 | 14 | @Data 15 | @AllArgsConstructor 16 | public static class Item { 17 | private String label; 18 | private String value; 19 | private long total; 20 | private long count; 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/QConfig.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables; 2 | 3 | import org.springframework.context.annotation.Configuration; 4 | import org.springframework.data.jpa.datatables.qrepository.QDataTablesRepositoryFactoryBean; 5 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories; 6 | 7 | @Configuration 8 | @EnableJpaRepositories(repositoryFactoryBeanClass = QDataTablesRepositoryFactoryBean.class, 9 | basePackages = "org.springframework.data.jpa.datatables.qrepository") 10 | public class QConfig { 11 | 12 | } 13 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/repository/User.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | import jakarta.persistence.Entity; 4 | import jakarta.persistence.Id; 5 | import jakarta.persistence.Table; 6 | import lombok.Data; 7 | import org.hibernate.annotations.Nationalized; 8 | 9 | @Entity 10 | @Data 11 | @Table(name = "users") 12 | public class User { 13 | 14 | @Id long id; 15 | @Nationalized String name; 16 | 17 | public User() { 18 | } 19 | 20 | public User(long id, String name) { 21 | this.id = id; 22 | this.name = name; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/model/C.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.model; 2 | 3 | import lombok.AccessLevel; 4 | import lombok.AllArgsConstructor; 5 | import lombok.Data; 6 | import lombok.NoArgsConstructor; 7 | 8 | import jakarta.persistence.*; 9 | 10 | @Data 11 | @AllArgsConstructor 12 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 13 | @Entity 14 | @Table(name = "c") 15 | class C { 16 | 17 | private @Id String name; 18 | 19 | private String someValue; 20 | 21 | @ManyToOne(cascade = CascadeType.ALL) 22 | @JoinColumn(name = "parent_id") 23 | private C parent; 24 | 25 | } -------------------------------------------------------------------------------- /jquery.spring-friendly.min.js: -------------------------------------------------------------------------------- 1 | !function(e){function n(e,t,r,o){var u;if(jQuery.isArray(t))jQuery.each(t,function(t,u){r||i.test(e)?o(e,u):n(e+"["+("object"==typeof u?t:"")+"]",u,r,o)});else if(r||"object"!==jQuery.type(t))o(e,t);else for(u in t)n(e+"."+u,t[u],r,o)}var t=/%20/g,i=/\[\]$/;e.param=function(e,i){var r,o=[],u=function(e,n){n=jQuery.isFunction(n)?n():null==n?"":n,o[o.length]=encodeURIComponent(e)+"="+encodeURIComponent(n)};if(void 0===i&&(i=jQuery.ajaxSettings&&jQuery.ajaxSettings.traditional),jQuery.isArray(e)||e.jquery&&!jQuery.isPlainObject(e))jQuery.each(e,function(){u(this.name,this.value)});else for(r in e)n(r,e[r],i,u);return o.join("&").replace(t,"+")}}(jQuery); -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/mapping/Search.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.mapping; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import jakarta.validation.constraints.NotNull; 8 | 9 | @Data 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | public class Search { 13 | 14 | /** 15 | * Global search value. To be applied to all columns which have searchable as true. 16 | */ 17 | @NotNull 18 | private String value; 19 | 20 | /** 21 | * true if the global filter should be treated as a regular expression for advanced searching, 22 | * false otherwise. Note that normally server-side processing scripts will not perform regular 23 | * expression searching for performance reasons on large data sets, but it is technically possible 24 | * and at the discretion of your script. 25 | */ 26 | @NotNull 27 | private Boolean regex; 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/mapping/Order.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.mapping; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import jakarta.validation.constraints.Min; 8 | import jakarta.validation.constraints.NotNull; 9 | import jakarta.validation.constraints.Pattern; 10 | 11 | @Data 12 | @NoArgsConstructor 13 | @AllArgsConstructor 14 | public class Order { 15 | 16 | /** 17 | * Column to which ordering should be applied. This is an index reference to the columns array of 18 | * information that is also submitted to the server. 19 | */ 20 | @NotNull 21 | @Min(0) 22 | private Integer column; 23 | 24 | /** 25 | * Ordering direction for this column. It will be asc or desc to indicate ascending ordering or 26 | * descending ordering, respectively. 27 | */ 28 | @NotNull 29 | @Pattern(regexp = "(desc|asc)") 30 | private String dir; 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/Node.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables; 2 | 3 | import java.util.ArrayList; 4 | import java.util.List; 5 | 6 | class Node { 7 | private final String name; 8 | private final T data; 9 | private List> children = new ArrayList<>(); 10 | 11 | Node(String name, T data) { 12 | this.name = name; 13 | this.data = data; 14 | } 15 | 16 | Node(String name) { 17 | this.name = name; 18 | this.data = null; 19 | } 20 | 21 | void addChild(Node child) { 22 | children.add(child); 23 | } 24 | 25 | Node getOrCreateChild(String name) { 26 | for (Node child : children) { 27 | if (child.name.equals(name)) { 28 | return child; 29 | } 30 | } 31 | Node child = new Node<>(name); 32 | children.add(child); 33 | return child; 34 | } 35 | 36 | boolean isLeaf() { 37 | return this.children.isEmpty(); 38 | } 39 | 40 | public T getData() { 41 | return data; 42 | } 43 | 44 | public String getName() { 45 | return name; 46 | } 47 | 48 | List> getChildren() { 49 | return children; 50 | } 51 | } -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/mapping/DataTablesInputTest.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.mapping; 2 | 3 | import org.junit.jupiter.api.Test;; 4 | 5 | import java.util.*; 6 | 7 | import static java.util.Arrays.asList; 8 | import static org.assertj.core.api.Assertions.assertThat; 9 | import static org.assertj.core.api.Assertions.entry; 10 | 11 | public class DataTablesInputTest { 12 | 13 | @Test 14 | public void testParseSearchPanes() { 15 | DataTablesInput input = new DataTablesInput(); 16 | HashMap queryParams = new HashMap<>(); 17 | queryParams.put("searchPanes.attr1.0", "1"); 18 | queryParams.put("searchPanes.attr1.1", "2"); 19 | queryParams.put("searchPanes.attr2.0", "3"); 20 | queryParams.put("searchPanes.attr3.test", "4"); 21 | queryParams.put("searchPanes.attr4.0", "5"); 22 | queryParams.put("ignored", "6"); 23 | queryParams.put("searchPanes.a.t.t.r.5.0", "7"); 24 | 25 | input.parseSearchPanesFromQueryParams(queryParams, asList("attr1", "attr2", "a.t.t.r.5")); 26 | 27 | assertThat(input.getSearchPanes()).containsOnly( 28 | entry("attr1", Set.of("1", "2")), 29 | entry("attr2", Set.of("3")), 30 | entry("a.t.t.r.5", Set.of("7")) 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/repository/EmployeeController.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | import jakarta.validation.Valid; 4 | import org.springframework.data.jpa.datatables.mapping.DataTablesInput; 5 | import org.springframework.data.jpa.datatables.mapping.DataTablesOutput; 6 | import org.springframework.data.jpa.datatables.model.Employee; 7 | import org.springframework.web.bind.annotation.RequestBody; 8 | import org.springframework.web.bind.annotation.RequestMapping; 9 | import org.springframework.web.bind.annotation.RequestMethod; 10 | import org.springframework.web.bind.annotation.RestController; 11 | 12 | @RestController 13 | class EmployeeController { 14 | private final EmployeeRepository employeeRepository; 15 | 16 | public EmployeeController(EmployeeRepository employeeRepository) { 17 | this.employeeRepository = employeeRepository; 18 | } 19 | 20 | @RequestMapping(value = "/employees", method = RequestMethod.GET) 21 | public DataTablesOutput findEmployees(@Valid DataTablesInput input) { 22 | return employeeRepository.findAll(input); 23 | } 24 | 25 | @RequestMapping(value = "/employees", method = RequestMethod.POST) 26 | public DataTablesOutput findEmployeesWithPOST( 27 | @Valid @RequestBody DataTablesInput input) { 28 | return employeeRepository.findAll(input); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/qrepository/QRelationshipsRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.qrepository; 2 | 3 | import org.junit.jupiter.api.Disabled; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.extension.ExtendWith; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.data.jpa.datatables.Config; 8 | import org.springframework.data.jpa.datatables.QConfig; 9 | import org.springframework.data.jpa.datatables.mapping.DataTablesInput; 10 | import org.springframework.data.jpa.datatables.mapping.DataTablesOutput; 11 | import org.springframework.data.jpa.datatables.model.A; 12 | import org.springframework.data.jpa.datatables.repository.RelationshipsRepositoryTest; 13 | import org.springframework.test.context.ContextConfiguration; 14 | import org.springframework.test.context.junit.jupiter.SpringExtension; 15 | 16 | @ExtendWith(SpringExtension.class) 17 | @ContextConfiguration(classes = {Config.class, QConfig.class}) 18 | public class QRelationshipsRepositoryTest extends RelationshipsRepositoryTest { 19 | 20 | @Autowired 21 | private QRelationshipsRepository repository; 22 | 23 | @Override 24 | protected DataTablesOutput getOutput(DataTablesInput input) { 25 | return repository.findAll(input); 26 | } 27 | 28 | @Test 29 | @Disabled 30 | @Override 31 | public void checkFetchJoin() { 32 | // see https://github.com/darrachequesne/spring-data-jpa-datatables/issues/7 33 | } 34 | } -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/model/Office.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.model; 2 | 3 | import jakarta.persistence.JoinColumn; 4 | import jakarta.persistence.ManyToOne; 5 | import jakarta.persistence.Transient; 6 | import lombok.AccessLevel; 7 | import lombok.Builder; 8 | import lombok.Data; 9 | import lombok.Setter; 10 | import lombok.experimental.Tolerate; 11 | 12 | import jakarta.persistence.Entity; 13 | import jakarta.persistence.Id; 14 | import jakarta.persistence.Table; 15 | 16 | @Data 17 | @Setter(AccessLevel.NONE) 18 | @Builder 19 | @Entity 20 | @Table(name = "offices") 21 | public class Office { 22 | @Id private int id; 23 | private String city; 24 | private String country; 25 | 26 | @Tolerate 27 | private Office() {} 28 | 29 | @Transient 30 | public static Office TOKYO = Office.builder() 31 | .id(1) 32 | .city("Tokyo") 33 | .country("Japan") 34 | .build(); 35 | 36 | @Transient 37 | public static Office LONDON = Office.builder() 38 | .id(2) 39 | .city("London") 40 | .country("UK") 41 | .build(); 42 | 43 | @Transient 44 | public static Office SAN_FRANCISCO = Office.builder() 45 | .id(3) 46 | .city("San Francisco") 47 | .country("USA") 48 | .build(); 49 | 50 | @Transient 51 | public static Office NEW_YORK = Office.builder() 52 | .id(4) 53 | .city("New York") 54 | .country("USA") 55 | .build(); 56 | } -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/mapping/Column.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.mapping; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Data; 5 | import lombok.NoArgsConstructor; 6 | 7 | import jakarta.validation.constraints.NotBlank; 8 | import jakarta.validation.constraints.NotNull; 9 | 10 | @Data 11 | @NoArgsConstructor 12 | @AllArgsConstructor 13 | public class Column { 14 | 15 | /** 16 | * Column's data source 17 | * 18 | * @see http://datatables.net/reference/option/columns.data 19 | */ 20 | @NotBlank 21 | private String data; 22 | 23 | /** 24 | * Column's name 25 | * 26 | * @see http://datatables.net/reference/option/columns.name 27 | */ 28 | private String name; 29 | 30 | /** 31 | * Flag to indicate if this column is searchable (true) or not (false). 32 | * 33 | * @see http://datatables.net/reference/option/columns.searchable 34 | */ 35 | @NotNull 36 | private Boolean searchable; 37 | 38 | /** 39 | * Flag to indicate if this column is orderable (true) or not (false). 40 | * 41 | * @see http://datatables.net/reference/option/columns.orderable 42 | */ 43 | @NotNull 44 | private Boolean orderable; 45 | 46 | /** 47 | * Search value to apply to this specific column. 48 | */ 49 | @NotNull 50 | private Search search; 51 | 52 | /** 53 | * Set the search value to apply to this column 54 | * 55 | * @param searchValue if any, the search value to apply 56 | */ 57 | public void setSearchValue(String searchValue) { 58 | this.search.setValue(searchValue); 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/repository/RepositoryTest.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.junit.jupiter.api.Test; 6 | import org.junit.jupiter.api.extension.ExtendWith; 7 | import org.springframework.aop.framework.Advised; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.data.jpa.datatables.Config; 10 | import org.springframework.data.jpa.datatables.QConfig; 11 | import org.springframework.data.jpa.datatables.qrepository.QDataTablesRepositoryImpl; 12 | import org.springframework.data.jpa.datatables.qrepository.QEmployeeRepository; 13 | import org.springframework.data.jpa.repository.support.SimpleJpaRepository; 14 | import org.springframework.test.context.ContextConfiguration; 15 | import org.springframework.test.context.junit.jupiter.SpringExtension; 16 | 17 | @ExtendWith(SpringExtension.class) 18 | @ContextConfiguration(classes = {Config.class, QConfig.class}) 19 | class RepositoryTest { 20 | 21 | private @Autowired EmployeeRepository employeeRepository; 22 | private @Autowired QEmployeeRepository qEmployeeRepository; 23 | private @Autowired OfficeRepository officeRepository; 24 | 25 | @Test 26 | void checkGeneratedRepositories() { 27 | assertThat(getTargetObject(employeeRepository)).isEqualTo(DataTablesRepositoryImpl.class); 28 | assertThat(getTargetObject(officeRepository)).isEqualTo(SimpleJpaRepository.class); 29 | assertThat(getTargetObject(qEmployeeRepository)).isEqualTo(QDataTablesRepositoryImpl.class); 30 | } 31 | 32 | // returns the class of the proxied object 33 | private Class getTargetObject(Object proxy) { 34 | return ((Advised) proxy).getTargetSource().getTargetClass(); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/repository/UserRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.api.extension.ExtendWith; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.data.jpa.datatables.Config; 7 | import org.springframework.data.jpa.datatables.mapping.DataTablesInput; 8 | import org.springframework.data.jpa.datatables.mapping.DataTablesOutput; 9 | import org.springframework.test.context.ContextConfiguration; 10 | import org.springframework.test.context.junit.jupiter.DisabledIf; 11 | import org.springframework.test.context.junit.jupiter.SpringExtension; 12 | 13 | import static org.assertj.core.api.Assertions.assertThat; 14 | 15 | @ExtendWith(SpringExtension.class) 16 | @ContextConfiguration(classes = Config.class) 17 | public class UserRepositoryTest { 18 | 19 | @Autowired private UserRepository userRepository; 20 | 21 | private void init() { 22 | userRepository.save(new User(1L, "r")); 23 | userRepository.save(new User(2L, "ř")); 24 | userRepository.save(new User(3L, "ŗ")); 25 | userRepository.save(new User(4L, "ɍ")); 26 | userRepository.save(new User(5L, "ȓ")); 27 | } 28 | 29 | @Test 30 | @DisabledIf(value = "#{'${spring.profiles.active}' == 'mysql'}", loadContext = true) 31 | void withNationalizedColumn() { 32 | init(); 33 | 34 | DataTablesInput input = new DataTablesInput(); 35 | input.addColumn("name", true, true, "ř"); 36 | 37 | DataTablesOutput output = userRepository.findAll(input); 38 | 39 | assertThat(output.getRecordsFiltered()).isEqualTo(1); 40 | assertThat(output.getData().get(0).getId()).isEqualTo(2L); 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/model/A.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.model; 2 | 3 | import lombok.*; 4 | 5 | import jakarta.persistence.*; 6 | import java.util.Collections; 7 | import java.util.List; 8 | 9 | import static java.util.Arrays.asList; 10 | import static java.util.Collections.singletonList; 11 | 12 | @Data 13 | @AllArgsConstructor 14 | @NoArgsConstructor(access = AccessLevel.PRIVATE) 15 | @EqualsAndHashCode(exclude = "b") 16 | @ToString(exclude = "b") 17 | @Builder 18 | @Entity 19 | @Table(name = "a") 20 | public class A { 21 | 22 | private @Id String name; 23 | 24 | @OneToMany(cascade = CascadeType.ALL) 25 | @JoinColumn(name="a_id") 26 | private List b; 27 | 28 | @ManyToOne(cascade = CascadeType.ALL) 29 | @JoinColumn(name = "c_id") 30 | private C c; 31 | 32 | @Embedded 33 | private D d; 34 | 35 | private static C C1 = new C("C1", "VAL1", new C("C3", "VAL3", null)); 36 | private static C C2 = new C("C2", "VAL2", null); 37 | 38 | public static A A1 = A.builder() 39 | .name("A1") 40 | .b(asList( 41 | new B("B1", "VAL1"), 42 | new B("B2", "VAL2") 43 | )) 44 | .c(C1) 45 | .d(new D("D1")) 46 | .build(); 47 | 48 | public static A A2 = A.builder() 49 | .name("A2") 50 | .b(singletonList( 51 | new B("B3", "VAL3") 52 | )) 53 | .c(C2) 54 | .d(new D("D2")) 55 | .build(); 56 | 57 | public static A A3 = A.builder() 58 | .name("A3") 59 | .b(Collections.emptyList()) 60 | .c(C2) 61 | .build(); 62 | 63 | public static List ALL = asList( 64 | A1, 65 | A2, 66 | A3 67 | ); 68 | 69 | } -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/qrepository/QDataTablesRepositoryFactoryBean.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.qrepository; 2 | 3 | import org.springframework.beans.factory.FactoryBean; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; 6 | import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean; 7 | import org.springframework.data.repository.core.RepositoryMetadata; 8 | import org.springframework.data.repository.core.support.RepositoryFactorySupport; 9 | 10 | import jakarta.persistence.EntityManager; 11 | import java.io.Serializable; 12 | 13 | /** 14 | * {@link FactoryBean} creating DataTablesRepositoryFactory instances. 15 | * 16 | * @author Damien Arrachequesne 17 | */ 18 | public class QDataTablesRepositoryFactoryBean, T, ID extends Serializable> 19 | extends JpaRepositoryFactoryBean { 20 | 21 | public QDataTablesRepositoryFactoryBean(Class repositoryInterface) { 22 | super(repositoryInterface); 23 | } 24 | 25 | @Override 26 | protected RepositoryFactorySupport createRepositoryFactory(EntityManager entityManager) { 27 | return new DataTablesRepositoryFactory(entityManager); 28 | } 29 | 30 | private static class DataTablesRepositoryFactory extends JpaRepositoryFactory { 31 | DataTablesRepositoryFactory(EntityManager entityManager) { 32 | super(entityManager); 33 | } 34 | 35 | @Override 36 | protected Class getRepositoryBaseClass(RepositoryMetadata metadata) { 37 | Class repositoryInterface = metadata.getRepositoryInterface(); 38 | if (QDataTablesRepository.class.isAssignableFrom(repositoryInterface)) { 39 | return QDataTablesRepositoryImpl.class; 40 | } else { 41 | return super.getRepositoryBaseClass(metadata); 42 | } 43 | 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/repository/DataTablesRepositoryFactoryBean.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | import org.springframework.beans.factory.FactoryBean; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; 6 | import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean; 7 | import org.springframework.data.repository.core.RepositoryMetadata; 8 | import org.springframework.data.repository.core.support.RepositoryFactorySupport; 9 | 10 | import jakarta.persistence.EntityManager; 11 | import java.io.Serializable; 12 | 13 | /** 14 | * {@link FactoryBean} creating DataTablesRepositoryFactory instances. 15 | * 16 | * @author Damien Arrachequesne 17 | */ 18 | public class DataTablesRepositoryFactoryBean, T, ID extends Serializable> 19 | extends JpaRepositoryFactoryBean { 20 | 21 | public DataTablesRepositoryFactoryBean(Class repositoryInterface) { 22 | super(repositoryInterface); 23 | } 24 | 25 | protected RepositoryFactorySupport createRepositoryFactory(EntityManager entityManager) { 26 | return new DataTablesRepositoryFactory(entityManager); 27 | } 28 | 29 | private static class DataTablesRepositoryFactory 30 | extends JpaRepositoryFactory { 31 | 32 | public DataTablesRepositoryFactory(EntityManager entityManager) { 33 | super(entityManager); 34 | } 35 | 36 | @Override 37 | protected Class getRepositoryBaseClass(RepositoryMetadata metadata) { 38 | Class repositoryInterface = metadata.getRepositoryInterface(); 39 | if (DataTablesRepository.class.isAssignableFrom(repositoryInterface)) { 40 | return DataTablesRepositoryImpl.class; 41 | } else { 42 | return super.getRepositoryBaseClass(metadata); 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/mapping/DataTablesOutput.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.mapping; 2 | 3 | import com.fasterxml.jackson.annotation.JsonView; 4 | import lombok.Data; 5 | 6 | import java.util.Collections; 7 | import java.util.List; 8 | 9 | @Data 10 | public class DataTablesOutput { 11 | 12 | /** 13 | * The draw counter that this object is a response to - from the draw parameter sent as part of 14 | * the data request. Note that it is strongly recommended for security reasons that you cast this 15 | * parameter to an integer, rather than simply echoing back to the client what it sent in the draw 16 | * parameter, in order to prevent Cross Site Scripting (XSS) attacks. 17 | */ 18 | @JsonView(View.class) 19 | private int draw; 20 | 21 | /** 22 | * Total records, before filtering (i.e. the total number of records in the database) 23 | */ 24 | @JsonView(View.class) 25 | private long recordsTotal = 0L; 26 | 27 | /** 28 | * Total records, after filtering (i.e. the total number of records after filtering has been 29 | * applied - not just the number of records being returned for this page of data). 30 | */ 31 | @JsonView(View.class) 32 | private long recordsFiltered = 0L; 33 | 34 | /** 35 | * The data to be displayed in the table. This is an array of data source objects, one for each 36 | * row, which will be used by DataTables. Note that this parameter's name can be changed using the 37 | * ajaxDT option's dataSrc property. 38 | */ 39 | @JsonView(View.class) 40 | private List data = Collections.emptyList(); 41 | 42 | /** 43 | * Output for the SearchPanes extension 44 | */ 45 | @JsonView(View.class) 46 | private SearchPanes searchPanes; 47 | 48 | /** 49 | * Optional: If an error occurs during the running of the server-side processing script, you can 50 | * inform the user of this error by passing back the error message to be displayed using this 51 | * parameter. Do not include if there is no error. 52 | */ 53 | @JsonView(View.class) 54 | private String error; 55 | 56 | public interface View { 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/GlobalFilter.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables; 2 | 3 | import com.querydsl.core.types.Ops; 4 | import com.querydsl.core.types.dsl.Expressions; 5 | import com.querydsl.core.types.dsl.PathBuilder; 6 | import com.querydsl.core.types.dsl.StringOperation; 7 | 8 | import jakarta.persistence.criteria.CriteriaBuilder; 9 | import jakarta.persistence.criteria.Expression; 10 | import jakarta.persistence.criteria.From; 11 | import jakarta.persistence.criteria.Predicate; 12 | import org.hibernate.query.criteria.JpaExpression; 13 | 14 | /** 15 | * Filter which creates a basic "WHERE ... LIKE ..." clause 16 | */ 17 | class GlobalFilter implements Filter { 18 | private final String escapedRawValue; 19 | 20 | GlobalFilter(String filterValue) { 21 | escapedRawValue = escapeValue(filterValue); 22 | } 23 | 24 | String nullOrTrimmedValue(String value) { 25 | return "\\NULL".equals(value) ? "NULL" : value.trim(); 26 | } 27 | 28 | private String escapeValue(String filterValue) { 29 | return "%" + nullOrTrimmedValue(filterValue).toLowerCase() 30 | .replaceAll("~", "~~") 31 | .replaceAll("%", "~%") 32 | .replaceAll("_", "~_") + "%"; 33 | } 34 | 35 | @Override 36 | public Predicate createPredicate(From from, CriteriaBuilder criteriaBuilder, String attributeName) { 37 | Expression expression = from.get(attributeName); 38 | return criteriaBuilder.like(criteriaBuilder.lower(castAsStringIfNeeded(expression)), escapedRawValue, '~'); 39 | } 40 | 41 | @SuppressWarnings("unchecked") 42 | private Expression castAsStringIfNeeded(Expression expression) { 43 | if (expression.getJavaType() == String.class) { 44 | return (Expression) expression; 45 | } else { 46 | return ((JpaExpression) expression).cast(String.class); 47 | } 48 | } 49 | 50 | @Override 51 | public com.querydsl.core.types.Predicate createPredicate(PathBuilder pathBuilder, String attributeName) { 52 | StringOperation path = Expressions.stringOperation(Ops.STRING_CAST, pathBuilder.get(attributeName)); 53 | return path.lower().like(escapedRawValue, '~'); 54 | } 55 | } -------------------------------------------------------------------------------- /jquery.spring-friendly.js: -------------------------------------------------------------------------------- 1 | // From https://github.com/jquery/jquery/blob/master/src/serialize.js 2 | // Overrides data serialization to allow Spring MVC to correctly map input parameters : column[0][data] now becomes column[0].data 3 | (function($) { 4 | var r20 = /%20/g, rbracket = /\[\]$/, rCRLF = /\r?\n/g, rsubmitterTypes = /^(?:submit|button|image|reset|file)$/i, rsubmittable = /^(?:input|select|textarea|keygen)/i; 5 | 6 | function customBuildParams(prefix, obj, traditional, add) { 7 | var name; 8 | 9 | if (jQuery.isArray(obj)) { 10 | // Serialize array item. 11 | jQuery.each(obj, function(i, v) { 12 | if (traditional || rbracket.test(prefix)) { 13 | // Treat each array item as a scalar. 14 | add(prefix, v); 15 | 16 | } else { 17 | // Item is non-scalar (array or object), encode its numeric 18 | // index. 19 | customBuildParams(prefix + "[" 20 | + (typeof v === "object" ? i : "") + "]", v, 21 | traditional, add); 22 | } 23 | }); 24 | 25 | } else if (!traditional && jQuery.type(obj) === "object") { 26 | // Serialize object item. 27 | for (name in obj) { 28 | // This is where the magic happens 29 | customBuildParams(prefix + "." + name, obj[name], traditional, 30 | add); 31 | } 32 | 33 | } else { 34 | // Serialize scalar item. 35 | add(prefix, obj); 36 | } 37 | } 38 | 39 | $.param = function(a, traditional) { 40 | var prefix, s = [], add = function(key, value) { 41 | // If value is a function, invoke it and return its value 42 | value = jQuery.isFunction(value) ? value() : (value == null ? "" 43 | : value); 44 | s[s.length] = encodeURIComponent(key) + "=" 45 | + encodeURIComponent(value); 46 | }; 47 | 48 | // Set traditional to true for jQuery <= 1.3.2 behavior. 49 | if (traditional === undefined) { 50 | traditional = jQuery.ajaxSettings 51 | && jQuery.ajaxSettings.traditional; 52 | } 53 | 54 | // If an array was passed in, assume that it is an array of form 55 | // elements. 56 | if (jQuery.isArray(a) || (a.jquery && !jQuery.isPlainObject(a))) { 57 | // Serialize the form elements 58 | jQuery.each(a, function() { 59 | add(this.name, this.value); 60 | }); 61 | 62 | } else { 63 | // If traditional, encode the "old" way (the way 1.3.2 or older 64 | // did it), otherwise encode params recursively. 65 | for (prefix in a) { 66 | customBuildParams(prefix, a[prefix], traditional, add); 67 | } 68 | } 69 | 70 | // Return the resulting serialization 71 | return s.join("&").replace(r20, "+"); 72 | }; 73 | })(jQuery); -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/PredicateBuilder.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables; 2 | 3 | import com.querydsl.core.BooleanBuilder; 4 | import com.querydsl.core.types.Predicate; 5 | import com.querydsl.core.types.dsl.PathBuilder; 6 | import org.springframework.data.jpa.datatables.mapping.DataTablesInput; 7 | 8 | import java.util.ArrayList; 9 | import java.util.List; 10 | 11 | public class PredicateBuilder extends AbstractPredicateBuilder { 12 | private final PathBuilder entity; 13 | private List columnPredicates = new ArrayList<>(); 14 | private List globalPredicates = new ArrayList<>(); 15 | 16 | public PredicateBuilder(PathBuilder entity, DataTablesInput input) { 17 | super(input); 18 | this.entity = entity; 19 | } 20 | 21 | @Override 22 | public Predicate build() { 23 | initPredicatesRecursively(tree, entity); 24 | 25 | if (input.getSearchPanes() != null) { 26 | input.getSearchPanes().forEach((attribute, values) -> { 27 | if (!values.isEmpty()) { 28 | columnPredicates.add(entity.get(attribute).in(values)); 29 | } 30 | }); 31 | } 32 | 33 | return createFinalPredicate(); 34 | } 35 | 36 | private void initPredicatesRecursively(Node node, PathBuilder pathBuilder) { 37 | if (node.isLeaf()) { 38 | boolean hasColumnFilter = node.getData() != null; 39 | if (hasColumnFilter) { 40 | Filter columnFilter = node.getData(); 41 | columnPredicates.add(columnFilter.createPredicate(pathBuilder, node.getName())); 42 | } else if (hasGlobalFilter) { 43 | Filter globalFilter = tree.getData(); 44 | globalPredicates.add(globalFilter.createPredicate(pathBuilder, node.getName())); 45 | } 46 | } 47 | for (Node child : node.getChildren()) { 48 | initPredicatesRecursively(child, child.isLeaf() ? pathBuilder : pathBuilder.get(child.getName())); 49 | } 50 | } 51 | 52 | private Predicate createFinalPredicate() { 53 | BooleanBuilder predicate = new BooleanBuilder(); 54 | 55 | for (Predicate columnPredicate : columnPredicates) { 56 | predicate = predicate.and(columnPredicate); 57 | } 58 | 59 | if (!globalPredicates.isEmpty()) { 60 | predicate = predicate.andAnyOf(globalPredicates.toArray(new Predicate[0])); 61 | } 62 | 63 | return predicate; 64 | } 65 | 66 | } -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/qrepository/QEmployeeRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.qrepository; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.util.function.Function; 6 | import org.junit.jupiter.api.Disabled; 7 | import org.junit.jupiter.api.Test; 8 | import org.junit.jupiter.api.extension.ExtendWith; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.data.jpa.datatables.Config; 11 | import org.springframework.data.jpa.datatables.QConfig; 12 | import org.springframework.data.jpa.datatables.mapping.DataTablesInput; 13 | import org.springframework.data.jpa.datatables.mapping.DataTablesOutput; 14 | import org.springframework.data.jpa.datatables.model.Employee; 15 | import org.springframework.data.jpa.datatables.model.EmployeeDto; 16 | import org.springframework.data.jpa.datatables.model.QEmployee; 17 | import org.springframework.data.jpa.datatables.repository.EmployeeRepositoryTest; 18 | import org.springframework.test.context.ContextConfiguration; 19 | import org.springframework.test.context.junit.jupiter.SpringExtension; 20 | 21 | @ExtendWith(SpringExtension.class) 22 | @ContextConfiguration(classes = {Config.class, QConfig.class}) 23 | public class QEmployeeRepositoryTest extends EmployeeRepositoryTest { 24 | @Autowired 25 | private QEmployeeRepository employeeRepository; 26 | 27 | @Override 28 | protected DataTablesOutput getOutput(DataTablesInput input) { 29 | return employeeRepository.findAll(input); 30 | } 31 | 32 | @Override 33 | protected DataTablesOutput getOutput(DataTablesInput input, Function converter) { 34 | return employeeRepository.findAll(input, converter); 35 | } 36 | 37 | @Test 38 | @Override 39 | public void withAnAdditionalSpecification() { 40 | DataTablesInput input = createInput(); 41 | 42 | DataTablesOutput output = employeeRepository.findAll(input, QEmployee.employee.position.eq("Software Engineer")); 43 | assertThat(output.getRecordsFiltered()).isEqualTo(2); 44 | assertThat(output.getRecordsTotal()).isEqualTo(Employee.ALL.size()); 45 | } 46 | 47 | @Test 48 | @Override 49 | public void withAPreFilteringSpecification() { 50 | DataTablesOutput output = employeeRepository.findAll(createInput(), null, QEmployee.employee.position.eq("Software Engineer")); 51 | assertThat(output.getRecordsFiltered()).isEqualTo(2); 52 | assertThat(output.getRecordsTotal()).isEqualTo(2); 53 | } 54 | 55 | @Test 56 | @Disabled 57 | @Override 58 | public void unknownColumn() { 59 | // the findAll() method throws "Transaction silently rolled back because it has been marked as rollback-only", needs investigation 60 | } 61 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * 0' 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | jdk-version: 16 | - 17 17 | - 21 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up JDK ${{ matrix.jdk-version }} 24 | uses: actions/setup-java@v4 25 | with: 26 | java-version: ${{ matrix.jdk-version }} 27 | distribution: adopt 28 | cache: maven 29 | 30 | - name: Test with Maven 31 | run: mvn -B test 32 | 33 | test-postgres: 34 | runs-on: ubuntu-latest 35 | 36 | services: 37 | postgres: 38 | image: postgres 39 | env: 40 | POSTGRES_PASSWORD: postgres 41 | options: >- 42 | --health-cmd pg_isready 43 | --health-interval 10s 44 | --health-timeout 5s 45 | --health-retries 5 46 | ports: 47 | - 5432:5432 48 | 49 | steps: 50 | - name: Checkout repository 51 | uses: actions/checkout@v4 52 | 53 | - name: Set up JDK 17 54 | uses: actions/setup-java@v4 55 | with: 56 | java-version: 17 57 | distribution: adopt 58 | cache: maven 59 | 60 | - name: Test with Maven 61 | run: mvn -B test 62 | env: 63 | spring_profiles_active: pgsql 64 | 65 | test-mysql: 66 | runs-on: ubuntu-latest 67 | 68 | services: 69 | mysql: 70 | image: mysql:5.7 71 | env: 72 | MYSQL_DATABASE: test 73 | MYSQL_ROOT_PASSWORD: changeit 74 | options: >- 75 | --health-cmd="mysqladmin ping" 76 | --health-interval 10s 77 | --health-timeout 5s 78 | --health-retries 5 79 | ports: 80 | - 3306:3306 81 | 82 | steps: 83 | - name: Checkout repository 84 | uses: actions/checkout@v4 85 | 86 | - name: Set up JDK 17 87 | uses: actions/setup-java@v4 88 | with: 89 | java-version: 17 90 | distribution: adopt 91 | cache: maven 92 | 93 | - name: Test with Maven 94 | run: mvn -B test 95 | env: 96 | spring_profiles_active: mysql 97 | 98 | test-sqlserver: 99 | runs-on: ubuntu-latest 100 | 101 | services: 102 | sqlserver: 103 | image: mcr.microsoft.com/mssql/server:2022-latest 104 | ports: 105 | - 1433:1433 106 | env: 107 | ACCEPT_EULA: Y 108 | MSSQL_SA_PASSWORD: "Changeit_123" 109 | 110 | steps: 111 | - name: Checkout repository 112 | uses: actions/checkout@v4 113 | 114 | - name: Set up JDK 17 115 | uses: actions/setup-java@v4 116 | with: 117 | java-version: 17 118 | distribution: adopt 119 | cache: maven 120 | 121 | - name: Test with Maven 122 | run: mvn -B test 123 | env: 124 | spring_profiles_active: sqlserver 125 | -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/qrepository/QDataTablesRepository.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.qrepository; 2 | 3 | import com.querydsl.core.types.Predicate; 4 | import org.springframework.data.jpa.datatables.mapping.DataTablesInput; 5 | import org.springframework.data.jpa.datatables.mapping.DataTablesOutput; 6 | import org.springframework.data.querydsl.QuerydslPredicateExecutor; 7 | import org.springframework.data.repository.NoRepositoryBean; 8 | import org.springframework.data.repository.PagingAndSortingRepository; 9 | 10 | import java.io.Serializable; 11 | import java.util.function.Function; 12 | 13 | /** 14 | * Convenience interface to allow pulling in {@link PagingAndSortingRepository} and 15 | * {@link QuerydslPredicateExecutor} functionality in one go. 16 | * 17 | * @author Damien Arrachequesne 18 | */ 19 | @NoRepositoryBean 20 | public interface QDataTablesRepository 21 | extends PagingAndSortingRepository, QuerydslPredicateExecutor { 22 | 23 | /** 24 | * Returns the filtered list for the given {@link DataTablesInput}. 25 | * 26 | * @param input the {@link DataTablesInput} mapped from the Ajax request 27 | * @return a {@link DataTablesOutput} 28 | */ 29 | DataTablesOutput findAll(DataTablesInput input); 30 | 31 | /** 32 | * Returns the filtered list for the given {@link DataTablesInput}. 33 | * 34 | * @param input the {@link DataTablesInput} mapped from the Ajax request 35 | * @param additionalPredicate an additional {@link Predicate} to apply to the query (with an "AND" 36 | * clause) 37 | * @return a {@link DataTablesOutput} 38 | */ 39 | DataTablesOutput findAll(DataTablesInput input, Predicate additionalPredicate); 40 | 41 | /** 42 | * Returns the filtered list for the given {@link DataTablesInput}. 43 | * 44 | * @param input the {@link DataTablesInput} mapped from the Ajax request 45 | * @param additionalPredicate an additional {@link Predicate} to apply to the query (with an "AND" 46 | * clause) 47 | * @param preFilteringPredicate a pre-filtering {@link Predicate} to apply to the query (with an 48 | * "AND" clause) 49 | * @return a {@link DataTablesOutput} 50 | */ 51 | DataTablesOutput findAll(DataTablesInput input, Predicate additionalPredicate, 52 | Predicate preFilteringPredicate); 53 | 54 | /** 55 | * Returns the filtered list for the given {@link DataTablesInput}. 56 | * 57 | * @param input the {@link DataTablesInput} mapped from the Ajax request 58 | * @param converter the {@link Function} to apply to the results of the query 59 | * @return a {@link DataTablesOutput} 60 | */ 61 | DataTablesOutput findAll(DataTablesInput input, Function converter); 62 | 63 | /** 64 | * Returns the filtered list for the given {@link DataTablesInput}. 65 | * 66 | * @param input the {@link DataTablesInput} mapped from the Ajax request 67 | * @param additionalPredicate an additional {@link Predicate} to apply to the query (with an "AND" 68 | * clause) 69 | * @param preFilteringPredicate a pre-filtering {@link Predicate} to apply to the query (with an 70 | * "AND" clause) 71 | * @param converter the {@link Function} to apply to the results of the query 72 | * @return a {@link DataTablesOutput} 73 | */ 74 | DataTablesOutput findAll(DataTablesInput input, Predicate additionalPredicate, 75 | Predicate preFilteringPredicate, Function converter); 76 | 77 | } 78 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/repository/RelationshipsRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.hibernate.SessionFactory; 6 | import org.hibernate.stat.Statistics; 7 | import org.junit.jupiter.api.BeforeAll; 8 | import org.junit.jupiter.api.Test; 9 | import org.junit.jupiter.api.TestInstance; 10 | import org.junit.jupiter.api.extension.ExtendWith; 11 | import org.springframework.beans.factory.annotation.Autowired; 12 | import org.springframework.data.jpa.datatables.Config; 13 | import org.springframework.data.jpa.datatables.mapping.DataTablesInput; 14 | import org.springframework.data.jpa.datatables.mapping.DataTablesOutput; 15 | import org.springframework.data.jpa.datatables.model.A; 16 | import org.springframework.test.context.ContextConfiguration; 17 | import org.springframework.test.context.junit.jupiter.SpringExtension; 18 | 19 | @ExtendWith(SpringExtension.class) 20 | @ContextConfiguration(classes = Config.class) 21 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 22 | public class RelationshipsRepositoryTest { 23 | @Autowired 24 | private RelationshipsRepository repository; 25 | 26 | @Autowired 27 | private SessionFactory sessionFactory; 28 | 29 | protected DataTablesOutput getOutput(DataTablesInput input) { 30 | return repository.findAll(input); 31 | } 32 | 33 | @BeforeAll 34 | public void init() { 35 | repository.saveAll(A.ALL); 36 | } 37 | 38 | @Test 39 | void manyToOne() { 40 | DataTablesInput input = createInput(); 41 | 42 | input.getColumn("c.someValue").setSearchValue("VAL2"); 43 | DataTablesOutput output = getOutput(input); 44 | assertThat(output.getData()).containsOnly(A.A2, A.A3); 45 | } 46 | 47 | @Test 48 | void twoLevels() { 49 | DataTablesInput input = createInput(); 50 | 51 | input.getColumn("c.parent.someValue").setSearchValue("VAL3"); 52 | DataTablesOutput output = getOutput(input); 53 | assertThat(output.getData()).containsOnly(A.A1); 54 | } 55 | 56 | @Test 57 | void embedded() { 58 | DataTablesInput input = createInput(); 59 | 60 | input.getColumn("d.someValue").setSearchValue("D1"); 61 | DataTablesOutput output = getOutput(input); 62 | assertThat(output.getData()).containsOnly(A.A1); 63 | } 64 | 65 | @Test 66 | protected void checkFetchJoin() { 67 | Statistics statistics = sessionFactory.getStatistics(); 68 | statistics.setStatisticsEnabled(true); 69 | 70 | DataTablesOutput output = getOutput(createInput()); 71 | 72 | assertThat(output.getRecordsFiltered()).isEqualTo(3); 73 | assertThat(statistics.getPrepareStatementCount()).isEqualTo(2); 74 | assertThat(statistics.getEntityLoadCount()).isEqualTo(3 /* A */ + 3 /* C */); 75 | } 76 | 77 | protected static DataTablesInput createInput() { 78 | DataTablesInput input = new DataTablesInput(); 79 | input.addColumn("name", true, true, ""); 80 | input.addColumn("b.name", true, true, ""); 81 | input.addColumn("b.someValue", true, true, ""); 82 | input.addColumn("c.name", true, true, ""); 83 | input.addColumn("c.someValue", true, true, ""); 84 | input.addColumn("c.parent.name", true, true, ""); 85 | input.addColumn("c.parent.someValue", true, true, ""); 86 | input.addColumn("d.someValue", true, true, ""); 87 | return input; 88 | } 89 | } -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/Config.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables; 2 | 3 | import java.sql.SQLException; 4 | import java.util.Properties; 5 | import javax.sql.DataSource; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.hibernate.SessionFactory; 8 | import org.springframework.context.annotation.Bean; 9 | import org.springframework.context.annotation.Configuration; 10 | import org.springframework.context.annotation.Profile; 11 | import org.springframework.data.jpa.datatables.repository.DataTablesRepositoryFactoryBean; 12 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories; 13 | import org.springframework.jdbc.datasource.DriverManagerDataSource; 14 | import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; 15 | import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; 16 | import org.springframework.orm.hibernate5.LocalSessionFactoryBean; 17 | import org.springframework.orm.jpa.JpaTransactionManager; 18 | 19 | /** 20 | * Spring JavaConfig configuration for general infrastructure. 21 | */ 22 | @Slf4j 23 | @Configuration 24 | @EnableJpaRepositories(repositoryFactoryBeanClass = DataTablesRepositoryFactoryBean.class, 25 | basePackages = {"org.springframework.data.jpa.datatables.model", 26 | "org.springframework.data.jpa.datatables.repository"}) 27 | public class Config { 28 | 29 | @Bean 30 | @Profile({"default", "h2"}) 31 | public DataSource dataSource_H2() throws SQLException { 32 | return new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.H2).build(); 33 | } 34 | 35 | @Bean 36 | @Profile("mysql") 37 | public DataSource dataSource_MySQL() throws SQLException { 38 | DriverManagerDataSource dataSource = new DriverManagerDataSource(); 39 | dataSource.setDriverClassName("com.mysql.jdbc.Driver"); 40 | dataSource.setUrl("jdbc:mysql://127.0.0.1/test"); 41 | dataSource.setUsername("root"); 42 | dataSource.setPassword("changeit"); 43 | return dataSource; 44 | } 45 | 46 | @Bean 47 | @Profile("pgsql") 48 | public DataSource dataSource_PostgreSQL() throws SQLException { 49 | DriverManagerDataSource dataSource = new DriverManagerDataSource(); 50 | dataSource.setDriverClassName("org.postgresql.Driver"); 51 | dataSource.setUrl("jdbc:postgresql://127.0.0.1/postgres"); 52 | dataSource.setUsername("postgres"); 53 | dataSource.setPassword("postgres"); 54 | return dataSource; 55 | } 56 | 57 | @Bean 58 | @Profile("sqlserver") 59 | public DataSource dataSource_SQLServer() throws SQLException { 60 | DriverManagerDataSource dataSource = new DriverManagerDataSource(); 61 | dataSource.setDriverClassName("com.microsoft.sqlserver.jdbc.SQLServerDriver"); 62 | dataSource.setUrl("jdbc:sqlserver://localhost:1433;encrypt=false;"); 63 | dataSource.setUsername("sa"); 64 | dataSource.setPassword("Changeit_123"); 65 | return dataSource; 66 | } 67 | 68 | @Bean 69 | public SessionFactory entityManagerFactory(DataSource dataSource) throws Exception { 70 | LocalSessionFactoryBean factory = new LocalSessionFactoryBean(); 71 | factory.setPackagesToScan(Config.class.getPackage().getName()); 72 | factory.setDataSource(dataSource); 73 | 74 | Properties properties = new Properties(); 75 | properties.setProperty("hibernate.hbm2ddl.auto", "create"); 76 | factory.setHibernateProperties(properties); 77 | 78 | factory.afterPropertiesSet(); 79 | return factory.getObject(); 80 | } 81 | 82 | @Bean 83 | public JpaTransactionManager transactionManager(SessionFactory sessionFactory) throws Exception { 84 | return new JpaTransactionManager(sessionFactory); 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/repository/DataTablesRepository.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | import org.springframework.data.jpa.datatables.mapping.DataTablesInput; 4 | import org.springframework.data.jpa.datatables.mapping.DataTablesOutput; 5 | import org.springframework.data.jpa.domain.Specification; 6 | import org.springframework.data.jpa.repository.JpaRepository; 7 | import org.springframework.data.jpa.repository.JpaSpecificationExecutor; 8 | import org.springframework.data.repository.NoRepositoryBean; 9 | import org.springframework.data.repository.PagingAndSortingRepository; 10 | 11 | import java.io.Serializable; 12 | import java.util.function.Function; 13 | 14 | /** 15 | * Convenience interface to allow pulling in {@link PagingAndSortingRepository} and 16 | * {@link JpaSpecificationExecutor} functionality in one go. 17 | * 18 | * @author Damien Arrachequesne 19 | */ 20 | @NoRepositoryBean 21 | public interface DataTablesRepository 22 | extends PagingAndSortingRepository, JpaSpecificationExecutor, JpaRepository { 23 | 24 | /** 25 | * Returns the filtered list for the given {@link DataTablesInput}. 26 | * 27 | * @param input the {@link DataTablesInput} mapped from the Ajax request 28 | * @return a {@link DataTablesOutput} 29 | */ 30 | DataTablesOutput findAll(DataTablesInput input); 31 | 32 | /** 33 | * Returns the filtered list for the given {@link DataTablesInput}. 34 | * 35 | * @param input the {@link DataTablesInput} mapped from the Ajax request 36 | * @param additionalSpecification an additional {@link Specification} to apply to the query (with 37 | * an "AND" clause) 38 | * @return a {@link DataTablesOutput} 39 | */ 40 | DataTablesOutput findAll(DataTablesInput input, Specification additionalSpecification); 41 | 42 | /** 43 | * Returns the filtered list for the given {@link DataTablesInput}. 44 | * 45 | * @param input the {@link DataTablesInput} mapped from the Ajax request 46 | * @param additionalSpecification an additional {@link Specification} to apply to the query (with 47 | * an "AND" clause) 48 | * @param preFilteringSpecification a pre-filtering {@link Specification} to apply to the query 49 | * (with an "AND" clause) 50 | * @return a {@link DataTablesOutput} 51 | */ 52 | DataTablesOutput findAll(DataTablesInput input, Specification additionalSpecification, 53 | Specification preFilteringSpecification); 54 | 55 | /** 56 | * Returns the filtered list for the given {@link DataTablesInput}. 57 | * 58 | * @param input the {@link DataTablesInput} mapped from the Ajax request 59 | * @param converter the {@link Function} to apply to the results of the query 60 | * @return a {@link DataTablesOutput} 61 | */ 62 | DataTablesOutput findAll(DataTablesInput input, Function converter); 63 | 64 | /** 65 | * Returns the filtered list for the given {@link DataTablesInput}. 66 | * 67 | * @param input the {@link DataTablesInput} mapped from the Ajax request 68 | * @param additionalSpecification an additional {@link Specification} to apply to the query (with 69 | * an "AND" clause) 70 | * @param preFilteringSpecification a pre-filtering {@link Specification} to apply to the query 71 | * (with an "AND" clause) 72 | * @param converter the {@link Function} to apply to the results of the query 73 | * @return a {@link DataTablesOutput} 74 | */ 75 | DataTablesOutput findAll(DataTablesInput input, Specification additionalSpecification, 76 | Specification preFilteringSpecification, Function converter); 77 | 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/ColumnFilter.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables; 2 | 3 | import com.querydsl.core.types.Ops; 4 | import com.querydsl.core.types.Predicate; 5 | import com.querydsl.core.types.dsl.*; 6 | 7 | import jakarta.persistence.criteria.CriteriaBuilder; 8 | import jakarta.persistence.criteria.Expression; 9 | import jakarta.persistence.criteria.From; 10 | import java.util.HashSet; 11 | import java.util.Set; 12 | 13 | import static java.util.Collections.unmodifiableSet; 14 | 15 | /** 16 | * Filter which parses the input value to create one of the following predicates: 17 | *
    18 | *
  • WHERE ... LIKE ..., see {@link GlobalFilter}
  • 19 | *
  • WHERE ... IN ... when the input contains multiple values separated by "+"
  • 20 | *
  • WHERE ... IS NULL when the input is equals to "NULL"
  • 21 | *
  • WHERE ... IN ... OR ... IS NULL
  • 22 | *
23 | */ 24 | class ColumnFilter extends GlobalFilter { 25 | private final Set values; 26 | private final Set booleanValues; 27 | private boolean addNullCase; 28 | private boolean isBooleanComparison; 29 | 30 | ColumnFilter(String filterValue) { 31 | super(filterValue); 32 | 33 | isBooleanComparison = true; 34 | Set values = new HashSet<>(); 35 | for (String value : filterValue.split("\\+")) { 36 | if ("NULL".equals(value)) { 37 | addNullCase = true; 38 | } else { 39 | isBooleanComparison &= isBoolean(value); 40 | values.add(nullOrTrimmedValue(value)); 41 | } 42 | } 43 | this.values = unmodifiableSet(values); 44 | 45 | Set booleanValues = new HashSet<>(); 46 | if (isBooleanComparison) { 47 | for (String value : values) { 48 | booleanValues.add(Boolean.valueOf(value)); 49 | } 50 | } 51 | this.booleanValues = unmodifiableSet(booleanValues); 52 | } 53 | 54 | private boolean isBoolean(String filterValue) { 55 | return "TRUE".equalsIgnoreCase(filterValue) || "FALSE".equalsIgnoreCase(filterValue); 56 | } 57 | 58 | @Override 59 | public Predicate createPredicate(PathBuilder pathBuilder, String attributeName) { 60 | StringOperation path = Expressions.stringOperation(Ops.STRING_CAST, pathBuilder.get(attributeName)); 61 | BooleanPath booleanPath = pathBuilder.getBoolean(attributeName); 62 | 63 | if (values.isEmpty()) { 64 | return addNullCase ? path.isNull() : null; 65 | } else if (isBasicFilter()) { 66 | return super.createPredicate(pathBuilder, attributeName); 67 | } 68 | 69 | BooleanExpression predicate = isBooleanComparison ? booleanPath.in(booleanValues) : path.in(values); 70 | if (addNullCase) predicate = predicate.or(path.isNull()); 71 | return predicate; 72 | } 73 | 74 | @Override 75 | public jakarta.persistence.criteria.Predicate createPredicate(From from, CriteriaBuilder criteriaBuilder, String attributeName) { 76 | Expression expression = from.get(attributeName); 77 | 78 | if (values.isEmpty()) { 79 | return addNullCase ? expression.isNull() : criteriaBuilder.conjunction(); 80 | } else if (isBasicFilter()) { 81 | return super.createPredicate(from, criteriaBuilder, attributeName); 82 | } 83 | 84 | jakarta.persistence.criteria.Predicate predicate; 85 | if (isBooleanComparison) { 86 | predicate = expression.in(booleanValues); 87 | } else { 88 | predicate = expression.as(String.class).in(values); 89 | } 90 | if (addNullCase) predicate = criteriaBuilder.or(predicate, expression.isNull()); 91 | 92 | return predicate; 93 | } 94 | 95 | private boolean isBasicFilter() { 96 | return values.size() == 1 && !addNullCase && !isBooleanComparison; 97 | } 98 | } -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/model/Employee.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.model; 2 | 3 | import static java.util.Arrays.asList; 4 | import static java.util.Comparator.comparingInt; 5 | import static java.util.stream.Collectors.toList; 6 | 7 | import jakarta.persistence.CascadeType; 8 | import jakarta.persistence.Entity; 9 | import jakarta.persistence.Id; 10 | import jakarta.persistence.JoinColumn; 11 | import jakarta.persistence.ManyToOne; 12 | import jakarta.persistence.Table; 13 | import jakarta.persistence.Transient; 14 | import java.util.List; 15 | import lombok.AccessLevel; 16 | import lombok.Builder; 17 | import lombok.Data; 18 | import lombok.Setter; 19 | import lombok.experimental.Tolerate; 20 | 21 | @Data 22 | @Setter(AccessLevel.NONE) 23 | @Builder 24 | @Entity 25 | @Table(name = "employees") 26 | public class Employee { 27 | @Id private int id; 28 | private String firstName; 29 | private String lastName; 30 | private String position; 31 | private int age; 32 | private boolean isWorkingRemotely; 33 | private String comment; 34 | 35 | @ManyToOne(cascade = CascadeType.ALL) 36 | @JoinColumn(name = "id_office") 37 | private Office office; 38 | 39 | @Tolerate 40 | private Employee() {} 41 | 42 | @Transient 43 | public static Employee AIRI_SATOU = Employee.builder() 44 | .id(5407) 45 | .firstName("Airi") 46 | .lastName("Satou") 47 | .position("Accountant") 48 | .age(33) 49 | .office(Office.TOKYO) 50 | .comment(null) 51 | .build(); 52 | 53 | @Transient 54 | public static Employee ANGELICA_RAMOS = Employee.builder() 55 | .id(5797) 56 | .firstName("Angelica") 57 | .lastName("Ramos") 58 | .position("Chief Executive Officer (CEO)") 59 | .age(47) 60 | .office(Office.LONDON) 61 | .comment("\\NULL") 62 | .build(); 63 | 64 | @Transient 65 | public static Employee ASHTON_COX = Employee.builder() 66 | .id(1562) 67 | .firstName("Ashton") 68 | .lastName("Cox") 69 | .position("Junior Technical Author") 70 | .age(66) 71 | .office(Office.SAN_FRANCISCO) 72 | .isWorkingRemotely(true) 73 | .comment("~foo~~") 74 | .build(); 75 | 76 | @Transient 77 | public static Employee BRADLEY_GREER = Employee.builder() 78 | .id(2558) 79 | .firstName("Bradley") 80 | .lastName("Greer") 81 | .position("Software Engineer") 82 | .age(41) 83 | .office(Office.LONDON) 84 | .comment("%foo%%") 85 | .build(); 86 | 87 | @Transient 88 | public static Employee BRENDEN_WAGNER = Employee.builder() 89 | .id(1314) 90 | .firstName("Brenden") 91 | .lastName("Wagner") 92 | .position("Software Engineer") 93 | .age(28) 94 | .office(Office.SAN_FRANCISCO) 95 | .comment("_foo__") 96 | .build(); 97 | 98 | @Transient 99 | public static Employee BRIELLE_WILLIAMSON = Employee.builder() 100 | .id(4804) 101 | .firstName("Brielle") 102 | .lastName("Williamson") 103 | .position("Integration Specialist") 104 | .age(61) 105 | .office(Office.NEW_YORK) 106 | .comment("@foo@@") 107 | .build(); 108 | 109 | @Transient 110 | public static List ALL = asList( 111 | AIRI_SATOU, 112 | ANGELICA_RAMOS, 113 | ASHTON_COX, 114 | BRADLEY_GREER, 115 | BRENDEN_WAGNER, 116 | BRIELLE_WILLIAMSON 117 | ); 118 | 119 | public static List ALL_SORTED_BY_AGE = 120 | ALL.stream().sorted(comparingInt(e -> e.age)).collect(toList()); 121 | 122 | public static List ALL_SORTED_BY_AGE_DESC = 123 | ALL.stream().sorted(comparingInt(e -> -e.age)).collect(toList()); 124 | } -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/SpecificationBuilder.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables; 2 | 3 | import jakarta.persistence.metamodel.Bindable.BindableType; 4 | import org.springframework.data.jpa.datatables.mapping.DataTablesInput; 5 | import org.springframework.data.jpa.domain.Specification; 6 | import org.springframework.lang.NonNull; 7 | 8 | import jakarta.persistence.criteria.*; 9 | import java.util.ArrayList; 10 | import java.util.List; 11 | 12 | public class SpecificationBuilder extends AbstractPredicateBuilder> { 13 | public SpecificationBuilder(DataTablesInput input) { 14 | super(input); 15 | } 16 | 17 | @Override 18 | public Specification build() { 19 | return new DataTablesSpecification<>(); 20 | } 21 | 22 | private class DataTablesSpecification implements Specification { 23 | @Override 24 | public Predicate toPredicate(@NonNull Root root, @NonNull CriteriaQuery query, @NonNull CriteriaBuilder criteriaBuilder) { 25 | Predicates predicates = new Predicates(); 26 | initPredicatesRecursively(predicates, query, tree, root, root, criteriaBuilder); 27 | 28 | if (input.getSearchPanes() != null) { 29 | input.getSearchPanes().forEach((attribute, values) -> { 30 | if (!values.isEmpty()) { 31 | Predicate predicate = SpecificationBuilder.getPathRecursively(root, attribute).in(values); 32 | predicates.columns.add(predicate); 33 | } 34 | }); 35 | } 36 | 37 | return predicates.toPredicate(criteriaBuilder); 38 | } 39 | 40 | private static boolean isCountQuery(CriteriaQuery query) { 41 | return query.getResultType() == Long.class; 42 | } 43 | 44 | private static boolean isAggregateQuery(CriteriaQuery query) { 45 | return query.getGroupList().size() > 0; 46 | } 47 | 48 | private void initPredicatesRecursively(Predicates predicates, CriteriaQuery query, Node node, From from, FetchParent fetch, CriteriaBuilder criteriaBuilder) { 49 | if (node.isLeaf()) { 50 | boolean hasColumnFilter = node.getData() != null; 51 | if (hasColumnFilter) { 52 | Filter columnFilter = node.getData(); 53 | predicates.columns.add(columnFilter.createPredicate(from, criteriaBuilder, node.getName())); 54 | } else if (hasGlobalFilter) { 55 | Filter globalFilter = tree.getData(); 56 | predicates.global.add(globalFilter.createPredicate(from, criteriaBuilder, node.getName())); 57 | } 58 | } 59 | for (Node child : node.getChildren()) { 60 | Path path = from.get(child.getName()); 61 | if (path.getModel().getBindableType() == BindableType.PLURAL_ATTRIBUTE) { 62 | // ignore OneToMany and ManyToMany relationships 63 | continue; 64 | } 65 | if (child.isLeaf()) { 66 | initPredicatesRecursively(predicates, query, child, from, fetch, criteriaBuilder); 67 | } else { 68 | Join join = from.join(child.getName(), JoinType.LEFT); 69 | 70 | if (isCountQuery(query) || isAggregateQuery(query)) { 71 | initPredicatesRecursively(predicates, query, child, join, join, criteriaBuilder); 72 | } else { 73 | Fetch childFetch = fetch.fetch(child.getName(), JoinType.LEFT); 74 | initPredicatesRecursively(predicates, query, child, join, childFetch, criteriaBuilder); 75 | } 76 | } 77 | } 78 | } 79 | } 80 | 81 | private static class Predicates { 82 | public List columns = new ArrayList<>(); 83 | public List global = new ArrayList<>(); 84 | 85 | Predicate toPredicate(CriteriaBuilder criteriaBuilder) { 86 | if (!global.isEmpty()) { 87 | columns.add(criteriaBuilder.or(global.toArray(new Predicate[0]))); 88 | } 89 | 90 | return columns.isEmpty() ? criteriaBuilder.conjunction() : criteriaBuilder.and(columns.toArray(new Predicate[0])); 91 | } 92 | } 93 | 94 | public static Path getPathRecursively(Root root, String attribute) { 95 | String[] parts = attribute.split("\\."); 96 | Path path = root; 97 | for (String part : parts) { 98 | path = path.get(part); 99 | } 100 | return path; 101 | } 102 | } -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/mapping/DataTablesInput.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.mapping; 2 | 3 | import lombok.Data; 4 | 5 | import jakarta.validation.constraints.Min; 6 | import jakarta.validation.constraints.NotEmpty; 7 | import jakarta.validation.constraints.NotNull; 8 | import java.util.*; 9 | 10 | /** 11 | * The format of the payload sent by the client. 12 | * 13 | * @see datatables.net reference 14 | */ 15 | @Data 16 | public class DataTablesInput { 17 | /** 18 | * Draw counter. This is used by DataTables to ensure that the Ajax returns from server-side 19 | * processing requests are drawn in sequence by DataTables (Ajax requests are asynchronous and 20 | * thus can return out of sequence). This is used as part of the draw return parameter (see 21 | * below). 22 | */ 23 | @NotNull 24 | @Min(0) 25 | private Integer draw = 1; 26 | 27 | /** 28 | * Paging first record indicator. This is the start point in the current data set (0 index based - 29 | * i.e. 0 is the first record). 30 | */ 31 | @NotNull 32 | @Min(0) 33 | private Integer start = 0; 34 | 35 | /** 36 | * Number of records that the table can display in the current draw. It is expected that the 37 | * number of records returned will be equal to this number, unless the server has fewer records to 38 | * return. Note that this can be -1 to indicate that all records should be returned (although that 39 | * negates any benefits of server-side processing!) 40 | */ 41 | @NotNull 42 | @Min(-1) 43 | private Integer length = 10; 44 | 45 | /** 46 | * Global search parameter. 47 | */ 48 | @NotNull 49 | private Search search = new Search(); 50 | 51 | /** 52 | * Order parameter 53 | */ 54 | private List order = new ArrayList<>(); 55 | 56 | /** 57 | * Per-column search parameter 58 | */ 59 | @NotEmpty 60 | private List columns = new ArrayList<>(); 61 | 62 | /** 63 | * Input for the SearchPanes extension 64 | */ 65 | private Map> searchPanes; 66 | 67 | /** 68 | * 69 | * @return a {@link Map} of {@link Column} indexed by name 70 | */ 71 | public Map getColumnsAsMap() { 72 | Map map = new HashMap<>(); 73 | for (Column column : columns) { 74 | map.put(column.getData(), column); 75 | } 76 | return map; 77 | } 78 | 79 | /** 80 | * Find a column by its name 81 | * 82 | * @param columnName the name of the column 83 | * @return the given Column, or null if not found 84 | */ 85 | public Column getColumn(String columnName) { 86 | if (columnName == null) { 87 | return null; 88 | } 89 | for (Column column : columns) { 90 | if (columnName.equals(column.getData())) { 91 | return column; 92 | } 93 | } 94 | return null; 95 | } 96 | 97 | /** 98 | * Add a new column 99 | * 100 | * @param columnName the name of the column 101 | * @param searchable whether the column is searchable or not 102 | * @param orderable whether the column is orderable or not 103 | * @param searchValue if any, the search value to apply 104 | */ 105 | public void addColumn(String columnName, boolean searchable, boolean orderable, 106 | String searchValue) { 107 | this.columns.add(new Column(columnName, "", searchable, orderable, 108 | new Search(searchValue, false))); 109 | } 110 | 111 | /** 112 | * Add an order on the given column 113 | * 114 | * @param columnName the name of the column 115 | * @param ascending whether the sorting is ascending or descending 116 | */ 117 | public void addOrder(String columnName, boolean ascending) { 118 | if (columnName == null) { 119 | return; 120 | } 121 | for (int i = 0; i < columns.size(); i++) { 122 | if (!columnName.equals(columns.get(i).getData())) { 123 | continue; 124 | } 125 | order.add(new Order(i, ascending ? "asc" : "desc")); 126 | } 127 | } 128 | 129 | public void parseSearchPanesFromQueryParams(Map queryParams, Collection attributes) { 130 | Map> searchPanes = new HashMap<>(); 131 | 132 | for (String attribute : attributes) { 133 | Set values = new HashSet<>(); 134 | for (int i = 0; ; i++) { 135 | String paramName = "searchPanes." + attribute + "." + i; 136 | String paramValue = queryParams.get(paramName); 137 | if (paramValue != null) { 138 | values.add(paramValue); 139 | } else { 140 | break; 141 | } 142 | } 143 | searchPanes.put(attribute, values); 144 | } 145 | 146 | this.searchPanes = searchPanes; 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/repository/EmployeeControllerTest.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | import org.junit.jupiter.api.BeforeEach; 4 | import org.junit.jupiter.api.Test; 5 | import org.junit.jupiter.api.extension.ExtendWith; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; 8 | import org.springframework.boot.test.context.SpringBootTest; 9 | import org.springframework.data.jpa.datatables.TestApplication; 10 | import org.springframework.data.jpa.datatables.model.Employee; 11 | import org.springframework.http.MediaType; 12 | import org.springframework.test.context.junit.jupiter.SpringExtension; 13 | import org.springframework.test.web.servlet.MockMvc; 14 | import org.springframework.util.MultiValueMap; 15 | 16 | import java.util.HashMap; 17 | import java.util.Map; 18 | 19 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 20 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 21 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; 22 | 23 | @ExtendWith(SpringExtension.class) 24 | @SpringBootTest( 25 | webEnvironment = SpringBootTest.WebEnvironment.MOCK, 26 | classes = TestApplication.class) 27 | @AutoConfigureMockMvc 28 | class EmployeeControllerTest { 29 | 30 | @Autowired private EmployeeRepository employeeRepository; 31 | 32 | @BeforeEach 33 | public void init() { 34 | this.employeeRepository.saveAll(Employee.ALL); 35 | } 36 | 37 | private static Map createQuery() { 38 | var query = new HashMap(); 39 | 40 | query.put("draw", "1"); 41 | query.put("start", "0"); 42 | query.put("length", "10"); 43 | 44 | query.put("search.value", ""); 45 | query.put("search.regex", "false"); 46 | 47 | query.put("order[0].column", "0"); 48 | query.put("order[0].dir", "asc"); 49 | 50 | query.put("columns[0].data", "id"); 51 | query.put("columns[0].searchable", "true"); 52 | query.put("columns[0].orderable", "true"); 53 | query.put("columns[0].search.value", ""); 54 | query.put("columns[0].search.regex", "false"); 55 | 56 | return query; 57 | } 58 | 59 | @Test 60 | void basic(@Autowired MockMvc mvc) throws Exception { 61 | var query = createQuery(); 62 | 63 | mvc.perform(get("/employees").queryParams(MultiValueMap.fromSingleValue(query))) 64 | .andExpect(status().isOk()) 65 | .andExpect(jsonPath("draw").value(1)) 66 | .andExpect(jsonPath("recordsTotal").value(6)) 67 | .andExpect(jsonPath("recordsFiltered").value(6)) 68 | .andExpect(jsonPath("data[0].firstName").value("Brenden")) 69 | .andExpect(jsonPath("error").isEmpty()); 70 | } 71 | 72 | @Test 73 | void page(@Autowired MockMvc mvc) throws Exception { 74 | var query = createQuery(); 75 | 76 | query.put("draw", "2"); 77 | query.put("start", "1"); 78 | query.put("length", "1"); 79 | 80 | mvc.perform(get("/employees").queryParams(MultiValueMap.fromSingleValue(query))) 81 | .andExpect(status().isOk()) 82 | .andExpect(jsonPath("draw").value(2)) 83 | .andExpect(jsonPath("recordsTotal").value(6)) 84 | .andExpect(jsonPath("recordsFiltered").value(6)) 85 | .andExpect(jsonPath("data[0].firstName").value("Ashton")) 86 | .andExpect(jsonPath("error").isEmpty()); 87 | } 88 | 89 | @Test 90 | void invalidStart(@Autowired MockMvc mvc) throws Exception { 91 | var query = createQuery(); 92 | 93 | query.put("start", "-1"); 94 | 95 | mvc.perform(get("/employees").queryParams(MultiValueMap.fromSingleValue(query))) 96 | .andExpect(status().is4xxClientError()); 97 | } 98 | 99 | @Test 100 | void withPOST(@Autowired MockMvc mvc) throws Exception { 101 | mvc.perform( 102 | post("/employees") 103 | .contentType(MediaType.APPLICATION_JSON) 104 | .content( 105 | """ 106 | { 107 | "draw": "1", 108 | "start": "0", 109 | "length": "10", 110 | 111 | "search": { 112 | "value": "", 113 | "regex": false 114 | }, 115 | 116 | "order": [ 117 | { 118 | "column": 0, 119 | "dir": "asc" 120 | } 121 | ], 122 | 123 | "columns": [ 124 | { 125 | "data": "id", 126 | "searchable": true, 127 | "orderable": true, 128 | "search": { 129 | "value": "", 130 | "regex": false 131 | } 132 | } 133 | ] 134 | } 135 | """)) 136 | .andExpect(status().isOk()) 137 | .andExpect(jsonPath("draw").value(1)) 138 | .andExpect(jsonPath("recordsTotal").value(6)) 139 | .andExpect(jsonPath("recordsFiltered").value(6)) 140 | .andExpect(jsonPath("data[0].firstName").value("Brenden")) 141 | .andExpect(jsonPath("error").isEmpty()); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/AbstractPredicateBuilder.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables; 2 | 3 | import org.springframework.data.domain.Pageable; 4 | import org.springframework.data.domain.Sort; 5 | import org.springframework.data.jpa.datatables.mapping.Column; 6 | import org.springframework.data.jpa.datatables.mapping.DataTablesInput; 7 | import org.springframework.data.jpa.datatables.mapping.Search; 8 | import org.springframework.lang.NonNull; 9 | import org.springframework.util.StringUtils; 10 | 11 | import java.util.ArrayList; 12 | import java.util.List; 13 | 14 | abstract class AbstractPredicateBuilder { 15 | protected final DataTablesInput input; 16 | final boolean hasGlobalFilter; 17 | final Node tree; 18 | 19 | AbstractPredicateBuilder(DataTablesInput input) { 20 | this.input = input; 21 | this.hasGlobalFilter = input.getSearch() != null && StringUtils.hasText(input.getSearch().getValue()); 22 | if (this.hasGlobalFilter) { 23 | tree = new Node<>(null, new GlobalFilter(input.getSearch().getValue())); 24 | } else { 25 | tree = new Node<>(null); 26 | } 27 | initTree(input); 28 | } 29 | 30 | private void initTree(DataTablesInput input) { 31 | for (Column column : input.getColumns()) { 32 | if (column.getSearchable()) { 33 | addChild(tree, 0, column.getData().split("\\."), column.getSearch()); 34 | } 35 | } 36 | } 37 | 38 | private void addChild(Node parent, int index, String[] names, Search search) { 39 | boolean isLast = index + 1 == names.length; 40 | if (isLast) { 41 | boolean hasColumnFilter = search != null && StringUtils.hasText(search.getValue()); 42 | parent.addChild(new Node<>(names[index], hasColumnFilter ? new ColumnFilter(search.getValue()) : null)); 43 | } else { 44 | Node child = parent.getOrCreateChild(names[index]); 45 | addChild(child, index + 1, names, search); 46 | } 47 | } 48 | 49 | /** 50 | * Creates a 'LIMIT .. OFFSET .. ORDER BY ..' clause for the given {@link DataTablesInput}. 51 | * 52 | * @return a {@link Pageable}, must not be {@literal null}. 53 | */ 54 | public Pageable createPageable() { 55 | List orders = new ArrayList<>(); 56 | for (org.springframework.data.jpa.datatables.mapping.Order order : input.getOrder()) { 57 | Column column = input.getColumns().get(order.getColumn()); 58 | if (column.getOrderable()) { 59 | String sortColumn = column.getData(); 60 | Sort.Direction sortDirection = Sort.Direction.fromString(order.getDir()); 61 | orders.add(new Sort.Order(sortDirection, sortColumn)); 62 | } 63 | } 64 | Sort sort = orders.isEmpty() ? Sort.unsorted() : Sort.by(orders); 65 | 66 | if (input.getLength() == -1) { 67 | input.setStart(0); 68 | input.setLength(Integer.MAX_VALUE); 69 | } 70 | return new DataTablesPageRequest(input.getStart(), input.getLength(), sort); 71 | } 72 | 73 | public abstract T build(); 74 | 75 | private class DataTablesPageRequest implements Pageable { 76 | private final int offset; 77 | private final int pageSize; 78 | private final Sort sort; 79 | 80 | DataTablesPageRequest(int offset, int pageSize, Sort sort) { 81 | this.offset = offset; 82 | this.pageSize = pageSize; 83 | this.sort = sort; 84 | } 85 | 86 | @Override 87 | public long getOffset() { 88 | return offset; 89 | } 90 | 91 | @Override 92 | public int getPageSize() { 93 | return pageSize; 94 | } 95 | 96 | @Override 97 | @NonNull 98 | public Sort getSort() { 99 | return sort; 100 | } 101 | 102 | @Override 103 | @NonNull 104 | public Pageable next() { 105 | throw new UnsupportedOperationException(); 106 | } 107 | 108 | @Override 109 | @NonNull 110 | public Pageable previousOrFirst() { 111 | throw new UnsupportedOperationException(); 112 | } 113 | 114 | @Override 115 | @NonNull 116 | public Pageable first() { 117 | throw new UnsupportedOperationException(); 118 | } 119 | 120 | @Override 121 | public boolean hasPrevious() { 122 | throw new UnsupportedOperationException(); 123 | } 124 | 125 | @Override 126 | public int getPageNumber() { 127 | throw new UnsupportedOperationException(); 128 | } 129 | 130 | @Override 131 | public Pageable withPage(int pageNumber) { 132 | throw new UnsupportedOperationException(); 133 | } 134 | } 135 | 136 | } -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/repository/DataTablesRepositoryImpl.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | import jakarta.persistence.EntityManager; 4 | import jakarta.persistence.criteria.CriteriaBuilder; 5 | import jakarta.persistence.criteria.CriteriaQuery; 6 | import jakarta.persistence.criteria.Path; 7 | import jakarta.persistence.criteria.Root; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.data.domain.Page; 10 | import org.springframework.data.jpa.datatables.SpecificationBuilder; 11 | import org.springframework.data.jpa.datatables.mapping.DataTablesInput; 12 | import org.springframework.data.jpa.datatables.mapping.DataTablesOutput; 13 | import org.springframework.data.jpa.datatables.mapping.SearchPanes; 14 | import org.springframework.data.jpa.domain.Specification; 15 | import org.springframework.data.jpa.repository.support.JpaEntityInformation; 16 | import org.springframework.data.jpa.repository.support.SimpleJpaRepository; 17 | 18 | import java.io.Serializable; 19 | import java.util.ArrayList; 20 | import java.util.HashMap; 21 | import java.util.List; 22 | import java.util.Map; 23 | import java.util.function.Function; 24 | 25 | @Slf4j 26 | public class DataTablesRepositoryImpl extends SimpleJpaRepository 27 | implements DataTablesRepository { 28 | private final EntityManager entityManager; 29 | 30 | DataTablesRepositoryImpl(JpaEntityInformation entityInformation, 31 | EntityManager entityManager) { 32 | 33 | super(entityInformation, entityManager); 34 | this.entityManager = entityManager; 35 | } 36 | 37 | @Override 38 | public DataTablesOutput findAll(DataTablesInput input) { 39 | return findAll(input, null, null, null); 40 | } 41 | 42 | @Override 43 | public DataTablesOutput findAll(DataTablesInput input, 44 | Specification additionalSpecification) { 45 | return findAll(input, additionalSpecification, null, null); 46 | } 47 | 48 | @Override 49 | public DataTablesOutput findAll(DataTablesInput input, 50 | Specification additionalSpecification, Specification preFilteringSpecification) { 51 | return findAll(input, additionalSpecification, preFilteringSpecification, null); 52 | } 53 | 54 | @Override 55 | public DataTablesOutput findAll(DataTablesInput input, Function converter) { 56 | return findAll(input, null, null, converter); 57 | } 58 | 59 | @Override 60 | public DataTablesOutput findAll(DataTablesInput input, 61 | Specification additionalSpecification, Specification preFilteringSpecification, 62 | Function converter) { 63 | log.debug("input: {}", input); 64 | 65 | DataTablesOutput output = new DataTablesOutput<>(); 66 | output.setDraw(input.getDraw()); 67 | if (input.getLength() == 0) { 68 | return output; 69 | } 70 | 71 | try { 72 | long recordsTotal = 73 | preFilteringSpecification == null ? count() : count(preFilteringSpecification); 74 | if (recordsTotal == 0) { 75 | return output; 76 | } 77 | output.setRecordsTotal(recordsTotal); 78 | 79 | SpecificationBuilder specificationBuilder = new SpecificationBuilder<>(input); 80 | Specification specification = Specification.where(specificationBuilder.build()) 81 | .and(additionalSpecification) 82 | .and(preFilteringSpecification); 83 | Page data = findAll(specification, specificationBuilder.createPageable()); 84 | 85 | @SuppressWarnings("unchecked") 86 | List content = 87 | converter == null ? (List) data.getContent() : data.map(converter).getContent(); 88 | output.setData(content); 89 | output.setRecordsFiltered(data.getTotalElements()); 90 | 91 | if (input.getSearchPanes() != null) { 92 | output.setSearchPanes(computeSearchPanes(input, specification)); 93 | } 94 | } catch (Exception e) { 95 | log.warn("error while fetching records", e); 96 | output.setError(e.toString()); 97 | } 98 | 99 | log.debug("output: {}", output); 100 | return output; 101 | } 102 | 103 | private SearchPanes computeSearchPanes(DataTablesInput input, Specification specification) { 104 | Map> options = new HashMap<>(); 105 | 106 | input.getSearchPanes().forEach((attribute, values) -> { 107 | CriteriaBuilder criteriaBuilder = this.entityManager.getCriteriaBuilder(); 108 | CriteriaQuery query = criteriaBuilder.createQuery(Object[].class); 109 | Root root = query.from(getDomainClass()); 110 | Path path = SpecificationBuilder.getPathRecursively(root, attribute); 111 | 112 | query.multiselect(path, criteriaBuilder.count(root)); 113 | query.groupBy(path); 114 | query.where(specification.toPredicate(root, query, criteriaBuilder)); 115 | 116 | List items = new ArrayList<>(); 117 | 118 | this.entityManager.createQuery(query).getResultList().forEach(objects -> { 119 | String value = String.valueOf(objects[0]); 120 | long count = (long) objects[1]; 121 | // FIXME the number of items after filtering is the same as the total number of items, so the 'searchPanes.viewTotal' 122 | // feature will not work properly. Fixing this would require two distinct queries, one with filtering and the other 123 | // without. Reference: https://datatables.net/reference/feature/searchPanes.viewTotal 124 | items.add(new SearchPanes.Item(value, value, count, count)); 125 | }); 126 | 127 | options.put(attribute, items); 128 | }); 129 | 130 | return new SearchPanes(options); 131 | } 132 | 133 | } 134 | -------------------------------------------------------------------------------- /src/main/java/org/springframework/data/jpa/datatables/qrepository/QDataTablesRepositoryImpl.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.qrepository; 2 | 3 | import com.querydsl.core.BooleanBuilder; 4 | import com.querydsl.core.types.EntityPath; 5 | import com.querydsl.core.types.Ops; 6 | import com.querydsl.core.types.Predicate; 7 | import com.querydsl.core.types.dsl.PathBuilder; 8 | import lombok.extern.slf4j.Slf4j; 9 | import org.springframework.data.domain.Page; 10 | import org.springframework.data.jpa.datatables.PredicateBuilder; 11 | import org.springframework.data.jpa.datatables.mapping.DataTablesInput; 12 | import org.springframework.data.jpa.datatables.mapping.DataTablesOutput; 13 | import org.springframework.data.jpa.datatables.mapping.SearchPanes; 14 | import org.springframework.data.jpa.repository.support.JpaEntityInformation; 15 | import org.springframework.data.jpa.repository.support.QuerydslJpaRepository; 16 | import org.springframework.data.querydsl.EntityPathResolver; 17 | import org.springframework.data.querydsl.SimpleEntityPathResolver; 18 | 19 | import jakarta.persistence.EntityManager; 20 | import java.io.Serializable; 21 | import java.util.ArrayList; 22 | import java.util.HashMap; 23 | import java.util.List; 24 | import java.util.Map; 25 | import java.util.function.Function; 26 | 27 | import static com.querydsl.core.types.dsl.Expressions.stringOperation; 28 | 29 | @Slf4j 30 | public class QDataTablesRepositoryImpl 31 | extends QuerydslJpaRepository implements QDataTablesRepository { 32 | 33 | private static final EntityPathResolver DEFAULT_ENTITY_PATH_RESOLVER = 34 | SimpleEntityPathResolver.INSTANCE; 35 | 36 | private final PathBuilder builder; 37 | 38 | QDataTablesRepositoryImpl(JpaEntityInformation entityInformation, 39 | EntityManager entityManager) { 40 | this(entityInformation, entityManager, DEFAULT_ENTITY_PATH_RESOLVER); 41 | } 42 | 43 | public QDataTablesRepositoryImpl(JpaEntityInformation entityInformation, 44 | EntityManager entityManager, EntityPathResolver resolver) { 45 | super(entityInformation, entityManager); 46 | EntityPath path = resolver.createPath(entityInformation.getJavaType()); 47 | this.builder = new PathBuilder<>(path.getType(), path.getMetadata()); 48 | } 49 | 50 | @Override 51 | public DataTablesOutput findAll(DataTablesInput input) { 52 | return findAll(input, null, null, null); 53 | } 54 | 55 | @Override 56 | public DataTablesOutput findAll(DataTablesInput input, Predicate additionalPredicate) { 57 | return findAll(input, additionalPredicate, null, null); 58 | } 59 | 60 | @Override 61 | public DataTablesOutput findAll(DataTablesInput input, Predicate additionalPredicate, 62 | Predicate preFilteringPredicate) { 63 | return findAll(input, additionalPredicate, preFilteringPredicate, null); 64 | } 65 | 66 | @Override 67 | public DataTablesOutput findAll(DataTablesInput input, Function converter) { 68 | return findAll(input, null, null, converter); 69 | } 70 | 71 | @Override 72 | public DataTablesOutput findAll(DataTablesInput input, Predicate additionalPredicate, 73 | Predicate preFilteringPredicate, Function converter) { 74 | log.debug("input: {}", input); 75 | 76 | DataTablesOutput output = new DataTablesOutput<>(); 77 | output.setDraw(input.getDraw()); 78 | if (input.getLength() == 0) { 79 | return output; 80 | } 81 | 82 | try { 83 | long recordsTotal = preFilteringPredicate == null ? count() : count(preFilteringPredicate); 84 | if (recordsTotal == 0) { 85 | return output; 86 | } 87 | output.setRecordsTotal(recordsTotal); 88 | 89 | PredicateBuilder predicateBuilder = new PredicateBuilder(this.builder, input); 90 | BooleanBuilder booleanBuilder = new BooleanBuilder() 91 | .and(predicateBuilder.build()) 92 | .and(additionalPredicate) 93 | .and(preFilteringPredicate); 94 | Predicate predicate = booleanBuilder.getValue(); 95 | Page data = predicate != null 96 | ? findAll(predicate, predicateBuilder.createPageable()) 97 | : findAll(predicateBuilder.createPageable()); 98 | 99 | @SuppressWarnings("unchecked") 100 | List content = 101 | converter == null ? (List) data.getContent() : data.map(converter).getContent(); 102 | output.setData(content); 103 | output.setRecordsFiltered(data.getTotalElements()); 104 | 105 | if (input.getSearchPanes() != null) { 106 | output.setSearchPanes(computeSearchPanes(input, predicate)); 107 | } 108 | } catch (Exception e) { 109 | log.warn("error while fetching records", e); 110 | output.setError(e.toString()); 111 | } 112 | 113 | log.debug("output: {}", output); 114 | return output; 115 | } 116 | 117 | private SearchPanes computeSearchPanes(DataTablesInput input, Predicate predicate) { 118 | Map> options = new HashMap<>(); 119 | 120 | input.getSearchPanes().forEach((attribute, values) -> { 121 | List items = new ArrayList<>(); 122 | PathBuilder path = this.builder.get(attribute); 123 | 124 | this.createQuery() 125 | .select(stringOperation(Ops.STRING_CAST, path), path.count()) 126 | .where(predicate) 127 | .groupBy(path) 128 | .fetchResults() 129 | .getResults() 130 | .forEach(tuple -> { 131 | String value = tuple.get(0, String.class); 132 | long count = tuple.get(1, Long.class); 133 | items.add(new SearchPanes.Item(value, value, count, count)); 134 | }); 135 | 136 | options.put(attribute, items); 137 | }); 138 | 139 | return new SearchPanes(options); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # History 2 | 3 | | Version | Release date | Spring Boot compatibility | 4 | |--------------------------|---------------|---------------------------| 5 | | [7.1.0](#710-2025-07-02) | July 2025 | " | 6 | | [7.0.1](#701-2025-02-18) | February 2025 | " | 7 | | [7.0.0](#700-2025-02-13) | February 2025 | `>= 3.4.0` | 8 | | [6.0.4](#604-2024-04-03) | April 2024 | " | 9 | | [6.0.3](#603-2024-03-24) | March 2024 | " | 10 | | [6.0.2](#602-2024-03-03) | March 2024 | " | 11 | | [6.0.1](#601-2023-02-12) | February 2023 | " | 12 | | [6.0.0](#600-2023-01-02) | January 2023 | `>= 3.O.0 && < 3.4.0` | 13 | | [5.2.0](#520-2022-05-19) | May 2022 | " | 14 | | [5.1.0](#510-2021-03-17) | March 2021 | " | 15 | | [5.0.0](#500-2018-03-01) | March 2018 | `>= 2.O.0 && < 3.0.0` | 16 | | [4.3](#43-2017-12-24) | December 2017 | " | 17 | | [4.2](#42-2017-12-24) | December 2017 | " | 18 | | [4.1](#41-2017-04-05) | April 2017 | " | 19 | | [4.0](#40-2017-03-06) | March 2017 | " | 20 | | [3.1](#31-2016-12-16) | December 2016 | " | 21 | | [3.0](#30-2016-11-19) | November 2016 | `>= 1.O.0 && < 2.0.0` | 22 | 23 | 24 | # Release notes 25 | 26 | ## [7.1.0](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v7.0.1...v7.1.0) (2025-07-02) 27 | 28 | `spring-boot-dependencies` was updated from version `3.4.0` (Nov 2024) to `3.5.3` (Jun 2025). 29 | 30 | Note: version ranges in `` are supported starting with Maven 4 (https://issues.apache.org/jira/browse/MNG-4463). 31 | 32 | 33 | 34 | ## [7.0.1](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v7.0.0...v7.0.1) (2025-02-18) 35 | 36 | 37 | ### Bug Fixes 38 | 39 | * properly compute search panes with related entities ([cc4b439](https://github.com/darrachequesne/spring-data-jpa-datatables/commit/cc4b439f2a7b30070c9fa1f4bcb9dab2ceb0bfc0)) 40 | 41 | 42 | 43 | ## [7.0.0](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v6.0.4...v7.0.0) (2025-02-13) 44 | 45 | ### Features 46 | 47 | * upgrade to Spring Boot 3.4.0 ([fd5c9f7](https://github.com/darrachequesne/spring-data-jpa-datatables/commit/fd5c9f7aac04babf7d73370d39d19b33c2310571)) 48 | 49 | ⚠ BREAKING CHANGE ⚠ 50 | 51 | `hibernate-core` is upgraded from `6.4.x` to `6.6.x`, which contains an important breaking change regarding type casts: 52 | 53 | > `Expression.as()` doesn’t do anymore a real type conversions, it’s just an unsafe typecast on the Expression object itself. 54 | 55 | Reference: https://docs.jboss.org/hibernate/orm/6.6/migration-guide/migration-guide.html#criteria-query 56 | 57 | Note: this change is not compatible with older versions of Spring Boot, as `JpaExpression.cast()` was added in `hibernate-core@6.6`. 58 | 59 | 60 | 61 | ## [6.0.4](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v6.0.3...v6.0.4) (2024-04-03) 62 | 63 | 64 | ### Bug Fixes 65 | 66 | * properly compute search panes with related entities ([495cfbc](https://github.com/darrachequesne/spring-data-jpa-datatables/commit/495cfbc4cf6e110bf7b6dcb47d7bfd8587056169)) 67 | 68 | 69 | 70 | ## [6.0.3](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v6.0.2...v6.0.3) (2024-03-24) 71 | 72 | 73 | ### Bug Fixes 74 | 75 | * **sqlserver:** prevent cast from NVARCHAR to VARCHAR ([f1e0ecd](https://github.com/darrachequesne/spring-data-jpa-datatables/commit/f1e0ecdcc73c3983683d4ddbcfe62fdc7862a70b)) 76 | 77 | 78 | 79 | ## [6.0.2](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v6.0.1...v6.0.2) (2024-03-03) 80 | 81 | 82 | ### Bug Fixes 83 | 84 | * allow order array to be empty ([a214d5b](https://github.com/darrachequesne/spring-data-jpa-datatables/commit/a214d5bb199fff4ccd578c3bbb71ee64f3a0f198)) 85 | * apply any prefiltering specification to the search panes ([e83b4d5](https://github.com/darrachequesne/spring-data-jpa-datatables/commit/e83b4d580c7cc021059c46322e99155051400214)) 86 | 87 | 88 | 89 | ## [6.0.1](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v6.0.0...v6.0.1) (2023-02-12) 90 | 91 | 92 | ### Bug Fixes 93 | 94 | * fix integration with Spring Boot 3 ([a6a8a0d](https://github.com/darrachequesne/spring-data-jpa-datatables/commit/a6a8a0d9d97919e8321927ac4f35078844cdfa26)) 95 | 96 | 97 | 98 | ## [6.0.0](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v5.2.0...v6.0.0) (2023-01-02) 99 | 100 | 101 | ### Features 102 | 103 | * upgrade to Spring Boot 3.0.0 ([d4c810e](https://github.com/darrachequesne/spring-data-jpa-datatables/commit/d4c810e0444556906b8639dead0861adea27ee69)) 104 | 105 | 106 | 107 | ## [5.2.0](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v5.1.0...v5.2.0) (2022-05-19) 108 | 109 | 110 | ### Features 111 | 112 | * log errors ([#144](https://github.com/darrachequesne/spring-data-jpa-datatables/issues/144)) ([d102cfa](https://github.com/darrachequesne/spring-data-jpa-datatables/commit/d102cfabc3a67b3dd1768e373e21f0855f94a43a)) 113 | 114 | 115 | 116 | ## [5.1.0](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v5.0.0...v5.1.0) (2021-03-17) 117 | 118 | ### Features 119 | 120 | * add support for the SearchPanes extension ([16803f9](https://github.com/darrachequesne/spring-data-jpa-datatables/commit/16803f9d1e4f8c8c7b128a55b0be96d8cec36382)) 121 | 122 | 123 | 124 | ## [5.0.0](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v4.3...v5.0.0) (2018-03-01) 125 | 126 | 127 | ### BREAKING CHANGES 128 | 129 | * Update to spring boot 2.0.0 ([#73](https://github.com/darrachequesne/spring-data-jpa-datatables/pull/73)) 130 | 131 | 132 | 133 | ## [4.3](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v4.2...v4.3) (2017-12-24) 134 | 135 | 136 | ### Bug Fixes 137 | 138 | * Remove JOIN FETCH when using COUNT query ([#68](https://github.com/darrachequesne/spring-data-jpa-datatables/pull/68)) 139 | 140 | 141 | 142 | ## [4.2](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v4.1...v4.2) (2017-12-24) 143 | 144 | 145 | ### Bug Fixes 146 | 147 | * Add proper JOIN FETCH clause ([#67](https://github.com/darrachequesne/spring-data-jpa-datatables/pull/67)) 148 | * Remove column duplicates when using JOIN FETCH ([#64](https://github.com/darrachequesne/spring-data-jpa-datatables/pull/64)) 149 | 150 | 151 | 152 | ## [4.1](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v4.0...v4.1) (2017-04-05) 153 | 154 | 155 | ### Bug Fixes 156 | 157 | * Fix searching with the separator "+" ([#55](https://github.com/darrachequesne/spring-data-jpa-datatables/pull/55)) 158 | 159 | 160 | 161 | ## [4.0](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v3.1...v4.0) (2017-03-06) 162 | 163 | 164 | ### BREAKING CHANGES 165 | 166 | * Update bom to Brussels-RELEASE version ([#51](https://github.com/darrachequesne/spring-data-jpa-datatables/pull/51)) 167 | 168 | 169 | 170 | ## [3.1](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v3.0...v3.1) (2016-12-16) 171 | 172 | 173 | ### Features 174 | 175 | * Add the ability to filter on NULL values ([#44](https://github.com/darrachequesne/spring-data-jpa-datatables/pull/44)) 176 | 177 | 178 | 179 | ## [3.0](https://github.com/darrachequesne/spring-data-jpa-datatables/compare/v2.6...v3.0) (2016-11-19) 180 | 181 | 182 | ### Bug Fixes 183 | 184 | * Restrict eager loading to @OneToOne and @OneToMany relationships ([#39](https://github.com/darrachequesne/spring-data-jpa-datatables/pull/39)) 185 | 186 | 187 | 2.6 / 2016-10-13 188 | ================== 189 | 190 | * Add tests for MySQL and PostgreSQL (#32) 191 | * Add tests for querydsl implementation (#33) 192 | * Update travis status badge to point towards master (#28) 193 | 194 | 2.5 / 2016-08-18 195 | ================== 196 | 197 | * Update the paging calculation (#24) 198 | 199 | 2.4 / 2016-08-14 200 | ================== 201 | 202 | * Add support for additional converter (#21) 203 | 204 | 2.3 / 2016-06-12 205 | ================== 206 | 207 | * Ensure related entities are eagerly loaded (#16) 208 | * Add some helpers and refactor tests (#15) 209 | * Add support for nested @ManyToOne relationships (#14) 210 | 211 | 2.2 / 2016-05-13 212 | ================== 213 | 214 | * Set an empty list as default value for output data 215 | * Fix for using @Embedded class (by @wimdeblauwe) 216 | 217 | 2.1 / 2016-04-09 218 | ================== 219 | 220 | * Add toString methods to mappings 221 | * Prevent unnecessary query when no results are found by the count query 222 | * Add an optional pre-filtering specification 223 | * Update code style 224 | * Fix string cast for QueryDSL predicates (fix #6) 225 | 226 | 2.0 / 2016-03-04 227 | ================== 228 | 229 | * Add support for QueryDSL 230 | 231 | 1.5 / 2016-03-01 232 | ================== 233 | 234 | * Add helper to get a map of the columns, indexed by name 235 | * Add escape character for LIKE clauses 236 | * Fix direction regexp 237 | 238 | 1.4 / 2016-02-02 239 | ================== 240 | 241 | * Fixed factory always generating dataTablesRepositories 242 | * Add JDK6 test back 243 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | com.github.darrachequesne 7 | spring-data-jpa-datatables 8 | 7.1.0 9 | 10 | Spring Data JPA for DataTables 11 | Spring Data JPA extension to work with the great jQuery plug-in DataTables (http://datatables.net/) 12 | https://github.com/darrachequesne/spring-data-jpa-datatables 13 | 14 | 15 | 16 | Damien Arrachequesne 17 | damien.arrachequesne@gmail.com 18 | Freelancer 19 | 20 | 21 | 22 | 23 | 24 | Apache License, Version 2.0 25 | http://www.apache.org/licenses/LICENSE-2.0 26 | 27 | Copyright 2011-2012 the original author or authors. 28 | 29 | Licensed under the Apache License, Version 2.0 (the "License"); 30 | you may not use this file except in compliance with the License. 31 | You may obtain a copy of the License at 32 | 33 | http://www.apache.org/licenses/LICENSE-2.0 34 | 35 | Unless required by applicable law or agreed to in writing, software 36 | distributed under the License is distributed on an "AS IS" BASIS, 37 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 38 | implied. 39 | See the License for the specific language governing permissions and 40 | limitations under the License. 41 | 42 | 43 | 44 | 45 | 46 | https://github.com/darrachequesne/spring-data-jpa-datatables 47 | 48 | 49 | 50 | 51 | 3.0.0-M7 52 | 1.6.13 53 | 3.0.1 54 | 55 | 56 | 17 57 | ${java.version} 58 | ${java.version} 59 | UTF-8 60 | 61 | 62 | 63 | 64 | 65 | org.springframework.boot 66 | spring-boot-dependencies 67 | 3.5.3 68 | pom 69 | import 70 | 71 | 72 | 73 | 74 | 75 | 76 | org.projectlombok 77 | lombok 78 | provided 79 | 80 | 81 | 82 | org.springframework.data 83 | spring-data-jpa 84 | 85 | 86 | 87 | com.fasterxml.jackson.core 88 | jackson-databind 89 | 90 | 91 | 92 | jakarta.validation 93 | jakarta.validation-api 94 | 95 | 96 | 97 | 98 | org.hibernate.orm 99 | hibernate-core 100 | 101 | 102 | com.querydsl 103 | querydsl-apt 104 | 5.0.0 105 | jakarta 106 | 107 | 108 | 109 | com.querydsl 110 | querydsl-jpa 111 | 5.0.0 112 | jakarta 113 | 114 | 115 | 116 | jakarta.annotation 117 | jakarta.annotation-api 118 | compile 119 | 120 | 121 | 122 | 123 | org.junit.jupiter 124 | junit-jupiter-engine 125 | test 126 | 127 | 128 | 129 | org.assertj 130 | assertj-core 131 | test 132 | 133 | 134 | 135 | org.springframework.boot 136 | spring-boot-starter-web 137 | test 138 | 139 | 140 | 141 | org.springframework.boot 142 | spring-boot-starter-validation 143 | test 144 | 145 | 146 | 147 | org.springframework.boot 148 | spring-boot-starter-test 149 | test 150 | 151 | 152 | 153 | org.springframework 154 | spring-test 155 | test 156 | 157 | 158 | 159 | com.h2database 160 | h2 161 | test 162 | 163 | 164 | 165 | com.mysql 166 | mysql-connector-j 167 | test 168 | 169 | 170 | 171 | org.postgresql 172 | postgresql 173 | test 174 | 175 | 176 | 177 | com.microsoft.sqlserver 178 | mssql-jdbc 179 | test 180 | 181 | 182 | 183 | org.slf4j 184 | slf4j-reload4j 185 | test 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | com.mysema.maven 194 | apt-maven-plugin 195 | 1.1.3 196 | 197 | 198 | 199 | process 200 | 201 | 202 | target/generated-sources/java 203 | com.querydsl.apt.jpa.JPAAnnotationProcessor 204 | 205 | 206 | 207 | 208 | 209 | org.apache.maven.plugins 210 | maven-compiler-plugin 211 | 3.10.1 212 | 213 | ${maven.compiler.source} 214 | ${maven.compiler.target} 215 | ${source.encoding} 216 | 217 | 218 | 219 | org.apache.maven.plugins 220 | maven-resources-plugin 221 | 3.3.0 222 | 223 | ${source.encoding} 224 | 225 | 226 | 227 | org.apache.maven.plugins 228 | maven-source-plugin 229 | 3.2.1 230 | 231 | 232 | org.apache.maven.plugins 233 | maven-release-plugin 234 | ${version.plugin.maven-release-plugin} 235 | 236 | true 237 | false 238 | release 239 | deploy 240 | 241 | 242 | 243 | org.apache.maven.plugins 244 | maven-javadoc-plugin 245 | 3.4.1 246 | 247 | 248 | 249 | org.sonatype.central 250 | central-publishing-maven-plugin 251 | 0.8.0 252 | true 253 | 254 | central 255 | 256 | 257 | 258 | 259 | org.apache.maven.plugins 260 | maven-surefire-plugin 261 | 3.0.0-M7 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | release 271 | 272 | 273 | 274 | org.apache.maven.plugins 275 | maven-source-plugin 276 | 277 | 278 | attach-sources 279 | 280 | jar 281 | 282 | 283 | 284 | 285 | 286 | org.apache.maven.plugins 287 | maven-javadoc-plugin 288 | 289 | 290 | attach-sources 291 | 292 | jar 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | org.apache.maven.plugins 301 | maven-gpg-plugin 302 | ${version.plugin.maven-gpg-plugin} 303 | 304 | 305 | sign-artifacts 306 | verify 307 | 308 | sign 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /src/test/java/org/springframework/data/jpa/datatables/repository/EmployeeRepositoryTest.java: -------------------------------------------------------------------------------- 1 | package org.springframework.data.jpa.datatables.repository; 2 | 3 | import static java.util.Arrays.asList; 4 | import static java.util.Collections.emptySet; 5 | import static org.assertj.core.api.Assertions.assertThat; 6 | 7 | import jakarta.persistence.criteria.CriteriaBuilder; 8 | import jakarta.persistence.criteria.CriteriaQuery; 9 | import jakarta.persistence.criteria.Predicate; 10 | import jakarta.persistence.criteria.Root; 11 | import java.util.HashMap; 12 | import java.util.HashSet; 13 | import java.util.Map; 14 | import java.util.Set; 15 | import java.util.function.Function; 16 | 17 | import org.junit.jupiter.api.BeforeAll; 18 | import org.junit.jupiter.api.Test; 19 | import org.junit.jupiter.api.TestInstance; 20 | import org.junit.jupiter.api.extension.ExtendWith; 21 | import org.springframework.beans.factory.annotation.Autowired; 22 | import org.springframework.data.jpa.datatables.Config; 23 | import org.springframework.data.jpa.datatables.mapping.DataTablesInput; 24 | import org.springframework.data.jpa.datatables.mapping.DataTablesOutput; 25 | import org.springframework.data.jpa.datatables.mapping.SearchPanes; 26 | import org.springframework.data.jpa.datatables.model.Employee; 27 | import org.springframework.data.jpa.datatables.model.EmployeeDto; 28 | import org.springframework.data.jpa.domain.Specification; 29 | import org.springframework.test.context.ContextConfiguration; 30 | import org.springframework.test.context.junit.jupiter.SpringExtension; 31 | 32 | @ExtendWith(SpringExtension.class) 33 | @ContextConfiguration(classes = Config.class) 34 | @TestInstance(TestInstance.Lifecycle.PER_CLASS) 35 | public class EmployeeRepositoryTest { 36 | @Autowired 37 | private EmployeeRepository employeeRepository; 38 | 39 | protected DataTablesOutput getOutput(DataTablesInput input) { 40 | return employeeRepository.findAll(input); 41 | } 42 | 43 | protected DataTablesOutput getOutput(DataTablesInput input, Function converter) { 44 | return employeeRepository.findAll(input, converter); 45 | } 46 | 47 | @BeforeAll 48 | public void init() { 49 | employeeRepository.saveAll(Employee.ALL); 50 | } 51 | 52 | @Test 53 | void basic() { 54 | DataTablesOutput output = getOutput(createInput()); 55 | assertThat(output.getDraw()).isEqualTo(1); 56 | assertThat(output.getError()).isNull(); 57 | assertThat(output.getRecordsFiltered()).isEqualTo(Employee.ALL.size()); 58 | assertThat(output.getRecordsTotal()).isEqualTo(Employee.ALL.size()); 59 | assertThat(output.getData()).containsAll(Employee.ALL); 60 | } 61 | 62 | @Test 63 | void paginated() { 64 | DataTablesInput input = createInput(); 65 | input.setDraw(2); 66 | input.setLength(5); 67 | input.setStart(5); 68 | 69 | DataTablesOutput output = getOutput(input); 70 | assertThat(output.getDraw()).isEqualTo(2); 71 | assertThat(output.getRecordsFiltered()).isEqualTo(Employee.ALL.size()); 72 | assertThat(output.getRecordsTotal()).isEqualTo(Employee.ALL.size()); 73 | assertThat(output.getData()).hasSize(Employee.ALL.size() % 5); 74 | } 75 | 76 | @Test 77 | void sortAscending() { 78 | DataTablesInput input = createInput(); 79 | 80 | input.addOrder("age", true); 81 | 82 | DataTablesOutput output = getOutput(input); 83 | assertThat(output.getData()).containsExactlyElementsOf(Employee.ALL_SORTED_BY_AGE); 84 | } 85 | 86 | @Test 87 | void sortDescending() { 88 | DataTablesInput input = createInput(); 89 | 90 | input.addOrder("age", false); 91 | 92 | DataTablesOutput output = getOutput(input); 93 | assertThat(output.getData()).containsExactlyElementsOf(Employee.ALL_SORTED_BY_AGE_DESC); 94 | } 95 | 96 | @Test 97 | void globalFilter() { 98 | DataTablesInput input = createInput(); 99 | 100 | input.getSearch().setValue("William"); 101 | 102 | DataTablesOutput output = getOutput(input); 103 | assertThat(output.getData()).containsOnly(Employee.BRIELLE_WILLIAMSON); 104 | } 105 | 106 | @Test 107 | void globalFilterWithMultiplePages() { 108 | DataTablesInput input = createInput(); 109 | 110 | input.getSearch().setValue("e"); 111 | input.setLength(1); 112 | 113 | DataTablesOutput output = getOutput(input); 114 | assertThat(output.getError()).isNull(); 115 | assertThat(output.getRecordsFiltered()).isEqualTo(6); 116 | assertThat(output.getRecordsTotal()).isEqualTo(6); 117 | } 118 | 119 | @Test 120 | void globalFilterIgnoreCaseIgnoreSpace() { 121 | DataTablesInput input = createInput(); 122 | 123 | input.getSearch().setValue(" aMoS "); 124 | 125 | DataTablesOutput output = getOutput(input); 126 | assertThat(output.getData()).containsOnly(Employee.ANGELICA_RAMOS); 127 | } 128 | 129 | @Test 130 | void columnFilter() { 131 | DataTablesInput input = createInput(); 132 | 133 | input.getColumn("lastName").setSearchValue(" AmOs "); 134 | 135 | DataTablesOutput output = getOutput(input); 136 | assertThat(output.getData()).containsOnly(Employee.ANGELICA_RAMOS); 137 | } 138 | 139 | @Test 140 | void multipleColumnFilters() { 141 | DataTablesInput input = createInput(); 142 | 143 | input.getColumn("age").setSearchValue("28"); 144 | input.getColumn("position").setSearchValue("Software"); 145 | 146 | DataTablesOutput output = getOutput(input); 147 | assertThat(output.getData()).containsOnly(Employee.BRENDEN_WAGNER); 148 | } 149 | 150 | @Test 151 | void columnFilterWithMultipleCases() { 152 | DataTablesInput input = createInput(); 153 | 154 | input.getColumn("position").setSearchValue("Accountant+Junior Technical Author"); 155 | 156 | DataTablesOutput output = getOutput(input); 157 | assertThat(output.getRecordsFiltered()).isEqualTo(2); 158 | assertThat(output.getData()).containsOnly(Employee.AIRI_SATOU, Employee.ASHTON_COX); 159 | } 160 | 161 | @Test 162 | void columnFilterWithNoCase() { 163 | DataTablesInput input = createInput(); 164 | 165 | input.getColumn("position").setSearchValue("+"); 166 | 167 | DataTablesOutput output = getOutput(input); 168 | assertThat(output.getRecordsFiltered()).isEqualTo(Employee.ALL.size()); 169 | } 170 | 171 | @Test 172 | void zeroLength() { 173 | DataTablesInput input = createInput(); 174 | 175 | input.setLength(0); 176 | 177 | DataTablesOutput output = getOutput(input); 178 | assertThat(output.getRecordsFiltered()).isZero(); 179 | assertThat(output.getData()).isEmpty(); 180 | } 181 | 182 | @Test 183 | void negativeLength() { 184 | DataTablesInput input = createInput(); 185 | 186 | input.setLength(-1); 187 | 188 | DataTablesOutput output = getOutput(input); 189 | assertThat(output.getRecordsFiltered()).isEqualTo(Employee.ALL.size()); 190 | assertThat(output.getRecordsTotal()).isEqualTo(Employee.ALL.size()); 191 | } 192 | 193 | @Test 194 | void multipleColumnFiltersOnManyToOneRelationship() { 195 | DataTablesInput input = createInput(); 196 | 197 | input.getColumn("office.city").setSearchValue("new york"); 198 | input.getColumn("office.country").setSearchValue("USA"); 199 | 200 | DataTablesOutput output = getOutput(input); 201 | assertThat(output.getRecordsFiltered()).isEqualTo(1); 202 | assertThat(output.getData()).containsOnly(Employee.BRIELLE_WILLIAMSON); 203 | } 204 | 205 | @Test 206 | void withConverter() { 207 | DataTablesInput input = createInput(); 208 | 209 | input.getColumn("firstName").setSearchValue("airi"); 210 | 211 | DataTablesOutput output = getOutput(input, employee -> 212 | new EmployeeDto(employee.getId(), employee.getFirstName(), employee.getLastName())); 213 | assertThat(output.getData()).containsOnly(EmployeeDto.AIRI_SATOU); 214 | } 215 | 216 | @Test 217 | protected void withAnAdditionalSpecification() { 218 | DataTablesOutput output = employeeRepository.findAll(createInput(), new SoftwareEngineersOnly<>()); 219 | assertThat(output.getRecordsFiltered()).isEqualTo(2); 220 | assertThat(output.getRecordsTotal()).isEqualTo(Employee.ALL.size()); 221 | } 222 | 223 | @Test 224 | protected void withAPreFilteringSpecification() { 225 | DataTablesOutput output = employeeRepository.findAll(createInput(), null, new SoftwareEngineersOnly<>()); 226 | assertThat(output.getRecordsFiltered()).isEqualTo(2); 227 | assertThat(output.getRecordsTotal()).isEqualTo(2); 228 | } 229 | 230 | private static class SoftwareEngineersOnly implements Specification { 231 | @Override 232 | public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder criteriaBuilder) { 233 | return criteriaBuilder.equal(root.get("position"), "Software Engineer"); 234 | } 235 | } 236 | 237 | @Test 238 | void columnFilterWithNull() { 239 | DataTablesInput input = createInput(); 240 | 241 | input.getColumn("comment").setSearchValue("NULL"); 242 | 243 | DataTablesOutput output = getOutput(input); 244 | assertThat(output.getData()).containsOnly(Employee.AIRI_SATOU); 245 | } 246 | 247 | @Test 248 | void columnFilterWithNullEscaped() { 249 | DataTablesInput input = createInput(); 250 | 251 | input.getColumn("comment").setSearchValue("\\NULL"); 252 | 253 | DataTablesOutput output = getOutput(input); 254 | assertThat(output.getData()).containsOnly(Employee.ANGELICA_RAMOS); 255 | } 256 | 257 | @Test 258 | void columnFilterWithEscapeCharacters() { 259 | DataTablesInput input = createInput(); 260 | 261 | input.getColumn("comment").setSearchValue("foo~"); 262 | DataTablesOutput output = getOutput(input); 263 | assertThat(output.getData()).containsOnly(Employee.ASHTON_COX); 264 | 265 | input.getColumn("comment").setSearchValue("foo%"); 266 | output = getOutput(input); 267 | assertThat(output.getData()).containsOnly(Employee.BRADLEY_GREER); 268 | 269 | input.getColumn("comment").setSearchValue("foo_"); 270 | output = getOutput(input); 271 | assertThat(output.getData()).containsOnly(Employee.BRENDEN_WAGNER); 272 | } 273 | 274 | @Test 275 | void columnFilterWithValueOrNull() { 276 | DataTablesInput input = createInput(); 277 | 278 | input.getColumn("comment").setSearchValue("@foo@@+NULL"); 279 | 280 | DataTablesOutput output = getOutput(input); 281 | assertThat(output.getData()).containsOnly(Employee.AIRI_SATOU, Employee.BRIELLE_WILLIAMSON); 282 | } 283 | 284 | @Test 285 | void columnFilterBoolean() { 286 | DataTablesInput input = createInput(); 287 | 288 | input.getColumn("isWorkingRemotely").setSearchValue("true"); 289 | 290 | DataTablesOutput output = getOutput(input); 291 | assertThat(output.getData()).containsOnly(Employee.ASHTON_COX); 292 | } 293 | 294 | @Test 295 | void columnFilterBooleanBothCases() { 296 | DataTablesInput input = createInput(); 297 | 298 | input.getColumn("isWorkingRemotely").setSearchValue("true+false"); 299 | 300 | DataTablesOutput output = getOutput(input); 301 | assertThat(output.getData()).containsAll(Employee.ALL); 302 | } 303 | 304 | @Test 305 | protected void unknownColumn() { 306 | DataTablesInput input = createInput(); 307 | 308 | input.addColumn("unknown", true, true, "test"); 309 | 310 | DataTablesOutput output = getOutput(input); 311 | assertThat(output.getError()).isNotNull(); 312 | } 313 | 314 | @Test 315 | void withSearchPanes() { 316 | DataTablesInput input = createInput(); 317 | 318 | Map> searchPanes = new HashMap<>(); 319 | searchPanes.put("position", new HashSet<>(asList("Software Engineer", "Integration Specialist"))); 320 | searchPanes.put("age", emptySet()); 321 | searchPanes.put("office.city", emptySet()); 322 | 323 | input.setSearchPanes(searchPanes); 324 | 325 | DataTablesOutput output = getOutput(input); 326 | assertThat(output.getRecordsFiltered()).isEqualTo(3); 327 | assertThat(output.getSearchPanes()).isNotNull(); 328 | 329 | assertThat(output.getSearchPanes().getOptions().size()).isEqualTo(3); 330 | 331 | assertThat(output.getSearchPanes().getOptions().get("position")).containsOnly( 332 | new SearchPanes.Item("Software Engineer", "Software Engineer", 2, 2), 333 | new SearchPanes.Item("Integration Specialist", "Integration Specialist", 1, 1) 334 | ); 335 | assertThat(output.getSearchPanes().getOptions().get("age")).containsOnly( 336 | new SearchPanes.Item("28", "28", 1, 1), 337 | new SearchPanes.Item("41", "41", 1, 1), 338 | new SearchPanes.Item("61", "61", 1, 1) 339 | ); 340 | assertThat(output.getSearchPanes().getOptions().get("office.city")).containsOnly( 341 | new SearchPanes.Item("London", "London", 1, 1), 342 | new SearchPanes.Item("New York", "New York", 1, 1), 343 | new SearchPanes.Item("San Francisco", "San Francisco", 1, 1) 344 | ); 345 | } 346 | 347 | @Test 348 | void withSearchPanesAndAPreFilteringSpecification() { 349 | DataTablesInput input = createInput(); 350 | 351 | Map> searchPanes = new HashMap<>(); 352 | searchPanes.put("position", new HashSet<>(asList("Software Engineer", "Integration Specialist"))); 353 | searchPanes.put("age", emptySet()); 354 | 355 | input.setSearchPanes(searchPanes); 356 | 357 | DataTablesOutput output = employeeRepository.findAll(input, null, new SoftwareEngineersOnly<>()); 358 | assertThat(output.getRecordsFiltered()).isEqualTo(2); 359 | assertThat(output.getSearchPanes()).isNotNull(); 360 | 361 | assertThat(output.getSearchPanes().getOptions().get("position")).containsOnly( 362 | new SearchPanes.Item("Software Engineer", "Software Engineer", 2, 2) 363 | ); 364 | assertThat(output.getSearchPanes().getOptions().get("age")).containsOnly( 365 | new SearchPanes.Item("28", "28", 1, 1), 366 | new SearchPanes.Item("41", "41", 1, 1) 367 | ); 368 | } 369 | 370 | @Test 371 | void withSearchPanesAndFilterOnRelationship() { 372 | DataTablesInput input = createInput(); 373 | 374 | Map> searchPanes = new HashMap<>(); 375 | searchPanes.put("position", emptySet()); 376 | searchPanes.put("age", emptySet()); 377 | searchPanes.put("office.city", Set.of("London", "New York")); 378 | 379 | input.setSearchPanes(searchPanes); 380 | 381 | DataTablesOutput output = getOutput(input); 382 | assertThat(output.getRecordsFiltered()).isEqualTo(3); 383 | 384 | assertThat(output.getSearchPanes().getOptions().get("office.city")).containsOnly( 385 | new SearchPanes.Item("London", "London", 2, 2), 386 | new SearchPanes.Item("New York", "New York", 1, 1) 387 | ); 388 | } 389 | 390 | protected static DataTablesInput createInput() { 391 | DataTablesInput input = new DataTablesInput(); 392 | input.addColumn("id", true, true, ""); 393 | input.addColumn("firstName", true, true, ""); 394 | input.addColumn("lastName", true, true, ""); 395 | input.addColumn("fullName", false, true, ""); 396 | input.addColumn("position", true, true, ""); 397 | input.addColumn("age", true, true, ""); 398 | input.addColumn("isWorkingRemotely", true, true, ""); 399 | input.addColumn("comment", true, true, ""); 400 | 401 | input.addColumn("action_column", false, false, ""); 402 | 403 | input.addColumn("office.id", true, false, ""); 404 | input.addColumn("office.city", true, true, ""); 405 | input.addColumn("office.country", true, true, ""); 406 | 407 | return input; 408 | } 409 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/darrachequesne/spring-data-jpa-datatables/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/darrachequesne/spring-data-jpa-datatables/actions) 2 | [![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.github.darrachequesne/spring-data-jpa-datatables/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.github.darrachequesne/spring-data-jpa-datatables) 3 | 4 | # spring-data-jpa-datatables 5 | 6 | This project is an extension of the [Spring Data JPA](https://github.com/spring-projects/spring-data-jpa) project to ease its use with jQuery plugin [DataTables](http://datatables.net/) with **server-side processing enabled**. 7 | 8 | This will allow you to handle the Ajax requests sent by DataTables for each draw of the information on the page (i.e. when paging, ordering, searching, etc.) from Spring **@RestController**. 9 | 10 | For a MongoDB counterpart, please see [spring-data-mongodb-datatables](https://github.com/darrachequesne/spring-data-mongodb-datatables). 11 | 12 | **Example:** 13 | 14 | ```java 15 | @RestController 16 | public class UserRestController { 17 | 18 | @Autowired 19 | private UserRepository userRepository; 20 | 21 | @RequestMapping(value = "/data/users", method = RequestMethod.GET) 22 | public DataTablesOutput getUsers(@Valid DataTablesInput input) { 23 | return userRepository.findAll(input); 24 | } 25 | } 26 | ``` 27 | 28 | ![Example](https://user-images.githubusercontent.com/13031701/43364754-92f8de16-9320-11e8-9ee2-cc072e1eef8c.gif) 29 | 30 | 31 | ## Contents 32 | 33 | - [Maven dependency](#maven-dependency) 34 | - [Getting started](#getting-started) 35 | - [Step 1 - Enable the use of the `DataTablesRepository` factory](#step-1---enable-the-use-of-the-datatablesrepository-factory) 36 | - [Step 2 - Create a new entity](#step-2---create-a-new-entity) 37 | - [Step 3 - Extend the DataTablesRepository interface](#step-3---extend-the-datatablesrepository-interface) 38 | - [Step 4 - Use the repository in your controllers](#step-4---use-the-repository-in-your-controllers) 39 | - [Step 5 - On the client-side, create a new DataTable object](#step-5---on-the-client-side-create-a-new-datatable-object) 40 | - [Step 6 - Fix the serialization / deserialization of the query parameters](#step-6---fix-the-serialization--deserialization-of-the-query-parameters) 41 | - [API](#api) 42 | - [How to](#how-to) 43 | - [Apply filters](#apply-filters) 44 | - [Manage non-searchable fields](#manage-non-searchable-fields) 45 | - [Limit the exposed attributes of the entities](#limit-the-exposed-attributes-of-the-entities) 46 | - [Search on a rendered column](#search-on-a-rendered-column) 47 | - [Use with the SearchPanes extension](#use-with-the-searchpanes-extension) 48 | - [Handle `@OneToMany` and `@ManyToMany` relationships](#handle-onetomany-and-manytomany-relationships) 49 | - [Search for a specific value in a column](#search-for-a-specific-value-in-a-column) 50 | - [Examples of additional specification](#examples-of-additional-specification) 51 | - [Specific date](#specific-date) 52 | - [Range of integers](#range-of-integers) 53 | - [Range of dates](#range-of-dates) 54 | - [Troubleshooting](#troubleshooting) 55 | 56 | ## Maven dependency 57 | 58 | ```xml 59 | 60 | com.github.darrachequesne 61 | spring-data-jpa-datatables 62 | 7.1.0 63 | 64 | ``` 65 | 66 | Compatibility with Spring Boot: 67 | 68 | | Version | Spring Boot version | 69 | |---------------|-----------------------| 70 | | 7.x | `>= 3.4.0` | 71 | | 6.x | `>= 3.0.0 && < 3.4.0` | 72 | | 5.x | `>= 2.O.0 && < 3.0.0` | 73 | | 4.x and below | `>= 1.O.0 && < 2.0.0` | 74 | 75 | 76 | Back to [top](#contents). 77 | 78 | 79 | ## Getting started 80 | 81 | Please see the [sample project](https://github.com/darrachequesne/spring-data-jpa-datatables-sample) for a complete example. 82 | 83 | ### Step 1 - Enable the use of the `DataTablesRepository` factory 84 | 85 | With either 86 | 87 | ```java 88 | @Configuration 89 | @EnableJpaRepositories(repositoryFactoryBeanClass = DataTablesRepositoryFactoryBean.class) 90 | public class DataTablesConfiguration {} 91 | ``` 92 | 93 | or its XML counterpart 94 | 95 | ```xml 96 | 97 | ``` 98 | 99 | You can restrict the scope of the factory with `@EnableJpaRepositories(repositoryFactoryBeanClass = DataTablesRepositoryFactoryBean.class, basePackages = "my.package.for.datatables.repositories")`. In that case, only the repositories in the given package will be instantiated as `DataTablesRepositoryImpl` on run. 100 | 101 | ```java 102 | @Configuration 103 | @EnableJpaRepositories(basePackages = "my.default.package") 104 | public class DefaultJpaConfiguration {} 105 | 106 | @Configuration 107 | @EnableJpaRepositories(repositoryFactoryBeanClass = DataTablesRepositoryFactoryBean.class, basePackages = "my.package.for.datatables.repositories") 108 | public class DataTablesConfiguration {} 109 | ``` 110 | 111 | ### Step 2 - Create a new entity 112 | 113 | ```java 114 | @Entity 115 | public class User { 116 | 117 | private Integer id; 118 | 119 | private String mail; 120 | 121 | @ManyToOne 122 | @JoinColumn(name = "id_address") 123 | private Address address; 124 | 125 | } 126 | ``` 127 | 128 | ### Step 3 - Extend the DataTablesRepository interface 129 | 130 | ```java 131 | public interface UserRepository extends DataTablesRepository {} 132 | ``` 133 | 134 | The `DataTablesRepository` interface extends both [PagingAndSortingRepository](https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/repository/PagingAndSortingRepository.html) and [JpaSpecificationExecutor](https://docs.spring.io/spring-data/jpa/docs/current/api/org/springframework/data/jpa/repository/JpaSpecificationExecutor.html). 135 | 136 | ### Step 4 - Use the repository in your controllers 137 | 138 | ```java 139 | @RestController 140 | @RequiredArgsConstructor 141 | public class MyController { 142 | private final UserRepository userRepository; 143 | 144 | @RequestMapping(value = "/data/users", method = RequestMethod.GET) 145 | public DataTablesOutput getUsers(@Valid DataTablesInput input) { 146 | return userRepository.findAll(input); 147 | } 148 | } 149 | ``` 150 | 151 | ### Step 5 - On the client-side, create a new DataTable object 152 | 153 | ```javascript 154 | $(document).ready(function() { 155 | var table = $('table#sample').DataTable({ 156 | ajax : '/data/users', 157 | serverSide : true, 158 | columns : [{ 159 | data : 'id' 160 | }, { 161 | data : 'mail' 162 | }, { 163 | data : 'address.town', 164 | render: function (data, type, row) { 165 | return data || ''; 166 | } 167 | }] 168 | }); 169 | } 170 | ``` 171 | 172 | ### Step 6 - Fix the serialization / deserialization of the query parameters 173 | 174 | By default, the [parameters](https://datatables.net/manual/server-side#Sent-parameters) sent by the plugin cannot be deserialized by Spring MVC and will throw the following exception: `InvalidPropertyException: Invalid property 'columns[0][data]' of bean class [org.springframework.data.jpa.datatables.mapping.DataTablesInput]`. 175 | 176 | There are multiple solutions to this issue: 177 | 178 | - [Solution n°1 - custom serialization](#solution-n1---custom-serialization) 179 | - [Solution n°2 - POST requests](#solution-n2---post-requests) 180 | - [Solution n°3 - manual serialization](#solution-n3---manual-serialization) 181 | 182 | #### Solution n°1 - custom serialization 183 | 184 | You need to include the [jquery.spring-friendly.js](jquery.spring-friendly.js) file found at the root of the repository. 185 | 186 | ```html 187 |