projections: Spring Data JPA interface, record, nested and dynamic projections with the SQL they generate
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b5sejRr8s3kapmRY2g
This commit is contained in:
@@ -9,6 +9,7 @@ Companion code for a series of posts on [ankurm.com](https://ankurm.com) coverin
|
|||||||
| [`elasticsearch/`](./elasticsearch) | Full-Text Search with Spring Data Elasticsearch on Spring Boot 4 | Elasticsearch 9.4.8, Spring Data Elasticsearch 6.1.1 |
|
| [`elasticsearch/`](./elasticsearch) | Full-Text Search with Spring Data Elasticsearch on Spring Boot 4 | Elasticsearch 9.4.8, Spring Data Elasticsearch 6.1.1 |
|
||||||
| [`distributed-cache/`](./distributed-cache) | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast 5.5.0 (embedded), Infinispan 16.1.4 (embedded), Redis 8.8.3 (Testcontainers) |
|
| [`distributed-cache/`](./distributed-cache) | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast 5.5.0 (embedded), Infinispan 16.1.4 (embedded), Redis 8.8.3 (Testcontainers) |
|
||||||
| [`r2dbc/`](./r2dbc) | Reactive Data Access with Spring Data R2DBC: CRUD, Transactions and When to Choose It | Spring Data R2DBC 4.1.2, r2dbc-postgresql 1.1.3.RELEASE, PostgreSQL 17 (Testcontainers 2.0.5) |
|
| [`r2dbc/`](./r2dbc) | Reactive Data Access with Spring Data R2DBC: CRUD, Transactions and When to Choose It | Spring Data R2DBC 4.1.2, r2dbc-postgresql 1.1.3.RELEASE, PostgreSQL 17 (Testcontainers 2.0.5) |
|
||||||
|
| [`projections/`](./projections) | Spring Data JPA Projections: Interface, Record and Dynamic Projections (With the SQL They Generate) | Spring Data JPA 4.1.2, Hibernate 7.4.13.Final, H2 2.4.240 |
|
||||||
|
|
||||||
Each module has its own `pom.xml`, a `docker-compose.yml` for its backing services, and a `docs/output/` directory holding the real, re-runnable transcripts that every number quoted in its post traces back to. There are no narrative doc chapters here — the deeper material for each post lives in accordions inside the post itself.
|
Each module has its own `pom.xml`, a `docker-compose.yml` for its backing services, and a `docs/output/` directory holding the real, re-runnable transcripts that every number quoted in its post traces back to. There are no narrative doc chapters here — the deeper material for each post lives in accordions inside the post itself.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# projections
|
||||||
|
|
||||||
|
Companion code for **Spring Data JPA Projections: Interface, Record and Dynamic Projections (With the SQL They Generate)** on [ankurm.com](https://ankurm.com).
|
||||||
|
|
||||||
|
| Component | Version |
|
||||||
|
|---|---|
|
||||||
|
| Spring Boot | 4.1.2 |
|
||||||
|
| Spring Data JPA | 4.1.2 |
|
||||||
|
| Hibernate ORM | 7.4.13.Final |
|
||||||
|
| H2 (in-memory) | 2.4.240 |
|
||||||
|
| JDK | Temurin 25.0.5 LTS |
|
||||||
|
|
||||||
|
No Docker needed. Every SQL statement is captured by `SqlRecorder`, a Hibernate `StatementInspector`, and every test writes the SQL it saw to `output/` while asserting the same facts, so the numbers in the post fail the build if they stop being true.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/run-all.sh # regenerates every file in output/
|
||||||
|
```
|
||||||
|
|
||||||
|
| Source file | What it shows |
|
||||||
|
|---|---|
|
||||||
|
| `EmployeeSummary` | closed interface projection |
|
||||||
|
| `EmployeeFullName` | open interface projection (`@Value` SpEL) |
|
||||||
|
| `EmployeeFullNameClosed` | the fix for the open projection: a default method |
|
||||||
|
| `EmployeeWithDepartment` | nested interface projection |
|
||||||
|
| `EmployeeDto` | record DTO projection |
|
||||||
|
| `DepartmentPay` | target of a JPQL constructor expression |
|
||||||
|
| `EmployeeRepository` | every query-method form, numbered 1 to 14 |
|
||||||
|
| `SqlRecorder` | the SQL capture mechanism |
|
||||||
|
|
||||||
|
| Transcript | Shows |
|
||||||
|
|---|---|
|
||||||
|
| `output/01-entity-vs-closed-vs-dto.txt` | 7 columns vs 2 columns, managed entities 4 vs 0 |
|
||||||
|
| `output/02-open-projection-trap.txt` | an open projection selects all 7 columns |
|
||||||
|
| `output/11-default-method-fix.txt` | a default method keeps the projection closed (2 columns) |
|
||||||
|
| `output/03-nested-projection.txt` | nested projection pulls the whole department row |
|
||||||
|
| `output/04-dynamic-projection.txt` | one method, three shapes |
|
||||||
|
| `output/05-query-forms.txt` | `@Query` aliases, constructor expression, native query |
|
||||||
|
| `output/06-pitfall-entity-query.txt` | `select e` declared as an interface projection loads everything |
|
||||||
|
| `output/07-pitfall-missing-aliases.txt` | tuple select without aliases returns nulls |
|
||||||
|
| `output/08-pitfall-unknown-property.txt` | unknown projection property |
|
||||||
|
| `output/09-paging-and-proxy.txt` | paging runs a count query; projections are JDK proxies |
|
||||||
|
| `output/10-entity-query-as-dto-rewrite.txt` | `select e` declared as a record is rewritten to 2 columns |
|
||||||
|
|
||||||
|
There is no `docs/` folder: the explanations live in the post.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# entity result: findByDepartmentName("Sales")
|
||||||
|
select e1_0.id,e1_0.bio,e1_0.department_id,e1_0.email,e1_0.first_name,e1_0.last_name,e1_0.salary from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
|
||||||
|
columns selected: 7, entities now managed by the persistence context: 4
|
||||||
|
|
||||||
|
# closed interface projection: findSummaryByDepartmentName("Sales")
|
||||||
|
select e1_0.first_name,e1_0.last_name from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
|
||||||
|
columns selected: 2, entities now managed by the persistence context: 0
|
||||||
|
|
||||||
|
# record DTO projection: findDtoByDepartmentName("Sales")
|
||||||
|
select e1_0.first_name,e1_0.last_name from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
|
||||||
|
columns selected: 2, entities now managed by the persistence context: 0
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# open projection: findFullNameByDepartmentName("Sales") (getFullName uses @Value SpEL)
|
||||||
|
select e1_0.id,e1_0.bio,e1_0.department_id,e1_0.email,e1_0.first_name,e1_0.last_name,e1_0.salary from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
|
||||||
|
columns selected: 7, entities now managed by the persistence context: 4
|
||||||
|
getFullName() of first row: First1 Last1
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# nested projection: asks for firstName and department.name only
|
||||||
|
select e1_0.first_name,d1_0.id,d1_0.location,d1_0.name from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
|
||||||
|
columns selected: 4 (1 from employee + 3 from department), entities now managed: 1
|
||||||
|
department name read from the projection: Sales
|
||||||
|
extra statements after reading it: 0
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# one repository method, three result shapes: findByDepartmentName("Sales", Class<T>)
|
||||||
|
Employee -> 4 rows, 7 columns: select e1_0.id,e1_0.bio,e1_0.department_id,e1_0.email,e1_0.first_name,e1_0.last_name,e1_0.salary
|
||||||
|
EmployeeSummary -> 4 rows, 2 columns: select e1_0.first_name,e1_0.last_name
|
||||||
|
EmployeeDto -> 4 rows, 2 columns: select e1_0.first_name,e1_0.last_name
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# @Query with aliases, declared as an interface projection
|
||||||
|
select e1_0.first_name,e1_0.last_name from employee e1_0 where e1_0.salary>?
|
||||||
|
rows: 6, first getFirstName(): First6
|
||||||
|
|
||||||
|
# @Query with a constructor expression (select new ...DepartmentPay)
|
||||||
|
select d1_0.name,count(e1_0.id),sum(e1_0.salary) from employee e1_0 join department d1_0 on d1_0.id=e1_0.department_id group by d1_0.name order by d1_0.name
|
||||||
|
DepartmentPay[department=Engineering, headcount=4, totalSalary=218000.00]
|
||||||
|
DepartmentPay[department=HR, headcount=4, totalSalary=226000.00]
|
||||||
|
DepartmentPay[department=Sales, headcount=4, totalSalary=222000.00]
|
||||||
|
|
||||||
|
# native query with column aliases equal to the getter names
|
||||||
|
select first_name as firstName, last_name as lastName from employee where salary > ?
|
||||||
|
rows: 6, first getFirstName(): First6
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# @Query("select e from Employee e ...") declared as List<EmployeeSummary>
|
||||||
|
select e1_0.id,e1_0.bio,e1_0.department_id,e1_0.email,e1_0.first_name,e1_0.last_name,e1_0.salary from employee e1_0 where e1_0.salary>?
|
||||||
|
columns selected: 7, entities now managed: 6, rows: 6
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# @Query("select e.firstName, e.lastName from Employee e ...") with NO aliases
|
||||||
|
select e1_0.first_name,e1_0.last_name from employee e1_0 where e1_0.salary>?
|
||||||
|
rows: 6
|
||||||
|
getFirstName() of first row: null
|
||||||
|
getLastName() of first row: null
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# dynamic projection onto an interface with getNickname(), which Employee does not have
|
||||||
|
org.springframework.data.core.PropertyReferenceException
|
||||||
|
No property 'nickname' found for type 'Employee'
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Page<EmployeeSummary>, page size 2
|
||||||
|
select e1_0.first_name,e1_0.last_name from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=? fetch first ? rows only
|
||||||
|
select count(*) from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
|
||||||
|
statements: 2, rows in page: 2, total elements: 4
|
||||||
|
|
||||||
|
# what an interface projection really is at runtime
|
||||||
|
is a java.lang.reflect.Proxy: true
|
||||||
|
is an Employee: false
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# @Query("select e from Employee e ...") declared as List<EmployeeDto> (a record)
|
||||||
|
select e1_0.first_name,e1_0.last_name from employee e1_0 where e1_0.salary>?
|
||||||
|
columns selected: 2, entities now managed: 0, rows: 6
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# closed projection with a default method: findFullNameClosedByDepartmentName("Sales")
|
||||||
|
select e1_0.first_name,e1_0.last_name from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
|
||||||
|
columns selected: 2, entities now managed by the persistence context: 0
|
||||||
|
getFullName() of first row: First1 Last1
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.2</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>projections-demo</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
<maven.compiler.release>25</maven.compiler.release>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerates every file in output/ by running the test suite (in-memory H2, no Docker needed).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
rm -f output/*.txt
|
||||||
|
mvn -B -q test
|
||||||
|
echo "wrote:"; ls output
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.ankurm.projections;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
public class Department {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
protected Department() {}
|
||||||
|
public Department(String name, String location) { this.name = name; this.location = location; }
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public String getLocation() { return location; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.ankurm.projections;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/** Target of the constructor expression in EmployeeRepository.payByDepartment. */
|
||||||
|
public record DepartmentPay(String department, long headcount, BigDecimal totalSalary) {}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.ankurm.projections;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
public class Employee {
|
||||||
|
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
private String firstName;
|
||||||
|
private String lastName;
|
||||||
|
private String email;
|
||||||
|
private BigDecimal salary;
|
||||||
|
@Column(length = 2000)
|
||||||
|
private String bio;
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
private Department department;
|
||||||
|
|
||||||
|
protected Employee() {}
|
||||||
|
public Employee(String firstName, String lastName, String email, BigDecimal salary, String bio, Department department) {
|
||||||
|
this.firstName = firstName; this.lastName = lastName; this.email = email;
|
||||||
|
this.salary = salary; this.bio = bio; this.department = department;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public String getFirstName() { return firstName; }
|
||||||
|
public String getLastName() { return lastName; }
|
||||||
|
public String getEmail() { return email; }
|
||||||
|
public BigDecimal getSalary() { return salary; }
|
||||||
|
public String getBio() { return bio; }
|
||||||
|
public Department getDepartment() { return department; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package com.ankurm.projections;
|
||||||
|
|
||||||
|
/** Class-based (DTO) projection written as a record: Spring Data calls the canonical constructor. */
|
||||||
|
public record EmployeeDto(String firstName, String lastName) {}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.ankurm.projections;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
||||||
|
/** Open interface projection: a SpEL expression makes Spring Data unable to know which columns it needs. */
|
||||||
|
public interface EmployeeFullName {
|
||||||
|
@Value("#{target.firstName + ' ' + target.lastName}")
|
||||||
|
String getFullName();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ankurm.projections;
|
||||||
|
|
||||||
|
/** Closed projection with a computed value: the computation is a plain default method, no SpEL. */
|
||||||
|
public interface EmployeeFullNameClosed {
|
||||||
|
String getFirstName();
|
||||||
|
String getLastName();
|
||||||
|
|
||||||
|
default String getFullName() {
|
||||||
|
return getFirstName() + " " + getLastName();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.ankurm.projections;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
|
||||||
|
|
||||||
|
// 1. Derived query, entity result (the baseline).
|
||||||
|
List<Employee> findByDepartmentName(String departmentName);
|
||||||
|
|
||||||
|
// 2. Same derived query, closed interface projection.
|
||||||
|
List<EmployeeSummary> findSummaryByDepartmentName(String departmentName);
|
||||||
|
|
||||||
|
// 3. Open projection (SpEL).
|
||||||
|
List<EmployeeFullName> findFullNameByDepartmentName(String departmentName);
|
||||||
|
|
||||||
|
// 4. Nested interface projection.
|
||||||
|
List<EmployeeWithDepartment> findNestedByDepartmentName(String departmentName);
|
||||||
|
|
||||||
|
// 5. Record DTO projection.
|
||||||
|
List<EmployeeDto> findDtoByDepartmentName(String departmentName);
|
||||||
|
|
||||||
|
// 6. Dynamic projection: the caller picks the shape.
|
||||||
|
<T> List<T> findByDepartmentName(String departmentName, Class<T> type);
|
||||||
|
|
||||||
|
// 7. @Query returning the entity but declared as an interface projection (aliases required).
|
||||||
|
@Query("select e.firstName as firstName, e.lastName as lastName from Employee e where e.salary > :min")
|
||||||
|
List<EmployeeSummary> richerThan(@Param("min") java.math.BigDecimal min);
|
||||||
|
|
||||||
|
// 8. Constructor expression in JPQL.
|
||||||
|
@Query("select new com.ankurm.projections.DepartmentPay(d.name, count(e), sum(e.salary)) "
|
||||||
|
+ "from Employee e join e.department d group by d.name order by d.name")
|
||||||
|
List<DepartmentPay> payByDepartment();
|
||||||
|
|
||||||
|
// 9. Native query + interface projection (alias must equal the getter name).
|
||||||
|
@Query(value = "select first_name as firstName, last_name as lastName from employee where salary > :min", nativeQuery = true)
|
||||||
|
List<EmployeeSummary> nativeRicherThan(@Param("min") java.math.BigDecimal min);
|
||||||
|
|
||||||
|
// 10. Paging with a projection.
|
||||||
|
Page<EmployeeSummary> findSummaryByDepartmentName(String departmentName, Pageable pageable);
|
||||||
|
|
||||||
|
// 11. Pitfall: @Query selects the whole entity; the interface projection is applied afterwards in Java.
|
||||||
|
@Query("select e from Employee e where e.salary > :min")
|
||||||
|
List<EmployeeSummary> entityQueryAsProjection(@Param("min") java.math.BigDecimal min);
|
||||||
|
|
||||||
|
// 12. Pitfall: tuple select without aliases, declared as an interface projection.
|
||||||
|
@Query("select e.firstName, e.lastName from Employee e where e.salary > :min")
|
||||||
|
List<EmployeeSummary> noAliases(@Param("min") java.math.BigDecimal min);
|
||||||
|
|
||||||
|
// 13. @Query selecting the entity, declared as a record DTO: Spring Data rewrites it to a constructor expression.
|
||||||
|
@Query("select e from Employee e where e.salary > :min")
|
||||||
|
List<EmployeeDto> entityQueryAsDto(@Param("min") java.math.BigDecimal min);
|
||||||
|
|
||||||
|
// 14. The fix for 3: a closed projection whose computed value is a default method.
|
||||||
|
List<EmployeeFullNameClosed> findFullNameClosedByDepartmentName(String departmentName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.ankurm.projections;
|
||||||
|
|
||||||
|
/** Closed interface projection: every getter matches an entity property, nothing computed. */
|
||||||
|
public interface EmployeeSummary {
|
||||||
|
String getFirstName();
|
||||||
|
String getLastName();
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ankurm.projections;
|
||||||
|
|
||||||
|
/** Nested closed projection: one property reaches into the department association. */
|
||||||
|
public interface EmployeeWithDepartment {
|
||||||
|
String getFirstName();
|
||||||
|
DepartmentName getDepartment();
|
||||||
|
|
||||||
|
interface DepartmentName {
|
||||||
|
String getName();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ankurm.projections;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class ProjectionsApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(ProjectionsApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.ankurm.projections;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import org.hibernate.resource.jdbc.spi.StatementInspector;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records every SQL string Hibernate is about to send to the database.
|
||||||
|
* Registered through hibernate.session_factory.statement_inspector in application.properties.
|
||||||
|
* Hibernate instantiates it itself, so the captured list has to be static.
|
||||||
|
*/
|
||||||
|
public class SqlRecorder implements StatementInspector {
|
||||||
|
private static final List<String> CAPTURED = new CopyOnWriteArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String inspect(String sql) {
|
||||||
|
CAPTURED.add(sql);
|
||||||
|
return sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() { CAPTURED.clear(); }
|
||||||
|
|
||||||
|
public static List<String> captured() { return List.copyOf(CAPTURED); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
spring.datasource.url=jdbc:h2:mem:projections;DB_CLOSE_DELAY=-1
|
||||||
|
spring.jpa.hibernate.ddl-auto=create-drop
|
||||||
|
spring.jpa.open-in-view=false
|
||||||
|
# Every SQL statement is captured by SqlRecorder (a Hibernate StatementInspector), not by show_sql.
|
||||||
|
spring.jpa.properties.hibernate.session_factory.statement_inspector=com.ankurm.projections.SqlRecorder
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
package com.ankurm.projections;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import org.hibernate.Session;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
|
||||||
|
/** Every test writes an output/NN-*.txt transcript while asserting the same facts. */
|
||||||
|
@DataJpaTest
|
||||||
|
class ProjectionsTest {
|
||||||
|
|
||||||
|
@Autowired EmployeeRepository repo;
|
||||||
|
@Autowired EntityManager em;
|
||||||
|
|
||||||
|
/** Three departments, twelve employees, four per department. Salaries 50000..61000. */
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
for (String d : new String[] {"Engineering", "Sales", "HR"}) em.persist(new Department(d, d + "-City"));
|
||||||
|
em.flush();
|
||||||
|
List<Department> ds = em.createQuery("select d from Department d order by d.id", Department.class).getResultList();
|
||||||
|
for (int i = 0; i < 12; i++)
|
||||||
|
em.persist(new Employee("First" + i, "Last" + i, "e" + i + "@x.com",
|
||||||
|
BigDecimal.valueOf(50000 + i * 1000L), "bio " + i, ds.get(i % 3)));
|
||||||
|
em.flush();
|
||||||
|
em.clear();
|
||||||
|
SqlRecorder.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The single SQL statement the last call produced. */
|
||||||
|
private String onlySql() {
|
||||||
|
List<String> sql = SqlRecorder.captured();
|
||||||
|
assertThat(sql).hasSize(1);
|
||||||
|
return sql.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of columns in the SELECT list (no function calls with commas in these queries). */
|
||||||
|
private static int columns(String sql) {
|
||||||
|
String list = sql.substring("select ".length(), sql.indexOf(" from "));
|
||||||
|
return list.split(",").length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long managed() { return em.unwrap(Session.class).getStatistics().getEntityCount(); }
|
||||||
|
|
||||||
|
private void reset() { SqlRecorder.clear(); em.clear(); }
|
||||||
|
|
||||||
|
// ---- 01: entity vs closed interface vs record DTO ------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void entityVersusClosedVersusDto() {
|
||||||
|
Transcript t = new Transcript("01-entity-vs-closed-vs-dto.txt");
|
||||||
|
|
||||||
|
List<Employee> entities = repo.findByDepartmentName("Sales");
|
||||||
|
String entitySql = onlySql();
|
||||||
|
long entityManaged = managed();
|
||||||
|
t.line("# entity result: findByDepartmentName(\"Sales\")");
|
||||||
|
t.line(entitySql);
|
||||||
|
t.line("columns selected: %d, entities now managed by the persistence context: %d", columns(entitySql), entityManaged);
|
||||||
|
reset();
|
||||||
|
|
||||||
|
List<EmployeeSummary> closed = repo.findSummaryByDepartmentName("Sales");
|
||||||
|
String closedSql = onlySql();
|
||||||
|
long closedManaged = managed();
|
||||||
|
t.line("");
|
||||||
|
t.line("# closed interface projection: findSummaryByDepartmentName(\"Sales\")");
|
||||||
|
t.line(closedSql);
|
||||||
|
t.line("columns selected: %d, entities now managed by the persistence context: %d", columns(closedSql), closedManaged);
|
||||||
|
reset();
|
||||||
|
|
||||||
|
List<EmployeeDto> dto = repo.findDtoByDepartmentName("Sales");
|
||||||
|
String dtoSql = onlySql();
|
||||||
|
long dtoManaged = managed();
|
||||||
|
t.line("");
|
||||||
|
t.line("# record DTO projection: findDtoByDepartmentName(\"Sales\")");
|
||||||
|
t.line(dtoSql);
|
||||||
|
t.line("columns selected: %d, entities now managed by the persistence context: %d", columns(dtoSql), dtoManaged);
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(entities).hasSize(4);
|
||||||
|
assertThat(columns(entitySql)).isEqualTo(7);
|
||||||
|
assertThat(entityManaged).isEqualTo(4);
|
||||||
|
assertThat(closed).hasSize(4);
|
||||||
|
assertThat(columns(closedSql)).isEqualTo(2);
|
||||||
|
assertThat(closedManaged).isZero();
|
||||||
|
assertThat(dto).hasSize(4);
|
||||||
|
assertThat(dto.get(0)).isInstanceOf(EmployeeDto.class);
|
||||||
|
assertThat(columns(dtoSql)).isEqualTo(2);
|
||||||
|
assertThat(dtoManaged).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 02: open projection loads the whole entity --------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void openProjectionLoadsEverything() {
|
||||||
|
Transcript t = new Transcript("02-open-projection-trap.txt");
|
||||||
|
|
||||||
|
List<EmployeeFullName> open = repo.findFullNameByDepartmentName("Sales");
|
||||||
|
String sql = onlySql();
|
||||||
|
long m = managed();
|
||||||
|
String name = open.get(0).getFullName();
|
||||||
|
t.line("# open projection: findFullNameByDepartmentName(\"Sales\") (getFullName uses @Value SpEL)");
|
||||||
|
t.line(sql);
|
||||||
|
t.line("columns selected: %d, entities now managed by the persistence context: %d", columns(sql), m);
|
||||||
|
t.line("getFullName() of first row: %s", name);
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(columns(sql)).isEqualTo(7);
|
||||||
|
assertThat(m).isEqualTo(4);
|
||||||
|
assertThat(name).isEqualTo("First1 Last1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultMethodKeepsProjectionClosed() {
|
||||||
|
Transcript t = new Transcript("11-default-method-fix.txt");
|
||||||
|
|
||||||
|
List<EmployeeFullNameClosed> rows = repo.findFullNameClosedByDepartmentName("Sales");
|
||||||
|
String sql = onlySql();
|
||||||
|
long m = managed();
|
||||||
|
String name = rows.get(0).getFullName();
|
||||||
|
t.line("# closed projection with a default method: findFullNameClosedByDepartmentName(\"Sales\")");
|
||||||
|
t.line(sql);
|
||||||
|
t.line("columns selected: %d, entities now managed by the persistence context: %d", columns(sql), m);
|
||||||
|
t.line("getFullName() of first row: %s", name);
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(columns(sql)).isEqualTo(2);
|
||||||
|
assertThat(m).isZero();
|
||||||
|
assertThat(name).isEqualTo("First1 Last1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 03: nested projection -----------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nestedProjection() {
|
||||||
|
Transcript t = new Transcript("03-nested-projection.txt");
|
||||||
|
|
||||||
|
List<EmployeeWithDepartment> nested = repo.findNestedByDepartmentName("Sales");
|
||||||
|
String sql = onlySql();
|
||||||
|
long m = managed();
|
||||||
|
String deptName = nested.get(0).getDepartment().getName();
|
||||||
|
int statementsAfterAccess = SqlRecorder.captured().size();
|
||||||
|
t.line("# nested projection: asks for firstName and department.name only");
|
||||||
|
t.line(sql);
|
||||||
|
t.line("columns selected: %d (1 from employee + 3 from department), entities now managed: %d", columns(sql), m);
|
||||||
|
t.line("department name read from the projection: %s", deptName);
|
||||||
|
t.line("extra statements after reading it: %d", statementsAfterAccess - 1);
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(columns(sql)).isEqualTo(4);
|
||||||
|
assertThat(sql).contains("d1_0.id", "d1_0.location", "d1_0.name");
|
||||||
|
assertThat(m).isEqualTo(1);
|
||||||
|
assertThat(deptName).isEqualTo("Sales");
|
||||||
|
assertThat(statementsAfterAccess).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 04: dynamic projections ---------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dynamicProjection() {
|
||||||
|
Transcript t = new Transcript("04-dynamic-projection.txt");
|
||||||
|
t.line("# one repository method, three result shapes: findByDepartmentName(\"Sales\", Class<T>)");
|
||||||
|
|
||||||
|
int[] cols = new int[3];
|
||||||
|
Class<?>[] types = {Employee.class, EmployeeSummary.class, EmployeeDto.class};
|
||||||
|
for (int i = 0; i < types.length; i++) {
|
||||||
|
List<?> result = repo.findByDepartmentName("Sales", types[i]);
|
||||||
|
String sql = onlySql();
|
||||||
|
cols[i] = columns(sql);
|
||||||
|
t.line("%-16s -> %d rows, %d columns: %s", types[i].getSimpleName(), result.size(), cols[i],
|
||||||
|
sql.substring(0, sql.indexOf(" from ")));
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(cols).containsExactly(7, 2, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 05: @Query forms ----------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void queryForms() {
|
||||||
|
Transcript t = new Transcript("05-query-forms.txt");
|
||||||
|
|
||||||
|
List<EmployeeSummary> aliased = repo.richerThan(BigDecimal.valueOf(55000));
|
||||||
|
String aliasSql = onlySql();
|
||||||
|
t.line("# @Query with aliases, declared as an interface projection");
|
||||||
|
t.line(aliasSql);
|
||||||
|
t.line("rows: %d, first getFirstName(): %s", aliased.size(), aliased.get(0).getFirstName());
|
||||||
|
reset();
|
||||||
|
|
||||||
|
List<DepartmentPay> pay = repo.payByDepartment();
|
||||||
|
String ctorSql = onlySql();
|
||||||
|
t.line("");
|
||||||
|
t.line("# @Query with a constructor expression (select new ...DepartmentPay)");
|
||||||
|
t.line(ctorSql);
|
||||||
|
pay.forEach(p -> t.line("%s", p));
|
||||||
|
reset();
|
||||||
|
|
||||||
|
List<EmployeeSummary> nativeRows = repo.nativeRicherThan(BigDecimal.valueOf(55000));
|
||||||
|
String nativeSql = onlySql();
|
||||||
|
t.line("");
|
||||||
|
t.line("# native query with column aliases equal to the getter names");
|
||||||
|
t.line(nativeSql);
|
||||||
|
t.line("rows: %d, first getFirstName(): %s", nativeRows.size(), nativeRows.get(0).getFirstName());
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(columns(aliasSql)).isEqualTo(2);
|
||||||
|
assertThat(aliased).hasSize(6);
|
||||||
|
assertThat(aliased.get(0).getFirstName()).isNotNull();
|
||||||
|
assertThat(pay).hasSize(3);
|
||||||
|
assertThat(pay.get(0)).isEqualTo(new DepartmentPay("Engineering", 4, new BigDecimal("218000.00")));
|
||||||
|
assertThat(nativeRows).hasSize(6);
|
||||||
|
assertThat(nativeRows.get(0).getFirstName()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 06: pitfalls --------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pitfallEntityQueryAsProjection() {
|
||||||
|
Transcript t = new Transcript("06-pitfall-entity-query.txt");
|
||||||
|
List<EmployeeSummary> rows = repo.entityQueryAsProjection(BigDecimal.valueOf(55000));
|
||||||
|
String sql = onlySql();
|
||||||
|
t.line("# @Query(\"select e from Employee e ...\") declared as List<EmployeeSummary>");
|
||||||
|
t.line(sql);
|
||||||
|
t.line("columns selected: %d, entities now managed: %d, rows: %d", columns(sql), managed(), rows.size());
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(columns(sql)).isEqualTo(7);
|
||||||
|
assertThat(managed()).isEqualTo(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void entityQueryAsDtoIsRewritten() {
|
||||||
|
Transcript t = new Transcript("10-entity-query-as-dto-rewrite.txt");
|
||||||
|
List<EmployeeDto> rows = repo.entityQueryAsDto(BigDecimal.valueOf(55000));
|
||||||
|
String sql = onlySql();
|
||||||
|
t.line("# @Query(\"select e from Employee e ...\") declared as List<EmployeeDto> (a record)");
|
||||||
|
t.line(sql);
|
||||||
|
t.line("columns selected: %d, entities now managed: %d, rows: %d", columns(sql), managed(), rows.size());
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(columns(sql)).isEqualTo(2);
|
||||||
|
assertThat(managed()).isZero();
|
||||||
|
assertThat(rows).hasSize(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pitfallMissingAliasesGivesNull() {
|
||||||
|
Transcript t = new Transcript("07-pitfall-missing-aliases.txt");
|
||||||
|
List<EmployeeSummary> rows = repo.noAliases(BigDecimal.valueOf(55000));
|
||||||
|
String sql = onlySql();
|
||||||
|
t.line("# @Query(\"select e.firstName, e.lastName from Employee e ...\") with NO aliases");
|
||||||
|
t.line(sql);
|
||||||
|
t.line("rows: %d", rows.size());
|
||||||
|
t.line("getFirstName() of first row: %s", rows.get(0).getFirstName());
|
||||||
|
t.line("getLastName() of first row: %s", rows.get(0).getLastName());
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(rows).hasSize(6);
|
||||||
|
assertThat(rows.get(0).getFirstName()).isNull();
|
||||||
|
assertThat(rows.get(0).getLastName()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pitfallUnknownProperty() {
|
||||||
|
Transcript t = new Transcript("08-pitfall-unknown-property.txt");
|
||||||
|
interface Nickname { String getNickname(); }
|
||||||
|
Throwable thrown = null;
|
||||||
|
try {
|
||||||
|
repo.findByDepartmentName("Sales", Nickname.class);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
thrown = e;
|
||||||
|
}
|
||||||
|
assertThat(thrown).isNotNull();
|
||||||
|
String first = String.valueOf(thrown.getMessage()).lines().findFirst().orElse("");
|
||||||
|
t.line("# dynamic projection onto an interface with getNickname(), which Employee does not have");
|
||||||
|
t.line("%s", thrown.getClass().getName());
|
||||||
|
t.line("%s", first);
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(first).contains("No property 'nickname' found for type 'Employee'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 09: paging and proxy ------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pagingAndProxy() {
|
||||||
|
Transcript t = new Transcript("09-paging-and-proxy.txt");
|
||||||
|
|
||||||
|
Page<EmployeeSummary> page = repo.findSummaryByDepartmentName("Sales", PageRequest.of(0, 2));
|
||||||
|
List<String> sql = SqlRecorder.captured();
|
||||||
|
t.line("# Page<EmployeeSummary>, page size 2");
|
||||||
|
sql.forEach(s -> t.line("%s", s));
|
||||||
|
t.line("statements: %d, rows in page: %d, total elements: %d", sql.size(), page.getContent().size(), page.getTotalElements());
|
||||||
|
reset();
|
||||||
|
|
||||||
|
EmployeeSummary first = repo.findSummaryByDepartmentName("Sales").get(0);
|
||||||
|
t.line("");
|
||||||
|
t.line("# what an interface projection really is at runtime");
|
||||||
|
t.line("is a java.lang.reflect.Proxy: %s", java.lang.reflect.Proxy.isProxyClass(first.getClass()));
|
||||||
|
t.line("is an Employee: %s", (Object) first instanceof Employee);
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertThat(sql).hasSize(2);
|
||||||
|
assertThat(sql.get(1)).startsWith("select count(*)");
|
||||||
|
assertThat(page.getTotalElements()).isEqualTo(4);
|
||||||
|
assertThat(java.lang.reflect.Proxy.isProxyClass(first.getClass())).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.ankurm.projections;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Writes output/NN-*.txt while a test asserts the same facts. */
|
||||||
|
final class Transcript {
|
||||||
|
private final List<String> lines = new ArrayList<>();
|
||||||
|
private final Path file;
|
||||||
|
|
||||||
|
Transcript(String name) { this.file = Path.of("output/" + name); }
|
||||||
|
|
||||||
|
Transcript line(String fmt, Object... args) {
|
||||||
|
String s = args.length == 0 ? fmt : fmt.formatted(args);
|
||||||
|
lines.add(s);
|
||||||
|
System.out.println(s);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
void write() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(file.getParent());
|
||||||
|
Files.writeString(file, String.join("\n", lines) + "\n");
|
||||||
|
} catch (IOException e) { throw new RuntimeException(e); }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user