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:
@@ -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