New posts for Spring and Hibernate
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.probe.HandWritten;
|
||||
import com.ankurm.mapstruct.probe.ViewMapper;
|
||||
import com.ankurm.mapstruct.probe.ViewMapperImpl;
|
||||
import com.ankurm.mapstruct.probe.Views.CustomerView;
|
||||
import com.github.dozermapper.core.DozerBeanMapperBuilder;
|
||||
import com.github.dozermapper.core.Mapper;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* An INDICATIVE comparison: a hand-rolled warm-up, a few timed rounds, one JVM, one machine. It is not JMH.
|
||||
* The numbers move from run to run; what is stable is the order of magnitude, so the test only asserts
|
||||
* that all three mappers produce the same result.
|
||||
*/
|
||||
class BenchmarkTests {
|
||||
|
||||
private static volatile long sink;
|
||||
|
||||
private static double nsPerOp(Function<Customer, CustomerView> f, Customer c, int iterations) {
|
||||
long start = System.nanoTime();
|
||||
long acc = 0;
|
||||
for (int i = 0; i < iterations; i++) {
|
||||
acc += f.apply(c).getOrders().size();
|
||||
}
|
||||
sink += acc;
|
||||
return (System.nanoTime() - start) / (double) iterations;
|
||||
}
|
||||
|
||||
@Test
|
||||
void handWrittenMapStructAndDozer() {
|
||||
try (var t = new Transcript("16-indicative-timing-vs-handwritten-and-dozer.txt",
|
||||
"INDICATIVE timings: hand-written vs MapStruct vs Dozer (setter-based beans)")) {
|
||||
Customer c = Fixtures.customer();
|
||||
ViewMapper mapStruct = new ViewMapperImpl();
|
||||
Mapper dozer = DozerBeanMapperBuilder.buildDefault();
|
||||
|
||||
long first = System.nanoTime();
|
||||
CustomerView viaDozer = dozer.map(c, CustomerView.class);
|
||||
double dozerFirstMs = (System.nanoTime() - first) / 1e6;
|
||||
first = System.nanoTime();
|
||||
CustomerView viaMapStruct = mapStruct.toView(c);
|
||||
double mapStructFirstMs = (System.nanoTime() - first) / 1e6;
|
||||
CustomerView viaHand = HandWritten.toView(c);
|
||||
|
||||
Function<Customer, CustomerView> hand = HandWritten::toView;
|
||||
Function<Customer, CustomerView> ms = mapStruct::toView;
|
||||
Function<Customer, CustomerView> dz = x -> dozer.map(x, CustomerView.class);
|
||||
|
||||
for (var f : List.of(hand, ms, dz)) {
|
||||
nsPerOp(f, c, 50_000); // warm-up
|
||||
}
|
||||
int n = 200_000;
|
||||
double h = Double.MAX_VALUE, m = Double.MAX_VALUE, d = Double.MAX_VALUE;
|
||||
for (int round = 0; round < 5; round++) {
|
||||
h = Math.min(h, nsPerOp(hand, c, n));
|
||||
m = Math.min(m, nsPerOp(ms, c, n));
|
||||
d = Math.min(d, nsPerOp(dz, c, n));
|
||||
}
|
||||
boolean equal = sameShape(viaHand, viaMapStruct) && sameShape(viaHand, viaDozer);
|
||||
|
||||
t.line("Java %s, one JVM, %d iterations per round, best of 5 rounds after a 50000-iteration warm-up", Runtime.version(), n);
|
||||
t.line("mapping: Customer -> CustomerView (1 address, 1 order with 2 lines, 1 set, 1 map)");
|
||||
t.blank();
|
||||
t.line("%-16s %10.0f ns/op", "hand-written", h);
|
||||
t.line("%-16s %10.0f ns/op (%.1fx hand-written)", "MapStruct 1.6.3", m, m / h);
|
||||
t.line("%-16s %10.0f ns/op (%.0fx hand-written)", "Dozer 7.0.0", d, d / h);
|
||||
t.blank();
|
||||
t.line("first call, cold: MapStruct %.2f ms, Dozer %.1f ms", mapStructFirstMs, dozerFirstMs);
|
||||
t.line("results equal: %s", equal);
|
||||
t.blank();
|
||||
t.line("Indicative only. Timings vary between runs and machines; do not quote them as a benchmark result.");
|
||||
|
||||
assertThat(equal).isTrue();
|
||||
assertThat(sink).isPositive();
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean sameShape(CustomerView a, CustomerView b) {
|
||||
var la = a.getOrders().get(0).getLines().get(1);
|
||||
var lb = b.getOrders().get(0).getLines().get(1);
|
||||
return a.getFirstName().equals(b.getFirstName()) && a.getTier().equals(b.getTier())
|
||||
&& a.getAddress().getPostalCode().equals(b.getAddress().getPostalCode())
|
||||
&& a.getRoles().equals(b.getRoles()) && a.getAttributes().equals(b.getAttributes())
|
||||
&& a.getOrders().get(0).getStatus().equals(b.getOrders().get(0).getStatus())
|
||||
&& la.getUnitPrice().compareTo(lb.getUnitPrice()) == 0 && la.getQuantity() == lb.getQuantity();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapper;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
|
||||
import java.time.Clock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
/** @SpringJUnitConfig is @ContextConfiguration + the JUnit extension: a context made of just the generated classes. */
|
||||
@SpringJUnitConfig(classes = {CustomerMapperImpl.class, AddressMapperImpl.class, OrderMapperImpl.class,
|
||||
ContextConfigurationMapperTest.PinnedClock.class})
|
||||
class ContextConfigurationMapperTest {
|
||||
|
||||
@Configuration
|
||||
static class PinnedClock {
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Fixtures.clock();
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired
|
||||
ConfigurableApplicationContext context;
|
||||
|
||||
@Autowired
|
||||
CustomerMapper mapper;
|
||||
|
||||
@Autowired
|
||||
Clock clock;
|
||||
|
||||
@Test
|
||||
void aContextOfOnlyTheGeneratedClasses() {
|
||||
try (var t = new Transcript("12-testing-mappers-contextconfiguration.txt",
|
||||
"@SpringJUnitConfig(classes = {the generated impls}): only the beans the mapper needs")) {
|
||||
int definitions = context.getBeanDefinitionCount();
|
||||
String name = mapper.toDto(Fixtures.customer(), clock).fullName();
|
||||
t.line("bean definitions in the context : %d", definitions);
|
||||
t.line("bean names : %s", String.join(", ", context.getBeanDefinitionNames()));
|
||||
t.line("mapper injected as : %s", mapper.getClass().getSimpleName());
|
||||
t.line("mapped : %s", name);
|
||||
|
||||
assertThat(definitions).isLessThan(20);
|
||||
assertThat(mapper).isInstanceOf(CustomerMapperImpl.class);
|
||||
assertThat(name).isEqualTo("Asha Mehta");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
|
||||
import com.ankurm.mapstruct.web.CustomerRepository;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
/** Shared test data and two helpers: a pinned clock, and a reader for the sources MapStruct generated. */
|
||||
public final class Fixtures {
|
||||
|
||||
private Fixtures() { }
|
||||
|
||||
/** 2026-11-05T00:00:00Z, so ages and creation timestamps are the same on every run. */
|
||||
public static Clock clock() {
|
||||
return Clock.fixed(Instant.parse("2026-11-05T00:00:00Z"), ZoneOffset.UTC);
|
||||
}
|
||||
|
||||
public static Customer customer() {
|
||||
return CustomerRepository.sample();
|
||||
}
|
||||
|
||||
/** What Spring would do with constructor injection, without Spring. */
|
||||
public static CustomerMapperImpl customerMapper() {
|
||||
return new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl());
|
||||
}
|
||||
|
||||
/**
|
||||
* The generated source for a mapper, trimmed to the class itself: no package, imports or the
|
||||
* {@code @Generated} annotation (whose date changes on every build).
|
||||
*/
|
||||
public static String generated(String root, String fqcn) {
|
||||
Path file = Path.of("target", root, fqcn.replace('.', '/') + ".java");
|
||||
try {
|
||||
String src = Files.readString(file).replace("\r\n", "\n");
|
||||
int start = src.indexOf("@Component");
|
||||
if (start < 0) {
|
||||
start = src.indexOf("public class");
|
||||
}
|
||||
return src.substring(start);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("no generated source at " + file, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String generatedMain(String fqcn) {
|
||||
return generated("generated-sources/annotations", fqcn);
|
||||
}
|
||||
|
||||
public static String generatedTest(String fqcn) {
|
||||
return generated("generated-test-sources/test-annotations", fqcn);
|
||||
}
|
||||
|
||||
/** One method out of generated source: its signature line down to the closing brace at four spaces of indent. */
|
||||
public static String method(String source, String signatureStart) {
|
||||
int i = source.indexOf(signatureStart);
|
||||
if (i < 0) {
|
||||
throw new IllegalStateException("no method starting with " + signatureStart);
|
||||
}
|
||||
int lineStart = source.lastIndexOf('\n', i) + 1;
|
||||
int end = source.indexOf("\n }\n", i);
|
||||
return source.substring(lineStart, end + "\n }".length());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Order;
|
||||
import com.ankurm.mapstruct.dto.InvoiceDto;
|
||||
import com.ankurm.mapstruct.dto.ReceiptDto;
|
||||
import com.ankurm.mapstruct.mapper.InvoiceMapper;
|
||||
import com.ankurm.mapstruct.mapper.InvoiceMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.InvoiceMapperImpl_;
|
||||
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.ReceiptMapperImpl;
|
||||
import com.ankurm.mapstruct.probe.Probes;
|
||||
import com.ankurm.mapstruct.probe.CycleContext;
|
||||
import com.ankurm.mapstruct.probe.CycleMapperImpl;
|
||||
import com.ankurm.mapstruct.probe.FactoryTrapMapperImpl;
|
||||
import com.ankurm.mapstruct.probe.HookOrderMapperImpl;
|
||||
import com.ankurm.mapstruct.probe.Probes.Employee;
|
||||
import java.util.Arrays;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
/** @BeforeMapping, @AfterMapping, @ObjectFactory, builders, decorators and @Context - and what records change. */
|
||||
@SpringJUnitConfig(classes = {InvoiceMapperImpl.class, InvoiceMapperImpl_.class, OrderMapperImpl.class})
|
||||
class HooksTests {
|
||||
|
||||
@Autowired
|
||||
ApplicationContext context;
|
||||
|
||||
@Autowired
|
||||
InvoiceMapper invoiceMapper;
|
||||
|
||||
@BeforeEach
|
||||
void clear() {
|
||||
Probes.TRACE.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void beforeMappingWithAMappingTargetRunsAfterTheRecordIsBuilt() {
|
||||
try (var t = new Transcript("07-hooks-on-records.txt",
|
||||
"@BeforeMapping / @AfterMapping on a record target versus a bean target (call order, recorded)")) {
|
||||
var mapper = new HookOrderMapperImpl();
|
||||
|
||||
mapper.toRecord(new Probes.Pair("pen", 3));
|
||||
t.section("target is a record: toRecord(Pair)");
|
||||
Probes.TRACE.forEach(t::line);
|
||||
var recordTrace = java.util.List.copyOf(Probes.TRACE);
|
||||
|
||||
Probes.TRACE.clear();
|
||||
mapper.toBean(new Probes.Pair("pen", 3));
|
||||
t.section("target is a mutable bean: toBean(Pair)");
|
||||
Probes.TRACE.forEach(t::line);
|
||||
var beanTrace = java.util.List.copyOf(Probes.TRACE);
|
||||
|
||||
String generated = Fixtures.generatedTest("com.ankurm.mapstruct.probe.HookOrderMapperImpl");
|
||||
t.section("the generated toRecord, which is why");
|
||||
t.block(Fixtures.method(generated, "toRecord("));
|
||||
|
||||
assertThat(recordTrace).containsExactly(
|
||||
"@BeforeMapping(source)",
|
||||
"@BeforeMapping(source, @MappingTarget Boxed) target=Boxed[name=pen, qty=3]",
|
||||
"@AfterMapping(source, @MappingTarget Boxed) target=Boxed[name=pen, qty=3]",
|
||||
"@AfterMapping(source)");
|
||||
assertThat(beanTrace).containsExactly(
|
||||
"@BeforeMapping(source)",
|
||||
"@BeforeMapping(source, @MappingTarget BoxedBean) name=null",
|
||||
" BoxedBean.setName(pen)",
|
||||
" BoxedBean.setQty(3)",
|
||||
"@AfterMapping(source, @MappingTarget BoxedBean) name=pen",
|
||||
"@AfterMapping(source)");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void anObjectFactoryThatReturnsARecordSilentlyDropsEveryProperty() {
|
||||
try (var t = new Transcript("08-object-factory-trap.txt",
|
||||
"@ObjectFactory returning a record: the factory's object is the result, and nothing is copied into it")) {
|
||||
var mapper = new FactoryTrapMapperImpl();
|
||||
var result = mapper.toRecord(new Probes.Pair("pen", 3));
|
||||
t.line("source : %s", new Probes.Pair("pen", 3));
|
||||
t.line("result : %s", result);
|
||||
t.line("compiled with unmappedTargetPolicy = ERROR, and no error was reported");
|
||||
t.section("the generated method");
|
||||
t.block(Fixtures.method(Fixtures.generatedTest("com.ankurm.mapstruct.probe.FactoryTrapMapperImpl"),
|
||||
"toRecord("));
|
||||
|
||||
assertThat(result).isEqualTo(new Probes.Boxed("from-the-factory", 0));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBuilderIsTheWayToPostProcessARecord() {
|
||||
try (var t = new Transcript("09-builder-and-decorator.txt",
|
||||
"Post-processing a record: @AfterMapping on its builder, or a @DecoratedWith decorator")) {
|
||||
Order order = Fixtures.customer().getOrders().get(0);
|
||||
|
||||
var receiptMapper = new ReceiptMapperImpl(new OrderMapperImpl());
|
||||
ReceiptDto receipt = receiptMapper.toReceipt(order);
|
||||
t.section("ReceiptMapper: the hook receives ReceiptDto.Builder");
|
||||
t.line("%s", receipt);
|
||||
t.block(Fixtures.method(Fixtures.generatedMain("com.ankurm.mapstruct.mapper.ReceiptMapperImpl"),
|
||||
"public ReceiptDto toReceipt("));
|
||||
|
||||
InvoiceDto invoice = invoiceMapper.toInvoice(order);
|
||||
InvoiceDto raw = context.getBean(InvoiceMapperImpl_.class).toInvoice(order);
|
||||
t.section("InvoiceMapper: @DecoratedWith, so two beans implement the interface");
|
||||
for (String name : context.getBeanNamesForType(InvoiceMapper.class)) {
|
||||
t.line("bean %-20s -> %s", name, context.getBean(name).getClass().getSimpleName());
|
||||
}
|
||||
t.line("injected InvoiceMapper (the @Primary one) : %s", invoice);
|
||||
t.line("the generated delegate on its own : %s", raw);
|
||||
t.section("the generated class that decorates");
|
||||
t.block(Fixtures.generatedMain("com.ankurm.mapstruct.mapper.InvoiceMapperImpl").stripTrailing());
|
||||
|
||||
assertThat(receipt.summary()).isEqualTo("order 100: 2 line(s)");
|
||||
assertThat(receipt.orderId()).isEqualTo(100L);
|
||||
assertThat(context.getBeanNamesForType(InvoiceMapper.class)).hasSize(2);
|
||||
assertThat(invoiceMapper).isInstanceOf(InvoiceMapperImpl.class);
|
||||
assertThat(invoice.display()).isEqualTo("#100 [PAID] 32.50");
|
||||
assertThat(raw.display()).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsCannotRepresentACycleEvenWithTheContextRecipe() {
|
||||
try (var t = new Transcript("10-context-and-cycles.txt",
|
||||
"@Context cycle tracking: it works for a bean target and cannot work for a record target")) {
|
||||
Employee ann = new Employee("Ann");
|
||||
Employee bob = new Employee("Bob");
|
||||
ann.manager = bob;
|
||||
bob.manager = ann;
|
||||
var mapper = new CycleMapperImpl();
|
||||
|
||||
var bean = mapper.toBean(ann, new CycleContext());
|
||||
t.line("bean target + @Context CycleContext : ann.manager.manager is ann? %s", bean.getManager().getManager() == bean);
|
||||
|
||||
Throwable thrown = null;
|
||||
try {
|
||||
mapper.toRecord(ann, new CycleContext());
|
||||
} catch (StackOverflowError e) {
|
||||
thrown = e;
|
||||
}
|
||||
t.line("record target + the same @Context : %s", thrown == null ? "mapped" : thrown.getClass().getName());
|
||||
|
||||
t.section("why: the generated record mapping recurses BEFORE it can call the constructor");
|
||||
t.block(Fixtures.method(Fixtures.generatedTest("com.ankurm.mapstruct.probe.CycleMapperImpl"),
|
||||
"toRecord("));
|
||||
|
||||
assertThat(bean.getManager().getManager()).isSameAs(bean);
|
||||
assertThat(thrown).isInstanceOf(StackOverflowError.class);
|
||||
assertThatThrownBy(() -> mapper.toRecord(ann, new CycleContext())).isInstanceOf(StackOverflowError.class);
|
||||
assertThat(Arrays.stream(Probes.EmployeeRecord.class.getRecordComponents()).count()).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.dto.AddressDto;
|
||||
import com.ankurm.mapstruct.dto.CustomerDto;
|
||||
import com.ankurm.mapstruct.dto.CustomerRequest;
|
||||
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Records as source and target, the generated code, and the @Mapping options that shape a mapping. */
|
||||
class MappingBasicsTests {
|
||||
|
||||
private final CustomerMapperImpl mapper = Fixtures.customerMapper();
|
||||
|
||||
@Test
|
||||
void recordsAreBuiltThroughTheirConstructorAndNeverThroughSetters() {
|
||||
try (var t = new Transcript("01-records-as-source-and-target.txt",
|
||||
"A record target has no setters: MapStruct calls the canonical constructor")) {
|
||||
long setters = Arrays.stream(CustomerDto.class.getDeclaredMethods())
|
||||
.filter(m -> m.getName().startsWith("set")).count();
|
||||
long customerSetters = Arrays.stream(Customer.class.getDeclaredMethods())
|
||||
.filter(m -> m.getName().startsWith("set")).count();
|
||||
t.line("CustomerDto is a record : %s", CustomerDto.class.isRecord());
|
||||
t.line("setters on CustomerDto : %d", setters);
|
||||
t.line("setters on Customer (entity) : %d", customerSetters);
|
||||
|
||||
String source = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.CustomerMapperImpl");
|
||||
String toDto = Fixtures.method(source, "public CustomerDto toDto(");
|
||||
String ctorLine = toDto.lines().filter(l -> l.contains("new CustomerDto(")).findFirst().orElseThrow().strip();
|
||||
t.section("the line the generated toDto uses to build the record");
|
||||
t.line(ctorLine);
|
||||
|
||||
String toEntity = Fixtures.method(source, "public Customer toEntity(");
|
||||
t.section("record as SOURCE: the generated toEntity reads accessors, not getters");
|
||||
toEntity.lines().filter(l -> l.contains("request.")).limit(4).forEach(l -> t.line(l.strip()));
|
||||
|
||||
assertThat(CustomerDto.class.isRecord()).isTrue();
|
||||
assertThat(setters).isZero();
|
||||
assertThat(customerSetters).isGreaterThan(10);
|
||||
assertThat(ctorLine).startsWith("CustomerDto customerDto = new CustomerDto( id, fullName,");
|
||||
assertThat(toEntity).contains("request.firstName()").doesNotContain("getFirstName");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theGeneratedImplementationIsPlainJavaYouCanRead() {
|
||||
try (var t = new Transcript("02-generated-implementation.txt",
|
||||
"What MapStruct 1.6.3 generated, read from target/generated-sources (header and imports trimmed)")) {
|
||||
String address = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.AddressMapperImpl");
|
||||
t.section("AddressMapperImpl");
|
||||
t.block(address);
|
||||
String customer = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.CustomerMapperImpl");
|
||||
t.section("CustomerMapperImpl: constructor injection, then toDto");
|
||||
t.block(customer.substring(0, customer.indexOf(" @Override")).stripTrailing());
|
||||
t.blank();
|
||||
t.block(Fixtures.method(customer, "public CustomerDto toDto("));
|
||||
|
||||
assertThat(address).contains("@Component").contains("public class AddressMapperImpl implements AddressMapper");
|
||||
assertThat(customer).contains("@Autowired").contains("public CustomerMapperImpl(AddressMapper addressMapper, OrderMapper orderMapper)");
|
||||
assertThat(customer).doesNotContain("reflect");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void entityToRecordWithNestedObjectsAndCollections() {
|
||||
try (var t = new Transcript("03-entity-to-record.txt",
|
||||
"Customer (mutable entity) -> CustomerDto (record), with nested Address, a List of Orders, a Set and a Map")) {
|
||||
Customer customer = Fixtures.customer();
|
||||
CustomerDto dto = mapper.toDto(customer, Fixtures.clock());
|
||||
|
||||
t.line("fullName : %s <- expression = java(first + \" \" + last)", dto.fullName());
|
||||
t.line("age : %d <- qualifiedByName, with the clock passed as @Context", dto.age());
|
||||
t.line("nickname : %s <- source was null, defaultValue = \"anonymous\"", dto.nickname());
|
||||
t.line("origin : %s <- constant = \"api\"", dto.origin());
|
||||
t.line("address : %s <- postalCode renamed to zip; null country got defaultValue IN", dto.address());
|
||||
t.line("roles : %s same instance as the entity's? %s", dto.roles(), dto.roles() == customer.getRoles());
|
||||
t.line("attributes : %s same instance as the entity's? %s", dto.attributes(), dto.attributes() == customer.getAttributes());
|
||||
t.section("List<Order> -> List<OrderDto>, with a computed total");
|
||||
var order = dto.orders().get(0);
|
||||
t.line("order id=%d status=%s total=%s", order.id(), order.status(), order.total());
|
||||
order.lines().forEach(l -> t.line(" %s", l));
|
||||
t.section("what is NOT in the record");
|
||||
t.line("passwordHash is a component of CustomerDto? %s", Arrays.stream(CustomerDto.class.getRecordComponents())
|
||||
.anyMatch(c -> c.getName().equals("passwordHash")));
|
||||
|
||||
assertThat(dto.fullName()).isEqualTo("Asha Mehta");
|
||||
assertThat(dto.age()).isEqualTo(36);
|
||||
assertThat(dto.nickname()).isEqualTo("anonymous");
|
||||
assertThat(dto.origin()).isEqualTo("api");
|
||||
assertThat(dto.address()).isEqualTo(new AddressDto("12 Park Road", "Pune", "411001", "IN"));
|
||||
assertThat(dto.roles()).isEqualTo(Set.of("USER")).isNotSameAs(customer.getRoles());
|
||||
assertThat(dto.attributes()).containsEntry("newsletter", "yes").isNotSameAs(customer.getAttributes());
|
||||
assertThat(order.status()).isEqualTo("PAID");
|
||||
assertThat(order.total()).isEqualByComparingTo(new BigDecimal("32.50"));
|
||||
assertThat(order.lines()).extracting("lineTotal").usingComparatorForType(BigDecimal::compareTo, BigDecimal.class)
|
||||
.containsExactly(new BigDecimal("25.00"), new BigDecimal("7.50"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordToEntityAndTheInverseConfiguration() {
|
||||
try (var t = new Transcript("04-record-to-entity.txt",
|
||||
"CustomerRequest (record) -> Customer (entity); AddressMapper.toEntity inherits the rename in reverse")) {
|
||||
var request = new CustomerRequest("Ravi", "Iyer", " [email protected] ", LocalDate.of(1985, 1, 2),
|
||||
null, null, new AddressDto("1 Lake View", "Chennai", "600001", null), Set.of("ADMIN"));
|
||||
Customer c = mapper.toEntity(request, Fixtures.clock());
|
||||
|
||||
t.line("email after @AfterMapping : '%s' (request carried '%s')", c.getEmail(), request.email());
|
||||
t.line("createdAt (from @Context) : %s", c.getCreatedAt());
|
||||
t.line("address.postalCode : %s <- @InheritInverseConfiguration turned zip back into postalCode", c.getAddress().getPostalCode());
|
||||
t.line("address.country : %s <- defaultValue is NOT inverted; toDto gives IN, toEntity does not", c.getAddress().getCountry());
|
||||
t.line("tier : %s <- the entity said STANDARD; the request said null", c.getTier());
|
||||
Customer kept = mapper.toEntityKeepingDefaults(request, Fixtures.clock());
|
||||
t.line("tier with nullValueCheckStrategy = ALWAYS : %s", kept.getTier());
|
||||
t.line("id / passwordHash / orders : %s / %s / %s <- ignore = true", c.getId(), c.getPasswordHash(), c.getOrders());
|
||||
|
||||
t.section("round trip: entity -> record -> entity");
|
||||
Customer original = Fixtures.customer();
|
||||
Customer back = mapper.toEntity(new CustomerRequest(original.getFirstName(), original.getLastName(),
|
||||
original.getEmail(), original.getBirthDate(), original.getNickname(), original.getTier(),
|
||||
new AddressMapperImpl().toDto(original.getAddress()), original.getRoles()), Fixtures.clock());
|
||||
t.line("passwordHash survives the round trip? %s", back.getPasswordHash() != null);
|
||||
t.line("address country survives? %s -> %s", original.getAddress().getCountry(), back.getAddress().getCountry());
|
||||
|
||||
assertThat(c.getEmail()).isEqualTo("[email protected]");
|
||||
assertThat(c.getCreatedAt()).isEqualTo(Fixtures.clock().instant());
|
||||
assertThat(c.getAddress().getPostalCode()).isEqualTo("600001");
|
||||
assertThat(c.getAddress().getCountry()).isNull();
|
||||
assertThat(c.getTier()).isNull();
|
||||
assertThat(kept.getTier()).isEqualTo("STANDARD");
|
||||
assertThat(c.getId()).isNull();
|
||||
assertThat(back.getPasswordHash()).isNull();
|
||||
assertThat(back.getAddress().getCountry()).isEqualTo("IN");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatedAddressInverseHasNoDefaultValue() {
|
||||
String address = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.AddressMapperImpl");
|
||||
assertThat(Fixtures.method(address, "public Address toEntity(")).doesNotContain("\"IN\"");
|
||||
assertThat(Fixtures.method(address, "public AddressDto toDto(")).contains("\"IN\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsAreCopiedNotShared() {
|
||||
Customer customer = Fixtures.customer();
|
||||
CustomerDto dto = mapper.toDto(customer, Fixtures.clock());
|
||||
assertThat(dto.orders()).isNotSameAs(customer.getOrders());
|
||||
List<?> before = List.copyOf(dto.orders());
|
||||
customer.getOrders().get(0).setId(999L);
|
||||
assertThat(dto.orders()).isEqualTo(before);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Address;
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.domain.Order;
|
||||
import com.ankurm.mapstruct.dto.AddressDto;
|
||||
import com.ankurm.mapstruct.dto.CustomerPatch;
|
||||
import com.ankurm.mapstruct.dto.OrderDto;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.LenientOrderMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Null strategies (property level and whole-object level) and what @MappingTarget updates really do. */
|
||||
class NullHandlingAndUpdateTests {
|
||||
|
||||
private final CustomerMapperImpl mapper = Fixtures.customerMapper();
|
||||
|
||||
private static String describe(Customer c) {
|
||||
Address a = c.getAddress();
|
||||
return "email=" + c.getEmail() + ", nickname=" + c.getNickname() + ", roles=" + c.getRoles()
|
||||
+ ", address=" + (a == null ? "null" : a.getStreet() + "/" + a.getCity() + "/" + a.getPostalCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullPropertyStrategyDecidesWhetherAPatchErasesData() {
|
||||
try (var t = new Transcript("05-null-strategies.txt",
|
||||
"Null handling: IGNORE vs the default on a patch, and RETURN_DEFAULT on whole objects and collections")) {
|
||||
var allNull = new CustomerPatch(null, null, null, null);
|
||||
|
||||
Customer ignoring = Fixtures.customer();
|
||||
ignoring.setNickname("ash");
|
||||
t.section("patch(): nullValuePropertyMappingStrategy = IGNORE, applied with a patch whose components are all null");
|
||||
t.line("before: %s", describe(ignoring));
|
||||
mapper.patch(allNull, ignoring);
|
||||
t.line("after : %s", describe(ignoring));
|
||||
|
||||
Customer replacing = Fixtures.customer();
|
||||
replacing.setNickname("ash");
|
||||
t.section("replace(): the default strategy (SET_TO_NULL), same all-null patch");
|
||||
t.line("before: %s", describe(replacing));
|
||||
mapper.replace(allNull, replacing);
|
||||
t.line("after : %s", describe(replacing));
|
||||
|
||||
assertThat(describe(ignoring)).isEqualTo("[email protected], nickname=ash, roles=[USER], address=12 Park Road/Pune/411001");
|
||||
assertThat(replacing.getEmail()).isNull();
|
||||
assertThat(replacing.getNickname()).isNull();
|
||||
assertThat(replacing.getRoles()).isNull();
|
||||
assertThat(replacing.getAddress()).isNull();
|
||||
|
||||
var strict = new OrderMapperImpl();
|
||||
var lenient = new LenientOrderMapperImpl(strict);
|
||||
Order noLines = new Order(7L, null, null, null);
|
||||
t.section("whole objects and collections: OrderMapper (default) vs LenientOrderMapper (RETURN_DEFAULT)");
|
||||
t.line("strict.toDto(null) : %s", strict.toDto((Order) null));
|
||||
t.line("lenient.toDto(null) : %s", lenient.toDto(null));
|
||||
OrderDto strictNoLines = strict.toDto(noLines);
|
||||
OrderDto lenientNoLines = lenient.toDto(noLines);
|
||||
t.line("strict order, lines = null : lines=%s", strictNoLines.lines());
|
||||
t.line("lenient order, lines = null : lines=%s", lenientNoLines.lines());
|
||||
t.line("strict.toDtos(null) : %s", strict.toDtos(null));
|
||||
|
||||
assertThat(strict.toDto((Order) null)).isNull();
|
||||
assertThat(lenient.toDto(null)).isNotNull();
|
||||
assertThat(lenient.toDto(null).lines()).isNull();
|
||||
assertThat(strictNoLines.lines()).isNull();
|
||||
assertThat(lenientNoLines.lines()).isNotNull().isEmpty();
|
||||
assertThat(strict.toDtos(null)).isNull();
|
||||
|
||||
String generated = Fixtures.generatedMain("com.ankurm.mapstruct.mapper.CustomerMapperImpl");
|
||||
t.section("the generated patch() (IGNORE): every assignment is guarded");
|
||||
t.block(Fixtures.method(generated, "public void patch("));
|
||||
assertThat(Fixtures.method(generated, "public void patch(")).contains("if ( patch.email() != null )");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesMutateTheTargetInPlaceIncludingItsCollections() {
|
||||
try (var t = new Transcript("06-update-in-place.txt",
|
||||
"@MappingTarget updates: nested objects and collections are changed in place")) {
|
||||
Customer c = Fixtures.customer();
|
||||
Address addressBefore = c.getAddress();
|
||||
Set<String> rolesBefore = c.getRoles();
|
||||
mapper.patch(new CustomerPatch(null, null, new AddressDto(null, "Mumbai", null, null), Set.of("USER", "ADMIN")), c);
|
||||
|
||||
t.section("patch only the city and the roles");
|
||||
t.line("address: %s", describe(c));
|
||||
t.line("same Address instance as before? %s <- AddressMapper.update was called on it", c.getAddress() == addressBefore);
|
||||
t.line("same roles Set instance as before? %s <- cleared and refilled, not replaced", c.getRoles() == rolesBefore);
|
||||
|
||||
Customer shared = Fixtures.customer();
|
||||
Set<String> alias = shared.getRoles();
|
||||
mapper.patch(new CustomerPatch(null, null, null, Set.of("AUDITOR")), shared);
|
||||
t.line("another holder of the old roles Set now sees: %s", alias);
|
||||
|
||||
Customer frozen = Fixtures.customer();
|
||||
frozen.setRoles(Set.of("USER"));
|
||||
t.section("an immutable collection on the target");
|
||||
Throwable thrown = null;
|
||||
try {
|
||||
mapper.patch(new CustomerPatch(null, null, null, Set.of("ADMIN")), frozen);
|
||||
} catch (RuntimeException e) {
|
||||
thrown = e;
|
||||
}
|
||||
t.line("patching roles on a Set.of(...) target threw: %s", thrown == null ? "nothing" : thrown.getClass().getName());
|
||||
|
||||
assertThat(c.getAddress()).isSameAs(addressBefore);
|
||||
assertThat(c.getAddress().getStreet()).isEqualTo("12 Park Road");
|
||||
assertThat(c.getAddress().getCity()).isEqualTo("Mumbai");
|
||||
assertThat(c.getRoles()).isSameAs(rolesBefore).containsExactlyInAnyOrder("USER", "ADMIN");
|
||||
assertThat(alias).containsExactly("AUDITOR");
|
||||
assertThat(thrown).isInstanceOf(UnsupportedOperationException.class);
|
||||
assertThatThrownBy(() -> mapper.patch(new CustomerPatch(null, null, null, Set.of("X")), frozen))
|
||||
.isInstanceOf(UnsupportedOperationException.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapper;
|
||||
import com.ankurm.mapstruct.web.CustomerController;
|
||||
import java.time.Clock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
/** The whole application: every bean, every auto-configuration. Correct, and far more than a mapper test needs. */
|
||||
@SpringBootTest
|
||||
class SpringBootMapperTest {
|
||||
|
||||
@Autowired
|
||||
ConfigurableApplicationContext context;
|
||||
|
||||
@Autowired
|
||||
CustomerMapper mapper;
|
||||
|
||||
@Autowired
|
||||
Clock clock;
|
||||
|
||||
@Test
|
||||
void theWholeApplicationContext() {
|
||||
try (var t = new Transcript("13-testing-mappers-springboottest.txt",
|
||||
"@SpringBootTest: the same mapper, inside the whole application")) {
|
||||
int definitions = context.getBeanDefinitionCount();
|
||||
String name = mapper.toDto(Fixtures.customer(), Fixtures.clock()).fullName();
|
||||
t.line("bean definitions in the context : %d", definitions);
|
||||
t.line("includes the web controller : %s", context.getBeanNamesForType(CustomerController.class).length == 1);
|
||||
t.line("clock is the application's own : %s", clock.getZone());
|
||||
t.line("mapped : %s", name);
|
||||
|
||||
assertThat(definitions).isGreaterThan(100);
|
||||
assertThat(name).isEqualTo("Asha Mehta");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.dto.AddressDto;
|
||||
import com.ankurm.mapstruct.dto.CustomerDto;
|
||||
import com.ankurm.mapstruct.mapper.AddressMapper;
|
||||
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapper;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
|
||||
import java.time.Clock;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
|
||||
/** Four ways to test a generated mapper, and one way to make a new DTO component fail the build until it is tested. */
|
||||
class TestingStrategiesTests {
|
||||
|
||||
// ---- 1. plain unit tests ---------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void plainUnitTests() {
|
||||
try (var t = new Transcript("11-testing-mappers-plain.txt", "Testing a generated mapper without Spring: Mappers.getMapper versus constructing the Impl")) {
|
||||
AddressMapper viaFactory = Mappers.getMapper(AddressMapper.class);
|
||||
t.section("1. Mappers.getMapper(...)");
|
||||
t.line("AddressMapper (no dependencies) : %s", viaFactory.getClass().getSimpleName());
|
||||
|
||||
Throwable thrown = null;
|
||||
try {
|
||||
Mappers.getMapper(CustomerMapper.class);
|
||||
} catch (RuntimeException e) {
|
||||
thrown = e;
|
||||
}
|
||||
t.line("CustomerMapper (uses = {...}) : %s", thrown == null ? "worked" : thrown.getClass().getSimpleName());
|
||||
Throwable cause = thrown;
|
||||
while (cause != null && cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
t.line(" root cause : %s", cause == null ? "-" : cause.getClass().getName() + ": " + cause.getMessage());
|
||||
|
||||
CustomerMapper byHand = new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl());
|
||||
t.section("2. new CustomerMapperImpl(new AddressMapperImpl(), new OrderMapperImpl())");
|
||||
t.line("no Spring, no reflection : %s", byHand.toDto(Fixtures.customer(), Fixtures.clock()).fullName());
|
||||
|
||||
assertThat(viaFactory).isInstanceOf(AddressMapperImpl.class);
|
||||
assertThat(thrown).isNotNull();
|
||||
assertThat(cause).isInstanceOf(NoSuchMethodException.class);
|
||||
assertThat(byHand.toDto(Fixtures.customer(), Fixtures.clock()).fullName()).isEqualTo("Asha Mehta");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- a property-level strategy ------------------------------------------------------------------
|
||||
|
||||
/** One mutation per DTO component: change the entity, and exactly that component must change. */
|
||||
private static Map<String, Mutation> mutations() {
|
||||
Map<String, Mutation> m = new LinkedHashMap<>();
|
||||
m.put("id", new Mutation(c -> c.setId(42L), CustomerDto::id));
|
||||
m.put("fullName", new Mutation(c -> c.setLastName("Shah"), CustomerDto::fullName));
|
||||
m.put("email", new Mutation(c -> c.setEmail("[email protected]"), CustomerDto::email));
|
||||
m.put("birthDate", new Mutation(c -> c.setBirthDate(LocalDate.of(2000, 1, 1)), CustomerDto::birthDate));
|
||||
m.put("age", new Mutation(c -> c.setBirthDate(LocalDate.of(2000, 1, 1)), CustomerDto::age));
|
||||
m.put("nickname", new Mutation(c -> c.setNickname("ash"), CustomerDto::nickname));
|
||||
m.put("address", new Mutation(c -> c.getAddress().setCity("Nagpur"), CustomerDto::address));
|
||||
m.put("roles", new Mutation(c -> c.getRoles().add("ADMIN"), CustomerDto::roles));
|
||||
m.put("attributes", new Mutation(c -> c.getAttributes().put("k", "v"), CustomerDto::attributes));
|
||||
m.put("orders", new Mutation(c -> c.getOrders().get(0).getLines().get(0).setQuantity(9), CustomerDto::orders));
|
||||
return m;
|
||||
}
|
||||
|
||||
private record Mutation(Consumer<Customer> change, Function<CustomerDto, Object> read) { }
|
||||
|
||||
@Test
|
||||
void everyDtoComponentIsCoveredByAMutationTest() {
|
||||
try (var t = new Transcript("14-property-level-test-strategy.txt",
|
||||
"Property-level mapper test: change one entity property, expect exactly the right DTO component to change")) {
|
||||
var mapper = Fixtures.customerMapper();
|
||||
Clock clock = Fixtures.clock();
|
||||
CustomerDto base = mapper.toDto(Fixtures.customer(), clock);
|
||||
List<String> failures = new ArrayList<>();
|
||||
|
||||
mutations().forEach((component, mutation) -> {
|
||||
Customer changed = Fixtures.customer();
|
||||
mutation.change().accept(changed);
|
||||
CustomerDto after = mapper.toDto(changed, clock);
|
||||
boolean moved = !mutation.read().apply(after).equals(mutation.read().apply(base));
|
||||
t.line("%-11s changes when its source changes : %s", component, moved);
|
||||
if (!moved) {
|
||||
failures.add(component);
|
||||
}
|
||||
});
|
||||
|
||||
var components = new TreeSet<String>();
|
||||
for (var rc : CustomerDto.class.getRecordComponents()) {
|
||||
components.add(rc.getName());
|
||||
}
|
||||
components.remove("origin"); // a constant: there is no source property to change
|
||||
t.blank();
|
||||
t.line("record components : %s", components);
|
||||
t.line("covered by a test : %s", new TreeSet<>(mutations().keySet()));
|
||||
|
||||
assertThat(failures).isEmpty();
|
||||
assertThat(new TreeSet<>(mutations().keySet())).isEqualTo(components);
|
||||
assertThat(base.origin()).isEqualTo("api");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapperIsUsableThroughItsInterfaceOnly() {
|
||||
// The same assertion style a team would copy: build the expected record by hand.
|
||||
CustomerDto dto = Fixtures.customerMapper().toDto(Fixtures.customer(), Fixtures.clock());
|
||||
assertThat(dto.address()).isEqualTo(new AddressDto("12 Park Road", "Pune", "411001", "IN"));
|
||||
assertThatThrownBy(() -> Mappers.getMapper(CustomerMapper.class)).isInstanceOf(RuntimeException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Writes a numbered transcript under {@code output/} and echoes it to the console.
|
||||
* Every console block quoted in the article comes out of one of these files verbatim.
|
||||
* Lines end in LF on every platform so the files diff cleanly.
|
||||
*/
|
||||
public final class Transcript implements AutoCloseable {
|
||||
|
||||
private final Path path;
|
||||
private final StringBuilder buffer = new StringBuilder();
|
||||
|
||||
public Transcript(String fileName, String title) {
|
||||
this.path = Path.of("output", fileName);
|
||||
buffer.append("# ").append(title).append('\n').append('\n');
|
||||
}
|
||||
|
||||
public Transcript line(String format, Object... args) {
|
||||
buffer.append(args.length == 0 ? format : String.format(format, args)).append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript blank() {
|
||||
buffer.append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript section(String heading) {
|
||||
buffer.append('\n').append("--- ").append(heading).append(" ---").append('\n');
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Appends a block of text verbatim (a trailing newline is added if missing). */
|
||||
public Transcript block(String text) {
|
||||
buffer.append(text);
|
||||
if (!text.endsWith("\n")) {
|
||||
buffer.append('\n');
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// Absolute paths of whoever ran the build are environment noise, not a finding.
|
||||
String dir = System.getProperty("user.dir");
|
||||
String text = buffer.toString().replace(dir.replace('\\', '/'), "<mapstruct>").replace(dir, "<mapstruct>");
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, text, StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.ankurm.mapstruct;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
|
||||
import com.ankurm.mapstruct.mapper.AddressMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.CustomerMapperImpl;
|
||||
import com.ankurm.mapstruct.mapper.OrderMapperImpl;
|
||||
import com.ankurm.mapstruct.web.CustomerController;
|
||||
import com.ankurm.mapstruct.web.CustomerRepository;
|
||||
import java.time.Clock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/** Records over HTTP on Boot 4: Jackson 3 reads and writes them with no extra configuration. */
|
||||
@WebMvcTest(CustomerController.class)
|
||||
@Import({CustomerMapperImpl.class, AddressMapperImpl.class, OrderMapperImpl.class, CustomerRepository.class,
|
||||
WebJsonTests.PinnedClock.class})
|
||||
class WebJsonTests {
|
||||
|
||||
@TestConfiguration
|
||||
static class PinnedClock {
|
||||
@Bean
|
||||
Clock clock() {
|
||||
return Fixtures.clock();
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired
|
||||
MockMvc mvc;
|
||||
|
||||
@Autowired
|
||||
JsonMapper jsonMapper;
|
||||
|
||||
@Test
|
||||
void recordsRoundTripThroughJackson3AndTheMapper() throws Exception {
|
||||
try (var t = new Transcript("15-spring-boot-4-records-over-http.txt",
|
||||
"MockMvc against CustomerController: records in, records out, Jackson 3 in the middle")) {
|
||||
t.line("JSON library bean : %s", jsonMapper.getClass().getName());
|
||||
|
||||
String get = mvc.perform(get("/customers/1")).andReturn().getResponse().getContentAsString();
|
||||
t.section("GET /customers/1");
|
||||
t.line(get);
|
||||
|
||||
String created = mvc.perform(post("/customers").contentType(MediaType.APPLICATION_JSON).content("""
|
||||
{"firstName":"Ravi","lastName":"Iyer","email":" [email protected] ","birthDate":"1985-01-02",
|
||||
"address":{"street":"1 Lake View","city":"Chennai","zip":"600001"},"roles":["ADMIN"]}"""))
|
||||
.andReturn().getResponse().getContentAsString();
|
||||
t.section("POST /customers (no nickname, no tier, no country)");
|
||||
t.line(created);
|
||||
|
||||
String patched = mvc.perform(patch("/customers/1").contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"nickname\":\"ash\"}")).andReturn().getResponse().getContentAsString();
|
||||
t.section("PATCH /customers/1 {\"nickname\":\"ash\"} (every other component of the patch record is null)");
|
||||
t.line(patched);
|
||||
|
||||
int missing = mvc.perform(get("/customers/99")).andReturn().getResponse().getStatus();
|
||||
t.section("GET /customers/99");
|
||||
t.line("status %d", missing);
|
||||
|
||||
assertThat(jsonMapper.getClass().getName()).startsWith("tools.jackson.");
|
||||
assertThat(get).contains("\"fullName\":\"Asha Mehta\"").contains("\"age\":36")
|
||||
.contains("\"birthDate\":\"1990-06-15\"").contains("\"nickname\":\"anonymous\"")
|
||||
.contains("\"zip\":\"411001\"").doesNotContain("passwordHash");
|
||||
assertThat(created).contains("\"email\":\"[email protected]\"").contains("\"fullName\":\"Ravi Iyer\"");
|
||||
assertThat(patched).contains("\"nickname\":\"ash\"").contains("\"email\":\"[email protected]\"");
|
||||
assertThat(missing).isEqualTo(404);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Map;
|
||||
import org.mapstruct.BeforeMapping;
|
||||
import org.mapstruct.MappingTarget;
|
||||
import org.mapstruct.TargetType;
|
||||
|
||||
/** The standard recipe: remember every source object already mapped and reuse its target. */
|
||||
public class CycleContext {
|
||||
|
||||
private final Map<Object, Object> known = new IdentityHashMap<>();
|
||||
|
||||
@BeforeMapping
|
||||
public <T> T lookup(Object source, @TargetType Class<T> targetType) {
|
||||
return targetType.cast(known.get(source));
|
||||
}
|
||||
|
||||
@BeforeMapping
|
||||
public void remember(Object source, @MappingTarget Object target) {
|
||||
known.put(source, target);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import com.ankurm.mapstruct.probe.Probes.Employee;
|
||||
import com.ankurm.mapstruct.probe.Probes.EmployeeBean;
|
||||
import com.ankurm.mapstruct.probe.Probes.EmployeeRecord;
|
||||
import org.mapstruct.Context;
|
||||
import org.mapstruct.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface CycleMapper {
|
||||
|
||||
EmployeeRecord toRecord(Employee employee, @Context CycleContext context);
|
||||
|
||||
EmployeeBean toBean(Employee employee, @Context CycleContext context);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import com.ankurm.mapstruct.probe.Probes.Boxed;
|
||||
import com.ankurm.mapstruct.probe.Probes.Pair;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.ObjectFactory;
|
||||
import org.mapstruct.ReportingPolicy;
|
||||
|
||||
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
|
||||
public interface FactoryTrapMapper {
|
||||
|
||||
Boxed toRecord(Pair pair);
|
||||
|
||||
@ObjectFactory
|
||||
default Boxed create(Pair pair) {
|
||||
return new Boxed("from-the-factory", 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import com.ankurm.mapstruct.domain.Order;
|
||||
import com.ankurm.mapstruct.domain.OrderLine;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
|
||||
/** The mapper you would write by hand: what MapStruct's output is being compared with. */
|
||||
public final class HandWritten {
|
||||
|
||||
private HandWritten() { }
|
||||
|
||||
public static Views.CustomerView toView(Customer c) {
|
||||
if (c == null) {
|
||||
return null;
|
||||
}
|
||||
Views.CustomerView v = new Views.CustomerView();
|
||||
v.setId(c.getId());
|
||||
v.setFirstName(c.getFirstName());
|
||||
v.setLastName(c.getLastName());
|
||||
v.setEmail(c.getEmail());
|
||||
v.setNickname(c.getNickname());
|
||||
v.setTier(c.getTier());
|
||||
if (c.getAddress() != null) {
|
||||
Views.AddressView a = new Views.AddressView();
|
||||
a.setStreet(c.getAddress().getStreet());
|
||||
a.setCity(c.getAddress().getCity());
|
||||
a.setPostalCode(c.getAddress().getPostalCode());
|
||||
a.setCountry(c.getAddress().getCountry());
|
||||
v.setAddress(a);
|
||||
}
|
||||
if (c.getRoles() != null) {
|
||||
v.setRoles(new LinkedHashSet<>(c.getRoles()));
|
||||
}
|
||||
if (c.getAttributes() != null) {
|
||||
v.setAttributes(new LinkedHashMap<>(c.getAttributes()));
|
||||
}
|
||||
if (c.getOrders() != null) {
|
||||
List<Views.OrderView> orders = new ArrayList<>(c.getOrders().size());
|
||||
for (Order o : c.getOrders()) {
|
||||
Views.OrderView ov = new Views.OrderView();
|
||||
ov.setId(o.getId());
|
||||
ov.setStatus(o.getStatus() == null ? null : o.getStatus().name());
|
||||
if (o.getLines() != null) {
|
||||
List<Views.LineView> lines = new ArrayList<>(o.getLines().size());
|
||||
for (OrderLine l : o.getLines()) {
|
||||
Views.LineView lv = new Views.LineView();
|
||||
lv.setSku(l.getSku());
|
||||
lv.setQuantity(l.getQuantity());
|
||||
lv.setUnitPrice(l.getUnitPrice());
|
||||
lines.add(lv);
|
||||
}
|
||||
ov.setLines(lines);
|
||||
}
|
||||
orders.add(ov);
|
||||
}
|
||||
v.setOrders(orders);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import static com.ankurm.mapstruct.probe.Probes.TRACE;
|
||||
|
||||
import com.ankurm.mapstruct.probe.Probes.Boxed;
|
||||
import com.ankurm.mapstruct.probe.Probes.BoxedBean;
|
||||
import com.ankurm.mapstruct.probe.Probes.Pair;
|
||||
import org.mapstruct.AfterMapping;
|
||||
import org.mapstruct.BeforeMapping;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.MappingTarget;
|
||||
|
||||
@Mapper
|
||||
public interface HookOrderMapper {
|
||||
|
||||
Boxed toRecord(Pair pair);
|
||||
|
||||
BoxedBean toBean(Pair pair);
|
||||
|
||||
@BeforeMapping
|
||||
default void beforeSource(Pair pair) {
|
||||
TRACE.add("@BeforeMapping(source)");
|
||||
}
|
||||
|
||||
@BeforeMapping
|
||||
default void beforeRecord(Pair pair, @MappingTarget Boxed target) {
|
||||
TRACE.add("@BeforeMapping(source, @MappingTarget Boxed) target=" + target);
|
||||
}
|
||||
|
||||
@AfterMapping
|
||||
default void afterRecord(Pair pair, @MappingTarget Boxed target) {
|
||||
TRACE.add("@AfterMapping(source, @MappingTarget Boxed) target=" + target);
|
||||
}
|
||||
|
||||
@BeforeMapping
|
||||
default void beforeBean(Pair pair, @MappingTarget BoxedBean target) {
|
||||
TRACE.add("@BeforeMapping(source, @MappingTarget BoxedBean) name=" + target.getName());
|
||||
}
|
||||
|
||||
@AfterMapping
|
||||
default void afterBean(Pair pair, @MappingTarget BoxedBean target) {
|
||||
TRACE.add("@AfterMapping(source, @MappingTarget BoxedBean) name=" + target.getName());
|
||||
}
|
||||
|
||||
@AfterMapping
|
||||
default void afterSource(Pair pair) {
|
||||
TRACE.add("@AfterMapping(source)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Small mappers in this package exist only to answer "what does MapStruct do when...?" questions.
|
||||
* They use MapStruct's default component model (plain classes, no Spring) and live in test sources.
|
||||
*/
|
||||
public final class Probes {
|
||||
|
||||
private Probes() { }
|
||||
|
||||
/** Everything the hooks do is appended here, in call order. */
|
||||
public static final List<String> TRACE = new ArrayList<>();
|
||||
|
||||
public record Pair(String name, int qty) { }
|
||||
|
||||
public record Boxed(String name, int qty) { }
|
||||
|
||||
public static class BoxedBean {
|
||||
private String name;
|
||||
private int qty;
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { TRACE.add(" BoxedBean.setName(" + name + ")"); this.name = name; }
|
||||
public int getQty() { return qty; }
|
||||
public void setQty(int qty) { TRACE.add(" BoxedBean.setQty(" + qty + ")"); this.qty = qty; }
|
||||
}
|
||||
|
||||
public static class Employee {
|
||||
public String name;
|
||||
public Employee manager;
|
||||
public Employee(String name) { this.name = name; }
|
||||
}
|
||||
|
||||
public record EmployeeRecord(String name, EmployeeRecord manager) { }
|
||||
|
||||
public static class EmployeeBean {
|
||||
private String name;
|
||||
private EmployeeBean manager;
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public EmployeeBean getManager() { return manager; }
|
||||
public void setManager(EmployeeBean manager) { this.manager = manager; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import com.ankurm.mapstruct.domain.Customer;
|
||||
import org.mapstruct.Mapper;
|
||||
|
||||
/** Same-named properties, so MapStruct needs no @Mapping at all. */
|
||||
@Mapper
|
||||
public interface ViewMapper {
|
||||
Views.CustomerView toView(Customer customer);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.ankurm.mapstruct.probe;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Mutable, setter-based copies of the DTO shape. The comparison uses setter-based beans because Dozer is a bean mapper. */
|
||||
public final class Views {
|
||||
|
||||
private Views() { }
|
||||
|
||||
public static class AddressView {
|
||||
private String street; private String city; private String postalCode; private String country;
|
||||
public String getStreet() { return street; }
|
||||
public void setStreet(String v) { street = v; }
|
||||
public String getCity() { return city; }
|
||||
public void setCity(String v) { city = v; }
|
||||
public String getPostalCode() { return postalCode; }
|
||||
public void setPostalCode(String v) { postalCode = v; }
|
||||
public String getCountry() { return country; }
|
||||
public void setCountry(String v) { country = v; }
|
||||
}
|
||||
|
||||
public static class LineView {
|
||||
private String sku; private int quantity; private BigDecimal unitPrice;
|
||||
public String getSku() { return sku; }
|
||||
public void setSku(String v) { sku = v; }
|
||||
public int getQuantity() { return quantity; }
|
||||
public void setQuantity(int v) { quantity = v; }
|
||||
public BigDecimal getUnitPrice() { return unitPrice; }
|
||||
public void setUnitPrice(BigDecimal v) { unitPrice = v; }
|
||||
}
|
||||
|
||||
public static class OrderView {
|
||||
private Long id; private String status; private List<LineView> lines;
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long v) { id = v; }
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String v) { status = v; }
|
||||
public List<LineView> getLines() { return lines; }
|
||||
public void setLines(List<LineView> v) { lines = v; }
|
||||
}
|
||||
|
||||
public static class CustomerView {
|
||||
private Long id; private String firstName; private String lastName; private String email;
|
||||
private String nickname; private String tier; private AddressView address;
|
||||
private Set<String> roles; private Map<String, String> attributes; private List<OrderView> orders;
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long v) { id = v; }
|
||||
public String getFirstName() { return firstName; }
|
||||
public void setFirstName(String v) { firstName = v; }
|
||||
public String getLastName() { return lastName; }
|
||||
public void setLastName(String v) { lastName = v; }
|
||||
public String getEmail() { return email; }
|
||||
public void setEmail(String v) { email = v; }
|
||||
public String getNickname() { return nickname; }
|
||||
public void setNickname(String v) { nickname = v; }
|
||||
public String getTier() { return tier; }
|
||||
public void setTier(String v) { tier = v; }
|
||||
public AddressView getAddress() { return address; }
|
||||
public void setAddress(AddressView v) { address = v; }
|
||||
public Set<String> getRoles() { return roles; }
|
||||
public void setRoles(Set<String> v) { roles = v; }
|
||||
public Map<String, String> getAttributes() { return attributes; }
|
||||
public void setAttributes(Map<String, String> v) { attributes = v; }
|
||||
public List<OrderView> getOrders() { return orders; }
|
||||
public void setOrders(List<OrderView> v) { orders = v; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user