From e3d1b550ae3964417b8b67e7de9906103b04d29e Mon Sep 17 00:00:00 2001 From: Ankur Mhatre Date: Thu, 12 Nov 2026 23:49:47 +0530 Subject: [PATCH] projections: Spring Data JPA interface, record, nested and dynamic projections with the SQL they generate Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017ic4b5sejRr8s3kapmRY2g --- README.md | 1 + projections/.gitignore | 1 + projections/README.md | 44 +++ .../output/01-entity-vs-closed-vs-dto.txt | 11 + .../output/02-open-projection-trap.txt | 4 + projections/output/03-nested-projection.txt | 5 + projections/output/04-dynamic-projection.txt | 4 + projections/output/05-query-forms.txt | 13 + .../output/06-pitfall-entity-query.txt | 3 + .../output/07-pitfall-missing-aliases.txt | 5 + .../output/08-pitfall-unknown-property.txt | 3 + projections/output/09-paging-and-proxy.txt | 8 + .../output/10-entity-query-as-dto-rewrite.txt | 3 + projections/output/11-default-method-fix.txt | 4 + projections/pom.xml | 48 +++ projections/scripts/run-all.sh | 7 + .../com/ankurm/projections/Department.java | 18 + .../com/ankurm/projections/DepartmentPay.java | 6 + .../java/com/ankurm/projections/Employee.java | 32 ++ .../com/ankurm/projections/EmployeeDto.java | 4 + .../ankurm/projections/EmployeeFullName.java | 9 + .../projections/EmployeeFullNameClosed.java | 11 + .../projections/EmployeeRepository.java | 60 ++++ .../ankurm/projections/EmployeeSummary.java | 7 + .../projections/EmployeeWithDepartment.java | 11 + .../projections/ProjectionsApplication.java | 11 + .../com/ankurm/projections/SqlRecorder.java | 24 ++ .../src/main/resources/application.properties | 5 + .../ankurm/projections/ProjectionsTest.java | 319 ++++++++++++++++++ .../com/ankurm/projections/Transcript.java | 29 ++ 30 files changed, 710 insertions(+) create mode 100644 projections/.gitignore create mode 100644 projections/README.md create mode 100644 projections/output/01-entity-vs-closed-vs-dto.txt create mode 100644 projections/output/02-open-projection-trap.txt create mode 100644 projections/output/03-nested-projection.txt create mode 100644 projections/output/04-dynamic-projection.txt create mode 100644 projections/output/05-query-forms.txt create mode 100644 projections/output/06-pitfall-entity-query.txt create mode 100644 projections/output/07-pitfall-missing-aliases.txt create mode 100644 projections/output/08-pitfall-unknown-property.txt create mode 100644 projections/output/09-paging-and-proxy.txt create mode 100644 projections/output/10-entity-query-as-dto-rewrite.txt create mode 100644 projections/output/11-default-method-fix.txt create mode 100644 projections/pom.xml create mode 100755 projections/scripts/run-all.sh create mode 100644 projections/src/main/java/com/ankurm/projections/Department.java create mode 100644 projections/src/main/java/com/ankurm/projections/DepartmentPay.java create mode 100644 projections/src/main/java/com/ankurm/projections/Employee.java create mode 100644 projections/src/main/java/com/ankurm/projections/EmployeeDto.java create mode 100644 projections/src/main/java/com/ankurm/projections/EmployeeFullName.java create mode 100644 projections/src/main/java/com/ankurm/projections/EmployeeFullNameClosed.java create mode 100644 projections/src/main/java/com/ankurm/projections/EmployeeRepository.java create mode 100644 projections/src/main/java/com/ankurm/projections/EmployeeSummary.java create mode 100644 projections/src/main/java/com/ankurm/projections/EmployeeWithDepartment.java create mode 100644 projections/src/main/java/com/ankurm/projections/ProjectionsApplication.java create mode 100644 projections/src/main/java/com/ankurm/projections/SqlRecorder.java create mode 100644 projections/src/main/resources/application.properties create mode 100644 projections/src/test/java/com/ankurm/projections/ProjectionsTest.java create mode 100644 projections/src/test/java/com/ankurm/projections/Transcript.java diff --git a/README.md b/README.md index 4e84938..c0dee27 100644 --- a/README.md +++ b/README.md @@ -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 | | [`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) | +| [`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. diff --git a/projections/.gitignore b/projections/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/projections/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/projections/README.md b/projections/README.md new file mode 100644 index 0000000..259319c --- /dev/null +++ b/projections/README.md @@ -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. diff --git a/projections/output/01-entity-vs-closed-vs-dto.txt b/projections/output/01-entity-vs-closed-vs-dto.txt new file mode 100644 index 0000000..10b87de --- /dev/null +++ b/projections/output/01-entity-vs-closed-vs-dto.txt @@ -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 diff --git a/projections/output/02-open-projection-trap.txt b/projections/output/02-open-projection-trap.txt new file mode 100644 index 0000000..f83874d --- /dev/null +++ b/projections/output/02-open-projection-trap.txt @@ -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 diff --git a/projections/output/03-nested-projection.txt b/projections/output/03-nested-projection.txt new file mode 100644 index 0000000..484c46a --- /dev/null +++ b/projections/output/03-nested-projection.txt @@ -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 diff --git a/projections/output/04-dynamic-projection.txt b/projections/output/04-dynamic-projection.txt new file mode 100644 index 0000000..80ba8cb --- /dev/null +++ b/projections/output/04-dynamic-projection.txt @@ -0,0 +1,4 @@ +# one repository method, three result shapes: findByDepartmentName("Sales", Class) +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 diff --git a/projections/output/05-query-forms.txt b/projections/output/05-query-forms.txt new file mode 100644 index 0000000..b3d84ff --- /dev/null +++ b/projections/output/05-query-forms.txt @@ -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 diff --git a/projections/output/06-pitfall-entity-query.txt b/projections/output/06-pitfall-entity-query.txt new file mode 100644 index 0000000..c7c61a8 --- /dev/null +++ b/projections/output/06-pitfall-entity-query.txt @@ -0,0 +1,3 @@ +# @Query("select e from Employee e ...") declared as List +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 diff --git a/projections/output/07-pitfall-missing-aliases.txt b/projections/output/07-pitfall-missing-aliases.txt new file mode 100644 index 0000000..f6600fe --- /dev/null +++ b/projections/output/07-pitfall-missing-aliases.txt @@ -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 diff --git a/projections/output/08-pitfall-unknown-property.txt b/projections/output/08-pitfall-unknown-property.txt new file mode 100644 index 0000000..e5a25ae --- /dev/null +++ b/projections/output/08-pitfall-unknown-property.txt @@ -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' diff --git a/projections/output/09-paging-and-proxy.txt b/projections/output/09-paging-and-proxy.txt new file mode 100644 index 0000000..69306e6 --- /dev/null +++ b/projections/output/09-paging-and-proxy.txt @@ -0,0 +1,8 @@ +# Page, 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 diff --git a/projections/output/10-entity-query-as-dto-rewrite.txt b/projections/output/10-entity-query-as-dto-rewrite.txt new file mode 100644 index 0000000..e614f09 --- /dev/null +++ b/projections/output/10-entity-query-as-dto-rewrite.txt @@ -0,0 +1,3 @@ +# @Query("select e from Employee e ...") declared as List (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 diff --git a/projections/output/11-default-method-fix.txt b/projections/output/11-default-method-fix.txt new file mode 100644 index 0000000..7bf8894 --- /dev/null +++ b/projections/output/11-default-method-fix.txt @@ -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 diff --git a/projections/pom.xml b/projections/pom.xml new file mode 100644 index 0000000..91bb854 --- /dev/null +++ b/projections/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.2 + + + com.ankurm + projections-demo + 1.0.0 + jar + + + 25 + 25 + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-data-jpa-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/projections/scripts/run-all.sh b/projections/scripts/run-all.sh new file mode 100755 index 0000000..39cc61c --- /dev/null +++ b/projections/scripts/run-all.sh @@ -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 diff --git a/projections/src/main/java/com/ankurm/projections/Department.java b/projections/src/main/java/com/ankurm/projections/Department.java new file mode 100644 index 0000000..fd00c37 --- /dev/null +++ b/projections/src/main/java/com/ankurm/projections/Department.java @@ -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; } +} diff --git a/projections/src/main/java/com/ankurm/projections/DepartmentPay.java b/projections/src/main/java/com/ankurm/projections/DepartmentPay.java new file mode 100644 index 0000000..4bbf919 --- /dev/null +++ b/projections/src/main/java/com/ankurm/projections/DepartmentPay.java @@ -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) {} diff --git a/projections/src/main/java/com/ankurm/projections/Employee.java b/projections/src/main/java/com/ankurm/projections/Employee.java new file mode 100644 index 0000000..98dcffc --- /dev/null +++ b/projections/src/main/java/com/ankurm/projections/Employee.java @@ -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; } +} diff --git a/projections/src/main/java/com/ankurm/projections/EmployeeDto.java b/projections/src/main/java/com/ankurm/projections/EmployeeDto.java new file mode 100644 index 0000000..1243930 --- /dev/null +++ b/projections/src/main/java/com/ankurm/projections/EmployeeDto.java @@ -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) {} diff --git a/projections/src/main/java/com/ankurm/projections/EmployeeFullName.java b/projections/src/main/java/com/ankurm/projections/EmployeeFullName.java new file mode 100644 index 0000000..8629857 --- /dev/null +++ b/projections/src/main/java/com/ankurm/projections/EmployeeFullName.java @@ -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(); +} diff --git a/projections/src/main/java/com/ankurm/projections/EmployeeFullNameClosed.java b/projections/src/main/java/com/ankurm/projections/EmployeeFullNameClosed.java new file mode 100644 index 0000000..172f885 --- /dev/null +++ b/projections/src/main/java/com/ankurm/projections/EmployeeFullNameClosed.java @@ -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(); + } +} diff --git a/projections/src/main/java/com/ankurm/projections/EmployeeRepository.java b/projections/src/main/java/com/ankurm/projections/EmployeeRepository.java new file mode 100644 index 0000000..dc0706f --- /dev/null +++ b/projections/src/main/java/com/ankurm/projections/EmployeeRepository.java @@ -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 { + + // 1. Derived query, entity result (the baseline). + List findByDepartmentName(String departmentName); + + // 2. Same derived query, closed interface projection. + List findSummaryByDepartmentName(String departmentName); + + // 3. Open projection (SpEL). + List findFullNameByDepartmentName(String departmentName); + + // 4. Nested interface projection. + List findNestedByDepartmentName(String departmentName); + + // 5. Record DTO projection. + List findDtoByDepartmentName(String departmentName); + + // 6. Dynamic projection: the caller picks the shape. + List findByDepartmentName(String departmentName, Class 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 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 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 nativeRicherThan(@Param("min") java.math.BigDecimal min); + + // 10. Paging with a projection. + Page 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 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 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 entityQueryAsDto(@Param("min") java.math.BigDecimal min); + + // 14. The fix for 3: a closed projection whose computed value is a default method. + List findFullNameClosedByDepartmentName(String departmentName); +} diff --git a/projections/src/main/java/com/ankurm/projections/EmployeeSummary.java b/projections/src/main/java/com/ankurm/projections/EmployeeSummary.java new file mode 100644 index 0000000..3cee8e6 --- /dev/null +++ b/projections/src/main/java/com/ankurm/projections/EmployeeSummary.java @@ -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(); +} diff --git a/projections/src/main/java/com/ankurm/projections/EmployeeWithDepartment.java b/projections/src/main/java/com/ankurm/projections/EmployeeWithDepartment.java new file mode 100644 index 0000000..4b4a3ec --- /dev/null +++ b/projections/src/main/java/com/ankurm/projections/EmployeeWithDepartment.java @@ -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(); + } +} diff --git a/projections/src/main/java/com/ankurm/projections/ProjectionsApplication.java b/projections/src/main/java/com/ankurm/projections/ProjectionsApplication.java new file mode 100644 index 0000000..c662601 --- /dev/null +++ b/projections/src/main/java/com/ankurm/projections/ProjectionsApplication.java @@ -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); + } +} diff --git a/projections/src/main/java/com/ankurm/projections/SqlRecorder.java b/projections/src/main/java/com/ankurm/projections/SqlRecorder.java new file mode 100644 index 0000000..5525f49 --- /dev/null +++ b/projections/src/main/java/com/ankurm/projections/SqlRecorder.java @@ -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 CAPTURED = new CopyOnWriteArrayList<>(); + + @Override + public String inspect(String sql) { + CAPTURED.add(sql); + return sql; + } + + public static void clear() { CAPTURED.clear(); } + + public static List captured() { return List.copyOf(CAPTURED); } +} diff --git a/projections/src/main/resources/application.properties b/projections/src/main/resources/application.properties new file mode 100644 index 0000000..234313e --- /dev/null +++ b/projections/src/main/resources/application.properties @@ -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 diff --git a/projections/src/test/java/com/ankurm/projections/ProjectionsTest.java b/projections/src/test/java/com/ankurm/projections/ProjectionsTest.java new file mode 100644 index 0000000..a761356 --- /dev/null +++ b/projections/src/test/java/com/ankurm/projections/ProjectionsTest.java @@ -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 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 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 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 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 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 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 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 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)"); + + 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 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 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 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 rows = repo.entityQueryAsProjection(BigDecimal.valueOf(55000)); + String sql = onlySql(); + t.line("# @Query(\"select e from Employee e ...\") declared as List"); + 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 rows = repo.entityQueryAsDto(BigDecimal.valueOf(55000)); + String sql = onlySql(); + t.line("# @Query(\"select e from Employee e ...\") declared as List (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 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 page = repo.findSummaryByDepartmentName("Sales", PageRequest.of(0, 2)); + List sql = SqlRecorder.captured(); + t.line("# Page, 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(); + } +} diff --git a/projections/src/test/java/com/ankurm/projections/Transcript.java b/projections/src/test/java/com/ankurm/projections/Transcript.java new file mode 100644 index 0000000..6f73901 --- /dev/null +++ b/projections/src/test/java/com/ankurm/projections/Transcript.java @@ -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 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); } + } +}