Files

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();
}
}