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:
2026-11-12 23:51:00 +05:30
co-authored by Claude Sonnet 5.5
parent 7d7d4e872e
commit e3d1b550ae
30 changed files with 710 additions and 0 deletions
+1
View File
@@ -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 &mdash; 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 &mdash; the deeper material for each post lives in accordions inside the post itself.
+1
View File
@@ -0,0 +1 @@
target/
+44
View File
@@ -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
+13
View File
@@ -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
+48
View File
@@ -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>
+7
View File
@@ -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); }
}
}