Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b5sejRr8s3kapmRY2g
320 lines
13 KiB
Java
320 lines
13 KiB
Java
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();
|
|
}
|
|
}
|